You've already forked The-API-Book
mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-08-10 21:51:42 +02:00
Weak Coupling chapter refactoring
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
### [Strong Coupling and Related Problems][back-compat-strong-coupling]
|
### [Strong Coupling and Related Problems][back-compat-strong-coupling]
|
||||||
|
|
||||||
To demonstrate the strong coupling problematics let us move to *really interesting* things. Let's continue our “variation analysis”: what if the partners wish to offer not only the standard beverages but their own unique coffee recipes to end-users? There is a catch in this question: the partner API as we described it in the previous chapter does not expose the very existence of the partner network to the end-user, and thus describes a simple case. Once we start providing methods to alter the core functionality, not just API extensions, we will soon face next-level problems.
|
To demonstrate the strong coupling problematics let us move to *really interesting* things. Let's continue our “variation analysis”: what if the partners wish to offer not only the standard beverages but their own unique coffee recipes to end-users? The catch is that the partner API as we described it in the previous chapter does not expose the very existence of the partner network to the end-user, and thus describes a simple case. Once we start providing methods to alter the core functionality, not just API extensions, we will soon face next-level problems.
|
||||||
|
|
||||||
So, let us add one more endpoint to register the partner's own recipe:
|
So, let us add one more endpoint for registering the partner's own recipe:
|
||||||
|
|
||||||
```
|
```
|
||||||
// Adds new recipe
|
// Adds new recipe
|
||||||
@@ -12,9 +12,9 @@ POST /v1/recipes
|
|||||||
"product_properties": {
|
"product_properties": {
|
||||||
"name",
|
"name",
|
||||||
"description",
|
"description",
|
||||||
"default_value"
|
"default_volume"
|
||||||
// Other properties, describing
|
// Other properties to describe
|
||||||
// a beverage to end-user
|
// the beverage to end-user
|
||||||
…
|
…
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,46 +46,49 @@ The flaw in the first option is that a partner might be willing to use the servi
|
|||||||
|
|
||||||
The localization flaws are not the only problem with 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 the `/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 with 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 the `/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 the `name` and `description` fields are simply two designations of the beverage for a user to read, a short one (to be displayed on the search results page) and a 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 they might not actually need two descriptions, but we are also *deceiving* them. The `name` is not “just a name” actually, it implies some restrictions: it has recommended length which is 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.”
|
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 the `name` and `description` fields are simply two designations of the beverage for a user to read, a short one (to be displayed on the search results page) and a long one (to be displayed in the extended product specification block). It actually means that we set the requirements to the API based on some specific design. But *what if* a partner is making their own UI for their own app? Not only they might not actually need two descriptions, but we are also *deceiving* them. The `name` is not “just a name”, it implies some restrictions: it has recommended length which is 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 partners') 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.
|
There is also another side to this story. As UIs (both ours' and partners') tend to evolve, new visual elements will be eventually introduced. For example, a picture of the beverage, its energy value, allergen information, etc. The `product_properties` entity 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 in fact prescript implementing one entity (recipe) based on implementations of other entities (UI layout, localization rules). This approach disrespects the very basic principle of the “top to bottom” API design because **low-level entities must not define high-level ones**.
|
Problems we're facing are the problems of *strong coupling*. Each time we offer an interface like described above, we in fact prescript implementing one entity (recipe) based on implementations of other entities (UI layout, localization rules). This approach disrespects the very basic principle of the “top to bottom” API design because **low-level entities must not define high-level ones**.
|
||||||
|
|
||||||
#### The rule of contexts
|
#### The rule of contexts
|
||||||
|
|
||||||
To make things worse, let us state that the inverse principle is actually correct either: high-level entities must not define low-level ones as well, since that simply isn't their responsibility. The exit from this logical labyrinth is that high-level entities must *define a context*, which other objects are to interpret. To properly design the interfaces for adding a new recipe we shouldn't try to find a better data format; we need to understand what contexts, both explicit and implicit, exist in our subject area.
|
To make things worse, let us state that the inverse principle is also correct: high-level entities must not define low-level ones as well, since that simply isn't their responsibility. The exit from this logical labyrinth is that high-level entities must *define a context*, which other objects are to interpret. To properly design the interfaces for adding a new recipe we shouldn't try to find a 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 the requirements — what exactly partners 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, either internally or within an SDK:
|
We have already noted a localization context. There is some set of languages and regions we support in our API, and there are the requirements — what exactly partners 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, either internally or within an SDK:
|
||||||
|
|
||||||
```
|
```
|
||||||
l10n.volume.format(
|
l10n.volume.format = function(
|
||||||
value, language_code, country_code
|
value, language_code, country_code
|
||||||
)
|
) { … }
|
||||||
// l10n.formatVolume(
|
/*
|
||||||
// '300ml', 'en', 'UK'
|
l10n.formatVolume(
|
||||||
// ) → '300 ml'
|
'300ml', 'en', 'UK'
|
||||||
// l10n.formatVolume(
|
) → '300 ml'
|
||||||
// '300ml', 'en', 'US'
|
l10n.formatVolume(
|
||||||
// ) → '10 fl oz'
|
'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 through the partner API. Like this:
|
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 through the partner API. Like this:
|
||||||
|
|
||||||
```
|
```
|
||||||
// Add a general formatting rule
|
// Add a general formatting rule
|
||||||
// for Russian language
|
// for the Russian language
|
||||||
PUT /formatters/volume/ru
|
PUT /formatters/volume/ru
|
||||||
{
|
{
|
||||||
"template": "{volume} мл"
|
"template": "{volume} мл"
|
||||||
}
|
}
|
||||||
// Add a specific formatting rule
|
// Add a specific formatting rule
|
||||||
// for Russian language in the “US” region
|
// for the Russian language
|
||||||
|
// in the “US” region
|
||||||
PUT /formatters/volume/ru/US
|
PUT /formatters/volume/ru/US
|
||||||
{
|
{
|
||||||
// in the US, we need to recalculate
|
// in the US, we need to recalculate
|
||||||
// the number, then add a postfix
|
// the number, then add a postfix
|
||||||
"value_preparation": {
|
"value_transform": {
|
||||||
"action": "divide",
|
"action": "divide",
|
||||||
"divisor": 30
|
"divisor": 30
|
||||||
},
|
},
|
||||||
@@ -142,18 +145,7 @@ PUT /v1/recipes/{id}/properties/l10n/{lang}
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
or create a layout of their own and provide data fields it requires:
|
or create a layout of their own and provide the data fields it requires, or they may ultimately design their own UI and don't use this functionality at all, defining neither layouts nor corresponding data fields.
|
||||||
|
|
||||||
```
|
|
||||||
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 corresponding data fields.
|
|
||||||
|
|
||||||
Then our interface would ultimately look like this:
|
Then our interface would ultimately look like this:
|
||||||
|
|
||||||
@@ -164,7 +156,7 @@ POST /v1/recipes
|
|||||||
{ "id" }
|
{ "id" }
|
||||||
```
|
```
|
||||||
|
|
||||||
This conclusion might look highly counter-intuitive, but lacking any fields in a “Recipe” simply tells us 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:
|
This conclusion might look highly counter-intuitive, but lacking any fields in a `Recipe` simply tells us 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
|
POST /v1/recipe-builder
|
||||||
@@ -198,7 +190,7 @@ POST /v1/recipe-builder
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
We should also note that providing a newly created entity identifier by the requesting side 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. Obviously, we're risking getting lots of collisions on recipe names used by different partners, so we actually need to modify this operation: either the partner 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 the [“Describing Final Interfaces”](#api-design-describing-interfaces) chapter.
|
We should also note that providing a newly created entity identifier by the requesting side isn't exactly the best practice. However, since we decided from the very beginning to keep recipe identifiers semantically meaningful, we have to live on with this convention. Obviously, we're risking getting lots of collisions on recipe names used by different partners, so we actually need to modify this operation: either partners must always use a pair of identifiers (i.e. the recipe id plus partner's own id), or we need to introduce composite identifiers, as we recommended earlier in the [“Describing Final Interfaces”](#api-design-describing-interfaces) chapter.
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /v1/recipes/custom
|
POST /v1/recipes/custom
|
||||||
@@ -217,4 +209,21 @@ POST /v1/recipes/custom
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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`, for example) to allow editing standard recipes (and thus organizing our own recipes backoffice).
|
Also note that this format allows us to maintain an important extensibility point: different partners might have both shared and isolated namespaces. Furthermore, we might introduce special namespaces (like `common`, for example) to allow editing standard recipes (and thus organizing our own recipes backoffice).
|
||||||
|
|
||||||
|
**NB**: a mindful reader might have noted that this technique was already used in our API study much earlier in the [“Separating Abstraction Levels”](#api-design-separating-abstractions) chapter with regards to the “program” and “program run” entities. Indeed, we might do it without the `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 an 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 a 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.
|
@@ -1,92 +1,71 @@
|
|||||||
### [Weak Coupling][back-compat-weak-coupling]
|
### [Weak Coupling][back-compat-weak-coupling]
|
||||||
|
|
||||||
In the previous chapter we've demonstrated how breaking the 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 the [“Separating Abstraction Levels”](#api-design-separating-abstractions) chapter with regards to the “program” and “program run” entities. Indeed, we might do it without the `program-matcher` endpoint and make it this way:
|
In the 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. But let us return to the question we have previously mentioned in the [“Extending through Abstracting”](#back-compat-abstracting-extending) chapter: how should we parametrize the order preparation process implemented via a third-party API? In other words, what *is* this `order_execution_endpoint` required in the API type registration endpoint?
|
||||||
|
|
||||||
```
|
|
||||||
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 an 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 a 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 return to the question we have previously mentioned in the previous chapter: how should we parametrize the order preparation process implemented via third-party API. In other words, what's this `program_execution_endpoint` that we ask upon the API type registration?
|
|
||||||
|
|
||||||
```
|
```
|
||||||
PUT /v1/api-types/{api_type}
|
PUT /v1/api-types/{api_type}
|
||||||
{
|
{
|
||||||
"order_execution_endpoint": {
|
…
|
||||||
// ???
|
"order_execution_endpoint": {
|
||||||
}
|
// ???
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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 the current execution status, and finish (cancel) the order. An obvious way to provide the common interface is to require these three functions to be executed via a remote call, let's say, like this:
|
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 the current execution status, and finish (cancel) the order. An obvious way to provide the common interface is to require these three functions to be executed via a remote call, let's say, like this:
|
||||||
|
|
||||||
```
|
```
|
||||||
// This is an endpoint for partners
|
PUT /v1/api-types/{api_type}
|
||||||
// to register their coffee machines
|
|
||||||
// in the system
|
|
||||||
PUT /partners/{id}/coffee-machines
|
|
||||||
{
|
{
|
||||||
"coffee-machines": [{
|
…
|
||||||
"id",
|
"order_execution_endpoint": {
|
||||||
…
|
"program_run_endpoint": {
|
||||||
"order_execution_endpoint": {
|
/* Some description of
|
||||||
"program_run_endpoint": {
|
the remote function call */
|
||||||
/* Some description of
|
"type": "rpc",
|
||||||
the remote function call */
|
"endpoint": <URL>,
|
||||||
"type": "rpc",
|
"parameters"
|
||||||
"endpoint": <URL>,
|
},
|
||||||
"format"
|
"program_get_state_endpoint",
|
||||||
},
|
"program_cancel_endpoint"
|
||||||
"program_state_endpoint",
|
}
|
||||||
"program_cancel_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 different questions.
|
**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_get_state_endpoint` shall return, etc., but in this chapter, we're focusing on different questions.
|
||||||
|
|
||||||
Though this API looks absolutely universal, it's quite easy to demonstrate how once simple and clear API ends up being confusing and convoluted. This design presents two main problems.
|
Though this API looks absolutely universal, it's quite easy to demonstrate how once simple and clear API ends 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.
|
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 to the approach. In fact, we simply described what we'd already learned, not even trying to look at the larger picture.
|
||||||
2. This design is ultimately based on a single principle: every order preparation might be codified with these three imperative commands.
|
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 the \#2 principle, and that will uncover the implications of the \#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 contactless takeout. That would lead us to creating 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 or not). What *is* important is that both endpoint and new data fields would be optional because of backwards compatibility requirement.
|
We may easily disprove the \#2 statement, and that will uncover the implications of the \#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, request a contactless takeout. That would lead us to creating a new endpoint, let's say, `program_modify_endpoint`, and new difficulties in data format development (as new fields for contactless delivery requested and satisfied flags need to be passed both directions). What *is* important is that both the endpoint and the new data fields would be optional because of backwards compatibility requirement.
|
||||||
|
|
||||||
Now let's try to imagine a real-world example that doesn't fit into our “three imperatives to rule them all” picture. That's quite easy as well: what if we're plugging via our API not a coffee house, but a vending machine? From one side, it means that the `modify` endpoint and all related stuff are simply meaningless: a vending machine couldn't sprinkle cinnamon over a coffee cup, and the contactless takeout requirement means nothing to it. On the other side, the machine, unlike the people-operated café, requires *takeout approval*: the end-user places an order being somewhere in some other place then walks to the machine and pushes the “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.
|
Now let's try to imagine a real-world example that doesn't fit into our “three imperatives to rule them all” picture. That's quite easy as well: what if we're plugging via our API not a coffee house, but a vending machine? From one side, it means that the `modify` endpoint and all related stuff are simply meaningless: the contactless takeout requirement means nothing to a vending machine. On the other side, the machine, unlike the people-operated café, requires *takeout approval*: the end-user places an order while being somewhere in some other place then walks to the machine and pushes the “get the order” button in the app. We might, of course, require the user to stand up 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:
|
Programmable takeout approval requires one more endpoint, let's say, `program_takeout_endpoint`. And so we've lost our way in a forest of five endpoints:
|
||||||
* to have vending machines integrated a partner must implement the `program_takeout_endpoint`, but doesn't actually need the `program_modify_endpoint`;
|
* to have vending machines integrated a partner must implement the `program_takeout_endpoint`, but doesn't need the `program_modify_endpoint`;
|
||||||
* to have regular coffee houses integrated a partner must implement the `program_modify_endpoint`, but doesn't actually need the `program_takeout_endpoint`.
|
* to have regular coffee houses integrated a partner must implement the `program_modify_endpoint`, but doesn't need the `program_takeout_endpoint`.
|
||||||
|
|
||||||
Furthermore, we have to describe both endpoints in the docs. It's quite natural that the `takeout` endpoint is very specific; unlike cinnamon sprinkling, which we hid under the 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 developers would need to study the docs nonetheless to understand, which methods are needed in your specific situation, and which are not.
|
Furthermore, we have to describe both endpoints in the docs. It's quite natural that the `takeout` endpoint is very specific; unlike requesting contactless delivery, which we hid under the 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 developers 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 that 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 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 with half of the methods *isn't actually needed at all*, like the requesting a contactless takeout one.
|
**NB**: in this example, we assumed that passing `program_takeout_endpoint` parameter is the flag to the application to display the “get the order” button; it would be better to add something like a `supported_flow` field to the `PUT /api-types/` endpoint to provide an explicit flag instead of this implicit convention; however, this wouldn't change the problematics of stockpiling optional methods in the interface, so we skipped it to keep examples laconic.
|
||||||
|
|
||||||
|
We actually don't know, whether in the real world of coffee machine APIs this problem will 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 that 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 technical progress to the situation, i.e. imagine that after a while all coffee houses have become automated, we will finally end up with the situation with half of the methods *aren't actually needed at all*, like the requesting a contactless takeout one.
|
||||||
|
|
||||||
It is also worth mentioning that we unwittingly violated the abstraction levels isolation principle. At the vending machine API level, there is no such thing as a “contactless takeout,” that's actually a product concept.
|
It is also worth mentioning that we unwittingly violated the abstraction levels isolation principle. At the vending machine API level, there is no such thing as a “contactless takeout,” that's actually a product concept.
|
||||||
|
|
||||||
So, how would we tackle this issue? Using one of two possible approaches: either thoroughly study the entire subject area and its upcoming improvements for at least several years ahead, or abandon strong coupling in favor of a weak one. How would the *ideal* solution look from both sides? Something like this:
|
So, how would we tackle this issue? Using one of two possible approaches: either thoroughly study the entire subject area and its upcoming improvements for at least several years ahead, or abandon strong coupling in favor of a weak one. How would the *ideal* solution look to both parties? Something like this:
|
||||||
|
* the 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, send user's requests to a partner, allow user to collect their order;
|
||||||
* the 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;
|
* the underlying program execution API level doesn't care what other same-level implementations exist; it just interprets those parts of the task that make sense to it.
|
||||||
* the underlying program execution API level 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 the principles described in the previous chapter, we would find that this principle was already formulated: we need to describe *informational contexts* at every abstraction level and design a mechanism to translate them between levels. Furthermore, in a more general sense, we formulated it as early as in “The Data Flow” paragraph of the [“Separating Abstraction Levels”](#api-design-separating-abstractions) chapter.
|
If we take a look at the principles described in the previous chapter, we would find that this principle was already formulated: we need to describe *informational contexts* at every abstraction level and design a mechanism to translate them between levels. Furthermore, in a more general sense, we formulated it as early as in “The Data Flow” paragraph of the [“Separating Abstraction Levels”](#api-design-separating-abstractions) chapter.
|
||||||
|
|
||||||
In our case we need to implement the following mechanisms:
|
In our case we need to implement the following mechanisms:
|
||||||
* running a program creates a corresponding context comprising all the essential parameters;
|
* running a program creates a corresponding context comprising all the essential parameters;
|
||||||
* there is a method to stream the information regarding the state modifications: the execution level may read the context, learn about all the changes and report back the changes of its own.
|
* there is the information stream regarding the state modifications: 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 a two-way event stream in-between. If we were developing an SDK we would express the idea like this:
|
There are different techniques to organize this data flow, but, basically, we always have two contexts and a two-way data pipe in-between. If we were developing an SDK, we would express the idea with listening events, like this:
|
||||||
|
|
||||||
```
|
```
|
||||||
/* Partner's implementation of the program
|
/* Partner's implementation of the program
|
||||||
@@ -103,41 +82,44 @@ registerProgramRunHandler(
|
|||||||
() => {
|
() => {
|
||||||
// If takeout is requested, initiate
|
// If takeout is requested, initiate
|
||||||
// corresponding procedures
|
// corresponding procedures
|
||||||
execution.prepareTakeout(() => {
|
await execution.prepareTakeout();
|
||||||
// When the cup is ready for takeout,
|
// When the cup is ready for takeout,
|
||||||
// emit corresponding event for
|
// emit corresponding event for
|
||||||
// a higher-level entity to catch it
|
// a higher-level entity to catch it
|
||||||
execution.context
|
execution.context.emit('takeout_ready');
|
||||||
.emit('takeout_ready');
|
}
|
||||||
}
|
);
|
||||||
);
|
program.context.on(
|
||||||
}
|
'order_canceled',
|
||||||
);
|
() => {
|
||||||
|
await execution.cancel();
|
||||||
return execution.context;
|
execution.context.emit('canceled');
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return execution.context;
|
||||||
|
}
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
**NB**: In the 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 SNS/SQS.
|
**NB**: In the case of HTTP API corresponding example would look rather bulky as it involves implementing several additional endpoints for message exchange like `GET /program-run/events` and `GET /partner/{id}/execution/events`. We would leave this exercise to the reader. Also, it's worth mentioning that in real-world systems such event queues are usually organized using external event messaging systems like Apache Kafka or Amazon SNS/SQS.
|
||||||
|
|
||||||
At this point, a mindful reader might begin protesting because if we take a look at the nomenclature of the new entities, we will find that nothing changed in the problem statement. It actually became even more complicated:
|
At this point, a mindful reader might begin protesting because if we take a look at the nomenclature of the new entities, 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 calling the `takeout` method, we're now generating a pair of `takeout_requested`/`takeout_ready` events;
|
* instead of a long list of methods that shall be implemented to integrate the partner's API, we now have a long list of context entities and events they generate;
|
||||||
* instead of a long list of methods that shall be implemented to integrate the partner's API, we now have a long list of `context` objects fields and events they generate;
|
|
||||||
* and with regards to technological progress, we've changed nothing: now we have deprecated fields and events instead of deprecated methods.
|
* and with regards to technological progress, we've changed nothing: now we have deprecated fields and events instead of deprecated methods.
|
||||||
|
|
||||||
And this remark is totally 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 sides *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 not supported at all, or supported always and unconditionally.
|
And this remark is totally correct. Changing API formats doesn't solve any problems related to the evolution of functionality and underlying technology. Changing API formats serves another purpose: to make the code written by developers stay clean and maintainable. Why would strong-coupled integration (i.e. making entities interact via calling methods) render the code unreadable? Because both sides *are obliged* to implement the functionality which is meaningless in their corresponding subject areas. Code that integrates vending machines into the system *must* respond “ok” to the contactless delivery request — so after a while, these implementations would comprise a handful of methods that just always return `true` (or `false`).
|
||||||
|
|
||||||
The difference between strong coupling and weak coupling is that the field-event mechanism *isn't obligatory to both sides*. Let us remember what we sought to achieve:
|
The difference between strong coupling and weak coupling is that the field-event mechanism *isn't obligatory to both actors*. Let us remember what we sought to achieve:
|
||||||
|
* a higher-level context doesn't know how low-level API works — and it really doesn't; it describes the changes that occur within the context itself, and reacts only to those events that mean something to it;
|
||||||
|
* a 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 that could happen under its specific conditions.
|
||||||
|
|
||||||
* a higher-level context doesn't actually know how low-level API works — and it really doesn't; it describes the changes that occur within the context itself, and reacts only to those events that mean something to it;
|
It's ultimately possible that both sides would know nothing about each other and wouldn't interact at all, and this might happen with the evolution of underlying technologies.
|
||||||
* a 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 that 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.
|
**NB**: in the real world this might not be the case, e.g. we might *want* the application to know, whether the takeout request was successfully served or not, e.g. listen to the `takeout_ready` event and require the `takeout_ready` flag in the state of the execution context. Still, the general possibility of *not caring* about the implementation details is a very powerful technique that makes the application code much less complex — of course, unless this knowledge is important to the user.
|
||||||
|
|
||||||
Worth mentioning that the number of entities (fields, events), though effectively doubled compared to strong-coupled API design, increased qualitatively, not quantitatively. The `program` context describes fields and events in its own terms (type of beverage, volume, cinnamon sprinkling), while the `execution` context must reformulate those terms according to its own subject area (omitting redundant ones, by the way). It is also important that the `execution` context might concretize these properties for underlying objects according to their own specifics, while the `program` context must keep its properties general enough to be applicable to any possible underlying technology.
|
One more important feature of weak coupling is that it allows an entity to have several higher-level contexts. In typical subject areas, such a 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 it while developing user-facing UI libraries. We will cover this issue in detail in the “SDK and UI Libraries” section of this book.
|
||||||
|
|
||||||
One more important feature of weak coupling is that it allows an entity to have several higher-level contexts. In typical subject areas, such a 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 it while developing user-facing UI libraries. We will cover this issue in detail in the upcoming “SDK” section of this book.
|
|
||||||
|
|
||||||
#### The Inversion of Responsibility
|
#### The Inversion of Responsibility
|
||||||
|
|
||||||
@@ -158,60 +140,68 @@ registerProgramRunHandler(
|
|||||||
() => {
|
() => {
|
||||||
// If takeout is requested, initiate
|
// If takeout is requested, initiate
|
||||||
// corresponding procedures
|
// corresponding procedures
|
||||||
execution.prepareTakeout(() => {
|
await execution.prepareTakeout();
|
||||||
/* When the order is ready
|
/* When the order is ready
|
||||||
for takeout, signalize about that
|
for takeout, signalize about that
|
||||||
by calling a method, not
|
by calling the parent context
|
||||||
with event emitting */
|
method, not with event emitting */
|
||||||
// execution.context
|
// execution.context
|
||||||
// .emit('takeout_ready')
|
// .emit('takeout_ready')
|
||||||
program.context
|
program.context
|
||||||
.set('takeout_ready');
|
.set('takeout_ready');
|
||||||
// Or even more rigidly
|
// Or even more rigidly
|
||||||
// program.setTakeoutReady();
|
// program.setTakeoutReady();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
|
||||||
/* Since we're modifying parent context
|
/* Since we're modifying parent context
|
||||||
instead of emitting events, we don't
|
instead of emitting events, we don't
|
||||||
actually need to return anything */
|
actually need to return anything */
|
||||||
// return execution.context;
|
// 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 the *lower* abstraction level. Situations with different realizations of *higher* abstraction levels emerging are, of course, possible, but quite rare. The tree of alternative implementations usually grows from top to bottom.
|
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 bothering ourselves with weak coupling because we expect alternative implementations of the *lower* abstraction level to pop up. Situations with different realizations of *higher* abstraction levels emerging are, of course, possible, but quite rare. The tree of alternative implementations usually grows from root to leaves.
|
||||||
|
|
||||||
Another reason to justify this solution is that major changes occurring at different abstraction levels have different weights:
|
Another reason to justify this solution is that major changes occurring at different abstraction levels have different weights:
|
||||||
|
|
||||||
* if the technical level is under change, that must not affect product qualities and the code written by partners;
|
* 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.
|
* 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 API sell tickets instead of brewing coffee without breaking backwards compatibility, but the partners' code will still become obsolete.
|
||||||
|
|
||||||
In conclusion, because of the 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.
|
In conclusion, as higher-level APIs are evolving more slowly and much more consistently than low-level APIs, 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 the Redux paradigm, the code above would look like this:
|
**NB**: many contemporary frameworks explore a shared state approach, Redux being probably the most notable example. In the Redux paradigm, the code above would look like this:
|
||||||
|
|
||||||
```
|
```
|
||||||
execution.prepareTakeout(() => {
|
program.context.on(
|
||||||
// Instead of generating events
|
'takeout_requested',
|
||||||
// or calling higher-level methods,
|
() => {
|
||||||
// an `execution` entity calls
|
await execution.prepareTakeout();
|
||||||
// a global or quasi-global
|
// Instead of generating events
|
||||||
// callback to change a global state
|
// or calling higher-level methods,
|
||||||
dispatch(takeoutReady());
|
// an `execution` entity calls
|
||||||
});
|
// a global or quasi-global `dispatch`
|
||||||
|
// callback to change a global state
|
||||||
|
dispatch(takeoutReady());
|
||||||
|
}
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
Let us note that this approach *in general* doesn't contradict the weak 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 systems, it's still possible to use a global or quasi-global state manager, but you need to implement event or method call propagation through the hierarchy, i.e. ensure that a low-level entity always interacting with its closest higher-level neighbors only, delegating the responsibility of calling high-level or global methods to them.
|
Let us note that this approach *in general* doesn't contradict the weak 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 systems, it's still possible to use a global or quasi-global state manager, but you need to implement event or method call propagation through the hierarchy, i.e. ensure that a low-level entity always interacts with its closest higher-level neighbors only, delegating the responsibility of calling high-level or global methods to them.
|
||||||
|
|
||||||
```
|
```
|
||||||
execution.prepareTakeout(() => {
|
program.context.on(
|
||||||
// Instead of initiating global actions
|
'takeout_requested',
|
||||||
// an `execution` entity invokes
|
() => {
|
||||||
// its superior's dispatch functionality
|
await execution.prepareTakeout();
|
||||||
program.context.dispatch(takeoutReady());
|
// Instead of calling the global
|
||||||
});
|
// `dispatch` method, an `execution`
|
||||||
|
// entity invokes its superior's
|
||||||
|
// dispatch functionality
|
||||||
|
program.context.dispatch(takeoutReady());
|
||||||
|
}
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
// program.context.dispatch implementation
|
// program.context.dispatch implementation
|
||||||
ProgramContext.dispatch = (action) => {
|
ProgramContext.dispatch = (action) => {
|
||||||
@@ -227,47 +217,6 @@ ProgramContext.dispatch = (action) => {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Test Yourself
|
|
||||||
|
|
||||||
So, we have designed the interaction with third-party APIs as described in the previous paragraph. And now we should (actually, must) check whether these interfaces are compatible with our own abstraction we had developed in the [“Separating Abstraction Levels”](#api-design-separating-abstractions) chapter. In other words, could we start order execution if we operate the low-level API instead of the high-level one?
|
|
||||||
|
|
||||||
Let us recall that we had proposed the following abstract interfaces to work with arbitrary coffee machine API types:
|
|
||||||
|
|
||||||
* `POST /v1/program-matcher` returns the id of the program based on the coffee machine and recipe ids;
|
|
||||||
* `POST /v1/programs/{id}/run` executes the program.
|
|
||||||
|
|
||||||
As we can easily prove, it's quite simple to make these interfaces compatible: we only need to assign a `program_id` identifier to the (API type, recipe) pair, for example, through returning it in the `PUT /coffee-machines` method response:
|
|
||||||
|
|
||||||
```
|
|
||||||
PUT /v1/partners/{partnerId}/coffee-machines
|
|
||||||
{
|
|
||||||
"coffee_machines": [{
|
|
||||||
"id",
|
|
||||||
"api_type",
|
|
||||||
"location",
|
|
||||||
"supported_recipes"
|
|
||||||
}, …]
|
|
||||||
}
|
|
||||||
→
|
|
||||||
{
|
|
||||||
"coffee_machines": [{
|
|
||||||
"id",
|
|
||||||
"recipes_programs": [
|
|
||||||
{"recipe_id", "program_id"},
|
|
||||||
…
|
|
||||||
]
|
|
||||||
}, …]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
So the method we'd developed:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /v1/programs/{id}/run
|
|
||||||
```
|
|
||||||
|
|
||||||
will work with the partner's coffee machines (like it's a third API type).
|
|
||||||
|
|
||||||
#### Delegate!
|
#### Delegate!
|
||||||
|
|
||||||
From what was said, one more important conclusion follows: doing a real job, e.g. implementing some concrete actions (making coffee, in our case) should be delegated to the lower levels of the abstraction hierarchy. If the upper levels try to prescribe some specific implementation algorithms, then (as we have demonstrated on the `order_execution_endpoint` example) we will soon face a situation of inconsistent methods and interaction protocols nomenclature, most of which have no specific meaning when we talk about some specific hardware context.
|
From what was said, one more important conclusion follows: doing a real job, e.g. implementing some concrete actions (making coffee, in our case) should be delegated to the lower levels of the abstraction hierarchy. If the upper levels try to prescribe some specific implementation algorithms, then (as we have demonstrated on the `order_execution_endpoint` example) we will soon face a situation of inconsistent methods and interaction protocols nomenclature, most of which have no specific meaning when we talk about some specific hardware context.
|
||||||
|
@@ -12,7 +12,7 @@ POST /v1/recipes
|
|||||||
"product_properties": {
|
"product_properties": {
|
||||||
"name",
|
"name",
|
||||||
"description",
|
"description",
|
||||||
"default_value"
|
"default_volume"
|
||||||
// Прочие параметры, описывающие
|
// Прочие параметры, описывающие
|
||||||
// напиток для пользователя
|
// напиток для пользователя
|
||||||
…
|
…
|
||||||
@@ -59,15 +59,17 @@ POST /v1/recipes
|
|||||||
Как уже понятно, существует контекст локализации. Есть какой-то набор языков и регионов, которые мы поддерживаем в нашем API, и есть требования — что конкретно необходимо предоставить партнёру, чтобы API заработал на новом языке в новом регионе. Конкретно в случае объёма кофе где-то в недрах нашего API (во внутренней реализации или в составе SDK) есть функция форматирования строк для отображения объёма напитка:
|
Как уже понятно, существует контекст локализации. Есть какой-то набор языков и регионов, которые мы поддерживаем в нашем API, и есть требования — что конкретно необходимо предоставить партнёру, чтобы API заработал на новом языке в новом регионе. Конкретно в случае объёма кофе где-то в недрах нашего API (во внутренней реализации или в составе SDK) есть функция форматирования строк для отображения объёма напитка:
|
||||||
|
|
||||||
```
|
```
|
||||||
l10n.volume.format(
|
l10n.volume.format = function(
|
||||||
value, language_code, country_code
|
value, language_code, country_code
|
||||||
)
|
) { … }
|
||||||
// l10n.formatVolume(
|
/*
|
||||||
// '300ml', 'en', 'UK'
|
l10n.formatVolume(
|
||||||
// ) → '300 ml'
|
'300ml', 'en', 'UK'
|
||||||
// l10n.formatVolume(
|
) → '300 ml'
|
||||||
// '300ml', 'en', 'US'
|
l10n.formatVolume(
|
||||||
// ) → '10 fl oz'
|
'300ml', 'en', 'US'
|
||||||
|
) → '10 fl oz'
|
||||||
|
*/
|
||||||
```
|
```
|
||||||
|
|
||||||
Чтобы наш API корректно заработал с новым языком или регионом, партнёр должен или задать эту функцию через партнёрский API, или указать, какую из существующих локализаций необходимо использовать. Для этого мы абстрагируем-и-расширяем API, в соответствии с описанной в предыдущей главе процедурой, и добавляем новый эндпойнт — настройки форматирования:
|
Чтобы наш API корректно заработал с новым языком или регионом, партнёр должен или задать эту функцию через партнёрский API, или указать, какую из существующих локализаций необходимо использовать. Для этого мы абстрагируем-и-расширяем API, в соответствии с описанной в предыдущей главе процедурой, и добавляем новый эндпойнт — настройки форматирования:
|
||||||
@@ -85,7 +87,7 @@ PUT /formatters/volume/ru/US
|
|||||||
{
|
{
|
||||||
// В США требуется сначала пересчитать
|
// В США требуется сначала пересчитать
|
||||||
// объём, потом добавить постфикс
|
// объём, потом добавить постфикс
|
||||||
"value_preparation": {
|
"value_transform": {
|
||||||
"action": "divide",
|
"action": "divide",
|
||||||
"divisor": 30
|
"divisor": 30
|
||||||
},
|
},
|
||||||
@@ -144,18 +146,7 @@ PUT /v1/recipes/{id}/⮠
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Либо создать свой макет и задавать нужные для него поля:
|
Либо создать свой макет и задавать нужные для него поля. В конце концов, партнёр может отрисовывать UI самостоятельно и вообще не пользоваться этой техникой, не задавая ни макеты, ни поля.
|
||||||
|
|
||||||
```
|
|
||||||
POST /v1/layouts
|
|
||||||
{
|
|
||||||
"properties"
|
|
||||||
}
|
|
||||||
→
|
|
||||||
{ "id", "properties" }
|
|
||||||
```
|
|
||||||
|
|
||||||
В конце концов, партнёр может отрисовывать UI самостоятельно и вообще не пользоваться этой техникой, не задавая ни макеты, ни поля.
|
|
||||||
|
|
||||||
Наш интерфейс добавления рецепта получит в итоге вот такой вид:
|
Наш интерфейс добавления рецепта получит в итоге вот такой вид:
|
||||||
|
|
||||||
@@ -220,3 +211,23 @@ POST /v1/recipes/custom
|
|||||||
```
|
```
|
||||||
|
|
||||||
Заметим, что в таком формате мы сразу закладываем важное допущение: различные партнёры могут иметь как полностью изолированные неймспейсы, так и разделять их. Более того, мы можем ввести специальные неймспейсы типа "common", которые позволят публиковать новые рецепты для всех. (Это, кстати говоря, хорошо ещё и тем, что такой API мы сможем использовать для организации нашей собственной панели управления контентом.)
|
Заметим, что в таком формате мы сразу закладываем важное допущение: различные партнёры могут иметь как полностью изолированные неймспейсы, так и разделять их. Более того, мы можем ввести специальные неймспейсы типа "common", которые позволят публиковать новые рецепты для всех. (Это, кстати говоря, хорошо ещё и тем, что такой API мы сможем использовать для организации нашей собственной панели управления контентом.)
|
||||||
|
|
||||||
|
**NB**: внимательный читатель может подметить, что этот приём уже был продемонстрирован в нашем учебном API гораздо раньше в главе [«Разделение уровней абстракции»](#api-design-separating-abstractions) на примере сущностей «программа» и «запуск программы». В самом деле, мы могли бы обойтись без программ и без эндпойнта `program-matcher` и пойти вот таким путём:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/recipes/{id}/run-data/{api_type}
|
||||||
|
→
|
||||||
|
{ /* описание способа запуска
|
||||||
|
указанного рецепта на
|
||||||
|
машинах с поддержкой
|
||||||
|
указанного типа API */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Тогда разработчикам пришлось бы сделать примерно следующее для запуска приготовления кофе:
|
||||||
|
* выяснить тип API конкретной кофемашины;
|
||||||
|
* получить описание способа запуска программы выполнения рецепта на машине с API такого типа;
|
||||||
|
* в зависимости от типа API выполнить специфические команды запуска.
|
||||||
|
|
||||||
|
Очевидно, что такой интерфейс совершенно недопустим — просто потому, что в подавляющем большинстве случаев разработчикам совершенно неинтересно, какого рода API поддерживает та или иная кофемашина. Для того чтобы не допустить такого плохого интерфейса, мы ввели новую сущность «программа», которая по факту представляет собой не более чем просто идентификатор контекста, как и сущность «рецепт».
|
||||||
|
|
||||||
|
Аналогичным образом устроена и сущность `program_run_id`, идентификатор запуска программы. Он также по сути не имеет почти никакого интерфейса и состоит только из идентификатора запуска.
|
@@ -1,26 +1,6 @@
|
|||||||
### [Слабая связность][back-compat-weak-coupling]
|
### [Слабая связность][back-compat-weak-coupling]
|
||||||
|
|
||||||
В предыдущей главе мы продемонстрировали, как разрыв сильной связности приводит к декомпозиции сущностей и схлопыванию публичных интерфейсов до минимума. Внимательный читатель может подметить, что этот приём уже был продемонстрирован в нашем учебном API гораздо раньше в главе [«Разделение уровней абстракции»](#api-design-separating-abstractions) на примере сущностей «программа» и «запуск программы». В самом деле, мы могли бы обойтись без программ и без эндпойнта `program-matcher` и пойти вот таким путём:
|
В предыдущей главе мы продемонстрировали, как разрыв сильной связности приводит к декомпозиции сущностей и схлопыванию публичных интерфейсов до минимума. Вернёмся теперь к вопросу, который мы вскользь затронули в главе [«Расширение через абстрагирование»](#back-compat-abstracting-extending): каким образом нам нужно параметризовать приготовление заказа, если оно исполняется через сторонний API? Иными словами, что такое этот самый `order_execution_endpoint`, передавать который мы потребовали при регистрации нового типа API?
|
||||||
|
|
||||||
```
|
|
||||||
GET /v1/recipes/{id}/run-data/{api_type}
|
|
||||||
→
|
|
||||||
{ /* описание способа запуска
|
|
||||||
указанного рецепта на
|
|
||||||
машинах с поддержкой
|
|
||||||
указанного типа API */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
Тогда разработчикам пришлось бы сделать примерно следующее для запуска приготовления кофе:
|
|
||||||
* выяснить тип API конкретной кофемашины;
|
|
||||||
* получить описание способа запуска программы выполнения рецепта на машине с API такого типа;
|
|
||||||
* в зависимости от типа API выполнить специфические команды запуска.
|
|
||||||
|
|
||||||
Очевидно, что такой интерфейс совершенно недопустим — просто потому, что в подавляющем большинстве случаев разработчикам совершенно неинтересно, какого рода API поддерживает та или иная кофемашина. Для того чтобы не допустить такого плохого интерфейса, мы ввели новую сущность «программа», которая по факту представляет собой не более чем просто идентификатор контекста, как и сущность «рецепт».
|
|
||||||
|
|
||||||
Аналогичным образом устроена и сущность `program_run_id`, идентификатор запуска программы. Он также по сути не имеет почти никакого интерфейса и состоит только из идентификатора запуска.
|
|
||||||
|
|
||||||
Вернёмся теперь к вопросу, который мы вскользь затронули в предыдущей главе — каким образом нам параметризовать приготовление заказа, если оно исполняется через сторонний API. Иными словами, что такое этот самый `program_execution_endpoint`, передавать который мы потребовали при регистрации нового типа API?
|
|
||||||
|
|
||||||
```
|
```
|
||||||
PUT /v1/api-types/{api_type}
|
PUT /v1/api-types/{api_type}
|
||||||
@@ -34,97 +14,102 @@ PUT /v1/api-types/{api_type}
|
|||||||
Исходя из общей логики мы можем предположить, что любой API так или иначе будет выполнять три функции: запускать программы с указанными параметрами, возвращать текущий статус запуска и завершать (отменять) заказ. Самый очевидный подход к реализации такого API — просто потребовать от партнёра имплементировать вызов этих трёх функций удалённо, например следующим образом:
|
Исходя из общей логики мы можем предположить, что любой API так или иначе будет выполнять три функции: запускать программы с указанными параметрами, возвращать текущий статус запуска и завершать (отменять) заказ. Самый очевидный подход к реализации такого API — просто потребовать от партнёра имплементировать вызов этих трёх функций удалённо, например следующим образом:
|
||||||
|
|
||||||
```
|
```
|
||||||
// Эндпойнт добавления списка
|
|
||||||
// кофемашин партнёра
|
|
||||||
PUT /v1/api-types/{api_type}
|
PUT /v1/api-types/{api_type}
|
||||||
{
|
{
|
||||||
"order_execution_endpoint":
|
…
|
||||||
|
"order_execution_endpoint": {
|
||||||
"program_run_endpoint": {
|
"program_run_endpoint": {
|
||||||
/* Какое-то описание
|
/* Какое-то описание
|
||||||
удалённого вызова эндпойнта */
|
удалённого вызова */
|
||||||
"type": "rpc",
|
"type": "rpc",
|
||||||
"endpoint": <URL>,
|
"endpoint": <URL>,
|
||||||
"format"
|
"parameters"
|
||||||
},
|
},
|
||||||
"program_state_endpoint",
|
"program_get_state_endpoint",
|
||||||
"program_cancel_endpoint"
|
"program_cancel_endpoint"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**NB**: во многом таким образом мы переносим сложность разработки API в плоскость разработки форматов данных (каким образом мы будем передавать параметры запуска в `program_run_endpoint`, и в каком формате должен отвечать `program_state_endpoint`, но в рамках этой главы мы сфокусируемся на других вопросах.)
|
**NB**: во многом таким образом мы переносим сложность разработки API в плоскость разработки форматов данных (каким образом мы будем передавать параметры запуска в `program_run_endpoint`, и в каком формате должен отвечать `program_get_state_endpoint`, но в рамках этой главы мы сфокусируемся на других вопросах.)
|
||||||
|
|
||||||
Хотя это API и кажется абсолютно универсальным, на его примере можно легко показать, каким образом изначально простые и понятные API превращаются в сложные и запутанные. У этого дизайна есть две основные проблемы.
|
Хотя это API и кажется абсолютно универсальным, на его примере можно легко показать, каким образом изначально простые и понятные API превращаются в сложные и запутанные. У этого дизайна есть две основные проблемы:
|
||||||
|
|
||||||
1. Он хорошо описывает уже реализованные нами интеграции (т.е. в эту схему легко добавить поддержку известных нам типов API), но не привносит никакой гибкости в подход: по сути мы описали только известные нам способы интеграции, не попытавшись взглянуть на более общую картину.
|
1. Он хорошо описывает уже реализованные нами интеграции (т.е. в эту схему легко добавить поддержку известных нам типов API), но не привносит никакой гибкости в подход: по сути мы описали только известные нам способы интеграции, не попытавшись взглянуть на более общую картину.
|
||||||
2. Этот дизайн изначально основан на следующем принципе: любое приготовление заказа можно описать этими тремя императивными командами.
|
2. Этот дизайн изначально основан на следующем принципе: любое приготовление заказа можно описать этими тремя императивными командами.
|
||||||
|
|
||||||
Пункт 2 очень легко опровергнуть, что автоматически вскроет проблемы пункта 1. Предположим для начала, что в ходе развития функциональности мы решили дать пользователю возможность изменять свой заказ уже после того, как он создан — ну, например, попросить посыпать кофе корицей или выдать заказ бесконтактно. Это автоматически влечёт за собой добавление нового эндпойнта, ну скажем, `program_modify_endpoint`, и новых сложностей в формате обмена данными (нам нужно уметь понимать в реальном времени, можно ли этот конкретный кофе посыпать корицей). Что важно, и то, и другое (и эндпойнт, и новые поля данных) из соображений обратной совместимости будут необязательными.
|
Пункт 2 очень легко опровергнуть, что автоматически вскроет проблемы пункта 1. Предположим для начала, что в ходе развития функциональности мы решили дать пользователю возможность изменять свой заказ уже после того, как он создан — например, попросить выдать заказ бесконтактно. Это автоматически влечёт за собой добавление нового эндпойнта, ну скажем, `program_modify_endpoint`, и новых сложностей в формате обмена данными (нам нужно пересылать данные о появлении требования о бесконтактной доставке и его удовлетворении). Что важно, и то, и другое (и эндпойнт, и новые поля данных) из соображений обратной совместимости будут необязательными.
|
||||||
|
|
||||||
Теперь попытаемся придумать какой-нибудь пример реального мира, который не описывается нашими тремя императивами. Это довольно легко: допустим, мы подключим через наш API не кофейню, а вендинговый автомат. Это, с одной стороны, означает, что эндпойнт `modify` и вся его обвязка для этого типа API бесполезны — автомат не умеет посыпать кофе корицей, а требование бесконтактной выдачи попросту ничего не значит. С другой, автомат, в отличие от оперируемой людьми кофейни, требует программного способа *подтверждения выдачи* напитка: пользователь делает заказ, находясь где-то в другом месте, потом доходит до автомата и нажимает в приложении кнопку «выдать заказ». Мы могли бы, конечно, потребовать, чтобы пользователь создавал заказ автомату, стоя прямо перед ним, но это, в свою очередь, противоречит нашей изначальной концепции, в которой пользователь выбирает и заказывает напиток, исходя из доступных опций, а потом идёт в указанную точку, чтобы его забрать.
|
Теперь попытаемся придумать какой-нибудь пример реального мира, который не описывается нашими тремя императивами. Это довольно легко: допустим, мы подключим через наш API не кофейню, а вендинговый автомат. Это, с одной стороны, означает, что эндпойнт `modify` и вся его обвязка для этого типа API бесполезны — требование бесконтактной выдачи попросту ничего не значит для автомата. С другой, автомат, в отличие от оперируемой людьми кофейни, требует программного способа *подтверждения выдачи* напитка: пользователь делает заказ, находясь где-то в другом месте, потом доходит до автомата и нажимает в приложении кнопку «выдать заказ». Мы могли бы, конечно, потребовать, чтобы пользователь создавал заказ автомату, стоя прямо перед ним, но это, в свою очередь, противоречит нашей изначальной концепции, в которой пользователь выбирает и заказывает напиток, исходя из доступных опций, а потом идёт в указанную точку, чтобы его забрать.
|
||||||
|
|
||||||
Программная выдача напитка потребует добавления ещё одного эндпойнта, ну скажем, `program_takeout_endpoint`. И вот мы уже запутались в лесу из трёх эндпойнтов:
|
Программная выдача напитка потребует добавления ещё одного эндпойнта, ну скажем, `program_takeout_endpoint`. И вот мы уже запутались в лесу из пяти эндпойнтов:
|
||||||
* для работы вендинговых автоматов нужно реализовать эндпойнт `program_takeout_endpoint`, но не нужно реализовывать `program_modify_endpoint`;
|
* для работы вендинговых автоматов нужно реализовать эндпойнт `program_takeout_endpoint`, но не нужно реализовывать `program_modify_endpoint`;
|
||||||
* для работы обычных кофеен нужно реализовать эндпойнт `program_modify_endpoint`, но не нужно реализовывать `program_takeout_endpoint`.
|
* для работы обычных кофеен нужно реализовать эндпойнт `program_modify_endpoint`, но не нужно реализовывать `program_takeout_endpoint`.
|
||||||
|
|
||||||
При этом в документации интерфейса мы опишем и тот, и другой эндпойнт. Как несложно заметить, интерфейс `takeout` весьма специфичен. Если посыпку корицей мы как-то скрыли за общим `modify`, то на вот такие операции типа подтверждения выдачи нам каждый раз придётся заводить новый метод с уникальным названием. Несложно представить себе, как через несколько итераций интерфейс превратится в свалку из визуально похожих методов, притом формально необязательных — но для подключения своего API нужно будет прочитать документацию каждого и разобраться в том, нужен ли он в конкретной ситуации или нет.
|
При этом в документации интерфейса мы опишем и тот, и другой эндпойнт. Как несложно заметить, интерфейс `takeout` весьма специфичен. Если запрос бесконтактной доставки мы как-то скрыли за общим `modify`, то на вот такие операции типа подтверждения выдачи нам каждый раз придётся заводить новый метод с уникальным названием. Несложно представить себе, как через несколько итераций интерфейс превратится в свалку из визуально похожих методов, притом формально необязательных — но для подключения своего API нужно будет прочитать документацию каждого и разобраться в том, нужен ли он в конкретной ситуации или нет.
|
||||||
|
|
||||||
|
**NB**: в этом примере мы предполагаем, что наличие эндпойнта `program_takeout_endpoint` является триггером для приложения, которое должно показать кнопку «выдать заказ». Было бы лучше добавить что-то типа поля `supported_flow` в параметры вызова `PUT /api-types/`, чтобы этот флаг задавался явно, а не определялся из неочевидной конвенции. Однако в проблематике замусоривания интерфейсов опциональным методами это ничего не меняет, так что мы опустили эту тонкость ради лаконичности примеров.
|
||||||
|
|
||||||
|
Мы не знаем, правда ли в реальном мире API кофемашин возникнет проблема, подобная описанной. Но мы можем сказать со всей уверенностью, что *всегда*, когда речь идёт об интеграции «железного» уровня, происходят именно те процессы, которые мы описали: меняется нижележащая технология, и вроде бы понятный и ясный API превращается в свалку из legacy-методов, половина из которых не несёт в себе никакого практического смысла в рамках конкретной интеграции. Если мы добавим к проблеме ещё и технический прогресс — представим, например, что со временем все кофейни станут автоматическими — то мы быстро придём к ситуации, когда половина методов *вообще не нужна*, как метод запроса бесконтактной выдачи напитка.
|
||||||
|
|
||||||
Мы не знаем, правда ли в реальном мире API кофемашин возникнет проблема, подобная описанной. Но мы можем сказать со всей уверенностью, что *всегда*, когда речь идёт об интеграции «железного» уровня, происходят именно те процессы, которые мы описали: меняется нижележащая технология, и вроде бы понятный и ясный API превращается в свалку из легаси-методов, половина из которых не несёт в себе никакого практического смысла в рамках конкретной интеграции. Если мы добавим к проблеме ещё и технический прогресс — представим, например, что со временем все кофейни станут автоматическими — то мы быстро придём к ситуации, когда половина методов *вообще не нужна*, как метод запроса бесконтактной выдачи напитка.
|
|
||||||
|
|
||||||
Заметим также, что мы невольно начали нарушать принцип изоляции уровней абстракции. На уровне API вендингового автомата вообще не существует понятия «бесконтактная выдача», это по сути продуктовый термин.
|
Заметим также, что мы невольно начали нарушать принцип изоляции уровней абстракции. На уровне API вендингового автомата вообще не существует понятия «бесконтактная выдача», это по сути продуктовый термин.
|
||||||
|
|
||||||
Каким же образом мы можем решить эту проблему? Одним из двух способов: или досконально изучить предметную область и тренды её развития на несколько лет вперёд, или перейти от сильной связности к слабой. Как выглядит идеальное решение с точки зрения обеих взаимодействующих сторон? Как-то так:
|
Каким же образом мы можем решить эту проблему? Одним из двух способов: или досконально изучить предметную область и тренды её развития на несколько лет вперёд, или перейти от сильной связности к слабой. Как выглядит идеальное решение с точки зрения обеих взаимодействующих сторон? Как-то так:
|
||||||
* вышестоящий API программ не знает, как устроен уровень исполнения его команд; он формулирует задание так, как понимает на своём уровне: сварить такой-то кофе такого-то объёма, с корицей, выдать такому-то пользователю;
|
* вышестоящий API программ не знает, как устроен уровень исполнения его команд; он формулирует задание так, как понимает на своём уровне: сварить такой-то кофе такого-то объёма, передать пожелания пользователя партнёру, выдать заказ;
|
||||||
* нижележащий API исполнения программ не заботится о том, какие ещё вокруг бывают API того же уровня; он трактует только ту часть задания, которая имеет для него смысл.
|
* нижележащий API исполнения программ не заботится о том, какие ещё вокруг бывают API того же уровня; он трактует только ту часть задания, которая имеет для него смысл.
|
||||||
|
|
||||||
Если мы посмотрим на принципы, описанные в предыдущей главе, то обнаружим, что этот принцип мы уже формулировали: нам необходимо задать *информационный контекст* на каждом из уровней абстракции, и разработать механизм его трансляции. Более того, в общем виде он был сформулирован ещё в разделе «Потоки данных» главы [«Разделение уровней абстракции»](#api-design-separating-abstractions).
|
Если мы посмотрим на принципы, описанные в предыдущей главе, то обнаружим, что этот принцип мы уже формулировали: нам необходимо задать *информационный контекст* на каждом из уровней абстракции, и разработать механизм его трансляции. Более того, в общем виде он был сформулирован ещё в разделе «Потоки данных» главы [«Разделение уровней абстракции»](#api-design-separating-abstractions).
|
||||||
|
|
||||||
В нашем конкретном примере нам нужно имплементировать следующие механизмы:
|
В нашем конкретном примере нам нужно имплементировать следующие механизмы:
|
||||||
* запуск программы создаёт контекст её исполнения, содержащий все существенные параметры;
|
* запуск программы создаёт контекст её исполнения, содержащий все существенные параметры;
|
||||||
* существует способ обмена информацией об изменении данных: исполнитель может читать контекст, узнавать о всех его изменениях и сообщать обратно о изменениях своего состояния.
|
* существует поток обмена информацией об изменении состояния: исполнитель может читать контекст, узнавать о всех его модификациях и сообщать обратно о изменениях своего состояния.
|
||||||
|
|
||||||
Организовать и то, и другое можно разными способами, однако по сути мы имеем два описания состояния (верхне- и низкоуровневое) и поток событий между ними. В случае SDK эту идею можно было бы выразить так:
|
Организовать и то, и другое можно разными способами, однако по сути мы всегда имеем два контекста и поток событий между ними. В случае SDK эту идею можно мы бы выразили через генерацию событий:
|
||||||
|
|
||||||
```
|
```
|
||||||
/* Имплементация партнёром интерфейса
|
/* Имплементация партнёром интерфейса
|
||||||
запуска программы на его кофемашинах */
|
запуска программы на его кофемашинах */
|
||||||
registerProgramRunHandler(
|
registerProgramRunHandler(
|
||||||
apiType,
|
apiType,
|
||||||
(context) => {
|
(program) => {
|
||||||
// Инициализируем запуск исполнения
|
// Инициализация исполнения заказа
|
||||||
// программы на стороне партнёра
|
// на стороне партерна
|
||||||
let execution =
|
let execution = initExecution(…);
|
||||||
initExecution(context, …);
|
// Подписка на изменения состояния
|
||||||
// Подписываемся на события
|
// родительского контекста
|
||||||
// изменения контекста
|
program.context.on(
|
||||||
context.on(
|
|
||||||
'takeout_requested',
|
'takeout_requested',
|
||||||
() => {
|
() => {
|
||||||
// Если запрошена выдача напитка,
|
// Если запрошена выдача заказа,
|
||||||
// инициализируем выдачу
|
// инициировать нужные операции
|
||||||
execution.prepareTakeout(() => {
|
await execution.prepareTakeout();
|
||||||
// как только напиток
|
// Как только напиток готов к выдаче,
|
||||||
// готов к выдаче,
|
// оповестить об этом
|
||||||
// сигнализируем об этом
|
execution.context.emit('takeout_ready');
|
||||||
execution.context
|
|
||||||
.emit('takeout_ready');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
program.context.on(
|
||||||
|
'order_canceled',
|
||||||
|
() => {
|
||||||
|
await execution.cancel();
|
||||||
|
execution.context.emit('canceled');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return execution.context;
|
return execution.context;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
**NB**: в случае HTTP API соответствующий пример будет выглядеть более громоздко, поскольку потребует создания отдельных эндпойнтов чтения очередей событий типа `GET /program-run/events` и `GET /partner/{id}/execution/events`, это упражнение мы оставляем читателю. Следует также отметить, что в реальных системах потоки событий часто направляют через внешнюю шину типа Apache Kafka или Amazon SNS/SQS.
|
**NB**: в случае HTTP API соответствующий пример будет выглядеть более громоздко, поскольку потребует создания отдельных эндпойнтов для обмена сообщениями типа `GET /program-run/events` и `GET /partner/{id}/execution/events` — это упражнение мы оставляем читателю. Следует также отметить, что в реальных системах потоки событий часто направляют через внешнюю шину типа Apache Kafka или Amazon SNS/SQS.
|
||||||
|
|
||||||
Внимательный читатель может возразить нам, что фактически, если мы посмотрим на номенклатуру возникающих сущностей, мы ничего не изменили в постановке задачи, и даже усложнили её:
|
Внимательный читатель может возразить нам, что фактически, если мы посмотрим на номенклатуру возникающих сущностей, мы ничего не изменили в постановке задачи, и даже усложнили её:
|
||||||
* вместо вызова метода `takeout` мы теперь генерируем пару событий `takeout_requested`/`takeout_ready`;
|
* вместо вызова метода `takeout` мы теперь генерируем пару событий `takeout_requested` / `takeout_ready`;
|
||||||
* вместо длинного списка методов, которые необходимо реализовать для интеграции API партнёра, появляются длинные списки полей сущности `context` и событий, которые она генерирует;
|
* вместо длинного списка методов, которые необходимо реализовать для интеграции API партнёра, появляются длинные списки полей разных контекстов и событий, которые они генерирует;
|
||||||
* проблема устаревания технологии не меняется, вместо устаревших методов мы теперь имеем устаревшие поля и события.
|
* проблема устаревания технологии не меняется, вместо устаревших методов мы теперь имеем устаревшие поля и события.
|
||||||
|
|
||||||
Это замечание совершенно верно. Изменение формата API само по себе не решает проблем, связанных с эволюцией функциональности и нижележащей технологии. Формат API решает другую проблему: как оставить при этом код читаемым и поддерживаемым. Почему в примере с интеграцией через методы код становится нечитаемым? Потому что обе стороны *вынуждены* имплементировать функциональность, которая в их контексте бессмысленна; и эта имплементация будет состоять из какого-то (хорошо если явного!) способа ответить, что данная функциональность не поддерживается (или, наоборот, поддерживается всегда и безусловно).
|
Это замечание совершенно верно. Изменение формата API само по себе не решает проблем, связанных с эволюцией функциональности и нижележащей технологии. Формат API решает другую проблему: как оставить при этом партнерский код читаемым и поддерживаемым. Почему в примере с интеграцией через методы код становится нечитаемым? Потому что обе стороны *вынуждены* имплементировать функциональность, которая в их контексте бессмысленна. Код интеграции вендинговых автоматов *должен* ответить «принято» на запрос бесконтактной выдачи — и таким образом, со временем все имплементации будут состоять из множества методов, просто безусловно возвращающих `true` (или `false`).
|
||||||
|
|
||||||
Разница между жёстким связыванием и слабым в данном случае состоит в том, что механизм полей и событий *не является обязывающим*. Вспомним, чего мы добивались:
|
Разница между жёстким связыванием и слабым в данном случае состоит в том, что механизм полей и событий *не является обязывающим*. Вспомним, чего мы добивались:
|
||||||
* верхнеуровневый контекст не знает, как устроен низкоуровневый API — и он действительно не знает; он описывает те изменения, которые происходят *в нём самом* и реагирует только на те события, которые имеют смысл *для него самого*;
|
* верхнеуровневый контекст не знает, как устроен низкоуровневый API — и он действительно не знает; он описывает те изменения, которые происходят *в нём самом* и реагирует только на те события, которые имеют смысл *для него самого*;
|
||||||
@@ -132,9 +117,9 @@ registerProgramRunHandler(
|
|||||||
|
|
||||||
В пределе может вообще оказаться так, что обе стороны вообще ничего не знают друг о друге и никак не взаимодействуют — не исключаем, что на каком-то этапе развития технологии именно так и произойдёт.
|
В пределе может вообще оказаться так, что обе стороны вообще ничего не знают друг о друге и никак не взаимодействуют — не исключаем, что на каком-то этапе развития технологии именно так и произойдёт.
|
||||||
|
|
||||||
Важно также отметить, что, хотя количество сущностей (полей, событий) эффективно удваивается по сравнению с сильно связанным API, это удвоение является качественным, а не количественным. Контекст `program` содержит описание задания в своих терминах (вид напитка, объём, посыпка корицей); контекст `execution` должен эти термины переформулировать для своей предметной области (чтобы быть, в свою очередь, таким же информационным контекстом для ещё более низкоуровневого API). Что важно, `execution`-контекст имеет право эти термины конкретизировать, поскольку его нижележащие объекты будут уже работать в рамках какого-то конкретного API, в то время как `program`-контекст обязан выражаться в общих терминах, применимых к любой возможной нижележащей технологии.
|
**NB**: в реальном мире этого может и не произойти — мы, вероятно, всё-таки хотим, чтобы приложение обладало знанием о том, был ли запрос на выдачу напитка успешно выполнен или нет, что означает подписку на событие `takeout_ready` и проверку соответствующего флага в состоянии контекста исполнения. Тем не менее, сама по себе *возможность не знать* детали имплементации очень важна, поскольку она позволяет сделать код приложения гораздо проще — если это знание неважно для пользователя, конечно.
|
||||||
|
|
||||||
Ещё одним важным свойством слабой связности является то, что она позволяет сущности иметь несколько родительских контекстов. В обычных предметных областях такая ситуация выглядела бы ошибкой дизайна API, но в сложных системах, где присутствуют одновременно несколько агентов, влияющих на состояние системы, такая ситуация не является редкостью. В частности, вы почти наверняка столкнётесь с такого рода проблемами при разработке пользовательского UI. Более подробно о подобных двойных иерархиях мы расскажем в разделе, посвящённом разработке SDK.
|
Ещё одним важным свойством слабой связности является то, что она позволяет сущности иметь несколько родительских контекстов. В обычных предметных областях такая ситуация выглядела бы ошибкой дизайна API, но в сложных системах, где присутствуют одновременно несколько агентов, влияющих на состояние системы, такая ситуация не является редкостью. В частности, вы почти наверняка столкнётесь с такого рода проблемами при разработке пользовательского UI. Более подробно о подобных двойных иерархиях мы расскажем в разделе «SDK и UI-библиотеки» настоящей книги.
|
||||||
|
|
||||||
#### Инверсия ответственности
|
#### Инверсия ответственности
|
||||||
|
|
||||||
@@ -145,43 +130,41 @@ registerProgramRunHandler(
|
|||||||
запуска программы на его кофемашинах */
|
запуска программы на его кофемашинах */
|
||||||
registerProgramRunHandler(
|
registerProgramRunHandler(
|
||||||
apiType,
|
apiType,
|
||||||
(context) => {
|
(program) => {
|
||||||
// Инициализируем запуск исполнения
|
// Инициализация исполнения заказа
|
||||||
// программы на стороне партнёра
|
// на стороне партерна
|
||||||
let execution =
|
let execution = initExecution(…);
|
||||||
initExecution(context, …);
|
// Подписка на изменения состояния
|
||||||
// Подписываемся на события
|
// родительского контекста
|
||||||
// изменения контекста
|
program.context.on(
|
||||||
context.on(
|
|
||||||
'takeout_requested',
|
'takeout_requested',
|
||||||
() => {
|
() => {
|
||||||
// Если запрошена выдача напитка,
|
// Если запрошена выдача заказа,
|
||||||
// инициализируем выдачу
|
// инициировать нужные операции
|
||||||
execution.prepareTakeout(() => {
|
await execution.prepareTakeout();
|
||||||
/* как только напиток
|
/* Когда заказ готов к выдаче,
|
||||||
готов к выдаче,
|
сигнализируем об этом вызовом
|
||||||
сигнализируем об этом,
|
метода родительского контекста,
|
||||||
вызовом метода контекста,
|
а не генерацией события */
|
||||||
а не генерацией события */
|
// execution.context
|
||||||
// execution.context
|
// .emit('takeout_ready')
|
||||||
// .emit('takeout_ready')
|
program.context
|
||||||
context.set('takeout_ready');
|
.set('takeout_ready');
|
||||||
// Или ещё более жёстко:
|
// Или даже более строго
|
||||||
// context.setTakeoutReady();
|
// program.setTakeoutReady();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
/* Так как мы модифицируем родитеский
|
||||||
);
|
контекст вместо генерации событий,
|
||||||
// Так как мы сами
|
нам не нужно что-либо возвращать */
|
||||||
// изменяем родительский контекст
|
// return execution.context;
|
||||||
// нет нужды что-либо возвращать
|
}
|
||||||
// return execution.context;
|
);
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Вновь такое решение выглядит контринтуитивным, ведь мы снова вернулись к сильной связи двух уровней через жёстко определённые методы. Однако здесь есть важный момент: мы городим весь этот огород потому, что ожидаем появления альтернативных реализаций *нижележащего* уровня абстракции. Ситуации, когда появляются альтернативные реализации *вышележащего* уровня абстракции, конечно, возможны, но крайне редки. Обычно дерево альтернативных реализаций растёт сверху вниз.
|
Вновь такое решение выглядит контринтуитивным, ведь мы снова вернулись к сильной связи двух уровней через жёстко определённые методы. Однако здесь есть важный момент: мы городим весь этот огород потому, что ожидаем появления альтернативных реализаций *нижележащего* уровня абстракции. Ситуации, когда появляются альтернативные реализации *вышележащего* уровня абстракции, конечно, возможны, но крайне редки. Обычно дерево альтернативных реализаций растёт от корня к листьям.
|
||||||
|
|
||||||
Другой аспект заключается в том, что, хотя серьёзные изменения концепции возможны на любом из уровней абстракции, их вес принципиально разный:
|
Другой аргумент в пользу такого подхода заключается в том, что, хотя серьёзные изменения концепции возможны на любом из уровней абстракции, их вес принципиально разный:
|
||||||
* если меняется технический уровень, это не должно существенно влиять на продукт, а значит — на написанный партнёрами код;
|
* если меняется технический уровень, это не должно существенно влиять на продукт, а значит — на написанный партнёрами код;
|
||||||
* если меняется сам продукт, ну например мы начинаем продавать билеты на самолёт вместо приготовления кофе на заказ, сохранять обратную совместимость на промежуточных уровнях API *бесполезно*. Мы вполне можем продавать билеты на самолёт тем же самым API программ и контекстов, да только написанный партнёрами код всё равно надо будет полностью переписывать с нуля.
|
* если меняется сам продукт, ну например мы начинаем продавать билеты на самолёт вместо приготовления кофе на заказ, сохранять обратную совместимость на промежуточных уровнях API *бесполезно*. Мы вполне можем продавать билеты на самолёт тем же самым API программ и контекстов, да только написанный партнёрами код всё равно надо будет полностью переписывать с нуля.
|
||||||
|
|
||||||
@@ -190,26 +173,38 @@ registerProgramRunHandler(
|
|||||||
**NB**: во многих современных системах используется подход с общим разделяемым состоянием приложения. Пожалуй, самый популярный пример такой системы — Redux. В парадигме Redux вышеприведённый код выглядел бы так:
|
**NB**: во многих современных системах используется подход с общим разделяемым состоянием приложения. Пожалуй, самый популярный пример такой системы — Redux. В парадигме Redux вышеприведённый код выглядел бы так:
|
||||||
|
|
||||||
```
|
```
|
||||||
execution.prepareTakeout(() => {
|
program.context.on(
|
||||||
// Вместо обращения к вышестоящей сущности
|
'takeout_requested',
|
||||||
// или генерации события на себе,
|
() => {
|
||||||
// компонент обращается к глобальному
|
await execution.prepareTakeout();
|
||||||
// состоянию и вызывает действия над ним
|
// Вместо генерации событий
|
||||||
dispatch(takeoutReady());
|
// или вызова методов родительского
|
||||||
});
|
// контекста, сущность `execution`
|
||||||
|
// обращается к глобальному
|
||||||
|
// или квази-глобальному методу
|
||||||
|
// `dispatch`, который изменяет
|
||||||
|
// глобальное состояние
|
||||||
|
dispatch(takeoutReady());
|
||||||
|
}
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
Надо отметить, что такой подход *в принципе* не противоречит описанному принципу, но нарушает другой — изоляцию уровней абстракции, а поэтому плохо подходит для написания сложных API, в которых не гарантирована жёсткая иерархия компонентов. При этом использовать глобальный (или квази-глобальный) менеджер состояния в таких системах вполне возможно, но требуется имплементировать более сложную пропагацию сообщений по иерархии, а именно: подчинённый объект всегда вызывает методы только ближайшего вышестоящего объекта, а уже тот решает, как и каким образом этот вызов передать выше по иерархии.
|
Надо отметить, что такой подход *в принципе* не противоречит описанным идеям снижения связности компонентов, но нарушает другой — изоляцию уровней абстракции, а поэтому плохо подходит для написания сложных API, в которых не гарантирована жёсткая иерархия компонентов. При этом использовать глобальный (или квази-глобальный) менеджер состояния в таких системах вполне возможно, но требуется имплементировать более сложную пропагацию сообщений по иерархии, а именно: подчинённый объект всегда вызывает методы только ближайшего вышестоящего объекта, а уже тот решает, как и каким образом этот вызов передать выше по иерархии.
|
||||||
|
|
||||||
```
|
```
|
||||||
execution.prepareTakeout(() => {
|
program.context.on(
|
||||||
// Вместо обращения к вышестоящей сущности
|
'takeout_requested',
|
||||||
// или генерации события на себе,
|
() => {
|
||||||
// компонент обращается к вышестоящему
|
await execution.prepareTakeout();
|
||||||
// объекту
|
// Вместо вызова глобального `dispatch`,
|
||||||
context.dispatch(takeoutReady());
|
// сущность `execution` вызывает
|
||||||
});
|
// функциональность `dispatch`
|
||||||
|
// на своём родительском контексте
|
||||||
|
program.context.dispatch(takeoutReady());
|
||||||
|
}
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
// Имплементация program.context.dispatch
|
// Имплементация program.context.dispatch
|
||||||
ProgramContext.dispatch = (action) => {
|
ProgramContext.dispatch = (action) => {
|
||||||
@@ -226,47 +221,6 @@ ProgramContext.dispatch = (action) => {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Проверим себя
|
|
||||||
|
|
||||||
Описав указанным выше образом взаимодействие со сторонними API, мы можем (и должны) теперь рассмотреть вопрос, совместимы ли эти интерфейсы с нашими собственными абстракциями, которые мы разработали в в главе [«Разделение уровней абстракции»](#api-design-separating-abstractions); иными словами, можно ли запустить исполнение такого заказа, оперируя не высокоуровневым, а низкоуровневым API.
|
|
||||||
|
|
||||||
Напомним, что мы предложили вот такие абстрактные интерфейсы для работы с произвольными типами API кофемашин:
|
|
||||||
|
|
||||||
* `POST /v1/program-matcher` возвращает идентификатор программы по идентификатору кофемашины и рецепта;
|
|
||||||
* `POST /v1/programs/{id}/run` запускает программу на исполнение.
|
|
||||||
|
|
||||||
Как легко убедиться, добиться совместимости с этими интерфейсами очень просто: для этого достаточно присвоить идентификатор `program_id` паре (тип API, рецепт), например, вернув его из метода `PUT /coffee-machines`:
|
|
||||||
|
|
||||||
```
|
|
||||||
PUT /v1/partners/{partnerId}/coffee-machines
|
|
||||||
{
|
|
||||||
"coffee_machines": [{
|
|
||||||
"id",
|
|
||||||
"api_type",
|
|
||||||
"location",
|
|
||||||
"supported_recipes"
|
|
||||||
}, …]
|
|
||||||
}
|
|
||||||
→
|
|
||||||
{
|
|
||||||
"coffee_machines": [{
|
|
||||||
"id",
|
|
||||||
"recipes_programs": [
|
|
||||||
{"recipe_id", "program_id"},
|
|
||||||
…
|
|
||||||
]
|
|
||||||
}, …]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
И разработанный нами метод
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /v1/programs/{id}/run
|
|
||||||
```
|
|
||||||
|
|
||||||
будет работать и с партнёрскими кофемашинами (читай, с третьим видом API).
|
|
||||||
|
|
||||||
#### Делегируй!
|
#### Делегируй!
|
||||||
|
|
||||||
Из описанных выше принципов следует ещё один чрезвычайно важный вывод: выполнение реальной работы, то есть реализация каких-то конкретных действий (приготовление кофе, в нашем случае) должна быть делегирована низшим уровням иерархии абстракций. Если верхние уровни абстракции попробуют предписать конкретные алгоритмы исполнения, то, как мы увидели в примере с `order_execution_endpoint`, мы быстро придём к ситуации противоречивой номенклатуры методов и протоколов взаимодействия, бо́льшая часть которых в рамках конкретного «железа» не имеет смысла.
|
Из описанных выше принципов следует ещё один чрезвычайно важный вывод: выполнение реальной работы, то есть реализация каких-то конкретных действий (приготовление кофе, в нашем случае) должна быть делегирована низшим уровням иерархии абстракций. Если верхние уровни абстракции попробуют предписать конкретные алгоритмы исполнения, то, как мы увидели в примере с `order_execution_endpoint`, мы быстро придём к ситуации противоречивой номенклатуры методов и протоколов взаимодействия, бо́льшая часть которых в рамках конкретного «железа» не имеет смысла.
|
||||||
|
Reference in New Issue
Block a user