1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-03-17 20:42:26 +02:00

the REST Mythology translation

This commit is contained in:
Sergey Konstantinov 2021-06-02 12:05:36 +03:00
commit a759c4ec26
6 changed files with 327 additions and 24 deletions

View File

@ -61,11 +61,11 @@ And here the big question arises: what should we do with the `default_volume` fi
* either partners provide the corresponding number only, and we will make readable descriptions on our own behalf,
* or partners provide both the number and all of its localized representations.
The flaw in the first option is that a partner might be willing to use the service in some new country or language — and will be unable to do so until the API supports them. The flaw in the second option is that it works with pre-defined volumes only, so you can't order an arbitrary volume of beverage. So the very first step we've made effectively has us trapped.
The flaw in the first option is that a partner might be willing to use the service in some new country or language — and will be unable to do so until the API supports them. The flaw in the second option is that it works with predefined volumes only, so you can't order an arbitrary volume of beverage. So the very first step we've made effectively has us trapped.
The localization flaws are not the only problem of this API. We should ask ourselves a question — *why* do we realy need these `name` and `description`? They are simply non-machine-readable strings with no specific semantics. At first glance we need them to return them back in `/v1/search` method response, but that's not a proper answer: why do we really return these strings from `search`?
The localization flaws are not the only problem of this API. We should ask ourselves a question — *why* do we really need these `name` and `description`? They are simply non-machine-readable strings with no specific semantics. At first glance we need them to return them back in `/v1/search` method response, but that's not a proper answer: why do we really return these strings from `search`?
The correct answer lies a way beyond this specific interface. We need them *because some representation exists*. There is a UI for choosing beverage type. Probably `name` and `description` are simply two designations of the beverage type, short one (to be displayed on the search results page) and long one (to be displayed in the extended product specification block). It actually means that we are setting the requirements to the API based on some very specific design. But *what if* a partner is making their own UI for their own app? Not only two descriptions might be of no use for them, but we are also *deceiving* them. `name` is not ‘just a name’ actually, it implies some restrictions: it has recommended length, optimal to some specific UI, and it must look consistently on the search results page. Indeed, ‘our best quality™ coffee’ or ‘Invigorating Morning Freshness®’ designation would look very weird in-between ‘Capuccino’, ‘Lungo’, and ‘Latte’.
The correct answer lies a way beyond this specific interface. We need them *because some representation exists*. There is a UI for choosing beverage type. Probably `name` and `description` are simply two designations of the beverage type, short one (to be displayed on the search results page) and long one (to be displayed in the extended product specification block). It actually means that we are setting the requirements to the API based on some very specific design. But *what if* a partner is making their own UI for their own app? Not only two descriptions might be of no use for them, but we are also *deceiving* them. `name` is not ‘just a name’ actually, it implies some restrictions: it has recommended length, optimal to some specific UI, and it must look consistently on the search results page. Indeed, ‘our best quality™ coffee’ or ‘Invigorating Morning Freshness®’ designation would look very weird in-between ‘Cappuccino’, ‘Lungo’, and ‘Latte’.
There is also another side to this story. As UIs (both ours and partner's) tend to evolve, new visual elements will be eventually introduced. For example, a picture of a beverage, its energy value, allergen information, etc. `product_properties` will become a scrapyard for tons of optional fields, and learning how setting what field results in what effects in the UI will be an interesting quest, full of probes and mistakes.
@ -87,7 +87,7 @@ To make our API work correctly with a new language or region, the partner must e
```
// Add a general formatting rule
// for Russian langauge
// for Russian language
PUT /formatters/volume/ru
{
"template": "{volume} мл"
@ -204,7 +204,7 @@ POST /v1/recipe-builder
}
```
We should also note that providing a newly created entity identifier by client isn't exactly the best pattern. However, since we decided from the very beginning to keep recipe identifiers semantically meaningful, we have to live with this convention on. Obviously, we're risking getting lots of collisions on recipe naming used by different partners, so we actually need to modify this operation: either partners must always use a pair of identifiers (i.e. recipes's one plus partner's own id), or we need to introduce composite identifiers, as we recommended earlier in [Chapter 11](#chapter-11-paragraph-8).
We should also note that providing a newly created entity identifier by client isn't exactly the best pattern. However, since we decided from the very beginning to keep recipe identifiers semantically meaningful, we have to live with this convention on. Obviously, we're risking getting lots of collisions on recipe naming used by different partners, so we actually need to modify this operation: either partners must always use a pair of identifiers (i.e. recipe's one plus partner's own id), or we need to introduce composite identifiers, as we recommended earlier in [Chapter 11](#chapter-11-paragraph-8).
```
POST /v1/recipes/custom

View File

@ -69,7 +69,7 @@ So, how would we tackle this issue? Using one of two possible approaches: either
* higher-level program API level doesn't actually know how the execution of its commands works; it formulates the tasks at its own level of understanding: brew this recipe, sprinkle with cinnamon, allow this user to take it;
* underlying program execution API doesn't care what other same-level implementations exist; it just interprets those parts of the task which make sense to it.
If we take a look at principles described in previous chapter, we would find that this principle was already formulated: we need to describe *an informational context* at every abstraction level, and design a mechanism to translate it between levels. Furthermore, in more general sense we formulated it as early as in [‘The Data Flow’ paragraph in Chapter 9](#chapter-9)
If we take a look at principles described in previous chapter, we would find that this principle was already formulated: we need to describe *an informational context* at every abstraction level, and design a mechanism to translate it between levels. Furthermore, in more general sense we formulated it as early as in [‘The Data Flow’ paragraph in Chapter 9](#chapter-9).
In our case we need to implement the following mechanisms:
* running a program creates a corresponding context comprising all the essential parameters;
@ -81,7 +81,7 @@ There are different techniques to organize this data flow, but basically we alwa
/* Partner's implementation of program
run procedure for a custom API type */
registerProgramRunHandler(apiType, (program) => {
// Initiatiang an execution
// Initiating an execution
// on partner's side
let execution = initExecution(…);
// Listen to parent context's changes
@ -127,7 +127,7 @@ It becomes obvious from what said above that two-way weak coupling means signifi
/* Partner's implementation of program
run procedure for a custom API type */
registerProgramRunHandler(apiType, (program) => {
// Initiatiang an execution
// Initiating an execution
// on partner's side
let execution = initExecution(…);
// Listen to parent context's changes
@ -135,7 +135,7 @@ registerProgramRunHandler(apiType, (program) => {
// If takeout is requested, initiate
// corresponding procedures
execution.prepareTakeout(() => {
/* When the order is reasy for takeout,
/* When the order is ready for takeout,
signalize about that, but not
with event emitting */
// execution.context.emit('takeout_ready')

View File

@ -0,0 +1,167 @@
### The Mythology of REST
#### Hard knowledge
No other technology in the IT history generated as many fierce debates as REST did. The most remarkable thing is that disputants usually demonstrate totally no understanding of the subject under discussion.
Let's start with the very beginning. In 2000 Roy Fielding, one of the HTTP and URI specs authors, defended his doctoral dissertation on ‘Architectural Styles and the Design of Network-based Software Architectures’. Fifth chapter of this dissertation is ‘Representational State Transfer (REST)’. It might be found [there](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm).
As anyone may ascertain upon reading this chapter, it holds quite an abstract overview of distributed network architecture, which is bound to neither HTTP nor URI. Furthermore, it is not at all about designing APIs. In this chapter Fielding methodically enumerates the restrictions which distributed systems software engineer has to deal with. Here they are:
* server and client must not be aware of each other's internal implementation (client-server architecture);
* session is stored on client (stateless design);
* data must be marked as cached or non-cached;
* interaction interfaces must be uniform;
* systems are layered, e.g. server might be but a proxy to other servers;
* client's functionality might be extended by server providing code on demand.
That's all. Essentially REST is defined like this. In the rest of the chapter Fielding elaborates over different system implementation aspects, but all of them are just as well abstract. Literally: ‘the key abstraction of information in REST is a resource; any information that can be named can be a resource’.
The key conclusion from the Fielding's REST definition is actually this: *any network-based software in the world complies to REST principles*, except on very rare occasions.
Indeed:
* it's very hard to imagine a system which would have exactly no uniform interaction interface; developing such system would be virtually impossible;
* since there is interaction interface, then it might be mimicked, which means that client and server independency requirement might always be met;
* since we may always develop an alternate implementation for the server, then we might always make an architecture many-layered, inserting additional proxy between server and client;
* since client is a computing machine, it always stores some session state and caches some data;
* finally, code-on-demand requirement is a cunning one: we might always say that the data we've got over network comprise ‘instructions’ of some sort which client interprets.
Of course, all these speculations are ultimately sophistic, reduction to absurdity. Ironically, we might also reduce them to absurdity, taking opposite direction and proving REST requirements unrealizable. For example, the code-on-demand requirement contradicts to the client-server independency principle, since client must interpret server commands written in some specific language. As for the rule under ‘S’ letter (‘stateless’), systems which store exactly no client's context are virtually non-existent because they simply can't do anything valuable. (Which is stated in plain text, by the way: ‘communication … cannot take advantage of any stored context on the server’)
Finally, Fielding himself put additional entropy into the question, releasing [a statement](https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven) in 2008, explaining what he really wanted to say. This article states, in particular, that:
* A REST API should not be dependent on any single communication protocol;
* A REST API should spend almost all of its descriptive effort in defining the media types, and these media types shouldn't mean anything to client;
* A REST API must not define fixed resource names or hierarchies, they must be extracted from server responses.
Ultimately, REST as defined by Fielding-2008 implies that client somehow obtains the entry point to the REST API and since that moment must be able to carry on overall interaction with it, possessing no a priori knowledge of the API, and even more so, no specific code written for this purpose.
Saying nothing about Fielding's loose interpretation of his own dissertation, let us point out that no existing system in the world complies with the Fielding-2008 definition of REST.
#### REST: The Good Part
We don't actually know why of all overviews of abstract network-based architectures the Fielding's one became the most widely known. But it's clearly obvious that Fielding's theory being reflected in minds of millions of developers (including Fielding's own) morphed into a whole engineering subculture. Out of reducing the REST abstractions to the HTTP protocol and the URL standard, the chimaera of ‘RESTful API’ was born — the one which [nobody knowns the exact sense of](https://restfulapi.net/).
Are we saying that REST concept is meaningless? Not at all. We were just trying to demonstrate that it allows for too loose interpretation, which is simultaneously its main power and its main flaw.
From one side, from the plethora of interpretations the API developers built up some fuzzy, but still useful understanding of ‘right’ API architecture. From other side, if Fielding had elaborated over his vision in detail back in 2000, there would have been probably a few dozen of people who have heard anything of it.
So, what's ‘correct’ there in the REST approach to API design, as it formed in the collective conscience of developers? The thing is that it allows to use time more effectively — engineers' time and computers' time.
If we try to summarize all the REST discussion, we would get something like this: *you would rather design a distributed system in a manner allowing all interim agents to read the metadata of requests and responses passing through the system* (a ready commandment for RESTful Pastafarianism!).
The HTTP protocol has one very important feature: it allows external observer to understand a lot what happened with the request and the response, even if this observer has no clue what's the operation itself means:
* URL identifies some final receiver of the request, a unit of addressing;
* status code tells us whether the operation was carried out successfully or some error occurred; if the latter is what happened, then we are able to tell who's fault it is (client's or server's), and even fix the problem in some situations;
* out of request method we might tell whether the operation is modifying; if it is, we may also tell whether it's idempotent;
* request method and response status code and headers indicate whether the result is cacheable and what's the caching policy.
Importantly, all these data might be obtained without reading the entire response body; reading just headers is enough.
Why we say this is ‘right’? Because modern client-server interaction stack is *layered*, as Fielding points out. Developers write code atop some framework which dispatches requests; the framework is built upon programming language API, which calls operating system functions. After that the request (probably via interim proxies) reaches the server, which in its turn might present several abstraction layers in a form of a framework, programming language, OS, etc. Before actual server code there is usually a web server, seldom even several ones. Finally, in modern cloud architectures HTTP requests pass several additional abstractions, i.e. proxies and gateways, before it reaches the final handler. Obviously, if all these agents interpreted request metadata uniformly, it would allow dealing with many situations more efficiently, using less resources and requiring writing less code.
(Actually, with regards to many technical aspects interim agents are taking many opportunities, not asking the developers about them. For example, freely changing `Accept-Encoding` and therefore `Content-Length` while proxying requests and responses.)
Every REST principle, named by Fielding, allows for making interim software work better. The stateless paradigm is a key: proxies might be sure that request's metadata describe it unambiguosly.
Let's explore a simple example. Imagine we have operations for getting and deleting user's profile in our system. We may organize them in different ways. For example, like this:
```
// Get user's profile
GET /me
Cookie: session_id=<идентификатор сессии>
// Delete user's profile
GET /delete-me
Cookie: session_id=<идентификатор сессии>
```
Why this solution is defective from the interim agent's point of view?
1. Server can't cache responses; all `/me`-s are the same, since server can't easily obtain user's identifier from the cookie value; interim proxies cannot populate caches beforehand since they don't know session identifiers.
2. It's hard to organize sharding, e.g. storing different user's data on different network fragments; to do so you still need a method to exchange cookies for user's identifier.
We might partially solve the first problem by making the operations more machine-readable, moving sessions to the URL itself:
```
// Get user's profile
GET /me?session_id=<session identifier>
// Delete user's profile
GET /delete-me?session_id=<session identifier>
```
We still cannot easily organize sharding, but now server could have cache (it will be bloated with copies of the same user's data under different session identifiers, but at least it's impossible to respond incorrectly), but another problems pop up:
1. URLs shouldn't be stored in logs, since they contain secrets; furthermore, there are risks of leaking users' data, since a URL alone is now enough to access it.
3. Delete profile links are now to be kept in secret. If you post one of those into a messenger, then the messenger's link prefetcher will delete the profile.
So, how to make this ‘right’ according to the REST principles? Like that:
```
// Get user's profile
GET /user/{user_id}
Authorization: Bearer <token>
// Delete user's profile
DELETE /user/{user_id}
Authorization: Bearer <token>
```
Now the request URL explicitly points to the resource, so we may organize caches and event populate them ahead of time. We are now able to organize request routing depending on user's identifier, therefore sharding is now possible. Messengers' link prefetchers don't follow `DELETE` links; and even if they do, the operation won't be carried out without `Authorization` header.
Finally, one unobvious benefit we might get from this solution is an ability for interim gateway to check `Authorization` header and send the request further without it (preferably using secure connection or at least signing the request) — unlike in corresponding ‘session_id’ scheme, we are still able organize caching and sharding at any middle point. Furthermore, *agents might easily modify operations*; for example, proxying authorized request further as is, but showing special ‘public’ profile to unauthorized users by directing their requests to arbitrary URL like `GET /user/{user_id}/public-profile`. To do so you just need to attach `/public-profile` to the original URL, making no changes to other parts of the request. For contemporary microservice architectures *the ability to modify requests while routing reliably and at no cost is the most valuable benefit of REST*.
Let's make one small step further. What if the gateway proxied `DELETE /user/{user_id}` to the proper endpoint, but got no response back. What variants are possible?
**Option #1**. The gateway might generate HTML page containing error explanation, return it to the web server to return it to client to show it to the end-user. We've driven a bunch of bytes through the system and ultimately put the responsibility on customers. Also note that an error is indistinguishable from a success to all agents from web server on, since the response contains non-machine-readable response bearing '200 OK' status code. If the network connectivity between the gateway and the endpoint is really dropped, nobody will know this.
**Option #2**. The gateway might return an appropriate HTTP error, `504` for instance, to send it back to client to handle the error accordingly to client's inner logic, for example, to retry the request or show an error view to the customer. We've driven slightly less bytes through the system, logged the exception, and put the responsibility on client developers: it's now they who are to write the code handling `504` error.
**Option #3**. The gateway is aware that `DELETE` operations are idempotent, and it repeats the request. If there is no response again, then proceed option #1 or option #2. In this situation we put the full responsibility on system architects, who should design the system-wide retry policy (and guarantee that all `DELETE` operations are *really* idempotent), but we've got an important capability in return: the system became self-recoverable. It's now able to ‘self-restore’ in some cases which previously led to errors.
A mindful reader might note, that option #3 is the most technically challenging of all three, because it actually incorporates options #1 and #2: to make it work properly client developers still need to write the code working with the exceptions. But it's not exactly the same; there is a significant difference in writing code adapted to option #3 compared to #1 and #2: client developers don't care how the server retry policy is structured. The may assume that server has already done all the hygienic job; for example, immediate retrying is useless. All requests to server from this point of view behave similarly, so this functionality (waiting for responses, possible retrying) might be delegated to the framework.
If both frameworks, server's one and client one, are working as intended, situations of uncertainty are no longer a problem to client developers: they are not writing any ‘recover’ logic, when the request failed, but something could be done to ‘repair’ the state. Client-server interaction is now binary, it's either success or failure, with all marginal situations to be handled by other code.
Eventually the architecture which looked most intricate is now split into different responsibility domains, and every developer now minds their own business. Gateway developers are to guarantee optimal routing within the data center; framework developers implement functionality of request timeouts and retries; client developers write business logic, not a low-level recovery code.
**Of course** all these optimizations might be conducted without relying on standard HTTP methods / statuses / headers nomenclature, or the HTTP itself. You just need to develop uniform data format exposing all the metadata, and make interim agents and frameworks understand it. That's exactly what Fielding stated in his dissertations. But it's obviously highly desirable to have this code already being written by someone, not us.
Let us also note that numerous ‘REST API how-to’-s which could be found on the Internet in abundance, are not related to abovementioned principles, and sometimes even contradict them.
1. ‘Don't use verbs in URLs, nouns only’ — this is just a crutch to help organizing request metadata properly. With regards to working with URLs you actually need to accomplish two things:
* URLs being a cache key to every cacheable operation, and idempotency key to idempotent ones;
* it must be easy to understand from the URL how this operation is going to be routed through multilayered system — easy for humans and machines alike.
2. ‘Use HTTP verbs to describe actions upon resources’ — this is just putting the cart before the horse. The verb must tell us whether the operation is (non-)modifying, (non-)cacheable, (non-)idempotent, and whether the request has body. Instead of choosing the method out of these criteria, some mnemonics is introduced: if the verb aligns well with the semantics of the operation, it suits. This principle might be dangerously misleading in some cases. You might think that `DELETE /list?element_index=3` describes your intention to delete third element of a list perfectly, but this operation is not idempotent, so you can't use `DELETE` verb here.
3. ‘Use `POST` to create entities, `GET` to access them, `PUT` for full rewriting, `PATCH` for partial rewriting, and `DELETE` for removing’ — again just some mnemonics which allows to figure it out, which side-effects each of the verbs might possibly have. If we try to dig a bit deeper, we will soon realize that this advice quality is somewhat between ‘useless’ and ‘destructive’:
* using `GET` method in APIs makes sense when and only when you may supply reasonable caching headers; if you set `Cache-Control` to `no-cache`, then you simply have implicit `POST`; if you haven't set them at all, then some interim agent might set them for you;
* entities creation should be idempotent, ideally behind `PUT` verb (for example, using the [drafts scheme](#chapter-11-paragraph-13));
* partial updates via `PATCH` is a dangerous and dubious practice, it's better to [decompose it into several simple `PUT`s](#chapter-11-paragraph-12);
* finally, in modern systems entities are rarely deleted; they are rather archived or marked hidden, so `PUT /archive/entity_id` again would be more appropriate.
4. ‘Don't nest the resources’ — this rule just reflects the fact that entities' relations tend to evolve over time, and strict hierarchies eventually become not-so-strict.
5. ‘Use plural form for resources’, ‘enforce trailing slash’ and related pieces of advice, which are just about the code style, not REST.
In the end, let us dare to state four rules that would *actually* help in designing a REST API:
1. Comply with HTTP standard, *especially* regarding the semantics of HTTP verbs, statuses, and headers.
2. Use URLs as cache and idempotency keys.
3. Design the architecture in a manner allowing for routing the requests in multilayered system with just manipulating URL parts (host, path, query), statuses, and headers.
4. Treat your HTTP call signatures as a code, and apply all the same stylistic rules: signatures must be clear, semantic, consistent and readable.
#### REST benefits and disadvantages
The main advantage of building a REST API is an ability to rely on interim agents (from client frameworks to API gateways) reading request and response metadata and employ some actions based on it: tune caching policy, retries and timeouts, logging, caching, sharding, proxying, etc — without the need to write any additional code. It's important to stress it out, that if you are actually don't use all these abilities, you don't need REST.
The main disadvantage of building a REST API is having to rely on interim agents (from client frameworks to API gateways) reading request and response metadata and employ some actions based on it: tune caching policy, retries and timeouts, logging, caching, sharding, proxying, etc — even if you didn't ask for it. Furthermore, since the HTTP is a complicated standard and developers are not ideal, interim agents might treat request metadata *erroneously*. Especially if we talk about some exotic or hard to interpret standards.
Developing distributed systems in the REST paradigm is always a bargain: which functionality you agree to outsource, and which you do not. Alas, the proper balance is usually found with probes and mistakes.
#### On metaprogramming and Fielding's REST
Let us also say a couple of words on REST in its Fielding-2008 interpretation (which is actually based on a well-known [HATEOAS](https://en.wikipedia.org/wiki/HATEOAS) concept). From one side, that's quite a logical extension of the principles stated above: if not only metadata of the current operation were machine-readable, but all possible operations with the resource, it would allow us to build much more functional interim agents. The idea of meta-programming, when the client is such a powerful computing engine that could extend its own functionality without the necessity of hiring an engineer to read the API docs and write the code, looks extremely attractive to any technocrat.
The flaw in this idea is that the client would be extending itself without the developer to read the API docs and write the code. It might be working in the ideal world; in the real world it wouldn't. Any complex API is not ideal, there are always concepts which require a human to understand them (yet). And, as we said before, since an API is a multiplier to both your opportunities and mistakes, the automated API metaprogramming might be a very-very costly mistake.
Until Strong AI is developed, we still insist on writing the code working with APIs by a human who relies on detailed docs, not guesses the meaning of hyperlinks in server's response.

View File

@ -1,15 +1,136 @@
### Интерфейсы как универсальный паттерн
### Расширение через абстрагирование
Как мы указали в предыдущей главе, основные причины внесения изменений в API — развитие самого API (добавление новой функциональности) и эволюция платформ (клиентской, серверной и предметной) — следует предусматривать ещё на этапе проектирования. Может показаться, что совет этот полезен примерно так же, как и сократовское определение человека — очень конкретен, и столь же бесполезен — но это не так. Методология, позволяющая получить устойчивое к изменениям API, существует и вполне конкретна: это применение концепции «интерфейса» ко всем уровням абстракции.
Попробуем теперь по шагам воспроизвести последовательность шагов, которые мы сделали бы в реальном мире для предоставления функциональности подключения новых видов кофе-машин к нашей платформе. Предположим, что изначально наше API вообще не предоставляло никакого доступа к управлению запуском программ — единственными эндпойнтами, доступным клиенту, были бы функции поиска предложений и создания заказа.
На практике это означает следующая: нам необходимо рассмотреть каждую сущность нашего API и выделить её абстрактную модель, т.е. разделить все поля и методы сущности на две группы: те, которые абсолютно необходимы для корректного цикла работы API, и те, которые мы можем назвать «деталями имплементации». Первые образуют *интерфейс* сущности: если заменить одну конкретную реализацию этого интерфейса на другую, API будет продолжать работать.
```
let searchResults = api.search({
/* выбранные пользователем параметры */
});
**NB**: мы понимаем, что вносим некоторую путаницу, поскольку термин «интерфейс» также используется для обозначения совокупности свойств и методов сущности, да и вообще отвечает за букву «I» в самой аббревиатуре «API»; однако использование других терминов внесёт ещё больше путаницы. Мы могли бы оперировать выражениями «абстрактные типы данных» и «контрактное программирование», но это методологически неверно: разработка API в принципе представляет собой контрактное программирование, при этом большинство клиент-серверных архитектур подразумевают независимость имплементации клиента и сервера, так что никаких «неабстрактных» типов данных в них не существует. Термины типа «виртуальный класс» и «виртуальное наследование» неприменимы по той же причине. Мы могли бы использовать «фасад», но под «фасадом» обычно понимают всё-таки конкретную имплементацию, а не абстракцию. Ближе всего по смыслу подходят «концепты» в том смысле, который вкладывается в них в STL[[ref B. Stroustrup, A. Sutton. A Concept Design for the STL, p. 38]](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2012/n3351.pdf), но термин «интерфейс» нам кажется более понятным.
/* пользователь как-то взаимодействует
с результатами поиска и выбирает
конкретное предложение */
Мы будем использовать термин «интерфейс» как обобщение понятия «абстрактный тип данных» и «контракт». «Интерфейс» — это некоторое абстрактное подмножество абстрактного типа данных, «метаконтракт». Интерфейсы мы будем обозначать с помощью префикса `I`, например: `Recipe` — это модель данных «рецепт», а `IRecipe` — это интерфейс рецепта: «минимальная» модель данных и операций над ними, которая достаточна для корректной работы API. Объект `Recipe` таким образом имплементирует интерфейс `IRecipe`.
let selectedResult = userPick(searchResults);
Попробуем применить этот (дважды) абстрактный концепт к нашему кофейному API. Представьте, что на этапе разработки архитектуры бизнес выдвинул следующее требование: мы не только предоставляем доступ к оборудованию партнеров, но и предлагаем партнерам наше ПО (т.е. в данном случае API), чтобы они могли строить поверх него свои собственные приложения. Иными словами, подойдём к каждой концепции нашего API с вопросом «что, если?…»
api.postOrder({
offer: selectedResult.offer
});
```
**NB**: в рассматриваемых нами примерах мы будем выстраивать интерфейсы так, чтобы связывание разных сущностей происходило динамически в реальном времени; в реальном мире такие интеграции будут делаться на стороне сервера путём написания ad hoc кода и формирования конкретных договорённостей с конкретным клиентом, однако мы для целей обучения специально будем идти более сложным и абстрактным путём. Динамическое связывание в реалтайме применимо скорее к сложным программным конструктам типа API операционных систем или встраиваемых библиотек; приводить обучающие примеры на основе систем такой сложности было бы затруднительно.
На данном этапе вообще нет таких понятий, как «идентификатор кофе-машины» или «идентификатор рецепта» — достаточно передать только лишь оффер. Нужные идентификаторы могут храниться в базе данных по идентификатору оффера или быть закодированными в самом идентификаторе.
**Что произойдёт, если…** потребуется предоставить партнёру возможность готовить напитки по своему собственному рецепту?
**NB**: тем не менее, обратите внимание не то, что, хотя никакие данные помимо оффера для заказа не нужны, мы, тем не менее, оставили возможность эти данные передавать: функция placeOrder принимает не offerId, а потенциально расширяемую структуру данных.
API для регистрации своего обработчика запуска программ мы предложили в предыдущей главе:
```
api.registerProgramRunHandler(
apiType,
function (program) {
}
);
```
Заметим, что партнёр мог бы не пользоваться ни одной из вышеперечисленных функций (`search`, `postOrder`, `registerProgramHandler`) и попросту написать реализацию выбора и заказа напитка сам. Тогда, правда, возникает вопрос, зачем ему вообще тогда нужно наше API (см. «какую проблему мы решаем», [Глава 8](#chapter-8)). Предположим всё-таки, что партнёр не строит полностью свой сервис, а только встраивает свои собственные кофейни в нашу платформу. Как и раньше, мы рассматриваем динамическое связывание на клиенте в реальном времени в учебных целях, в реальной жизни такая функциональность предоставлялась бы как серверное партнёрское API.
Для полностью рабочей интеграции на данном этапе осталось два шага:
* указать соответствие рецептов и программ для нового типа API;
* предоставить список партнерских кофе-машин.
```
api.setRecipePrograms(apiType, [{
"recipe_id",
"programRunParameters": {
/* какое-то описание
параметров запуска
программы */
}
}]);
/* Во избежание коллизий сразу
заводим составные идентификаторы,
поэтому функция должна
их в каком-то виде возвращать */
let coffeeMachinesList =
api.coffeeMachines.putList({
namespace: partnerId,
coffeeMachines: [{
internalId,
apiType
}, …]
});
```
Теперь вся конструкция должна работать. Займёмся теперь, однако, вот каким упражнением:
1. Перечислим все неявные предположения, которые мы допустили.
2. Перечислим все неявные механизмы связывания, которые необходимы для функционирования платформы.
Может показаться, что в нашем API нет ни того, ни другого — но это неправда. Список таких неявностей довольно велик.
1. Cтатической информации, которую мы передаём внутри `supportedRecipes.program`, достаточно для запуска программы на исполнение, и она не зависит от конкретной кофе-машины.
2. Каждая кофе-машина поддерживает все возможные параметры рецепта (например, допустимый объём напитка).
3. Кофе-машины партнёра не требуется как-то специально выделять в списке результатов поиска, они имеют точно такую же карточку результата, как и предложения других партнеров, и ранжируются на общих основаниях.
4. Обработчик `postOrder` производит все необходимые проверки (например, действительно ли на данной кофе-машине можно сварить кофе по указанному рецепту), в том числе определяет тип API по офферу, и инициирует запуск нужной программы.
5. Цена напитка не зависит ни от партнёра, ни от типа кофе-машины.
Все эти пункты мы выписали с одной целью: нам нужно понять, каким конкретно образом мы будем переводить неявные договорённости в явные, если нам это потребуется. Например, если разные кофе-машины предоставляют разный объём функциональности — допустим, в каких-то кофейнях не посыпают корицей — что должно измениться в нашем API?
Самое простое решение, шаг 1: при регистрации кофе машины указать, какие опции она поддерживает.
Следующий вопрос: а *что, если* корицей вообще-то посыпают, но прямо сейчас она закончилась? Исходя из изложенного в предыдущей главе, нам тогда каким-то образом необходимо организовать поток обновлений состояния кофе-машин — это шаг 2.
А *что, если* корица закончилась в промежутке времени между получением заказа и его исполнением? Нужна тогда обратная связь, сообщение от уровня исполнения о невозможности исполнить заказ — это шаг 3.
На первый взгляд, на каждом шаге мы гадаем на кофейной гуще. Если нужно будет реализовать вот это, то мы поступим вот так. Но, если присмотреться внимательно, то закономерность можно найти: мы каждый раз берём какой-то частный случай и заменяем его более общим.
На шаге 2 нам нужно имплементировать две операции: (а) получение текущего списка поддерживаемых опций, (б) его динамическое обновление по наступлению какого-то события. Но ведь операции (а) нам как раз было бы достаточно, чтобы реализовать функциональность шага 1, т.е. предоставить информацию о поддерживаемых опциях статически.
На шаге 3 нам нужно на уровне запуска программ имплементировать поддержку определения количества доступной корицы и ввести какой-то сигнал о его изменении. Как только мы это сделаем — мы немедленно сможем реализовать операции (а) и (б), необходимые нам на шаге 2.
Если бы мы начинали сразу с шага 3, то никакой проблемы предоставить функциональность в объёме шагов 1 и 2 у нас бы не возникло. Но вот обратное неверно: мы можем так реализовать предыдущий шаг, что последующий шаг потребует введения излишних концепций или просто окажется невозможен без слома обратной совместимости. Например, если на шаге 1 мы привяжем определение доступности опции к офферу:
```
// возвращает список доступных опций
selectedResult.offer.getAvailableOptions()
```
Такое связывание прекрасно работает, пока мы оперируем неизменяемым фактом наличия или отсутствия поддержки опции. Но на шаге 3 такой интерфейс начинает вызывать большие вопросы: должен ли этот метод продолжать работать, если по офферу был сделан заказ? Если да, то, выходит, оффер должен уметь обратиться к рантайму кофе-машины, для которой был создан — что абсолютно точно вне его области ответственности. Если нет — тогда у нас получится два разных метода `getAvailableOptions` у двух разных объектов (один из них оффер, а второй, по-видимому, придётся привязать к сущности «заказ»), один имеет смысл до заказа, а другой — после (и тут ещё надо каким-то образом определить, что считается моментом заказа); и нам придётся ещё как-то объяснять, когда пользоваться первым, а когда вторым, и почему они могут вернуть разный результат.
Для того, чтобы избежать подобного рода проблем, нужно научиться «думать с конца»: предоставляя любой интерфейс нужно думать о нём как о частной реализации какой-то более общей логики, как о некотором шорткате или хэлпере для облегчения жизни разработчика, чтобы ему не приходилось зарываться в более слои документации. В частности, нужно чётко понимать, откуда финально берётся та информация, которую мы предоставляем через «шорткат»: указанной ошибки в связывании опций с оффером можно легко избежать, если вспомнить о том, что информация о наличии корицы приходит откуда-то из реального мира, или от датчика, или от баристы, никак не из нашей виртуальной системы офферов.
Вернёмся теперь к списку неявностей, который мы сформулировали в начале главы, и попробуем применить сформулированный выше принцип к каждому из пунктов.
1. Cтатической информации, которую мы передаём внутри `supportedRecipes.program`, достаточно для запуска программы на исполнение, и она не зависит от конкретной кофе-машины.
Статическая передача параметров в program — это частный случай динамической. Мы можем дать возможность сформировать параметры запуска программы динамически, а передачу `program` в `setRecipePrograms` объявить просто шорткатом для тех ситуаций, когда дополнительно формировать параметры не требуется. Например, так:
```
api.setRecipePrograms(apiType, [{
"recipe_id",
/* Можно задать одно из двух,
либо поле `program`
"programRunParameters": { … }
либо функцию формирования
параметров запуска */
"programRunParametersGenerator":
function (order) {
return {
// Это статический параметр
"program_id": 123,
// А это динамический
"volume": order.volume
};
}
}]);
```
**NB**: альтернативно можно развивать декларативный формат описания параметров, разрешив в нём подстановки и вычисления. Это упражнение мы оставим читателю.
2. Каждая кофе-машина поддерживает все возможные параметры рецепта (например, допустимый объём напитка).
Поддержка всех возможных параметров - это частный случай. Мы предоставили шорткат для того, чтобы
3. Кофе-машины партнёра не требуется как-то специально выделять в списке результатов поиска, они имеют точно такую же карточку результата, как и предложения других партнеров, и ранжируются на общих основаниях;
4. Обработчик `postOrder` производит все необходимые проверки (например, действительно ли на данной кофе-машине можно сварить кофе по указанному рецепту), в том числе определяет тип API по офферу, и инициирует запуск нужной программы;
5. Цена напитка не зависит ни от партнёра, ни от типа кофе-машины.

View File

@ -0,0 +1,15 @@
### Интерфейсы как универсальный паттерн
Как мы указали в предыдущей главе, основные причины внесения изменений в API — развитие самого API (добавление новой функциональности) и эволюция платформ (клиентской, серверной и предметной) — следует предусматривать ещё на этапе проектирования. Может показаться, что совет этот полезен примерно так же, как и сократовское определение человека — очень конкретен, и столь же бесполезен — но это не так. Методология, позволяющая получить устойчивое к изменениям API, существует и вполне конкретна: это применение концепции «интерфейса» ко всем уровням абстракции.
На практике это означает следующая: нам необходимо рассмотреть каждую сущность нашего API и выделить её абстрактную модель, т.е. разделить все поля и методы сущности на две группы: те, которые абсолютно необходимы для корректного цикла работы API, и те, которые мы можем назвать «деталями имплементации». Первые образуют *интерфейс* сущности: если заменить одну конкретную реализацию этого интерфейса на другую, API будет продолжать работать.
**NB**: мы понимаем, что вносим некоторую путаницу, поскольку термин «интерфейс» также используется для обозначения совокупности свойств и методов сущности, да и вообще отвечает за букву «I» в самой аббревиатуре «API»; однако использование других терминов внесёт ещё больше путаницы. Мы могли бы оперировать выражениями «абстрактные типы данных» и «контрактное программирование», но это методологически неверно: разработка API в принципе представляет собой контрактное программирование, при этом большинство клиент-серверных архитектур подразумевают независимость имплементации клиента и сервера, так что никаких «неабстрактных» типов данных в них не существует. Термины типа «виртуальный класс» и «виртуальное наследование» неприменимы по той же причине. Мы могли бы использовать «фасад», но под «фасадом» обычно понимают всё-таки конкретную имплементацию, а не абстракцию. Ближе всего по смыслу подходят «концепты» в том смысле, который вкладывается в них в STL[[ref B. Stroustrup, A. Sutton. A Concept Design for the STL, p. 38]](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2012/n3351.pdf), но термин «интерфейс» нам кажется более понятным.
Мы будем использовать термин «интерфейс» как обобщение понятия «абстрактный тип данных» и «контракт». «Интерфейс» — это некоторое абстрактное подмножество абстрактного типа данных, «метаконтракт». Интерфейсы мы будем обозначать с помощью префикса `I`, например: `Recipe` — это модель данных «рецепт», а `IRecipe` — это интерфейс рецепта: «минимальная» модель данных и операций над ними, которая достаточна для корректной работы API. Объект `Recipe` таким образом имплементирует интерфейс `IRecipe`.
Попробуем применить этот (дважды) абстрактный концепт к нашему кофейному API. Представьте, что на этапе разработки архитектуры бизнес выдвинул следующее требование: мы не только предоставляем доступ к оборудованию партнеров, но и предлагаем партнерам наше ПО (т.е. в данном случае API), чтобы они могли строить поверх него свои собственные приложения. Иными словами, подойдём к каждой концепции нашего API с вопросом «что, если?…»
**NB**: в рассматриваемых нами примерах мы будем выстраивать интерфейсы так, чтобы связывание разных сущностей происходило динамически в реальном времени; в реальном мире такие интеграции будут делаться на стороне сервера путём написания ad hoc кода и формирования конкретных договорённостей с конкретным клиентом, однако мы для целей обучения специально будем идти более сложным и абстрактным путём. Динамическое связывание в реалтайме применимо скорее к сложным программным конструктам типа API операционных систем или встраиваемых библиотек; приводить обучающие примеры на основе систем такой сложности было бы затруднительно.
**Что произойдёт, если…** потребуется предоставить партнёру возможность готовить напитки по своему собственному рецепту?

View File

@ -6,7 +6,7 @@
Начнём с самого начала. В 2000 году один из авторов спецификаций HTTP и URI Рой Филдинг защитил докторскую диссертацию на тему «Архитектурные стили и дизайн архитектуры сетевого программного обеспечения», пятая глава которой была озаглавлена как «Representational State Transfer (REST)». Диссертация доступна [по ссылке](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm).
Как нетрудно убедиться, прочитав эту главу, она представляет собой довольно абстрактный обзор распределённой сетевой архитектуры, вообще не привязанный ни к HTTP, ни к URL. Более того, она вовсе не посвящена правилам дизайна API; в этой главе Филдинг методично перечисляет ограничения, с которыми приходится сталкиваться разработчику распределённого сетевого программного обеспечения. Вот они:
Как нетрудно убедиться, прочитав эту главу, она представляет собой довольно абстрактный обзор распределённой сетевой архитектуры, вообще не привязанной ни к HTTP, ни к URL. Более того, она вовсе не посвящена правилам дизайна API; в этой главе Филдинг методично перечисляет ограничения, с которыми приходится сталкиваться разработчику распределённого сетевого программного обеспечения. Вот они:
* клиент и сервер не знают внутреннего устройства друг друга (клиент-серверная архитектура);
* сессия хранится на клиенте (stateless-дизайн);
@ -26,7 +26,7 @@
* поскольку клиент представляет собой вычислительную машину, он всегда хранит хоть какое-то состояние и кэширует хоть какие-то данные;
* наконец, code-on-demand вообще лукавое требование, поскольку всегда можно объявить данные, полученные по сети, «инструкциями» на некотором формальном языке, а код клиента — их интерпретатором.
Да, конечно, вышеприведённое рассуждение является софизмом, доведением до абсурда. Самое забавное в этом упражнении состоит в том, что мы можем довести его до абсурда и в другую сторону, объявив ограничения REST неисполнимыми. Например, очевидно, что требование code-on-demand противоречит требованию независимости клиента и сервера — клиент должен уметь интерпретировать код с сервера, написанный на вполне конкретном языке. Что касается правила на букву S («stateless»), то систем, в которых сервер *вообще не хранит никакого контекста клиента* в мире вообще практически нет, поскольку ничего полезного для клиента в такой системе сделать нельзя. (Что, кстати, постулируется в соответствующем разделе прямым текстом: «клиент не может получать никаких преимуществ от того, что на сервере хранится какой-то контекст».)
Да, конечно, вышеприведённое рассуждение является софизмом, доведением до абсурда. Самое забавное в этом упражнении состоит в том, что мы можем довести его до абсурда и в другую сторону, объявив ограничения REST неисполнимыми. Например, очевидно, что требование code-on-demand противоречит требованию независимости клиента и сервера — клиент должен уметь интерпретировать код с сервера, написанный на вполне конкретном языке. Что касается правила на букву S («stateless»), то систем, в которых сервер *вообще не хранит никакого контекста клиента* в мире вообще практически нет, поскольку ничего полезного для клиента в такой системе сделать нельзя. (Что, кстати, постулируется в соответствующем разделе прямым текстом: «коммуникация … не может получать никаких преимуществ от того, что на сервере хранится какой-то контекст».)
Наконец, сам Филдинг внёс дополнительную энтропию в вопрос, выпустив в 2008 году [разъяснение](https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven), что же он имел в виду. В частности, в этой статье утверждается, что:
* REST API не должно зависеть от протокола;
@ -54,7 +54,7 @@
* URL идентифицирует некоего конечного получателя запроса, какую-то единицу адресации;
* по статусу ответа можно понять, выполнена ли операция успешно или произошла ошибка; если имеет место быть ошибка — то можно понять, кто виноват (клиент или сервер), и даже в каких-то случаях понять, что же конкретно произошло;
* по методу запроса можно понять, является ли операция модифицирующей; если операция модифицирующая, то можно выяснить, является ли она идемпотентной;
* по методу запроса и заголовкам ответа можно понять, кэшируем ли результат операции, и, если да, то какова политика кэширования.
* по методу запроса и статусу и заголовкам ответа можно понять, кэшируем ли результат операции, и, если да, то какова политика кэширования.
Немаловажно здесь то, что все эти сведения можно получить, не вычитывая тело ответа целиком — достаточно прочитать служебную информацию из заголовков.
@ -75,7 +75,7 @@ GET /delete-me
Cookie: session_id=<идентификатор сессии>
```
Почему такая система неудачна с точки зрения сервера?
Почему такая система неудачна с точки зрения промежуточного агента?
1. Сервер не может кэшировать ответы; все /me для него одинаковые, поскольку он не умеет получать уникальный идентификатор пользователя из куки; в том числе промежуточные прокси не могут и заранее наполнить кэш, так как не знают идентификаторов сессий.
2. На сервере сложно организовать шардирование, т.е. хранение информации о разных пользователях в разных сегментах сети; для этого опять же потребуется уметь обменивать сессию на идентификатор пользователя.
@ -107,7 +107,7 @@ Authorization: Bearer <token>
Теперь URL запроса в точности идентифицирует ресурс, к которому обращаются, поэтому можно организовать кэш и даже заранее наполнить его; можно организовать маршрутизацию запроса в зависимости от идентификатора пользователя, т.е. появляется возможность шардирования. Префетчер мессенджера не пройдёт по DELETE-ссылке; а если он это и сделает, то без заголовка Authorization операция выполнена не будет.
Наконец, неочевидная польза такого решения заключается в следующем: промежуточный сервер-гейтвей, обрабатывающий запрос, может проверить заголовок Authorization и переслать запрос далее без него (желательно, конечно, по безопасному соединению или хотя бы подписав запрос). Тогда во внутренней среде можно будет свободно организовывать кэширование данных в любых промежуточных узлах. Более того, *агент может легко модифицировать операцию*: например, для авторизованных пользователей пересылать запрос дальше как есть, а для неавторизованным показывать публичный профиль, пересылая запрос на специальный URL, ну, скажем, `GET /user/{user_id}/public-profile` — для этого достаточно всего лишь дописать `/public-profile` к URL, не изменяя все остальные части запроса. Для современных микросервисных архитектур *возможность корректно и дёшево модифицировать запрос при маршрутизации является самым ценным преимуществом в концепции REST*.
Наконец, неочевидная польза такого решения заключается в следующем: промежуточный сервер-гейтвей, обрабатывающий запрос, может проверить заголовок Authorization и переслать запрос далее без него (желательно, конечно, по безопасному соединению или хотя бы подписав запрос). И, в отличие от схемы с идентификатором сессии, мы всё ёщё можем свободно организовывать кэширование данных в любых промежуточных узлах. Более того, *агент может легко модифицировать операцию*: например, для авторизованных пользователей пересылать запрос дальше как есть, а неавторизованным показывать публичный профиль, пересылая запрос на специальный URL, ну, скажем, `GET /user/{user_id}/public-profile` — для этого достаточно всего лишь дописать `/public-profile` к URL, не изменяя все остальные части запроса. Для современных микросервисных архитектур *возможность корректно и дёшево модифицировать запрос при маршрутизации является самым ценным преимуществом в концепции REST*.
Шагнём ещё чуть вперёд. Предположим, что гейтвей спроксировал запрос `DELETE /user/{user_id}` в нужный микросервис и не дождался ответа. Какие дальше возможны варианты?
@ -121,7 +121,7 @@ Authorization: Bearer <token>
Важно, что для разработчика клиента при правильно работающих фреймворках (клиентском и серверном) пропадают ситуации неопределённости: ему не надо предусматривать какую-то логику «восстановления», когда запрос вроде бы не прошёл, но его ещё можно попытаться исправить. Клиент-серверное взаимодействие становится бинарным — или успех, или ошибка, а все пограничные градации обрабатываются другим кодом.
В итоге, более сложная архитектура оказался разделена по уровням ответственности, и каждый разработчик занимается своим делом. Разработчик гейтвея гарантирует наиболее оптимальный роутинг внутри дата-центра, разработчик фреймворка предоставляет функциональность по реализации политики таймаутов и перезапросов, а разработчик клиента пишет *бизнес-логику* обработки ошибок, а не код восстановления из низкоуровневых состояний неопределённости.
В итоге, более сложная архитектура оказалась разделена по уровням ответственности, и каждый разработчик занимается своим делом. Разработчик гейтвея гарантирует наиболее оптимальный роутинг внутри дата-центра, разработчик фреймворка предоставляет функциональность по реализации политики таймаутов и перезапросов, а разработчик клиента пишет *бизнес-логику* обработки ошибок, а не код восстановления из низкоуровневых состояний неопределённости.
**Разумеется** все подобные оптимизации можно выполнить и без опоры на стандартную номенклатуру методов / статусов / заголовков HTTP, или даже вовсе поверх другого протокола. Достаточно разработать одинаковый формат данных, содержащий нужную мета-информацию, и научить промежуточные агенты и фреймворки его читать. В общем-то, именно это Филдинг и утверждает в своей диссертации. Но, конечно, очень желательно, чтобы этот код уже был кем-то написан за нас.
@ -164,4 +164,4 @@ Authorization: Bearer <token>
Недостатком этой идеи является тот факт, что клиент будет расширять сам себя без привлечения разработчика, который прочитает документацию API и напишет код работы с ним. Возможно, в идеальном мире так работает; в реальном — нет. Любое большое API неидеально, в нём всегда есть концепции, для понимания которых (пока что) требуется живой человек. А поскольку, повторимся, API работает мультипликатором и ваших возможностей, и ваших ошибок, автоматизированное метапрограммирование поверх API чревато очень-очень дорогими ошибками.
Пока сильный ИИ не разработан, мы всё-таки настаиваем на том, что код работы с API должен писать живой человек, который опирается на подробную документацию, а не догадки о смысле гиперссылок в ответе сервера.
Пока сильный ИИ не разработан, мы всё-таки настаиваем на том, что код работы с API должен писать живой человек, который опирается на подробную документацию, а не догадки о смысле гиперссылок в ответе сервера.