1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-08-10 21:51:42 +02:00

Fresh build

This commit is contained in:
Sergey Konstantinov
2023-04-08 16:47:48 +03:00
parent f40bc5c812
commit 4564618888
6 changed files with 151 additions and 177 deletions

Binary file not shown.

View File

@@ -3145,7 +3145,7 @@ PUT /formatters/volume/ru/US
<li>depending on the API type, run some specific commands.</li>
</ul>
<p>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 <code>program_run_id</code> entity is also organized in this manner, it also possesses no specific properties, being <em>just</em> a program run identifier.</p><div class="page-break"></div><h3><a href="#back-compat-weak-coupling" class="anchor" id="back-compat-weak-coupling">Chapter 17. Weak Coupling</a><a href="#chapter-17" class="secondary-anchor" id="chapter-17"> </a></h3>
<p>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 <a href="#back-compat-abstracting-extending">“Extending through Abstracting”</a> chapter: how should we parametrize the order preparation process implemented via a third-party API? In other words, what <em>is</em> this <code>order_execution_endpoint</code> required in the API type registration endpoint?</p>
<p>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 <a href="#back-compat-abstracting-extending">“Extending through Abstracting”</a> chapter: how should we parametrize the order preparation process implemented via a third-party API? In other words, what <em>is</em> the <code>order_execution_endpoint</code> required in the API type registration handler?</p>
<pre><code>PUT /v1/api-types/{api_type}
{
@@ -3171,14 +3171,14 @@ PUT /formatters/volume/ru/US
}
}
</code></pre>
<p><strong>NB</strong>: 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 <code>program_run_endpoint</code>, and what format the <code>program_get_state_endpoint</code> shall return, etc., but in this chapter, we're focusing on different questions.</p>
<p><strong>NB</strong>: by doing so, we transfer the complexity of developing the API onto the plane of developing appropriate data formats, e.g. developing formats for order parameters to the <code>program_run_endpoint</code>, and what format the <code>program_get_state_endpoint</code> shall return, etc., but in this chapter, we're focusing on different questions.</p>
<p>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:</p>
<ol>
<li>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.</li>
<li>This design is ultimately based on a single principle: every order preparation might be codified with these three imperative commands.</li>
</ol>
<p>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, <code>program_modify_endpoint</code>, and new difficulties in data format development (as new fields for contactless delivery requested and satisfied flags need to be passed both directions). What <em>is</em> important is that both the endpoint and the new data fields would be optional because of backwards compatibility requirement.</p>
<p>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 <code>modify</code> 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 <em>takeout approval</em>: 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.</p>
<p>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 not a coffee house, but a vending machine via our API? From one side, it means that the <code>modify</code> 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 <em>takeout approval</em>: 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.</p>
<p>Programmable takeout approval requires one more endpoint, let's say, <code>program_takeout_endpoint</code>. And so we've lost our way in a forest of five endpoints:</p>
<ul>
<li>to have vending machines integrated a partner must implement the <code>program_takeout_endpoint</code>, but doesn't need the <code>program_modify_endpoint</code>;</li>
@@ -3199,7 +3199,7 @@ PUT /formatters/volume/ru/US
<li>running a program creates a corresponding context comprising all the essential parameters;</li>
<li>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.</li>
</ul>
<p>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:</p>
<p>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 emitting and listening events, like this:</p>
<pre><code>/* Partner's implementation of the program
run procedure for a custom API type */
registerProgramRunHandler(
@@ -3208,11 +3208,11 @@ registerProgramRunHandler(
// Initiating an execution
// on partner's side
let execution = initExecution(…);
// Listen to parent context's changes
// Listen to parent context changes
program.context.on(
'takeout_requested',
() => {
// If takeout is requested, initiate
// If a takeout is requested, initiate
// corresponding procedures
await execution.prepareTakeout();
// When the cup is ready for takeout,
@@ -3233,7 +3233,7 @@ registerProgramRunHandler(
}
);
</code></pre>
<p><strong>NB</strong>: In the case of HTTP API corresponding example would look rather bulky as it involves implementing several additional endpoints for message exchange like <code>GET /program-run/events</code> and <code>GET /partner/{id}/execution/events</code>. 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.</p>
<p><strong>NB</strong>: In the case of HTTP API, a corresponding example would look rather bulky as it would require implementing several additional endpoints for the message exchange like <code>GET /program-run/events</code> and <code>GET /partner/{id}/execution/events</code>. 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.</p>
<p>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:</p>
<ul>
<li>instead of calling the <code>takeout</code> method, we're now generating a pair of <code>takeout_requested</code> / <code>takeout_ready</code> events;</li>
@@ -3259,11 +3259,11 @@ registerProgramRunHandler(
// Initiating an execution
// on partner's side
let execution = initExecution(…);
// Listen to parent context's changes
// Listen to parent context changes
program.context.on(
'takeout_requested',
() => {
// If takeout is requested, initiate
// If a takeout is requested, initiate
// corresponding procedures
await execution.prepareTakeout();
/* When the order is ready
@@ -3306,7 +3306,7 @@ registerProgramRunHandler(
}
);
</code></pre>
<p>Let us note that this approach <em>in general</em> 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.</p>
<p>Let us note that this approach <em>in general</em> doesn't contradict the weak coupling principle, but violates another one — of abstraction levels isolation, and therefore isn't very well suited 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, e.g. 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.</p>
<pre><code>program.context.on(
'takeout_requested',
() => {
@@ -3334,16 +3334,16 @@ ProgramContext.dispatch = (action) => {
</code></pre>
<h4>Delegate!</h4>
<p>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 <code>order_execution_endpoint</code> 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.</p>
<p>Contrariwise, applying the paradigm of concretizing the contexts at each new abstraction level, we will eventually fall into the bunny hole deep enough to have nothing to concretize: the context itself unambiguously matches the functionality we can programmatically control. And at that level, we must stop detailing contexts further, and just realize the algorithms needed. Worth mentioning that the abstraction deepness for different underlying platforms might vary.</p>
<p>Contrariwise, applying the paradigm of concretizing the contexts at each new abstraction level, we will eventually fall into the bunny hole deep enough to have nothing to concretize: the context itself unambiguously matches the functionality we can programmatically control. And at that level, we must stop detailing contexts further, and just realize the algorithms needed. It's worth mentioning that the abstraction deepness for different underlying platforms might vary.</p>
<p><strong>NB</strong>. In the <a href="#api-design-separating-abstractions">“Separating Abstraction Levels”</a> chapter we have illustrated exactly this: when we speak about the first coffee machine API type, there is no need to extend the tree of abstractions further than running programs, but with the second API type, we need one more intermediary abstraction level, namely the runtimes API.</p><div class="page-break"></div><h3><a href="#back-compat-universal-interfaces" class="anchor" id="back-compat-universal-interfaces">Chapter 18. Interfaces as a Universal Pattern</a><a href="#chapter-18" class="secondary-anchor" id="chapter-18"> </a></h3>
<p>Let us summarize what we have written in the three previous chapters.</p>
<p>Let us summarize what we have written in the three previous chapters:</p>
<ol>
<li>Extending API functionality is realized through abstracting: the entity nomenclature is to be reinterpreted so that existing methods become partial (ideally — the most frequent) simplified cases to more general functionality.</li>
<li>Extending API functionality is implemented through abstracting: the entity nomenclature is to be reinterpreted so that existing methods become partial (ideally — the most frequent) simplified cases to more general functionality.</li>
<li>Higher-level entities are to be the informational contexts for low-level ones, e.g. don't prescribe any specific behavior but translate their state and expose functionality to modify it (directly through calling some methods or indirectly through firing events).</li>
<li>Concrete functionality, e.g. working with “bare metal” hardware or underlying platform APIs, should be delegated to low-level entities.</li>
</ol>
<p><strong>NB</strong>. There is nothing novel about these rules: one might easily recognize them being the <a href="https://en.wikipedia.org/wiki/SOLID">SOLID</a> architecture principles. There is no surprise in that either, because SOLID concentrates on contract-oriented development, and APIs are contracts by definition. We've just added “abstraction levels” and “informational contexts” concepts there.</p>
<p>However, there is an unanswered question: how should we design the entity nomenclature from the beginning so that extending the API won't make it a mess of different inconsistent methods of different ages. The answer is pretty obvious: to avoid clumsy situations while abstracting (as with the coffee machine's supported options), all the entities must be originally considered being a specific implementation of a more general interface, even if there are no planned alternative implementations for them.</p>
<p><strong>NB</strong>. There is nothing novel about these rules: one might easily recognize them being the <a href="https://en.wikipedia.org/wiki/SOLID">SOLID</a> architecture principles. There is no surprise in that either, because SOLID concentrates on contract-oriented development, and APIs are contracts by definition. We've just added the “abstraction levels” and “informational contexts” concepts there.</p>
<p>However, there is an unanswered question: how should we design the entity nomenclature from the beginning so that extending the API won't make it a mess of different inconsistent methods of different ages. The answer is pretty obvious: to avoid clumsy situations while abstracting (as with the recipe properties), all the entities must be originally considered being a specific implementation of a more general interface, even if there are no planned alternative implementations for them.</p>
<p>For example, we should have asked ourselves a question while designing the <code>POST /search</code> API: what is a “search result”? What abstract interface does it implement? To answer this question we must neatly decompose this entity to find which facet of it is used for interacting with which objects.</p>
<p>Then we would have come to the understanding that a “search result” is actually a composition of two interfaces:</p>
<ul>
@@ -3353,10 +3353,10 @@ ProgramContext.dispatch = (action) => {
<p>or we can encode this data in the single <code>offer_id</code>;</p>
</li>
<li>
<p>to have this search result displayed in the app, we need a different data set: <code>name</code>, <code>description</code>, formatted and localized price.</p>
<p>to have this search result displayed in the app, we need a different data set: <code>name</code>, <code>description</code>, and formatted and localized prices.</p>
</li>
</ul>
<p>So our interface (let us call it <code>ISearchResult</code>) is actually a composition of two other interfaces: <code>IOrderParameters</code> (an entity that allows for creating an order) and <code>ISearchItemViewParameters</code> (some abstract representation of the search result in the UI). This interface split should automatically lead us to additional questions.</p>
<p>So our interface (let us call it <code>ISearchResult</code>) is actually a composition of two other interfaces: <code>IOrderParameters</code> (an entity that allows for creating an order) and <code>ISearchItemViewParameters</code> (some abstract representation of the search result in the UI). This interface split should automatically lead us to additional questions:</p>
<ol>
<li>
<p>How will we couple the former and the latter? Obviously, these two sub-interfaces are related: the machine-readable price must match the human-readable one, for example. This will naturally lead us to the “formatter” concept described in the <a href="#back-compat-strong-coupling">“Strong Coupling and Related Problems”</a> chapter.</p>
@@ -3365,21 +3365,21 @@ ProgramContext.dispatch = (action) => {
<p>And what is the “abstract representation of the search result in the UI”? Do we have other kinds of search, should the <code>ISearchItemViewParameters</code> interface be a subtype of some even more general interface, or maybe a composition of several such ones?</p>
</li>
</ol>
<p>Replacing specific implementations with interfaces not only allows us to answer more clearly many questions which should have popped out in the API design phase but also helps us to outline many possible API evolution vectors, which should help in avoiding API inconsistency problems in the future.</p><div class="page-break"></div><h3><a href="#back-compat-serenity-notepad" class="anchor" id="back-compat-serenity-notepad">Chapter 19. The Serenity Notepad</a><a href="#chapter-19" class="secondary-anchor" id="chapter-19"> </a></h3>
<p>Apart from the abovementioned abstract principles, let us give a list of concrete recommendations: how to make changes in the existing API to maintain the backwards compatibility.</p>
<p>Replacing specific implementations with interfaces not only allows us to respond more clearly to many concerns that pop up during the API design phase but also helps us to outline many possible API evolution directions, which should help us in avoiding API inconsistency problems in the future.</p><div class="page-break"></div><h3><a href="#back-compat-serenity-notepad" class="anchor" id="back-compat-serenity-notepad">Chapter 19. The Serenity Notepad</a><a href="#chapter-19" class="secondary-anchor" id="chapter-19"> </a></h3>
<p>Apart from the abovementioned abstract principles, let us give a list of concrete recommendations: how to make changes in existing APIs to maintain backwards compatibility.</p>
<h5><a href="#chapter-19-paragraph-1" id="chapter-19-paragraph-1" class="anchor">1. Remember the iceberg's waterline</a></h5>
<p>If you haven't given any formal guarantee, it doesn't mean that you can violate informal once. Often, even just fixing bugs in APIs might make some developers' code inoperable. We might illustrate it with a real-life example that the author of this book has actually faced once:</p>
<p>If you haven't given any formal guarantee, it doesn't mean that you can violate informal ones. Often, just fixing bugs in APIs might render some developers' code inoperable. We might illustrate it with a real-life example that the author of this book has actually faced once:</p>
<ul>
<li>there was an API to place a button into a visual container; according to the docs, it was taking its position (offsets to the container's corner) as a mandatory argument;</li>
<li>in reality, there was a bug: if the position was not supplied, no exception was thrown; buttons were simply stacked in the corner one after another;</li>
<li>after the error was fixed, we got a bunch of complaints: clients did really use this flaw to stack the buttons in the container's corner.</li>
<li>after the error had been fixed, we got a bunch of complaints: clients did really use this flaw to stack the buttons in the container's corner.</li>
</ul>
<p>If fixing the error might somehow affect real customers, you have no other choice but to emulate this erroneous behavior until the next major release. This situation is quite common if you develop a large API with a huge audience. For example, operating systems API developers literally have to transfer old bugs to new OS versions.</p>
<p>If fixing an error might somehow affect real customers, you have no other choice but to emulate this erroneous behavior until the next major release. This situation is quite common if you develop a large API with a huge audience. For example, operating systems developers literally have to transfer old bugs to new OS versions.</p>
<h5><a href="#chapter-19-paragraph-2" id="chapter-19-paragraph-2" class="anchor">2. Test the formal interface</a></h5>
<p>Any software must be tested, and APIs ain't an exclusion. However, there are some subtleties there: as APIs provide formal interfaces, it's the formal interfaces that are needed to be tested. That leads to several kinds of mistakes:</p>
<ol>
<li>Often the requirements like “the <code>getEntity</code> function returns the value previously being set by the <code>setEntity</code> function” appear to be too trivial to both developers and QA engineers to have a proper test. But it's quite possible to make a mistake there, and we have actually encountered such bugs several times.</li>
<li>The interface abstraction principle must be tested either. In theory, you might have considered each entity as an implementation of some interface; in practice, it might happen that you have forgotten something, and alternative implementations aren't actually possible. For testing purposes, it's highly desirable to have an alternative realization, even a provisional one.</li>
<li>The interface abstraction principle must be tested either. In theory, you might have considered each entity as an implementation of some interface; in practice, it might happen that you have forgotten something and alternative implementations aren't actually possible. For testing purposes, it's highly desirable to have an alternative realization, even a provisional one, for every interface.</li>
</ol>
<h5><a href="#chapter-19-paragraph-3" id="chapter-19-paragraph-3" class="anchor">3. Isolate the dependencies</a></h5>
<p>In the case of a gateway API that provides access to some underlying API or aggregates several APIs behind a single façade, there is a strong temptation to proxy the original interface as is, thus not introducing any changes to it and making a life much simpler by sparing an effort needed to implement the weak-coupled interaction between services. For example, while developing program execution interfaces as described in the <a href="#api-design-separating-abstractions">“Separating Abstraction Levels”</a> chapter we might have taken the existing first-kind coffee-machine API as a role model and provided it in our API by just proxying the requests and responses as is. Doing so is highly undesirable because of several reasons:</p>
@@ -3390,26 +3390,26 @@ ProgramContext.dispatch = (action) => {
<p>The best practice is quite the opposite: isolate the third-party API usage, e.g. develop an abstraction level that will allow for:</p>
<ul>
<li>keeping backwards compatibility intact because of extension capabilities incorporated in the API design;</li>
<li>negating partner's problems by the technical means:
<li>negating partner's problems by technical means:
<ul>
<li>limiting the partner's API usage in case of an unpredicted surge in your API usage;</li>
<li>limiting the partner's API usage in case of load surges;</li>
<li>implementing the retry policies or other methods of recovering after failures;</li>
<li>caching some data and states to have the ability to provide some (at least partial) functionality even if the partner's API is fully unreachable;</li>
<li>finally, configuring an automatical fallback to another partner or alternative API.</li>
<li>finally, configuring an automatic fallback to another partner or alternative API.</li>
</ul>
</li>
</ul>
<h5><a href="#chapter-19-paragraph-4" id="chapter-19-paragraph-4" class="anchor">4. Implement your API functionality atop of public interfaces</a></h5>
<h5><a href="#chapter-19-paragraph-4" id="chapter-19-paragraph-4" class="anchor">4. Implement your API functionality atop public interfaces</a></h5>
<p>There is an antipattern that occurs frequently: API developers use some internal closed implementations of some methods which exist in the public API. It happens because of two reasons:</p>
<ul>
<li>often the public API is just an addition to the existing specialized software, and the functionality, exposed via the API, isn't being ported back to the closed part of the project, or the public API developers simply don't know the corresponding internal functionality exists;</li>
<li>on a course of extending the API, some interfaces become abstract, but the existing functionality isn't affected; imagine that while implementing the <code>PUT /formatters</code> interface described in the the <a href="#back-compat-strong-coupling">“Strong Coupling and Related Problems”</a> chapter developers have created a new, more general version of the volume formatter but hasn't changed the implementation of the existing one, so it continues working in case of pre-existing languages.</li>
<li>in the course of extending the API, some interfaces become abstract, but the existing functionality isn't affected; imagine that while implementing the <code>PUT /formatters</code> interface described in the <a href="#back-compat-strong-coupling">“Strong Coupling and Related Problems”</a> chapter API developers have created a new, more general version of the volume formatter but hasn't changed the implementation of the existing one, so it continues working for pre-existing languages.</li>
</ul>
<p>There are obvious local problems with this approach (like the inconsistency in functions' behavior, or the bugs which were not found while testing the code), but also a bigger one: your API might be simply unusable if a developer tries any non-mainstream approach, because of performance issues, bugs, instability, etc.</p>
<p><strong>NB</strong>. The perfect example of avoiding this anti-pattern is compiler development; usually, the next compiler's version is compiled with the previous compiler's version.</p>
<p>There are obvious local problems with this approach (like the inconsistency in functions' behavior, or the bugs which were not found while testing the code), but also a bigger one: your API might be simply unusable if a developer tries any non-mainstream approach, because of performance issues, bugs, instability, etc., as the API developers themselves never tried to use this public interface for anything important.</p>
<p><strong>NB</strong>. The perfect example of avoiding this anti-pattern is the development of compilers; usually, the next compiler's version is compiled with the previous compiler's version.</p>
<h5><a href="#chapter-19-paragraph-5" id="chapter-19-paragraph-5" class="anchor">5. Keep a notepad</a></h5>
<p>Whatever tips and tricks that were described in the previous chapters you use, it's often quite probable that you can't do <em>anything</em> to prevent the API inconsistencies start piling up. It's possible to reduce the speed of this stockpiling, foresee some problems, and have some interface durability reserved for future use. But one can't foresee <em>everything</em>. At this stage, many developers tend to make some rash decisions, e.g. releasing a backwards-incompatible minor version to fix some design flaws.</p>
<p>We highly recommend never doing that. Remember that the API is a multiplier of your mistakes either. What we recommend is to keep a serenity notepad — to fix the lessons learned, and not to forget to apply this knowledge when the major API version is released.</p><div class="page-break"></div><h2><a href="#section-4" class="anchor" id="section-4">Section III. The API Product</a></h2><h3><a href="#api-product" class="anchor" id="api-product">Chapter 20. API as a Product</a><a href="#chapter-20" class="secondary-anchor" id="chapter-20"> </a></h3>
<p>Whatever tips and tricks described in the previous chapters you use, it's often quite probable that you can't do <em>anything</em> to prevent API inconsistencies from piling up. It's possible to reduce the speed of this stockpiling, foresee some problems, and have some interface durability reserved for future use. But one can't foresee <em>everything</em>. At this stage, many developers tend to make some rash decisions, e.g. releasing a backwards-incompatible minor version to fix some design flaws.</p>
<p>We highly recommend never doing that. Remember that the API is also a multiplier of your mistakes. What we recommend is to keep a serenity notepad — to write down the lessons learned, and not to forget to apply this knowledge when a new major API version is released.</p><div class="page-break"></div><h2><a href="#section-4" class="anchor" id="section-4">Section III. The API Product</a></h2><h3><a href="#api-product" class="anchor" id="api-product">Chapter 20. API as a Product</a><a href="#chapter-20" class="secondary-anchor" id="chapter-20"> </a></h3>
<p>There are two important statements regarding APIs viewed as products.</p>
<ol>
<li>

Binary file not shown.

Binary file not shown.

View File

@@ -2974,7 +2974,7 @@ POST /v1/recipes
"product_properties": {
"name",
"description",
"default_value"
"default_volume"
// Прочие параметры, описывающие
// напиток для пользователя
@@ -3007,15 +3007,17 @@ POST /v1/recipes
<h4>Правило контекстов</h4>
<p>Как бы парадоксально это ни звучало, обратное утверждение тоже верно: высокоуровневые сущности тоже не должны определять низкоуровневые. Это попросту не их ответственность. Выход из этого логического лабиринта таков: высокоуровневые сущности должны <em>определять контекст</em>, который другие объекты будут интерпретировать. Чтобы спроектировать добавление нового рецепта нам нужно не формат данных подобрать — нам нужно понять, какие (возможно, неявные, т.е. не представленные в виде API) контексты существуют в нашей предметной области.</p>
<p>Как уже понятно, существует контекст локализации. Есть какой-то набор языков и регионов, которые мы поддерживаем в нашем API, и есть требования — что конкретно необходимо предоставить партнёру, чтобы API заработал на новом языке в новом регионе. Конкретно в случае объёма кофе где-то в недрах нашего API (во внутренней реализации или в составе SDK) есть функция форматирования строк для отображения объёма напитка:</p>
<pre><code>l10n.volume.format(
<pre><code>l10n.volume.format = function(
value, language_code, country_code
)
// l10n.formatVolume(
// '300ml', 'en', 'UK'
// ) → '300 ml'
// l10n.formatVolume(
// '300ml', 'en', 'US'
// ) → '10 fl oz'
) { … }
/*
l10n.formatVolume(
'300ml', 'en', 'UK'
) → '300 ml'
l10n.formatVolume(
'300ml', 'en', 'US'
) → '10 fl oz'
*/
</code></pre>
<p>Чтобы наш API корректно заработал с новым языком или регионом, партнёр должен или задать эту функцию через партнёрский API, или указать, какую из существующих локализаций необходимо использовать. Для этого мы абстрагируем-и-расширяем API, в соответствии с описанной в предыдущей главе процедурой, и добавляем новый эндпойнт — настройки форматирования:</p>
<pre><code>// Добавляем общее правило форматирования
@@ -3030,7 +3032,7 @@ PUT /formatters/volume/ru/US
{
// В США требуется сначала пересчитать
// объём, потом добавить постфикс
"value_preparation": {
"value_transform": {
"action": "divide",
"divisor": 30
},
@@ -3080,15 +3082,7 @@ PUT /formatters/volume/ru/US
"search_title", "search_description"
}
</code></pre>
<p>Либо создать свой макет и задавать нужные для него поля:</p>
<pre><code>POST /v1/layouts
{
"properties"
}
{ "id", "properties" }
</code></pre>
<p>В конце концов, партнёр может отрисовывать UI самостоятельно и вообще не пользоваться этой техникой, не задавая ни макеты, ни поля.</p>
<p>Либо создать свой макет и задавать нужные для него поля. В конце концов, партнёр может отрисовывать UI самостоятельно и вообще не пользоваться этой техникой, не задавая ни макеты, ни поля.</p>
<p>Наш интерфейс добавления рецепта получит в итоге вот такой вид:</p>
<pre><code>POST /v1/recipes
{ "id" }
@@ -3142,8 +3136,8 @@ PUT /formatters/volume/ru/US
"my-coffee-company:lungo-customato"
}
</code></pre>
<p>Заметим, что в таком формате мы сразу закладываем важное допущение: различные партнёры могут иметь как полностью изолированные неймспейсы, так и разделять их. Более того, мы можем ввести специальные неймспейсы типа "common", которые позволят публиковать новые рецепты для всех. (Это, кстати говоря, хорошо ещё и тем, что такой API мы сможем использовать для организации нашей собственной панели управления контентом.)</p><div class="page-break"></div><h3><a href="#back-compat-weak-coupling" class="anchor" id="back-compat-weak-coupling">Глава 17. Слабая связность</a><a href="#chapter-17" class="secondary-anchor" id="chapter-17"> </a></h3>
<p>В предыдущей главе мы продемонстрировали, как разрыв сильной связности приводит к декомпозиции сущностей и схлопыванию публичных интерфейсов до минимума. Внимательный читатель может подметить, что этот приём уже был продемонстрирован в нашем учебном API гораздо раньше в главе <a href="#api-design-separating-abstractions">«Разделение уровней абстракции»</a> на примере сущностей «программа» и «запуск программы». В самом деле, мы могли бы обойтись без программ и без эндпойнта <code>program-matcher</code> и пойти вот таким путём:</p>
<p>Заметим, что в таком формате мы сразу закладываем важное допущение: различные партнёры могут иметь как полностью изолированные неймспейсы, так и разделять их. Более того, мы можем ввести специальные неймспейсы типа "common", которые позволят публиковать новые рецепты для всех. (Это, кстати говоря, хорошо ещё и тем, что такой API мы сможем использовать для организации нашей собственной панели управления контентом.)</p>
<p><strong>NB</strong>: внимательный читатель может подметить, что этот приём уже был продемонстрирован в нашем учебном API гораздо раньше в главе <a href="#api-design-separating-abstractions">«Разделение уровней абстракции»</a> на примере сущностей «программа» и «запуск программы». В самом деле, мы могли бы обойтись без программ и без эндпойнта <code>program-matcher</code> и пойти вот таким путём:</p>
<pre><code>GET /v1/recipes/{id}/run-data/{api_type}
{ /* описание способа запуска
@@ -3158,8 +3152,8 @@ PUT /formatters/volume/ru/US
<li>в зависимости от типа API выполнить специфические команды запуска.</li>
</ul>
<p>Очевидно, что такой интерфейс совершенно недопустим — просто потому, что в подавляющем большинстве случаев разработчикам совершенно неинтересно, какого рода API поддерживает та или иная кофемашина. Для того чтобы не допустить такого плохого интерфейса, мы ввели новую сущность «программа», которая по факту представляет собой не более чем просто идентификатор контекста, как и сущность «рецепт».</p>
<p>Аналогичным образом устроена и сущность <code>program_run_id</code>, идентификатор запуска программы. Он также по сути не имеет почти никакого интерфейса и состоит только из идентификатора запуска.</p>
<p>Вернёмся теперь к вопросу, который мы вскользь затронули в предыдущей главе — каким образом нам параметризовать приготовление заказа, если оно исполняется через сторонний API. Иными словами, что такое этот самый <code>program_execution_endpoint</code>, передавать который мы потребовали при регистрации нового типа API?</p>
<p>Аналогичным образом устроена и сущность <code>program_run_id</code>, идентификатор запуска программы. Он также по сути не имеет почти никакого интерфейса и состоит только из идентификатора запуска.</p><div class="page-break"></div><h3><a href="#back-compat-weak-coupling" class="anchor" id="back-compat-weak-coupling">Глава 17. Слабая связность</a><a href="#chapter-17" class="secondary-anchor" id="chapter-17"> </a></h3>
<p>В предыдущей главе мы продемонстрировали, как разрыв сильной связности приводит к декомпозиции сущностей и схлопыванию публичных интерфейсов до минимума. Вернёмся теперь к вопросу, который мы вскользь затронули в главе <a href="#back-compat-abstracting-extending">«Расширение через абстрагирование»</a>: каким образом нам нужно параметризовать приготовление заказа, если оно исполняется через сторонний API? Иными словами, что такое этот самый <code>order_execution_endpoint</code>, передавать который мы потребовали при регистрации нового типа API?</p>
<pre><code>PUT /v1/api-types/{api_type}
{
"order_execution_endpoint": {
@@ -3168,74 +3162,77 @@ PUT /formatters/volume/ru/US
}
</code></pre>
<p>Исходя из общей логики мы можем предположить, что любой API так или иначе будет выполнять три функции: запускать программы с указанными параметрами, возвращать текущий статус запуска и завершать (отменять) заказ. Самый очевидный подход к реализации такого API — просто потребовать от партнёра имплементировать вызов этих трёх функций удалённо, например следующим образом:</p>
<pre><code>// Эндпойнт добавления списка
// кофемашин партнёра
PUT /v1/api-types/{api_type}
<pre><code>PUT /v1/api-types/{api_type}
{
"order_execution_endpoint":
"order_execution_endpoint": {
"program_run_endpoint": {
/* Какое-то описание
удалённого вызова эндпойнта */
удалённого вызова */
"type": "rpc",
"endpoint": &#x3C;URL>,
"format"
"parameters"
},
"program_state_endpoint",
"program_get_state_endpoint",
"program_cancel_endpoint"
}
}
</code></pre>
<p><strong>NB</strong>: во многом таким образом мы переносим сложность разработки API в плоскость разработки форматов данных (каким образом мы будем передавать параметры запуска в <code>program_run_endpoint</code>, и в каком формате должен отвечать <code>program_state_endpoint</code>, но в рамках этой главы мы сфокусируемся на других вопросах.)</p>
<p>Хотя это API и кажется абсолютно универсальным, на его примере можно легко показать, каким образом изначально простые и понятные API превращаются в сложные и запутанные. У этого дизайна есть две основные проблемы.</p>
<p><strong>NB</strong>: во многом таким образом мы переносим сложность разработки API в плоскость разработки форматов данных (каким образом мы будем передавать параметры запуска в <code>program_run_endpoint</code>, и в каком формате должен отвечать <code>program_get_state_endpoint</code>, но в рамках этой главы мы сфокусируемся на других вопросах.)</p>
<p>Хотя это API и кажется абсолютно универсальным, на его примере можно легко показать, каким образом изначально простые и понятные API превращаются в сложные и запутанные. У этого дизайна есть две основные проблемы:</p>
<ol>
<li>Он хорошо описывает уже реализованные нами интеграции (т.е. в эту схему легко добавить поддержку известных нам типов API), но не привносит никакой гибкости в подход: по сути мы описали только известные нам способы интеграции, не попытавшись взглянуть на более общую картину.</li>
<li>Этот дизайн изначально основан на следующем принципе: любое приготовление заказа можно описать этими тремя императивными командами.</li>
</ol>
<p>Пункт 2 очень легко опровергнуть, что автоматически вскроет проблемы пункта 1. Предположим для начала, что в ходе развития функциональности мы решили дать пользователю возможность изменять свой заказ уже после того, как он создан — ну, например, попросить посыпать кофе корицей или выдать заказ бесконтактно. Это автоматически влечёт за собой добавление нового эндпойнта, ну скажем, <code>program_modify_endpoint</code>, и новых сложностей в формате обмена данными (нам нужно уметь понимать в реальном времени, можно ли этот конкретный кофе посыпать корицей). Что важно, и то, и другое (и эндпойнт, и новые поля данных) из соображений обратной совместимости будут необязательными.</p>
<p>Теперь попытаемся придумать какой-нибудь пример реального мира, который не описывается нашими тремя императивами. Это довольно легко: допустим, мы подключим через наш API не кофейню, а вендинговый автомат. Это, с одной стороны, означает, что эндпойнт <code>modify</code> и вся его обвязка для этого типа API бесполезны — автомат не умеет посыпать кофе корицей, а требование бесконтактной выдачи попросту ничего не значит. С другой, автомат, в отличие от оперируемой людьми кофейни, требует программного способа <em>подтверждения выдачи</em> напитка: пользователь делает заказ, находясь где-то в другом месте, потом доходит до автомата и нажимает в приложении кнопку «выдать заказ». Мы могли бы, конечно, потребовать, чтобы пользователь создавал заказ автомату, стоя прямо перед ним, но это, в свою очередь, противоречит нашей изначальной концепции, в которой пользователь выбирает и заказывает напиток, исходя из доступных опций, а потом идёт в указанную точку, чтобы его забрать.</p>
<p>Программная выдача напитка потребует добавления ещё одного эндпойнта, ну скажем, <code>program_takeout_endpoint</code>. И вот мы уже запутались в лесу из трёх эндпойнтов:</p>
<p>Пункт 2 очень легко опровергнуть, что автоматически вскроет проблемы пункта 1. Предположим для начала, что в ходе развития функциональности мы решили дать пользователю возможность изменять свой заказ уже после того, как он создан — например, попросить выдать заказ бесконтактно. Это автоматически влечёт за собой добавление нового эндпойнта, ну скажем, <code>program_modify_endpoint</code>, и новых сложностей в формате обмена данными (нам нужно пересылать данные о появлении требования о бесконтактной доставке и его удовлетворении). Что важно, и то, и другое (и эндпойнт, и новые поля данных) из соображений обратной совместимости будут необязательными.</p>
<p>Теперь попытаемся придумать какой-нибудь пример реального мира, который не описывается нашими тремя императивами. Это довольно легко: допустим, мы подключим через наш API не кофейню, а вендинговый автомат. Это, с одной стороны, означает, что эндпойнт <code>modify</code> и вся его обвязка для этого типа API бесполезны — требование бесконтактной выдачи попросту ничего не значит для автомата. С другой, автомат, в отличие от оперируемой людьми кофейни, требует программного способа <em>подтверждения выдачи</em> напитка: пользователь делает заказ, находясь где-то в другом месте, потом доходит до автомата и нажимает в приложении кнопку «выдать заказ». Мы могли бы, конечно, потребовать, чтобы пользователь создавал заказ автомату, стоя прямо перед ним, но это, в свою очередь, противоречит нашей изначальной концепции, в которой пользователь выбирает и заказывает напиток, исходя из доступных опций, а потом идёт в указанную точку, чтобы его забрать.</p>
<p>Программная выдача напитка потребует добавления ещё одного эндпойнта, ну скажем, <code>program_takeout_endpoint</code>. И вот мы уже запутались в лесу из пяти эндпойнтов:</p>
<ul>
<li>для работы вендинговых автоматов нужно реализовать эндпойнт <code>program_takeout_endpoint</code>, но не нужно реализовывать <code>program_modify_endpoint</code>;</li>
<li>для работы обычных кофеен нужно реализовать эндпойнт <code>program_modify_endpoint</code>, но не нужно реализовывать <code>program_takeout_endpoint</code>.</li>
</ul>
<p>При этом в документации интерфейса мы опишем и тот, и другой эндпойнт. Как несложно заметить, интерфейс <code>takeout</code> весьма специфичен. Если посыпку корицей мы как-то скрыли за общим <code>modify</code>, то на вот такие операции типа подтверждения выдачи нам каждый раз придётся заводить новый метод с уникальным названием. Несложно представить себе, как через несколько итераций интерфейс превратится в свалку из визуально похожих методов, притом формально необязательных — но для подключения своего API нужно будет прочитать документацию каждого и разобраться в том, нужен ли он в конкретной ситуации или нет.</p>
<p>Мы не знаем, правда ли в реальном мире API кофемашин возникнет проблема, подобная описанной. Но мы можем сказать со всей уверенностью, что <em>всегда</em>, когда речь идёт об интеграции «железного» уровня, происходят именно те процессы, которые мы описали: меняется нижележащая технология, и вроде бы понятный и ясный API превращается в свалку из легаси-методов, половина из которых не несёт в себе никакого практического смысла в рамках конкретной интеграции. Если мы добавим к проблеме ещё и технический прогресс — представим, например, что со временем все кофейни станут автоматическими — то мы быстро придём к ситуации, когда половина методов <em>вообще не нужна</em>, как метод запроса бесконтактной выдачи напитка.</p>
<p>При этом в документации интерфейса мы опишем и тот, и другой эндпойнт. Как несложно заметить, интерфейс <code>takeout</code> весьма специфичен. Если запрос бесконтактной доставки мы как-то скрыли за общим <code>modify</code>, то на вот такие операции типа подтверждения выдачи нам каждый раз придётся заводить новый метод с уникальным названием. Несложно представить себе, как через несколько итераций интерфейс превратится в свалку из визуально похожих методов, притом формально необязательных — но для подключения своего API нужно будет прочитать документацию каждого и разобраться в том, нужен ли он в конкретной ситуации или нет.</p>
<p><strong>NB</strong>: в этом примере мы предполагаем, что наличие эндпойнта <code>program_takeout_endpoint</code> является триггером для приложения, которое должно показать кнопку «выдать заказ». Было бы лучше добавить что-то типа поля <code>supported_flow</code> в параметры вызова <code>PUT /api-types/</code>, чтобы этот флаг задавался явно, а не определялся из неочевидной конвенции. Однако в проблематике замусоривания интерфейсов опциональным методами это ничего не меняет, так что мы опустили эту тонкость ради лаконичности примеров.</p>
<p>Мы не знаем, правда ли в реальном мире API кофемашин возникнет проблема, подобная описанной. Но мы можем сказать со всей уверенностью, что <em>всегда</em>, когда речь идёт об интеграции «железного» уровня, происходят именно те процессы, которые мы описали: меняется нижележащая технология, и вроде бы понятный и ясный API превращается в свалку из legacy-методов, половина из которых не несёт в себе никакого практического смысла в рамках конкретной интеграции. Если мы добавим к проблеме ещё и технический прогресс — представим, например, что со временем все кофейни станут автоматическими — то мы быстро придём к ситуации, когда половина методов <em>вообще не нужна</em>, как метод запроса бесконтактной выдачи напитка.</p>
<p>Заметим также, что мы невольно начали нарушать принцип изоляции уровней абстракции. На уровне API вендингового автомата вообще не существует понятия «бесконтактная выдача», это по сути продуктовый термин.</p>
<p>Каким же образом мы можем решить эту проблему? Одним из двух способов: или досконально изучить предметную область и тренды её развития на несколько лет вперёд, или перейти от сильной связности к слабой. Как выглядит идеальное решение с точки зрения обеих взаимодействующих сторон? Как-то так:</p>
<ul>
<li>вышестоящий API программ не знает, как устроен уровень исполнения его команд; он формулирует задание так, как понимает на своём уровне: сварить такой-то кофе такого-то объёма, с корицей, выдать такому-то пользователю;</li>
<li>вышестоящий API программ не знает, как устроен уровень исполнения его команд; он формулирует задание так, как понимает на своём уровне: сварить такой-то кофе такого-то объёма, передать пожелания пользователя партнёру, выдать заказ;</li>
<li>нижележащий API исполнения программ не заботится о том, какие ещё вокруг бывают API того же уровня; он трактует только ту часть задания, которая имеет для него смысл.</li>
</ul>
<p>Если мы посмотрим на принципы, описанные в предыдущей главе, то обнаружим, что этот принцип мы уже формулировали: нам необходимо задать <em>информационный контекст</em> на каждом из уровней абстракции, и разработать механизм его трансляции. Более того, в общем виде он был сформулирован ещё в разделе «Потоки данных» главы <a href="#api-design-separating-abstractions">«Разделение уровней абстракции»</a>.</p>
<p>В нашем конкретном примере нам нужно имплементировать следующие механизмы:</p>
<ul>
<li>запуск программы создаёт контекст её исполнения, содержащий все существенные параметры;</li>
<li>существует способ обмена информацией об изменении данных: исполнитель может читать контекст, узнавать о всех его изменениях и сообщать обратно о изменениях своего состояния.</li>
<li>существует поток обмена информацией об изменении состояния: исполнитель может читать контекст, узнавать о всех его модификациях и сообщать обратно о изменениях своего состояния.</li>
</ul>
<p>Организовать и то, и другое можно разными способами, однако по сути мы имеем два описания состояния (верхне- и низкоуровневое) и поток событий между ними. В случае SDK эту идею можно было бы выразить так:</p>
<p>Организовать и то, и другое можно разными способами, однако по сути мы всегда имеем два контекста и поток событий между ними. В случае SDK эту идею можно мы бы выразили через генерацию событий:</p>
<pre><code>/* Имплементация партнёром интерфейса
запуска программы на его кофемашинах */
registerProgramRunHandler(
apiType,
(context) => {
// Инициализируем запуск исполнения
// программы на стороне партнёра
let execution =
initExecution(context, …);
// Подписываемся на события
// изменения контекста
context.on(
(program) => {
// Инициализация исполнения заказа
// на стороне партерна
let execution = initExecution(…);
// Подписка на изменения состояния
// родительского контекста
program.context.on(
'takeout_requested',
() => {
// Если запрошена выдача напитка,
// инициализируем выдачу
execution.prepareTakeout(() => {
// как только напиток
// готов к выдаче,
// сигнализируем об этом
execution.context
.emit('takeout_ready');
});
// Если запрошена выдача заказа,
// инициировать нужные операции
await execution.prepareTakeout();
// Как только напиток готов к выдаче,
// оповестить об этом
execution.context.emit('takeout_ready');
}
);
program.context.on(
'order_canceled',
() => {
await execution.cancel();
execution.context.emit('canceled');
}
);
@@ -3243,85 +3240,94 @@ registerProgramRunHandler(
}
);
</code></pre>
<p><strong>NB</strong>: в случае HTTP API соответствующий пример будет выглядеть более громоздко, поскольку потребует создания отдельных эндпойнтов чтения очередей событий типа <code>GET /program-run/events</code> и <code>GET /partner/{id}/execution/events</code>, это упражнение мы оставляем читателю. Следует также отметить, что в реальных системах потоки событий часто направляют через внешнюю шину типа Apache Kafka или Amazon SNS/SQS.</p>
<p><strong>NB</strong>: в случае HTTP API соответствующий пример будет выглядеть более громоздко, поскольку потребует создания отдельных эндпойнтов для обмена сообщениями типа <code>GET /program-run/events</code> и <code>GET /partner/{id}/execution/events</code> это упражнение мы оставляем читателю. Следует также отметить, что в реальных системах потоки событий часто направляют через внешнюю шину типа Apache Kafka или Amazon SNS/SQS.</p>
<p>Внимательный читатель может возразить нам, что фактически, если мы посмотрим на номенклатуру возникающих сущностей, мы ничего не изменили в постановке задачи, и даже усложнили её:</p>
<ul>
<li>вместо вызова метода <code>takeout</code> мы теперь генерируем пару событий <code>takeout_requested</code> / <code>takeout_ready</code>;</li>
<li>вместо длинного списка методов, которые необходимо реализовать для интеграции API партнёра, появляются длинные списки полей сущности <code>context</code> и событий, которые она генерирует;</li>
<li>вместо длинного списка методов, которые необходимо реализовать для интеграции API партнёра, появляются длинные списки полей разных контекстов и событий, которые они генерирует;</li>
<li>проблема устаревания технологии не меняется, вместо устаревших методов мы теперь имеем устаревшие поля и события.</li>
</ul>
<p>Это замечание совершенно верно. Изменение формата API само по себе не решает проблем, связанных с эволюцией функциональности и нижележащей технологии. Формат API решает другую проблему: как оставить при этом код читаемым и поддерживаемым. Почему в примере с интеграцией через методы код становится нечитаемым? Потому что обе стороны <em>вынуждены</em> имплементировать функциональность, которая в их контексте бессмысленна; и эта имплементация будет состоять из какого-то (хорошо если явного!) способа ответить, что данная функциональность не поддерживается (или, наоборот, поддерживается всегда и безусловно).</p>
<p>Это замечание совершенно верно. Изменение формата API само по себе не решает проблем, связанных с эволюцией функциональности и нижележащей технологии. Формат API решает другую проблему: как оставить при этом партнерский код читаемым и поддерживаемым. Почему в примере с интеграцией через методы код становится нечитаемым? Потому что обе стороны <em>вынуждены</em> имплементировать функциональность, которая в их контексте бессмысленна. Код интеграции вендинговых автоматов <em>должен</em> ответить «принято» на запрос бесконтактной выдачи — и таким образом, со временем все имплементации будут состоять из множества методов, просто безусловно возвращающих <code>true</code> (или <code>false</code>).</p>
<p>Разница между жёстким связыванием и слабым в данном случае состоит в том, что механизм полей и событий <em>не является обязывающим</em>. Вспомним, чего мы добивались:</p>
<ul>
<li>верхнеуровневый контекст не знает, как устроен низкоуровневый API — и он действительно не знает; он описывает те изменения, которые происходят <em>в нём самом</em> и реагирует только на те события, которые имеют смысл <em>для него самого</em>;</li>
<li>низкоуровневый контекст не знает ничего об альтернативных реализациях — он обрабатывает только те события, которые имеют смысл на его уровне, и оповещает только о тех событиях, которые могут происходить в его конкретной реализации.</li>
</ul>
<p>В пределе может вообще оказаться так, что обе стороны вообще ничего не знают друг о друге и никак не взаимодействуют — не исключаем, что на каком-то этапе развития технологии именно так и произойдёт.</p>
<p>Важно также отметить, что, хотя количество сущностей (полей, событий) эффективно удваивается по сравнению с сильно связанным API, это удвоение является качественным, а не количественным. Контекст <code>program</code> содержит описание задания в своих терминах (вид напитка, объём, посыпка корицей); контекст <code>execution</code> должен эти термины переформулировать для своей предметной области (чтобы быть, в свою очередь, таким же информационным контекстом для ещё более низкоуровневого API). Что важно, <code>execution</code>-контекст имеет право эти термины конкретизировать, поскольку его нижележащие объекты будут уже работать в рамках какого-то конкретного API, в то время как <code>program</code>-контекст обязан выражаться в общих терминах, применимых к любой возможной нижележащей технологии.</p>
<p>Ещё одним важным свойством слабой связности является то, что она позволяет сущности иметь несколько родительских контекстов. В обычных предметных областях такая ситуация выглядела бы ошибкой дизайна API, но в сложных системах, где присутствуют одновременно несколько агентов, влияющих на состояние системы, такая ситуация не является редкостью. В частности, вы почти наверняка столкнётесь с такого рода проблемами при разработке пользовательского UI. Более подробно о подобных двойных иерархиях мы расскажем в разделе, посвящённом разработке SDK.</p>
<p><strong>NB</strong>: в реальном мире этого может и не произойти — мы, вероятно, всё-таки хотим, чтобы приложение обладало знанием о том, был ли запрос на выдачу напитка успешно выполнен или нет, что означает подписку на событие <code>takeout_ready</code> и проверку соответствующего флага в состоянии контекста исполнения. Тем не менее, сама по себе <em>возможность не знать</em> детали имплементации очень важна, поскольку она позволяет сделать код приложения гораздо проще — если это знание неважно для пользователя, конечно.</p>
<p>Ещё одним важным свойством слабой связности является то, что она позволяет сущности иметь несколько родительских контекстов. В обычных предметных областях такая ситуация выглядела бы ошибкой дизайна API, но в сложных системах, где присутствуют одновременно несколько агентов, влияющих на состояние системы, такая ситуация не является редкостью. В частности, вы почти наверняка столкнётесь с такого рода проблемами при разработке пользовательского UI. Более подробно о подобных двойных иерархиях мы расскажем в разделе «SDK и UI-библиотеки» настоящей книги.</p>
<h4>Инверсия ответственности</h4>
<p>Как несложно понять из вышесказанного, двусторонняя слабая связь означает существенное усложнение имплементации обоих уровней, что во многих ситуациях может оказаться излишним. Часто двустороннюю слабую связь можно без потери качества заменить на одностороннюю, а именно — разрешить нижележащей сущности вместо генерации событий напрямую вызывать методы из интерфейса более высокого уровня. Наш пример изменится примерно вот так:</p>
<pre><code>/* Имплементация партнёром интерфейса
запуска программы на его кофемашинах */
registerProgramRunHandler(
apiType,
(context) => {
// Инициализируем запуск исполнения
// программы на стороне партнёра
let execution =
initExecution(context, …);
// Подписываемся на события
// изменения контекста
context.on(
(program) => {
// Инициализация исполнения заказа
// на стороне партерна
let execution = initExecution(…);
// Подписка на изменения состояния
// родительского контекста
program.context.on(
'takeout_requested',
() => {
// Если запрошена выдача напитка,
// инициализируем выдачу
execution.prepareTakeout(() => {
/* как только напиток
готов к выдаче,
сигнализируем об этом,
вызовом метода контекста,
// Если запрошена выдача заказа,
// инициировать нужные операции
await execution.prepareTakeout();
/* Когда заказ готов к выдаче,
сигнализируем об этом вызовом
метода родительского контекста,
а не генерацией события */
// execution.context
// .emit('takeout_ready')
context.set('takeout_ready');
// Или ещё более жёстко:
// context.setTakeoutReady();
program.context
.set('takeout_ready');
// Или даже более строго
// program.setTakeoutReady();
}
);
}
);
// Так как мы сами
// изменяем родительский контекст
// нет нужды что-либо возвращать
/* Так как мы модифицируем родитеский
контекст вместо генерации событий,
нам не нужно что-либо возвращать */
// return execution.context;
}
);
</code></pre>
<p>Вновь такое решение выглядит контринтуитивным, ведь мы снова вернулись к сильной связи двух уровней через жёстко определённые методы. Однако здесь есть важный момент: мы городим весь этот огород потому, что ожидаем появления альтернативных реализаций <em>нижележащего</em> уровня абстракции. Ситуации, когда появляются альтернативные реализации <em>вышележащего</em> уровня абстракции, конечно, возможны, но крайне редки. Обычно дерево альтернативных реализаций растёт сверху вниз.</p>
<p>Другой аспект заключается в том, что, хотя серьёзные изменения концепции возможны на любом из уровней абстракции, их вес принципиально разный:</p>
<p>Вновь такое решение выглядит контринтуитивным, ведь мы снова вернулись к сильной связи двух уровней через жёстко определённые методы. Однако здесь есть важный момент: мы городим весь этот огород потому, что ожидаем появления альтернативных реализаций <em>нижележащего</em> уровня абстракции. Ситуации, когда появляются альтернативные реализации <em>вышележащего</em> уровня абстракции, конечно, возможны, но крайне редки. Обычно дерево альтернативных реализаций растёт от корня к листьям.</p>
<p>Другой аргумент в пользу такого подхода заключается в том, что, хотя серьёзные изменения концепции возможны на любом из уровней абстракции, их вес принципиально разный:</p>
<ul>
<li>если меняется технический уровень, это не должно существенно влиять на продукт, а значит — на написанный партнёрами код;</li>
<li>если меняется сам продукт, ну например мы начинаем продавать билеты на самолёт вместо приготовления кофе на заказ, сохранять обратную совместимость на промежуточных уровнях API <em>бесполезно</em>. Мы вполне можем продавать билеты на самолёт тем же самым API программ и контекстов, да только написанный партнёрами код всё равно надо будет полностью переписывать с нуля.</li>
</ul>
<p>В конечном итоге это приводит к тому, что API вышележащих сущностей меняется медленнее и более последовательно по сравнению с API нижележащих уровней, а значит подобного рода «обратная» жёсткая связь зачастую вполне допустима и даже желательна исходя из соотношения «цена-качество».</p>
<p><strong>NB</strong>: во многих современных системах используется подход с общим разделяемым состоянием приложения. Пожалуй, самый популярный пример такой системы — Redux. В парадигме Redux вышеприведённый код выглядел бы так:</p>
<pre><code>execution.prepareTakeout(() => {
// Вместо обращения к вышестоящей сущности
// или генерации события на себе,
// компонент обращается к глобальному
// состоянию и вызывает действия над ним
<pre><code>program.context.on(
'takeout_requested',
() => {
await execution.prepareTakeout();
// Вместо генерации событий
// или вызова методов родительского
// контекста, сущность `execution`
// обращается к глобальному
// или квази-глобальному методу
// `dispatch`, который изменяет
// глобальное состояние
dispatch(takeoutReady());
});
}
);
</code></pre>
<p>Надо отметить, что такой подход <em>в принципе</em> не противоречит описанному принципу, но нарушает другой — изоляцию уровней абстракции, а поэтому плохо подходит для написания сложных API, в которых не гарантирована жёсткая иерархия компонентов. При этом использовать глобальный (или квази-глобальный) менеджер состояния в таких системах вполне возможно, но требуется имплементировать более сложную пропагацию сообщений по иерархии, а именно: подчинённый объект всегда вызывает методы только ближайшего вышестоящего объекта, а уже тот решает, как и каким образом этот вызов передать выше по иерархии.</p>
<pre><code>execution.prepareTakeout(() => {
// Вместо обращения к вышестоящей сущности
// или генерации события на себе,
// компонент обращается к вышестоящему
// объекту
context.dispatch(takeoutReady());
});
<p>Надо отметить, что такой подход <em>в принципе</em> не противоречит описанным идеям снижения связности компонентов, но нарушает другой — изоляцию уровней абстракции, а поэтому плохо подходит для написания сложных API, в которых не гарантирована жёсткая иерархия компонентов. При этом использовать глобальный (или квази-глобальный) менеджер состояния в таких системах вполне возможно, но требуется имплементировать более сложную пропагацию сообщений по иерархии, а именно: подчинённый объект всегда вызывает методы только ближайшего вышестоящего объекта, а уже тот решает, как и каким образом этот вызов передать выше по иерархии.</p>
<pre><code>program.context.on(
'takeout_requested',
() => {
await execution.prepareTakeout();
// Вместо вызова глобального `dispatch`,
// сущность `execution` вызывает
// функциональность `dispatch`
// на своём родительском контексте
program.context.dispatch(takeoutReady());
}
);
</code></pre>
<pre><code>// Имплементация program.context.dispatch
ProgramContext.dispatch = (action) => {
@@ -3337,38 +3343,6 @@ ProgramContext.dispatch = (action) => {
)
}
</code></pre>
<h4>Проверим себя</h4>
<p>Описав указанным выше образом взаимодействие со сторонними API, мы можем (и должны) теперь рассмотреть вопрос, совместимы ли эти интерфейсы с нашими собственными абстракциями, которые мы разработали в в главе <a href="#api-design-separating-abstractions">«Разделение уровней абстракции»</a>; иными словами, можно ли запустить исполнение такого заказа, оперируя не высокоуровневым, а низкоуровневым API.</p>
<p>Напомним, что мы предложили вот такие абстрактные интерфейсы для работы с произвольными типами API кофемашин:</p>
<ul>
<li><code>POST /v1/program-matcher</code> возвращает идентификатор программы по идентификатору кофемашины и рецепта;</li>
<li><code>POST /v1/programs/{id}/run</code> запускает программу на исполнение.</li>
</ul>
<p>Как легко убедиться, добиться совместимости с этими интерфейсами очень просто: для этого достаточно присвоить идентификатор <code>program_id</code> паре (тип API, рецепт), например, вернув его из метода <code>PUT /coffee-machines</code>:</p>
<pre><code>PUT /v1/partners/{partnerId}/coffee-machines
{
"coffee_machines": [{
"id",
"api_type",
"location",
"supported_recipes"
}, …]
}
{
"coffee_machines": [{
"id",
"recipes_programs": [
{"recipe_id", "program_id"},
]
}, …]
}
</code></pre>
<p>И разработанный нами метод</p>
<pre><code>POST /v1/programs/{id}/run
</code></pre>
<p>будет работать и с партнёрскими кофемашинами (читай, с третьим видом API).</p>
<h4>Делегируй!</h4>
<p>Из описанных выше принципов следует ещё один чрезвычайно важный вывод: выполнение реальной работы, то есть реализация каких-то конкретных действий (приготовление кофе, в нашем случае) должна быть делегирована низшим уровням иерархии абстракций. Если верхние уровни абстракции попробуют предписать конкретные алгоритмы исполнения, то, как мы увидели в примере с <code>order_execution_endpoint</code>, мы быстро придём к ситуации противоречивой номенклатуры методов и протоколов взаимодействия, бо́льшая часть которых в рамках конкретного «железа» не имеет смысла.</p>
<p>Напротив, применяя парадигму конкретизации контекста на каждом новом уровне абстракции мы рано или поздно спустимся вниз по кроличьей норе достаточно глубоко, чтобы конкретизировать было уже нечего: контекст однозначно соотносится с функциональностью, доступной для программного управления. И вот на этом уровне мы должны отказаться от дальнейшей детализации и непосредственно реализовать нужные алгоритмы. Важно отметить, что глубина абстрагирования будет различной для различных нижележащих платформ.</p>
@@ -3380,7 +3354,7 @@ ProgramContext.dispatch = (action) => {
<li>Конкретная функциональность, т.е. работа непосредственно с «железом», нижележащим API платформы, должна быть делегирована сущностям самого низкого уровня.</li>
</ol>
<p><strong>NB</strong>. В этих правилах нет ничего особенно нового: в них легко опознаются принципы архитектуры <a href="https://en.wikipedia.org/wiki/SOLID">SOLID</a> — что неудивительно, поскольку SOLID концентрируется на контрактно-ориентированном подходе к разработке, а API по определению и есть контракт. Мы лишь добавляем в эти принципы понятие уровней абстракции и информационных контекстов.</p>
<p>Остаётся, однако, неотвеченным вопрос о том, как изначально выстроить номенклатуру сущностей таким образом, чтобы расширение API не превращало её в мешанину из различных неконсистентных методов разных эпох. Впрочем, ответ на него довольно очевиден: чтобы при абстрагировании не возникало неловких ситуаций, подобно рассмотренному нами примеру с поддерживаемыми кофемашиной опциями, все сущности необходимо <em>изначально</em> рассматривать как частную реализацию некоторого более общего интерфейса, даже если никаких альтернативных реализаций в настоящий момент не предвидится.</p>
<p>Остаётся, однако, неотвеченным вопрос о том, как изначально выстроить номенклатуру сущностей таким образом, чтобы расширение API не превращало её в мешанину из различных неконсистентных методов разных эпох. Впрочем, ответ на него довольно очевиден: чтобы при абстрагировании не возникало неловких ситуаций, подобно рассмотренному нами примеру с полями рецептов, все сущности необходимо <em>изначально</em> рассматривать как частную реализацию некоторого более общего интерфейса, даже если никаких альтернативных реализаций в настоящий момент не предвидится.</p>
<p>Например, разрабатывая API эндпойнта <code>POST /search</code> мы должны были задать себе вопрос: а «результат поиска» — это абстракция над каким интерфейсом? Для этого нам нужно аккуратно декомпозировать эту сущность, чтобы понять, каким своим срезом она выступает во взаимодействии с какими объектами.</p>
<p>Тогда мы придём к пониманию, что результат поиска — это, на самом деле, композиция двух интерфейсов:</p>
<ul>

Binary file not shown.