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

Drafts transalted + typos and style fixes

This commit is contained in:
Sergey Konstantinov 2021-04-27 12:30:21 +03:00
parent ffd1fbe147
commit 4e69d1ebe0
3 changed files with 522 additions and 57 deletions

View File

@ -0,0 +1,225 @@
### Strong coupling and related problems
In previous chapters we tried to outline theoretical rules and principles, and illustrate them with practical examples. However, understanding principles of change-proof API design requires practice like nothing before. An ability to anticipate future growth problems comes from a handful of grave mistakes once made. One cannot foresee everything, but can elaborate a certain technical intuition.
So in following chapters we will try to probe our study API from the previous Section, testing its robustness from every possible viewpoint, thus carrying out some ‘variational analysis’ of our interfaces. More specifically, we will apply a ‘What If?’ question to every entity, as if we are to provide a possibility to write an alternate implementation of every piece of logic.
One important remark we're stressing here is that we're talking about alternate realizations of *business* logic, not about *entity implementation* variants. APIs are being changed to make something *usable* in the first place — something missing in the original design. Just re-implementing some interfaces makes no sense to your customers.
This observation helps narrowing the scope, sparing time on varying interfaces blindly. (Actually, there is always an infinite number of such variations, and it would be a Sisyphean labor to examine all of them.) We need to understand *why* such changes might be desirable, and then we may learn *how* they are to be made.
A second important remark is that many decisions allowing for such a variability are already incorporated in our API design. Some of them, like determining readiness, we explained in previous chapters in detail; some of them are provided with no comments, so it's now time to explain the logic behind these decisions.
**NB**. In our examples the interfaces will be constructed in a manner allowing for dynamic real-time linking of different entities. In practice such integrations usually imply writing an ad hoc code at server side with accordance to specific agreements made with specific partner. But for educational purposes we will pursue more abstract and complicated ways. Dynamic real-time linking is more typical to complex program constructions like operating system APIs or embeddable libraries; giving educational examples based on such sophisticated systems would be too inconvenient.
For the beginning, let us imagine that we decided to give to our partners an opportunity to serve their own unique coffee recipes. What would be the motivation to provide this functionality?
* maybe a partner's coffee houses chain seeks to offer their branded beverages to clients;
* maybe a partner is building their own application with their branded assortment upon our platform.
Either way, we need to start with a recipe. What data do we need to allow adding new recipes to the system? Let us remember what contexts the ‘Recipe’ entity is linking: this entity is needed to couple a user's choice with beverage preparation rules. At first glance it looks like we need to describe a ‘Recipe’ in this exact manner:
```
// Adds new recipe
POST /v1/recipes
{
"id",
"product_properties": {
"name",
"description",
"default_value"
// Other properties, describing
// a beverage to end-user
},
"execution_properties": {
// Program's identifier
"program_id",
// Program's execution parameters
"parameters"
}
}
```
At first glance, again, it looks like a reasonably simple interface, explicitly decomposed into abstraction levels. But let us imagine the future — what would happen with this interface when our system evolves further?
The first problem is obvious to those who read [chapter 11](#chapter-11-paragraph-20) thoroughly: product properties must be localized. That will lead us to the first change:
```
"product_properties": {
// "l10n" is a standard abbreviation
// for "localization"
"l10n" : [{
"language_code": "en",
"country_code": "US",
"name",
"description"
}, /* other languages and countries */ … ]
]
```
And here the big question arises: what should we do with the `default_volume` field? From one side, that's an objective quality measured in standardized units, and it's being passed to the program execution engine. From other side, in countries like the United States we had to specify beverage volume not like ‘300 ml’, but ‘10 fl oz’. We may propose two solutions:
* 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 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 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’.
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.
Problems we're facing are the problems of *strong coupling*. Each time we offer an interface like described above, we're in fact prescript implementing one entity (recipe) basing on implementations of other entities (UI layout, localization rules). This approach disrespects the very basic principle of designing APIs ‘top to bottom’, because **low-level entities must not define high-level ones**. To make things worse, let us mention that revers principle is actually correct either: high-level entities must not define low-level ones, since that simply isn't their responsibility.
#### The rule of contexts
The exit from this logical labyrinth is: high-level entities must *define a context*, which other objects are to interpret. To properly design adding new recipe interface we shouldn't try find better data format; we need to understand what contexts, both explicit and implicit, exist in our subject area.
We have already found a localization context. There is some set of languages and regions we support in our API, and there are requirements — what exactly the partner must provide to make our API work in a new region. More specifically, there must be some formatting function to represent beverage volume somewhere in our API code:
```
l10n.volume.format(value, language_code, country_code)
// l10n.formatVolume('300ml', 'en', 'UK') → '300 ml'
// l10n.formatVolume('300ml', 'en', 'US') → '10 fl oz'
```
To make our API work correctly with a new language or region, the partner must either define this function or point which pre-existing implementation to use. Like this:
```
// Add a general formatting rule
// for Russian langauge
PUT /formatters/volume/ru
{
"template": "{volume} мл"
}
// Add a specific formatting rule
// for Russian language in the ‘US’ region
PUT /formatters/volume/ru/US
{
// in US we need to recalculate
// the number, then add a postfix
"value_preparation": {
"action": "divide",
"divisor": 30
},
"template": "{volume} ун."
}
```
**NB**: we are more than aware that such simple format isn't enough to cover real-world localization use-cases, and one either rely on existing libraries, or design a sophisticated format for such templating, which takes into account such things as grammatical cases and rules of rounding numbers up, or allow defining formatting rules in a form of function code. The example above is simplified in purely educational purposes.
Let us deal with `name` and `description` problem then. To lower the coupling levels there we need to formalize (probably just to ourselves) a ‘layout’ concept. We are asking for providing `name` and `description` not because we just need them, but for representing them in some specific user interface. This specific UI might have an identifier or a semantic name.
```
GET /v1/layouts/{layout_id}
{
"id",
// We would probably have lots of layouts,
// so it's better to enable extensibility
// from the beginning
"kind": "recipe_search",
// Describe every property we require
// to have this layout rendered properly
"properties": [{
// Since we learned that `name`
// is actually a title for a search
// result snippet, it's much more
// convenient to have explicit
// `search_title` instead
"field": "search_title",
"view": {
// Machine-readable description
// of how this field is rendered
"min_length": "5em",
"max_length": "20em",
"overflow": "ellipsis"
}
}, …],
// Which fields are mandatory
"required": [
"search_title",
"search_description"
]
}
```
So the partner may decide, which option better suits them. They can provide mandatory fields for the standard layout:
```
PUT /v1/recipes/{id}/properties/l10n/{lang}
{
"search_title", "search_description"
}
```
or create a layout of their own and provide data fields it requires:
```
POST /v1/layouts
{
"properties"
}
{ "id", "properties" }
```
or they may ultimately design their own UI and don't use this functionality at all, defining neither layouts nor data fields.
The same technique, i.e. defining a specific entity responsible for matching a recipe and its traits for the underlying systems, might be used to detach `execution_properties` from the interface, thus allowing the partner to control how the recipe is being coupled with execution programs. Then our interface would ultimately look like:
```
POST /v1/recipes
{ "id" }
{ "id" }
```
This conclusion might look highly counter-intuitive, but lacking any fields in ‘Recipe’ simply tells as that this entity possesses no specific semantics of its own, and is simply an identifier of a context; a method to point out where to look for the data needed by other entities. In the real world we should implement a builder endpoint capable of creating all the related contexts with a single request:
```
POST /v1/recipe-builder
{
"id",
// Recipe's fixed properties
"product_properties": {
"default_volume",
"l10n"
},
// Execution data
"execution_properties"
// Create all the desirable layouts
"layouts": [{
"id", "kind", "properties"
}],
// Add all the formatters needed
"formatters": {
"volume": [
{ "language_code", "template" },
{ "language_code", "country_code", "template" }
]
},
// Other actions needed to be done
// to register new recipe in the system
}
```
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).
```
POST /v1/recipes/custom
{
// First part of the composite
// identifier, for example,
// the partner's own id
"namespace": "my-coffee-company",
// Second part of the identifier
"id_component": "lungo-customato"
}
{
"id": "my-coffee-company:lungo-customato"
}
```
Also note that this format allows us to maintain an important extensibility point: different partners might have totally isolated namespaces, or conversely share them. Furthermore, we might introduce special namespaces like ‘common’ to allow publish new recipes for everyone (and that, by the way, would allow us to organize our own backoffice to edit recipes).

View File

@ -0,0 +1,199 @@
### Weak coupling
In previous chapter we've demonstrated how breaking strong coupling of components leads to decomposing entities and collapsing their public interfaces down to a reasonable minimum. A mindful reader might have noted that this technique was already used in our API study much earlier in [Chapter 9](#chapter-9) with regards to ‘program’ and ‘program run’ entities. Indeed, we might do it without `program-matcher` endpoint and make it this way:
```
GET /v1/recipes/{id}/run-data/{api_type}
{ /* A description, how to
execute a specific recipe
using a specified API type */ }
```
Then developers would have to make this trick to get coffee prepared:
* learn the API type of the specific coffee-machine;
* get the execution description, as stated above;
* depending on the API type, run some specific commands.
Obviously, such interface is absolutely unacceptable, simply because in the majority of use cases developers don't care at all, which API type the specific coffee machine runs. To avoid the necessity of introducing such bad interfaces we created new ‘program’ entity, which constitutes merely a context identifier, just like a ‘recipe’ entity does. A `program_run_id` entity is also organized in this manner, it also possesses no specific properties, being *just* a program run identifier.
But let us ask ourselves a more interesting question. Our API allows for running programs on coffee machines with a known API. Let us imagine then that we have a partner with their own coffee houses with a plethora of coffee machines running different APIs. How would we allow the partner to get an access to the `programs` API, so they could plug their coffee machines to our system?
Out of general considerations we may assume that every such API would be capable of executing three functions: run a program with specified parameters, return current execution status, and finish (cancel) the order. An obvious way to provide the common interface is to require partner have this three functions support a remote call, for example, like this:
```
// This is an endpoint for partners
// to register their coffee machines
// in the system
PUT /partners/{id}/coffee-machines
{
"coffee-machines": [{
"id",
"program_api": {
"program_run_endpoint": {
/* Some description of
the remote function call */
"type": "rpc",
"endpoint": <URL>,
"format"
},
"program_state_endpoint",
"program_stop_endpoint"
}
}, …]
}
```
**NB**: doing so we're transferring the complexity of developing the API onto a plane of developing appropriate data formats, e.g. how exactly would we send order parameters to the `program_run_endpoint`, and what format the `program_state_endpoint` shall return, etc., but in this chapter we're focusing on another questions.
Though this API looks like absolutely universal, it's quite easy to demonstrate how once simple and clear API end up being confusing and convoluted. This design presents two main problems:
1. It describes nicely the integrations we've already implemented (it costs almost nothing to support the API types we already know), but brings no flexibility in the approach. In fact, we simply described what we'd already learned, not even trying to look at a larger picture.
2. This design is ultimately based on a single principle: every order preparation might be codified with these three imperative commands.
We may easily disprove No. 2 principle, and that will uncover the implications of No. 1. For the beginning, let us imagine that on a course of further service growth we decided to allow end users to change the order after the execution started. For example, ask for a cinnamon sprinkling or for a contactless takeout. That would lead us to adding a new endpoint, let's say, `program_modify_endpoint`, and new difficulties in data format development (we need to understand in the real time, could we actually sprinkle cinnamon on this specific cup of coffee). What *is* important is that both (endpoint *and* new data fields) would be optional because of backwards compatibility requirement.
Now let's try to imagine a real world example which doesn't fit into our ‘three imperatives to rule them all’ picture. That's quite easy either: what if we're plugging via our API not a coffee house, but a vending machine? From one side, it means that `modify` endpoint and all related stuff are simply meaningless: vending machine couldn't sprinkle cinnamon over a coffee cup, and contactless takeout requirement means nothing to it. From the other side, the machine, unlike people-operated café, requires *takeout approval*: an end user places an order being somewhere in some other place, then walks to the machine and pushes ‘get the order’ button in the app. We might, of course, require the user to stand in front of the machine when placing an order, but that would contradict the entire product concept of users selecting and ordering beverages and then walking to the takeout point.
Programmable takeout approval requires one more endpoint, let's say, `program_takeout_endpoint`. And so we've lost our way in a forest of three endpoints:
* to have vending machines integrated a partner must implement `program_takeout_endpoint`, but doesn't actually need `program_modify_endpoint`;
* to have regular coffee houses integrated a partner must implement `program_modify_endpoint`, but doesn't actually need `program_takeout_endpoint`.
Furthermore, we have to describe both endpoints in the docs. It's quite natural that `takeout` endpoint is very specific; unlike cinnamon sprinkling, which we hid under pretty general `modify` endpoint, operations like takeout approval will require introducing a new unique method every time. After several iterations we would have a scrapyard, full of similarly looking methods, mostly optional — but you would need to study the docs nonetheless to understand, which methods are needed in your specific situation, and which are not.
We actually don't know, whether in the real world of coffee machine APIs this problem will really occur or not. But we can say with all confidence regarding ‘bare metal’ integrations that the processes we described *always* happen. The underlying technology shifts; an API which seemed clear and straightforward, becomes a trash bin full of legacy methods, half of which borrows no practical sense under any specific set of conditions. If we add a technical progress to the situation, i.e. imagine that after a while all coffee houses become automated, we will finally end up with the situation when half of methods *aren't actually needed at all*, like requesting contactless takeout method.
It is also worth mentioning that we unwittingly violated the abstraction levels isolation principle. At a vending machine API level there is no such term as ‘contactless takeout’, that's actually a product concept.
So, how would we tackle this issue? Using one of two possible approaches: either thoroughly study all the subject area and its upcoming improvements for at least several years ahead, or abandon strong coupling in favor of weak one. How would the *ideal* solution look from both sides? Something like this:
* 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)
In our case we need to implement the following mechanisms:
* running a program creates a corresponding context comprising all the essential parameters;
* there is method to exchange the information regarding data changes: the execution level may read the context, learn about all the changes and report back the changes of its own.
There are different techniques to organize this data flow, but basically we always have two context descriptions and two-way event stream in-between. In case of developing an SDK we might express this idea like this:
```
/* Partner's implementation of program
run procedure for a custom API type */
registerProgramRunHandler(apiType, (program) => {
// Initiatiang an execution
// on partner's side
let execution = initExecution(…);
// Listen to parent context's changes
program.context.on('takeout_requested', () => {
// If takeout is requested, initiate
// corresponding procedures
execution.prepareTakeout(() => {
// When the cup is ready for takeout,
// emit corresponding event
// for higher-level entity to catch it
execution.context.emit('takeout_ready');
});
});
return execution.context;
});
```
**NB**: In case of HTTP API corresponding example would look rather bulky as it involves implementing several additional endpoints for message queues like `GET /program-run/events` and `GET /partner/{id}/execution/events`. We would leave this exercise to the reader. Also worth mentioning that in real-world systems such event queues are usually organized using external event message systems like Apache Kafka or Amazon SQS.
At this point a mindful reader might begin protesting, because if we take a look at the nomenclature of the entities emerged, we will find that nothing changed in the problem statement, it actually became even more complicated:
* instead of calling the `takeout` method we're now generating a pair of `takeout_requested`/`takeout_ready` events;
* instead of long list of methods which shall be implemented to integrate partner's API, we now have a long list of `context` objects fields and events they generate;
* and with regards to technological progress we changed nothing: now we have deprecated fields and events instead of deprecated methods.
And this remark is absolutely correct. Changing API formats doesn't solve any problems related to the evolution of functionality and underlying technology. Changing API formats solves another problem: how to make the code written by developers stay clean and maintainable. Why would strong-coupled integration (i.e. coupling entities via methods) render the code unreadable? Because both side *are obliged* to implement the functionality which is meaningless in their corresponding subject areas. And these implementations would actually comprise a handful of methods to say that this functionality is either unsupported, or supported always and unconditionally.
The difference between strong coupling and weak coupling is that field-event mechanism *isn't obligatory to both sides*. Let us remember what we sought to achieve:
* higher-level context doesn't actually know how low-level API works — and it really doesn't; it describes the changes which occurs within the context itself, and reacts only to those events which mean something to it;
* low-level context doesn't know anything about alternative implementations — and it really doesn't; it handles only those events which mean something at its level, and emits only those events which could actually happen under its specific conditions.
It's ultimately possible that both sides would know nothing about each other and wouldn't interact at all. This might actually happen at some point in the future with the evolution of underlying technologies.
Worth mentioning that a number of entities (fields, events), though effectively doubled compared to strong-coupled API design, raises qualitatively, not quantitatively. `program` context describes fields and events in its own terms (type of beverage, volume, cinnamon sprinkling), while `execution` context must reformulate those terms according to its own subject area (omitting redundant ones, by the way). It is also important that `execution` context might concretize these properties for underlying objects according to its own specifics, while `program` context must keep its properties general enough to be applicable to any possible underlying technology.
One more important feature of event-driven coupling is that it allows an entity to have several higher-level contexts. In typical subject areas such situation would look like an API design flaw, but in complex systems, with several system state-modifying agents present, such design patterns are not that rare. Specifically, you would likely face such situations while developing user-facing UI libraries. We will cover this issue in detail in the ‘SDK’ section of this book.
#### The Inversion of Responsibility
It becomes obvious from what said above that two-way weak coupling means significant increase of code complexity on both levels, which is often redundant. In many cases two-way event linking might be replaced with one-way linking without significant loss of design quality. That means allowing low-level entity to call higher-level methods directly instead of generating events. Let's alter our example:
```
/* Partner's implementation of program
run procedure for a custom API type */
registerProgramRunHandler(apiType, (program) => {
// Initiatiang an execution
// on partner's side
let execution = initExecution(…);
// Listen to parent context's changes
program.context.on('takeout_requested', () => {
// If takeout is requested, initiate
// corresponding procedures
execution.prepareTakeout(() => {
/* When the order is reasy for takeout,
signalize about that, but not
with event emitting */
// execution.context.emit('takeout_ready')
program.context.set('takeout_ready');
// Or even more rigidly
// program.setTakeoutReady();
});
});
/* Since we're modifying parent context
instead of emitting events, we don't
actually need to return anything */
// return execution.context;
});
}
```
Again, this solution might look counter-intuitive, since we efficiently returned to strong coupling via strictly defined methods. But there is an important difference: we're making all this stuff up because we expect alternative implementations of *lower* abstraction level. Situations when different realizations of *higher* abstraction levels emerge are, of course, possible, but quite rare. The tree of alternative implementations usually grows top to bottom.
Another reason to justify this solution is that major changes occurring at different abstraction levels have different weight:
* if the technical level is under change, that must not affect product qualities and the code written by partners;
* if the product is changing, i.e. we start selling flight tickets instead of preparing coffee, there is literally no sense to preserve backwards compatibility at technical abstraction levels. Ironically, we may actually make our program run API sell tickets instead of brewing coffee without breaking backwards compatibility, but the partners' code will still become obsolete.
As a conclusion, because of abovementioned reasons higher-level APIs are evolving more slowly and much more consistently than low-level APIs, which means that reverse strong coupling might often be acceptable or even desirable, at least from the price-quality ratio point of view.
**NB**: many contemporary frameworks explore a shared state approach, Redux being probably the most notable example. In Redux paradigm the code above would look like:
```
execution.prepareTakeout(() => {
// Instead of generating events
// or calling higher-level methods,
// an `execution` entity calls
// a global or quasi-global
// callback to change a global state
dispatch(takeoutReady());
});
```
Let us note that this approach *in general* doesn't contradict to loose coupling principle, but violates another one — of abstraction levels isolation, and therefore isn't suitable for writing branchy APIs with high hierarchy trees. In such system it's still possible to use global or quasi-global state manager, but you need to implement event or method call propagation through the hierarchy, i.e. ensure that low-level entity always interact with its closest higher-level neighbors only, delegating the responsibility of calling high-level or global methods to them.
```
execution.prepareTakeout(() => {
// Instead of initiating global actions
// an `execution` entity invokes
// its superior's dispatch functionality
program.context.dispatch(takeoutReady());
});
```
```
// program.context.dispatch implementation
ProgramContext.dispatch = (action) => {
// program.context calls its own
// superior or global object
// if there are no superiors
globalContext.dispatch(
// The action itself may and
// must be reformulated
// in appropriate terms
this.generateAction(action)
);
}
```

View File

@ -1,4 +1,4 @@
### Слабая связанность и инверсия ответственности
### Слабая связность
В предыдущей главе мы продемонстрировали, как разрыв сильной связанности приводит к декомпозиции сущностей и схлопыванию публичных интерфейсов до минимума. Внимательный читатель может подметить, что этот приём уже был продемонстрирован в нашем учебном API гораздо раньше [в главе 9](#chapter-9) на примере сущностей «программа» и «запуск программы». В самом деле, мы могли бы обойтись без программ и без эндпойнта `program-matcher` и пойти вот таким путём:
@ -29,21 +29,21 @@ GET /v1/recipes/{id}/run-data/{api_type}
// кофе-машин партнёра
PUT /partners/{id}/coffee-machines
{
"coffee-machines": [{
"id",
"program_api": {
"program_run_endpoint": {
/* Какое-то описание
удалённого вызова эндпойнта */
"type": "rpc",
"endpoint": <URL>,
"format"
},
"program_state_endpoint",
"program_stop_endpoint"
}
}, …]
"coffee-machines": [{
"id",
"program_api": {
"program_run_endpoint": {
/* Какое-то описание
удалённого вызова эндпойнта */
"type": "rpc",
"endpoint": <URL>,
"format"
},
"program_state_endpoint",
"program_stop_endpoint"
}
}, …]
}
```
@ -55,7 +55,7 @@ PUT /partners/{id}/coffee-machines
Пункт 2 очень легко опровергнуть, что автоматически вскроет проблемы пункта 1. Предположим для начала, что в ходе развития функциональности мы решили дать пользователю возможность изменять свой заказ уже после того, как он создан — ну, например, попросить посыпать кофе корицей или выдать заказ бесконтактно. Это автоматически влечёт за собой добавление нового эндпойнта, ну скажем, `program_modify_endpoint`, и новых сложностей в формате обмена данными (нам нужно уметь понимать в реальном времени, можно ли этот конкретный кофе посыпать корицей). Что важно, и то, и другое (и эндпойнт, и новые поля данных) из соображений обратной совместимости будут необязательными.
Теперь попытаемся придумать какой-нибудь пример реального мира, который не описывается нашими тремя императивами. Это довольно легко: допустим, мы подключим через наше API не кофейню, а вендинговый автомат. Это, с одной стороны, означает, что эндпойнт `modify` и вся его обвязка для этого типа API бесполезны — автомат не умеет посыпать кофе корицей, а требование бесконтактной выдачи попросту ничего не значит. С другой, автомат, в отличие от оперируемой людьми кофейни, требует программного способа *подтверждения выдачи* напитка: пользователь делает заказ, находясь где-то в другом месте, потом до ходит до автомата и нажимает в приложении кнопку «выдать заказ». Мы могли бы, конечно, потребовать, чтобы пользователь создавал заказ автомату, стоя прямо перед ним, но это, в свою очередь, противоречит нашей изначальной концепции, в которой пользователь выбирает и заказывает напиток, исходя из доступных опций, а потом идёт в указанную точку, чтобы его забрать.
Теперь попытаемся придумать какой-нибудь пример реального мира, который не описывается нашими тремя императивами. Это довольно легко: допустим, мы подключим через наше API не кофейню, а вендинговый автомат. Это, с одной стороны, означает, что эндпойнт `modify` и вся его обвязка для этого типа API бесполезны — автомат не умеет посыпать кофе корицей, а требование бесконтактной выдачи попросту ничего не значит. С другой, автомат, в отличие от оперируемой людьми кофейни, требует программного способа *подтверждения выдачи* напитка: пользователь делает заказ, находясь где-то в другом месте, потом доходит до автомата и нажимает в приложении кнопку «выдать заказ». Мы могли бы, конечно, потребовать, чтобы пользователь создавал заказ автомату, стоя прямо перед ним, но это, в свою очередь, противоречит нашей изначальной концепции, в которой пользователь выбирает и заказывает напиток, исходя из доступных опций, а потом идёт в указанную точку, чтобы его забрать.
Программная выдача напитка потребует добавления ещё одного эндпойнта, ну скажем, `program_takeout_endpoint`. И вот мы уже запутались в лесу из трёх эндпойнтов:
* для работы вендинговых автоматов нужно реализовать эндпойнт `program_takeout_endpoint`, но не нужно реализовывать `program_modify_endpoint`;
@ -63,7 +63,7 @@ PUT /partners/{id}/coffee-machines
При этом в документации интерфейса мы опишем и тот, и другой эндпойнт. Как несложно заметить, интерфейс `takeout` весьма специфичен. Если посыпку корицей мы как-то скрыли за общим `modify`, то на вот такие операции типа подтверждения выдачи нам каждый раз придётся заводить новый метод с уникальным названием. Несложно представить себе, как через несколько итераций интерфейс превратится в свалку из визуально похожих методов, притом формально необязательных — но для подключения своего API нужно будет прочитать документацию каждого и разобраться в том, нужен ли он в конкретной ситуации или нет.
Мы не знаем, правда ли в реальном мире API кофемашин возникнет проблема, подобная описанной. Но мы можем сказать со всей уверенностью, что *всегда*, когда речь идёт об интеграции «железного» уровня, происходят именно те процессы, которые мы описали: меняется нижележащая технология, и вроде бы понятное и ясное идиоматическое API превращается в свалку из легаси-методов, половина из которых не несёт в себе никакого практического смысла в рамках конкретной интеграции. Если мы добавим к проблеме ещё и технический прогресс — представим, например, что со временем все кофейни со временем станут автоматическими — то мы быстро придём к ситуации, когда половина методов *вообще не нужна*, как метод запроса бесконтактной выдачи напитка.
Мы не знаем, правда ли в реальном мире API кофемашин возникнет проблема, подобная описанной. Но мы можем сказать со всей уверенностью, что *всегда*, когда речь идёт об интеграции «железного» уровня, происходят именно те процессы, которые мы описали: меняется нижележащая технология, и вроде бы понятное и ясное API превращается в свалку из легаси-методов, половина из которых не несёт в себе никакого практического смысла в рамках конкретной интеграции. Если мы добавим к проблеме ещё и технический прогресс — представим, например, что со временем все кофейни со временем станут автоматическими — то мы быстро придём к ситуации, когда половина методов *вообще не нужна*, как метод запроса бесконтактной выдачи напитка.
Заметим также, что мы невольно начали нарушать принцип изоляции уровней абстракции. На уровне API вендингового автомата вообще не существует понятие «бесконтактная выдача», это по сути продуктовый термин.
@ -71,7 +71,7 @@ PUT /partners/{id}/coffee-machines
* вышестоящий API программ не знает, как устроен уровень исполнения его команд; он формулирует задание так, как понимает на своём уровне: сварить такой-то кофе такого-то объёма, с корицей, выдать такому-то пользователю;
* нижележащий API исполнения программ не заботится о том, какие ещё вокруг бывают API того же уровня; он трактует только ту часть задания, которая имеет для него смысл.
Если мы посмотрим на принципы, описанные в [разделе «Потоки данных»](#chapter-9), то обнаружим, что что-то такое мы уже формулировали: нам необходимо задать *информационный контекст* на каждом из уровней абстракции, и разработать механизм его трансляции.
Если мы посмотрим на принципы, описанные в предыдущей глава, то обнаружим, что этот принцип мы уже формулировали: нам необходимо задать *информационный контекст* на каждом из уровней абстракции, и разработать механизм его трансляции. Более того, в общем виде он был сформулирован ещё в [разделе «Потоки данных»](#chapter-9).
В нашем конкретном примере нам нужно имплементировать следующие механизмы:
* запуск программы создаёт контекст её исполнения, содержащий все существенные параметры;
@ -83,26 +83,26 @@ PUT /partners/{id}/coffee-machines
/* Имплементация партнёром интерфейса
запуска программы на его кофе-машинах */
registerProgramRunHandler(apiType, (program) => {
// Инициализируем запуск исполнения
// программы на стороне партнера
let execution = initExecution(…);
// Подписываемся на события
// изменения контекста
program.context.on('takeout_requested', () => {
// Если запрошена выдача напитка,
// инициализируем выдачу
execution.prepareTakeout(() => {
// как только напиток готов к выдаче,
// сигнализируем об этом
execution.context.emit('takeout_ready');
});
// Инициализируем запуск исполнения
// программы на стороне партнера
let execution = initExecution(…);
// Подписываемся на события
// изменения контекста
program.context.on('takeout_requested', () => {
// Если запрошена выдача напитка,
// инициализируем выдачу
execution.prepareTakeout(() => {
// как только напиток готов к выдаче,
// сигнализируем об этом
execution.context.emit('takeout_ready');
});
});
return execution;
return execution.context;
});
```
**NB**: В случае HTTP API соответствующий пример будет выглядеть более громоздко, поскольку потребует создания отдельных эндпойнтов чтения очередей событий типа `GET /program-run/events` и `GET /partner/{id}/execution/events`, это упражнение мы оставляем читателю. Следует также отметить, что в реальных системах потоки событий часто направляют через внешнюю шину типа Apache Kafka или Amazon SQS.
**NB**: в случае HTTP API соответствующий пример будет выглядеть более громоздко, поскольку потребует создания отдельных эндпойнтов чтения очередей событий типа `GET /program-run/events` и `GET /partner/{id}/execution/events`, это упражнение мы оставляем читателю. Следует также отметить, что в реальных системах потоки событий часто направляют через внешнюю шину типа Apache Kafka или Amazon SQS.
Внимательный читатель может возразить нам, что фактически, если мы посмотрим на номенклатуру возникающих сущностей, мы ничего не изменили в постановке задачи, и даже усложнили её:
* вместо вызова метода `takeout` мы теперь генерируем пару событий `takeout_requested`/`takeout_ready`;
@ -117,7 +117,9 @@ registerProgramRunHandler(apiType, (program) => {
В пределе может вообще оказаться так, обе стороны вообще ничего не знают друг о друге и никак не взаимодействуют — не исключаем, что на каком-то этапе развития технологии именно так и произойдёт.
Важно также отметить, что, хотя количество сущностей (полей, событий) эффективно удваивается по сравнению с сильно связанным API, это удвоение является качественным, а не количественным. Контекст `program` содержит описание задания в своих терминах (вид напитка, объём, посыпка корицей); контекст `execution` должен эти термины переформулировать для своей предметной области (чтобы быть, в свою очередь, таким же информационным контекстом для ещё более низкоуровневого API). Что важно, `execution`-контекст имеет право эти термины конкретизировать, поскольку его нижележащие объекты будут уже работать в рамках какого-то конкретного API.
Важно также отметить, что, хотя количество сущностей (полей, событий) эффективно удваивается по сравнению с сильно связанным API, это удвоение является качественным, а не количественным. Контекст `program` содержит описание задания в своих терминах (вид напитка, объём, посыпка корицей); контекст `execution` должен эти термины переформулировать для своей предметной области (чтобы быть, в свою очередь, таким же информационным контекстом для ещё более низкоуровневого API). Что важно, `execution`-контекст имеет право эти термины конкретизировать, поскольку его нижележащие объекты будут уже работать в рамках какого-то конкретного API, в то время как `program`-контекст обязан выражаться в общих терминах, применимых к любой возможной нижележащей технологии.
Ещё одним важным свойством такой событийной связности является то, что она позволяет сущности иметь несколько вышестоящих контекстов. В обычных предметных областях такая ситуация выглядела бы ошибкой дизайна API, но в сложных системах, где присутствуют одновременно несколько агентов, влияющих на состояние системы, такая ситуация не является редкостью. В частности, вы почти наверняка столкнётесь с такого рода проблемами при разработке пользовательского UI. Более подробно о подобных двойных иерархиям мы расскажем в разделе, посвященном разработке SDK.
#### Инверсия ответственности
@ -126,28 +128,28 @@ registerProgramRunHandler(apiType, (program) => {
```
/* Имплементация партнёром интерфейса
запуска программы на его кофе-машинах */
partnerApi.run(program) {
// Инициализируем запуск исполнения
// программы на стороне партнера
let execution = initExection(…);
// Подписываемся на события
// изменения контекста
program.context.on('takeout_requested', () => {
// Если запрошена выдача напитка,
// инициализируем выдачу
execution.prepareTakeout(() => {
/* как только напиток готов к выдаче,
сигнализируем об этом, но не
посредством генерации события */
// execution.context.emit('takeout_ready')
program.context.set('takeout_ready');
// Или ещё более жёстко:
// program.setTakeoutReady();
});
registerProgramRunHandler(apiType, (program) => {
// Инициализируем запуск исполнения
// программы на стороне партнера
let execution = initExection(…);
// Подписываемся на события
// изменения контекста
program.context.on('takeout_requested', () => {
// Если запрошена выдача напитка,
// инициализируем выдачу
execution.prepareTakeout(() => {
/* как только напиток готов к выдаче,
сигнализируем об этом, но не
посредством генерации события */
// execution.context.emit('takeout_ready')
program.context.set('takeout_ready');
// Или ещё более жёстко:
// program.setTakeoutReady();
});
// Так как мы сами изменяем родительский контекст
// нет нужды что-либо возвращать
// return execution;
});
// Так как мы сами изменяем родительский контекст
// нет нужды что-либо возвращать
// return execution.context;
}
```
@ -157,4 +159,43 @@ partnerApi.run(program) {
* если меняется технический уровень, это не должно существенно влиять на продукт, а значит — на написанный партнерами код;
* если меняется сам продукт, ну например мы начинаем продавать билеты на самолёт вместо приготовления кофе на заказ, сохранять обратную совместимость на промежуточных уровнях API *бесполезно*. Мы вполне можем продавать билеты на самолёт тем же самым API программ и контекстов, да только написанный партнёрами код всё равно надо будет полностью переписывать с нуля.
В конечном итоге это приводит к тому, что API вышележащих сущностей меняется медленнее и более последовательно по сравнению с API нижележащих уровней, а значит подобного рода «обратная» жёсткая связь зачастую вполне допустима и даже желательна исходя из соотношения «цена-качество».
В конечном итоге это приводит к тому, что API вышележащих сущностей меняется медленнее и более последовательно по сравнению с API нижележащих уровней, а значит подобного рода «обратная» жёсткая связь зачастую вполне допустима и даже желательна исходя из соотношения «цена-качество».
**NB**: во многих современных системах используется подход с общим разделяемым состоянием приложения. Пожалуй, самый популярный пример такой системы — Redux. В парадигме Redux вышеприведённый код выглядел бы так:
```
execution.prepareTakeout(() => {
// Вместо обращения к вышестоящей сущности
// или генерации события на себе,
// компонент обращается к глобальному
// состоянию и вызывает действия над ним
dispatch(takeoutReady());
});
```
Надо отметить, что такой подход *в принципе* не противоречит описанному принципу, но нарушает другой — изоляцию уровней абстракции, а поэтому плохо подходит для написания сложных API, в которых не гарантирована жесткая иерархия компонентов. При этом использовать глобальный (или квази-глобальный) менеджер состояния в таких системах вполне возможно, но требуется имплементировать более сложную пропагацию сообщений по иерархии, а именно: подчинённый объект всегда вызывает методы только ближайшего вышестоящего объекта, а уже тот решает, как и каким образом этот вызов передать выше по иерархии.
```
execution.prepareTakeout(() => {
// Вместо обращения к вышестоящей сущности
// или генерации события на себе,
// компонент обращается к вышестоящему
// объекту
program.context.dispatch(takeoutReady());
});
```
```
// Имплементация program.context.dispatch
ProgramContext.dispatch = (action) => {
// program.context обращается к своему
// вышестоящему объекту, или к глобальному
// состоянию, если такого объекта нет
globalContext.dispatch(
// При этом сама суть действия
// может и должна быть переформулирована
// в терминах соответствующего уровня
// абстракции
this.generateAction(action);
)
}
```