mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-01-23 17:53:04 +02:00
Section II is finished, translated, and built
This commit is contained in:
parent
7dd6587b94
commit
3928d1fb73
BIN
docs/API.en.epub
BIN
docs/API.en.epub
Binary file not shown.
708
docs/API.en.html
708
docs/API.en.html
@ -288,7 +288,7 @@ h1 {
|
||||
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
|
||||
<path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px" class="octo-arm"></path>
|
||||
<path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a>
|
||||
<div class="page-break"></div><nav><h2 class="toc">Table of Contents</h2><ul class="table-of-contents"><li><a href="#section-1">Introduction</a><ul><li><a href="#chapter-1">Chapter 1. On the Structure of This Book</a></li><li><a href="#chapter-2">Chapter 2. The API Definition</a></li><li><a href="#chapter-3">Chapter 3. API Quality Criteria</a></li><li><a href="#chapter-4">Chapter 4. Backwards Compatibility</a></li><li><a href="#chapter-5">Chapter 5. On Versioning</a></li><li><a href="#chapter-6">Chapter 6. Terms and Notation Keys</a></li></ul></li><li><a href="#section-2">Section I. The API Design</a><ul><li><a href="#chapter-7">Chapter 7. The API Contexts Pyramid</a></li><li><a href="#chapter-8">Chapter 8. Defining an Application Field</a></li><li><a href="#chapter-9">Chapter 9. Separating Abstraction Levels</a></li><li><a href="#chapter-10">Chapter 10. Isolating Responsibility Areas</a></li><li><a href="#chapter-11">Chapter 11. Describing Final Interfaces</a></li><li><a href="#chapter-12">Chapter 12. Annex to Section I. Generic API Example</a></li></ul></li></ul></nav><div style="page-break-after: always;"></div><h2><a href="#section-1" class="anchor" id="section-1">Introduction</a></h2><h3><a href="#chapter-1" class="anchor" id="chapter-1">Chapter 1. On the Structure of This Book</a></h3>
|
||||
<div class="page-break"></div><nav><h2 class="toc">Table of Contents</h2><ul class="table-of-contents"><li><a href="#section-1">Introduction</a><ul><li><a href="#chapter-1">Chapter 1. On the Structure of This Book</a></li><li><a href="#chapter-2">Chapter 2. The API Definition</a></li><li><a href="#chapter-3">Chapter 3. API Quality Criteria</a></li><li><a href="#chapter-4">Chapter 4. Backwards Compatibility</a></li><li><a href="#chapter-5">Chapter 5. On Versioning</a></li><li><a href="#chapter-6">Chapter 6. Terms and Notation Keys</a></li></ul></li><li><a href="#section-2">Section I. The API Design</a><ul><li><a href="#chapter-7">Chapter 7. The API Contexts Pyramid</a></li><li><a href="#chapter-8">Chapter 8. Defining an Application Field</a></li><li><a href="#chapter-9">Chapter 9. Separating Abstraction Levels</a></li><li><a href="#chapter-10">Chapter 10. Isolating Responsibility Areas</a></li><li><a href="#chapter-11">Chapter 11. Describing Final Interfaces</a></li><li><a href="#chapter-12">Chapter 12. Annex to Section I. Generic API Example</a></li></ul></li><li><a href="#section-3">Section II. Backwards Compatibility</a><ul><li><a href="#chapter-13">Chapter 13. The Backwards Compatibility Problem Statement</a></li><li><a href="#chapter-14">Chapter 14. On the Iceberg's Waterline</a></li><li><a href="#chapter-15">Chapter 15. Extending through Abstracting</a></li><li><a href="#chapter-16">Chapter 16. Strong coupling and related problems</a></li><li><a href="#chapter-17">Chapter 17. Weak coupling</a></li><li><a href="#chapter-18">Chapter 18. Interfaces as a Universal Pattern</a></li><li><a href="#chapter-19">Chapter 19. The Serenity Notepad</a></li></ul></li></ul></nav><div style="page-break-after: always;"></div><h2><a href="#section-1" class="anchor" id="section-1">Introduction</a></h2><h3><a href="#chapter-1" class="anchor" id="chapter-1">Chapter 1. On the Structure of This Book</a></h3>
|
||||
<p>The book you're holding in your hands comprises this Introduction and 'The API Design' Section. We will discuss designing APIs as a concept: how to build the architecture properly, from high-level planning down to final interfaces.</p>
|
||||
<p>Two more sections are planned for the future revisions of this book: Section II will be dedicated to an API's lifecycle: how interfaces evolve over time, and how to elaborate the product to match users' needs. Finally, Section III will be more about the un-engineering sides of the API, like API marketing, organizing support, and working with a community.</p>
|
||||
<p>First, two sections are interesting to engineers mostly, while the third section is more relevant to both engineers and product managers. However, we insist that the third section is the most important for the API software developer. Since an API is a product for engineers, you cannot simply pronounce a non-engineering team responsible for product planning and support. Nobody but you understands more about your API's product features.</p>
|
||||
@ -2064,6 +2064,710 @@ GET /v1/runtimes/{runtime_id}/state
|
||||
</code></pre>
|
||||
<pre><code>// Terminates the runtime
|
||||
POST /v1/runtimes/{id}/terminate
|
||||
</code></pre><div class="page-break"></div>
|
||||
</code></pre><div class="page-break"></div><h2><a href="#section-3" class="anchor" id="section-3">Section II. Backwards Compatibility</a></h2><h3><a href="#chapter-13" class="anchor" id="chapter-13">Chapter 13. The Backwards Compatibility Problem Statement</a></h3>
|
||||
<p>As usual, let's conceptually define ‘backwards compatibility’ before we start.</p>
|
||||
<p>Backwards compatibility is a feature of the entire API system to be stable in time. It means the following: <strong>the code which developers have written using your API, continues working functionally correctly for a long period of time</strong>. There are two important questions to this definition, and two explanations:</p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>What does ‘functionally correctly’ mean?</p>
|
||||
<p>It means that the code continues to serve its function, e.g. solve some users' problem. It doesn't mean it continues working indistinguishably: for example, if you're maintaining a UI library, changing functionally insignificant design details like shadow depth or border stoke type is backwards compatible, whereas changing visual components size is not.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>What does ‘a long period of time’ mean?</p>
|
||||
<p>From our point of view, backwards compatibility maintenance period should be reconciled with subject area applications lifetime. Platform LTS periods are a decent guidance in the most cases. Since apps will be rewritten anyway when the platform maintenance period ends, it is reasonable to expect developers to move to the new API version also. In mainstream subject areas (e.g. desktop and mobile operating systems) this period lasts several years.</p>
|
||||
</li>
|
||||
</ol>
|
||||
<p>From the definition becomes obvious why backwards compatibility needs to be maintained (including taking necessary measures at the API design stage). An outage, full or partial, caused by the API vendor, is an extremely uncomfortable situation for every developer, if not a disaster — especially if they pay money for the API usage.</p>
|
||||
<p>But let's take a look at the problem from another angle: why the maintaining backwards compatibility problem exists at all? Why would anyone <em>want</em> to break at? This question, though it looks quite trivial, is much more complicated than the previous one.</p>
|
||||
<p>We could say the <em>we break backwards compatibility to introduce new features to the API</em>. But that would be deceiving: new features are called <em>‘new’</em> just because they cannot affect existing implementations which are not using them. We must admit there are several associated problems, which lead to the aspiration to rewrite <em>our</em> code, the code of the API itself, and ship new major version:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>the code eventually becomes outdated; making changes, even introducing totally new functionality, is impractical;</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>the old interfaces aren't suited to encompass new features; we would love to extend existing entities with new properties, but simply couldn't;</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>finally, with years passing since the initial release, we understood more about the subject area and API usage best practices, and we would implement many things differently.</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>These arguments could be summarized frankly as ‘the API developers don't want to support the old code’. But this explanation is still incomplete: even if you're not going to rewrite the API code to add new functionality, or you're not going to add it at all, you still have to ship new API versions, minor and major alike.</p>
|
||||
<p><strong>NB</strong>: in this chapter we don't make any difference between minor versions and patches: ‘minor version’ means any backwards compatible API release.</p>
|
||||
<p>Let us remind that <a href="(https://twirl.github.io/The-API-Book/docs/API.en.html#chapter-2)">an API is a bridge</a>, a meaning of connecting different programmable contexts. No matter how strong our desire to keep the bridge intact is, for our capabilities are limited: we could lock the bridge, but we cannot command the rifts and the canyon itself. That's the source of the problems: we can't guarantee <em>our own</em> code wouldn't change, so at some point we will have to ask the clients to change <em>their</em> code.</p>
|
||||
<p>Apart from our own aspirations to change the API architecture, three other tectonic processes are happening at the same time: user agents, subject areas, and underlying platforms erosion.</p>
|
||||
<h4>Consumer applications fragmentation</h4>
|
||||
<p>When you shipped the very first API version, and first clients started to use it, the situation was perfect. There was only one version, and all clients were using just it. When this perfection ends, two scenarios are possible.</p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>If the platform allows for fetching code on-demand, like the good old Web does, and you weren't too lazy to implement that code-on-demand (in a form of a platform SDK — for example, JS API), then the evolution of your API is more or less under your control. Maintaining backwards compatibility effectively means keeping <em>the client library</em> backwards compatible. As for client-server interaction, you're free.</p>
|
||||
<p>It doesn't mean that you can't break backwards compatibility. You still can make a mess with cache control headers or just overlook a bug in the code. Besides, even code-on-demand systems don't get updated instantly. The author of this book faced the situation, when users were deliberately keeping a browser tab open <em>for weeks</em> to get rid of updates. But still, you usually don't have to support more than two API versions — the last one and the penultimate one. Furthermore, you may try to rewrite previous major version of the library, implementing it upon the actual version API.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>If code-on-demand isn't supported or is prohibited by the platform, as in modern mobile operating systems, then the situation becomes more severe. Each client effectively borrows a snapshot of the code, working with your API, frozen at the moment of compilation. Client application updates are scattered over time at much more extent than Web application updates. The most painful thing is that <em>some clients will never be up to date</em>, because of one of three reasons:</p>
|
||||
<ul>
|
||||
<li>developers simply don't want to update the app, its development stopped;</li>
|
||||
<li>users don't wont to get updates (sometimes because users think that developers ‘spoiled’ the app in new versions);</li>
|
||||
<li>users can't get updates because their devices are no longer supported.</li>
|
||||
</ul>
|
||||
<p>In modern times these three categories combined could easily constitute tens of percent of auditory. It implies that cutting the support of any API version might be remarkable — especially if developers' apps continue supporting a more broad spectrum of platforms than the API does.</p>
|
||||
<p>You could have never issued any SDK, providing just the server-side API, for example in a form of HTTP endpoints. You might think, given your API is less competitive on the market because of a lack of SDKs, that the backwards compatibility problem is mitigated. That's not true: if you don't provide an SDK, then developers will either adopt an unofficial one (if someone bothers to make it), or just write a framework of themselves — independently. ‘Your framework — your problems’ strategy, fortunately or not, works badly: if developers write poor quality code upon your API, then your API is of a poor quality itself. Definitely in the view of developers, possibly in the view of end users, if the API performance within the app is visible to them.</p>
|
||||
</li>
|
||||
</ol>
|
||||
<p>Certainly, if you provide a stateless API which doesn't need client SDKs (or they might be auto-generated from the spec), those problems will be much less noticeable, but not fully avoidable, unless you never issue any new API version. Otherwise you still had to deal with some distribution of users by API and SDK versions.</p>
|
||||
<h4>Subject area evolution</h4>
|
||||
<p>The other side of the canyon is the underlying functionality you're exposing via the API. It's, of course, not static and somehow evolves:</p>
|
||||
<ul>
|
||||
<li>new functionality emerges;</li>
|
||||
<li>older functionality shuts down;</li>
|
||||
<li>interfaces change.</li>
|
||||
</ul>
|
||||
<p>As usual, the API provides an abstraction to much more granular subject area. In case of our <a href="https://twirl.github.io/The-API-Book/docs/API.en.html#chapter-7">coffee machine API example</a> one might reasonably expect new models to pop up, which are to be supported by the platform. New models tend to provide new APIs, and it's hard to guarantee they might be included preserving the same high-level API. And anyway, the code needs to be altered, which might lead to incompatibility, albeit unintentional.</p>
|
||||
<p>Let us also stress that low-level API vendors are not always as resolute regarding maintaining backwards compatibility (actually, any software they provide) as (we hope so) you are. You should be prepared that keeping your API in an operational state, e.g. writing and supporting facades to the shifting subject area landscape, will be your problem, and rather a sudden one.</p>
|
||||
<h4>Platform drift</h4>
|
||||
<p>Finally, there is a third side to a story — the ‘canyon’ you're crossing over with a bridge of your API. Developers write code which is executed in some environment you can't control, and it's evolving. New versions of operating system, browsers, protocols, language SDKs emerge. New standards are being developed, new arrangements made, some of them being backwards incompatible, and nothing could be done about that.</p>
|
||||
<p>Older platform versions lead to the fragmentation, just like older apps versions do, because developers (including the API developers) are struggling with supporting older platforms, and users are struggling with platform updates — and often can't update at all, since newer platform versions require newer devices.</p>
|
||||
<p>The nastiest thing here is that not only incremental progress in a form of new platforms and protocols demands changing the API, but also a vulgar fashion does. Several years ago realistic 3d icons were popular, but since then the public taste changed in a favor of flat and abstract ones. UI components developers had to follow the fashion, rebuilding their libraries, either shipping new icons or replacing old ones. Similarly, right now ‘night mode’ support is introduced everywhere, demanding changes in a broad range of APIs.</p>
|
||||
<h4>Backwards compatibility policy</h4>
|
||||
<p>To summarize the above:</p>
|
||||
<ul>
|
||||
<li>you will have to deploy new API versions because of apps, platforms, and subject area evolution; different areas are evolving with different rate, but never a zero one;</li>
|
||||
<li>that will lead to fragmenting the API versions usage over different platforms and apps;</li>
|
||||
<li>you have to make decisions critically important to your API's sustainability in the customers view.</li>
|
||||
</ul>
|
||||
<p>Let's briefly describe these decisions and key factors of making them.</p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>How often new major API versions should be developed?</p>
|
||||
<p>That's primarily a <em>product</em> question. New major API version is to be released when the critical mass of functionality is achieved — a critical mass of features which couldn't be introduced in the previous API versions, or introducing them is too expensive. On stable markets such a situation occurs once in several years, usually. On emerging markets new API major versions might be shipped more frequently, only depending on your capabilities of supporting the zoo of previous versions. However, we should note that deploying a new version before the previous one was stabilized (which commonly takes from several months up to a year) is always a troubling sign to developers, meaning they're risking dealing with the unfinished platform glitches permanently.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>How many <em>major</em> versions should be supported at a time?</p>
|
||||
<p>As for major versions, we gave <em>theoretical</em> advice earlier: ideally, the major API version lifecycle should be a bit longer than the platform's one. In stable niches like desktop operating systems it constitutes 5 to 10 years. In new and emerging ones it is less, but still measures in years. <em>Practically</em> speaking you should look at the size of auditory which continues using older versions.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>How many <em>minor</em> versions (within one major version) should be supported at a time?</p>
|
||||
<p>As for minor versions, there are two options:</p>
|
||||
<ul>
|
||||
<li>if you provide server-side APIs and compiled SDKs only, you may basically do not expose minor versions at all, just the actual one: the server-side API is totally within your control, and you may fix any problem efficiently;</li>
|
||||
<li>if you provide code-on-demand SDKs, it is considered a good form to provide an access to previous minor versions of SDK for a period of time sufficient enough for developers to test their application and fix some issues if necessary. Since full rewriting isn't necessary, it's fine to align with apps release cycle duration in your industry, which is usually several months in worst cases.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
<p>We will address these questions in more details in the next chapters. Additionally, in the Section III we will also discuss, how to communicate to customers about new versions releases and older versions support discontinuance, and how to stimulate them to adopt new API versions.</p><div class="page-break"></div><h3><a href="#chapter-14" class="anchor" id="chapter-14">Chapter 14. On the Iceberg's Waterline</a></h3>
|
||||
<p>Before we start talking about the extensible API design, we should discuss the hygienic minimum. A huge number of problems would have never happened if API vendors had paid more attention to marking their area of responsibility.</p>
|
||||
<h5><a href="#chapter-14-paragraph-1" id="chapter-14-paragraph-1" class="anchor">1. Provide a minimal amount of functionality</a></h5>
|
||||
<p>At any moment in its lifetime, your API is like an iceberg: it comprises an observable (e.g. documented) part and a hidden one, undocumented. If the API is designed properly, these two parts correspond to each other just like the above-water and under-water parts of a real iceberg do, i.e. one to ten. Why so? Because of two obvious reasons.</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>Computers exist to make complicated things easy, not vice versa. The code developers write upon your API must describe a complicated problem's solution in neat and straightforward sentences. If developers have to write more code than the API itself comprises, then there is something rotten here. Probably, this API simply isn't needed at all.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Revoking the API functionality causes losses. If you've promised to provide some functionality, you will have to do so ‘forever’ (until this API version's maintenance period is over). Pronouncing some functionality deprecated is a tricky thing, potentially alienating your customers.</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>Rule #1 is the simplest: if some functionality might be withheld — then never expose it. It might be reformulated like: every entity, every field, every public API method is a <em>product solution</em>. There must be solid <em>product</em> reasons why some functionality is exposed.</p>
|
||||
<h5><a href="#chapter-14-paragraph-2" id="chapter-14-paragraph-2" class="anchor">2. Avoid gray zones and ambiguities</a></h5>
|
||||
<p>Your obligations to maintain some functionality must be stated as clearly as possible. Especially regarding those environments and platforms where no native capability to restrict access to undocumented functionality exists. Unfortunately, developers tend to consider some private features they found to be eligible for use, thus presuming the API vendor shall maintain them intact. Policy on such ‘findings’ must be articulated explicitly. At the very least, in case of such non-authorized usage of undocumented functionality, you might refer to the docs, and be in your own rights in the eyes of the community.</p>
|
||||
<p>However, API developers often legitimize such gray zones themselves, for example, by:</p>
|
||||
<ul>
|
||||
<li>returning undocumented fields in endpoints' responses;</li>
|
||||
<li>using private functionality in code examples — in the docs, responding to support messages, in conference talks, etc.</li>
|
||||
</ul>
|
||||
<p>One cannot make a partial commitment. Either you guarantee this code will always work or do not slip a slightest note such functionality exists.</p>
|
||||
<h5><a href="#chapter-14-paragraph-3" id="chapter-14-paragraph-3" class="anchor">3. Codify implicit agreements</a></h5>
|
||||
<p>The third principle is much less obvious. Pay close attention to the code which you're suggesting developers to develop: are there any conventions that you consider evident, but never wrote them down?</p>
|
||||
<p><strong>Example #1</strong>. Let's take a look at this order processing SDK example:</p>
|
||||
<pre><code>// Creates an order
|
||||
let order = api.createOrder();
|
||||
// Returns the order status
|
||||
let status = api.getStatus(order.id);
|
||||
</code></pre>
|
||||
<p>Let's imagine that you're struggling with scaling your service, and at some point moved to the asynchronous replication of the database. This would lead to the situation when querying for the order status right after order creating might return <code>404</code> if an asynchronous replica hasn't got the update yet. In fact, thus we abandon strict <a href="https://en.wikipedia.org/wiki/Consistency_model">consistency policy</a> in a favor of an eventual one.</p>
|
||||
<p>What would be the result? The code above will stop working. A developer creates an order, tries to get its status — but gets the error. It's very hard to predict what approach developers would implement to tackle this error. Probably, none at all.</p>
|
||||
<p>You may say something like, ‘But we've never promised the strict consistency in the first place’ — and that is obviously not true. You may say that if, and only if, you have really described the eventual consistency in the <code>createOrder</code> docs, and all your SDK examples look like:</p>
|
||||
<pre><code>let order = api.createOrder();
|
||||
let status;
|
||||
while (true) {
|
||||
try {
|
||||
status = api.getStatus(order.id);
|
||||
} catch (e) {
|
||||
if (e.httpStatusCode != 404 || timeoutExceeded()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (status) {
|
||||
…
|
||||
}
|
||||
</code></pre>
|
||||
<p>We presume we may skip the explanations why such code must never be written under any circumstances. If you're really providing a non-strictly consistent API, then either <code>createOrder</code> operation must be asynchronous and return the result when all replicas are synchronized, or the retry policy must be hidden inside <code>getStatus</code> operation implementation.</p>
|
||||
<p>If you failed to describe the eventual consistency in the first place, then you simply can't make these changes in the API. You will effectively break backwards compatibility, which will lead to huge problems with your customers' apps, intensified by the fact they can't be simply reproduced.</p>
|
||||
<p><strong>Example #2</strong>. Take a look at the following code:</p>
|
||||
<pre><code>let resolve;
|
||||
let promise = new Promise(
|
||||
function (innerResolve) {
|
||||
resolve = innerResolve;
|
||||
}
|
||||
);
|
||||
resolve();
|
||||
</code></pre>
|
||||
<p>This code presumes that the callback function passed to <code>new Promise</code> will be executed synchronously, and the <code>resolve</code> variable will be initialized before the <code>resolve()</code> function is called. But this assumption is based on nothing: there are no clues indicating that <code>new Promise</code> constructor executes the callback function synchronously.</p>
|
||||
<p>Of course, the developers of the language standard can afford such tricks; but you as an API developer cannot. You must at least document this behavior and make the signatures point to it; actually, good advice is to avoid such conventions, since they are simply unobvious while reading the code. And of course, under no circumstances you can actually change this behavior to an asynchronous one.</p>
|
||||
<p><strong>Example #3</strong>. Imagine you're providing animations API, which includes two independent functions:</p>
|
||||
<pre><code>// Animates object's width,
|
||||
// beginning with first value, ending with second
|
||||
// in a specified time period
|
||||
object.animateWidth('100px', '500px', '1s');
|
||||
// Observes object's width changes
|
||||
object.observe('widthchange', observerFunction);
|
||||
</code></pre>
|
||||
<p>A question arises: how frequently and at what time fractions the <code>observerFunction</code> will be called? Let's assume in the first SDK version we emulated step-by-step animation at 10 frames per second: then <code>observerFunction</code> will be called 10 times, getting values '140px', '180px', etc., up to '500px'. But then in a new API version, we switched to implementing both functions atop of a system's native functionality — and so you simply don't know, when and how frequently the <code>observerFunction</code> will be called.</p>
|
||||
<p>Just changing call frequency might result in making some code dysfunctional — for example, if the callback function makes some complex calculations, and no throttling is implemented since the developer just relied on your SDK's built-in throttling. And if the <code>observerFunction</code> ceases to be called when exactly '500px' is reached because of some system algorithms specifics, some code will be broken beyond any doubt.</p>
|
||||
<p>In this example, you should document the concrete contract (how often the observer function is called) and stick to it even if the underlying technology is changed.</p>
|
||||
<p><strong>Example #4</strong>. Imagine that customer orders are passing through a specific pipeline:</p>
|
||||
<pre><code>GET /v1/orders/{id}/events/history
|
||||
→
|
||||
{
|
||||
"event_history": [
|
||||
{
|
||||
"iso_datetime": "2020-12-29T00:35:00+03:00",
|
||||
"new_status": "created"
|
||||
},
|
||||
{
|
||||
"iso_datetime": "2020-12-29T00:35:10+03:00",
|
||||
"new_status": "payment_approved"
|
||||
},
|
||||
{
|
||||
"iso_datetime": "2020-12-29T00:35:20+03:00",
|
||||
"new_status": "preparing_started"
|
||||
},
|
||||
{
|
||||
"iso_datetime": "2020-12-29T00:35:30+03:00",
|
||||
"new_status": "ready"
|
||||
}
|
||||
]
|
||||
}
|
||||
</code></pre>
|
||||
<p>Suppose at some moment we decided to allow trustworthy clients to get their coffee in advance before the payment is confirmed. So an order will jump straight to "preparing_started", or event "ready", without a "payment_approved" event being emitted. It might appear to you that this modification <em>is</em> backwards compatible since you've never really promised any specific event order being maintained, but it is not.</p>
|
||||
<p>Let's assume that a developer (probably, your company's business partner) wrote some code implementing some valuable business procedure, for example, gathering income and expenses analytics. It's quite logical to expect this code operates a state machine, which switches from one state to another depending on getting (or getting not) specific events. This analytical code will be broken if the event order changes. In the best-case scenario, a developer will get some exceptions and have to cope with the error's cause; worst-case, partners will operate wrong statistics for an indefinite period of time until they find a mistake.</p>
|
||||
<p>A proper decision would be, in first, documenting the event order and allowed states; in second, continuing generating "payment_approved" event before "preparing_started" (since you're making a decision to prepare that order, so you're in fact approving the payment) and add extended payment information.</p>
|
||||
<p>This example leads us to the last rule.</p>
|
||||
<h5><a href="#chapter-14-paragraph-4" id="chapter-14-paragraph-4" class="anchor">4. Product logic must be backwards compatible as well</a></h5>
|
||||
<p>State transition graph, event order, possible causes of status changes — such critical things must be documented. Not every piece of business logic might be defined in a form of a programmatical contract; some cannot be represented at all.</p>
|
||||
<p>Imagine that one day you start to take phone calls. A client may contact the call center to cancel an order. You might even make this functionality <em>technically</em> backwards compatible, introducing new fields to the ‘order’ entity. But the end-user might simply <em>know</em> the number, and call it even if the app wasn't suggesting anything like that. Partner's business analytical code might be broken likewise, or start displaying weather on Mars since it was written knowing nothing about the possibility of canceling orders somehow in circumvention of the partner's systems.</p>
|
||||
<p><em>Technically</em> correct decision would be adding ‘canceling via call center allowed’ parameter to the order creation function. Conversely, call center operators may only cancel those orders which were created with this flag set. But that would be a bad decision from a <em>product</em> point of view. The only ‘good’ decision in this situation is to foresee the possibility of external order cancels in the first place. If you haven't foreseen it, your only option is the ‘Serenity Notepad’ to be discussed in the last chapter of this Section.</p><div class="page-break"></div><h3><a href="#chapter-15" class="anchor" id="chapter-15">Chapter 15. Extending through Abstracting</a></h3>
|
||||
<p>In previous chapters, we have tried to outline theoretical rules and illustrate them with practical examples. However, understanding the principles of change-proof API design requires practice above all things. An ability to anticipate future growth problems comes from a handful of grave mistakes once made. One cannot foresee everything but can develop certain technical intuition.</p>
|
||||
<p>So in the following chapters, we will try to probe our study API from the previous Section, testing its robustness from every possible viewpoint, thus carrying out some ‘variational analysis’ of our interfaces. More specifically, we will apply a ‘What If?’ question to every entity, as if we are to provide a possibility to write an alternate implementation of every piece of logic.</p>
|
||||
<p><strong>NB</strong>. In our examples, the interfaces will be constructed in a manner allowing for dynamic real-time linking of different entities. In practice, such integrations usually imply writing an ad hoc server-side code in accordance with specific agreements made with specific partners. But for educational purposes, we will pursue more abstract and complicated ways. Dynamic real-time linking is more typical in complex program constructs like operating system APIs or embeddable libraries; giving educational examples based on such sophisticated systems would be too inconvenient.</p>
|
||||
<p>Let's start with the basics. Imagine that we haven't exposed any other functionality but searching for offers and making orders, thus providing an API of two methods: <code>POST /offers/search</code> and <code>POST /orders</code>.</p>
|
||||
<p>Let us make the next logical step there and suppose that partners will wish to dynamically plug their own coffee machines (operating some previously unknown types of API) into our platform. To allow doing so, we have to negotiate a callback format that would allow us to call partners' APIs and expose two new endpoints providing the following capabilities:</p>
|
||||
<ul>
|
||||
<li>registering new API types in the system;</li>
|
||||
<li>providing the list of the coffee machines and their API types;</li>
|
||||
</ul>
|
||||
<p>For example, we might provide the following methods.</p>
|
||||
<pre><code>// 1. Register a new API type
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
"order_execution_endpoint": {
|
||||
// Callback function description
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<pre><code>// 2. Provide a list of coffee machines
|
||||
// with their API types
|
||||
PUT /v1/partners/{partnerId}/coffee-machines
|
||||
{
|
||||
"coffee_machines": [{
|
||||
"api_type",
|
||||
"location",
|
||||
"supported_recipes"
|
||||
}, …]
|
||||
}
|
||||
</code></pre>
|
||||
<p>So the mechanics is like that:</p>
|
||||
<ul>
|
||||
<li>a partner registers their API types, coffee machines, and supported recipes;</li>
|
||||
<li>with each incoming order, our server will call the callback function, providing the order data in the stipulated format.</li>
|
||||
</ul>
|
||||
<p>Now the partners might dynamically plug their coffee machines in and get the orders. But we now will do the following exercise:</p>
|
||||
<ul>
|
||||
<li>enumerate all the implicit assumptions we have made;</li>
|
||||
<li>enumerate all the implicit coupling mechanisms we need to haven the platform functioning properly.</li>
|
||||
</ul>
|
||||
<p>It may look like there are no such things in our API since it's quite simple and basically just describes making some HTTP call — but that's not true.</p>
|
||||
<ol>
|
||||
<li>It is implied that every coffee machine supports every order option like varying the beverage volume.</li>
|
||||
<li>There is no need to display some additional data to the end-user regarding coffee being brewed on these new coffee machines.</li>
|
||||
<li>The price of the beverage doesn't depend on the selected partner or coffee machine type.</li>
|
||||
</ol>
|
||||
<p>We have written down this list having one purpose in mind: we need to understand, how exactly will we make these implicit arrangements explicit if we need it. For example, if different coffee machines provide different functionality — for example, some of them provide fixed beverage volumes only — what would change in our API?</p>
|
||||
<p>The universal approach to making such amendments is: to consider the existing interface as a reduction of some more general one like if some parameters were set to defaults and therefore omitted. So making a change is always a three-step process.</p>
|
||||
<ol>
|
||||
<li>Explicitly define the programmatical contract <em>as it works right now</em>.</li>
|
||||
<li>Extend the functionality: add a new method allowing for tackling those restrictions set in the previous paragraph.</li>
|
||||
<li>Pronounce the existing interfaces (those defined in #1) being ‘helpers’ to new ones (those defined in #2) which sets some options to default values.</li>
|
||||
</ol>
|
||||
<p>More specifically, if we talk about changing available order options, we should do the following.</p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>Describe the current state. All coffee machines, plugged via the API, must support three options: sprinkling with cinnamon, changing the volume, and contactless delivery.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Add new ‘with-options’ endpoint:</p>
|
||||
<pre><code>PUT /v1/partners/{partnerId}/coffee-machines-with-options
|
||||
{
|
||||
"coffee_machines": [{
|
||||
"api_type",
|
||||
"location",
|
||||
"supported_recipes",
|
||||
"supported_options": [
|
||||
{"type": "volume_change"}
|
||||
]
|
||||
}, …]
|
||||
}
|
||||
</code></pre>
|
||||
</li>
|
||||
<li>
|
||||
<p>Pronounce <code>PUT /coffee-machines</code> endpoint as it now stands in the protocol being equivalent to calling <code>PUT /coffee-machines-with-options</code> if we pass those three options to it (sprinkling with cinnamon, changing the volume, contactless delivery) and therefore being a partial case — a helper to a more general call.</p>
|
||||
</li>
|
||||
</ol>
|
||||
<p>Usually, just adding a new optional parameter to the existing interface is enough; in our case, adding non-mandatory <code>options</code> to the <code>PUT /coffee-machines</code> endpoint.</p>
|
||||
<p><strong>NB</strong>. When we talk about defining the contract as it works right now, we're talking about <em>internal</em> agreements. We must have asked partners to support those three options while negotiating the interaction format. If we had failed to do so from the very beginning, and now are defining these in a course of expanding the public API, it's a very strong claim to break backwards compatibility, and we should never do that (see <a href="#chapter-14">Chapter 14</a>).</p>
|
||||
<h4>Limits of Applicability</h4>
|
||||
<p>Though this exercise looks very simple and universal, its consistent usage is possible only if the hierarchy of entities is well designed from the very beginning and, which is more important, the vector of the further API expansion is clear. Imagine that after some time passed, the options list got new items; let's say, adding syrup or a second espresso shot. We are totally capable of expanding the list — but not the defaults. So the ‘default’ <code>PUT /coffee-machines</code> interface will eventually become totally useless because the default set of three options will not only be any longer of use but will also look ridiculously: why these three options, what are the selection criteria? In fact, the defaults and the method list will be reflecting the historical stages of our API development, and that's totally not what you'd expect from the helpers and defaults nomenclature.</p>
|
||||
<p>Alas, this dilemma can't be easily resolved. From one side, we want developers to write neat and laconic code, so we must provide useful helpers and defaults. From the other side, we can't know in advance which options sets will be the most frequent after several years of the API expansion.</p>
|
||||
<p><strong>NB</strong>. We might mask this problem in the following manner: one day gather all these oddities and re-define all the defaults with one single parameter. For example, introduce a special method like <code>POST /use-defaults {"version": "v2"}</code> which would overwrite all the defaults with more suitable values. That will ease the learning curve, but your documentation will become even worse after that.</p>
|
||||
<p>In the real world, the only viable approach to somehow tackle the problem is the weak entity coupling, which we will discuss in the next chapter.</p><div class="page-break"></div><h3><a href="#chapter-16" class="anchor" id="chapter-16">Chapter 16. Strong coupling and related problems</a></h3>
|
||||
<p>To demonstrate the strong coupling problematics let us move to <em>really interesting</em> 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.</p>
|
||||
<p>So, let us add one more endpoint to register the partner's own recipe:</p>
|
||||
<pre><code>// Adds new recipe
|
||||
POST /v1/recipes
|
||||
{
|
||||
"id",
|
||||
"product_properties": {
|
||||
"name",
|
||||
"description",
|
||||
"default_value"
|
||||
// Other properties, describing
|
||||
// a beverage to end-user
|
||||
…
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<p>At first glance, again, it looks like a reasonably simple interface, explicitly decomposed into abstraction levels. But let us imagine the future — what would happen with this interface when our system evolves further?</p>
|
||||
<p>The first problem is obvious to those who read <a href="#chapter-11-paragraph-20">chapter 11</a> thoroughly: product properties must be localized. That will lead us to the first change:</p>
|
||||
<pre><code>"product_properties": {
|
||||
// "l10n" is a standard abbreviation
|
||||
// for "localization"
|
||||
"l10n" : [{
|
||||
"language_code": "en",
|
||||
"country_code": "US",
|
||||
"name",
|
||||
"description"
|
||||
}, /* other languages and countries */ … ]
|
||||
]
|
||||
</code></pre>
|
||||
<p>And here the first big question arises: what should we do with the <code>default_volume</code> field? From one side, that's an objective quality measured in standardized units, and it's being passed to the program execution engine. On the other side, in countries like the United States, we had to specify beverage volume not like ‘300 ml’, but ‘10 fl oz’. We may propose two solutions:</p>
|
||||
<ul>
|
||||
<li>either the partner provides the corresponding number only, and we will make readable descriptions on our own behalf,</li>
|
||||
<li>or the partner provides both the number and all of its localized representations.</li>
|
||||
</ul>
|
||||
<p>The flaw in the first option is that a partner might be willing to use the service in some new country or language — and will be unable to do so until the API supports them. The flaw in the second option is that it works with predefined volumes only, so you can't order an arbitrary beverage volume. So the very first step we've made effectively has us trapped.</p>
|
||||
<p>The localization flaws are not the only problem of this API. We should ask ourselves a question — <em>why</em> do we really need these <code>name</code> and <code>description</code>? They are simply non-machine-readable strings with no specific semantics. At first glance, we need them to return them back in the <code>/v1/search</code> method response, but that's not a proper answer: why do we really return these strings from <code>search</code>?</p>
|
||||
<p>The correct answer lies a way beyond this specific interface. We need them <em>because some representation exists</em>. There is a UI for choosing beverage type. Probably the <code>name</code> and <code>description</code> 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 <em>what if</em> a partner is making their own UI for their own app? Not only they might not actually need two descriptions, but we are also <em>deceiving</em> them. The <code>name</code> 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’.</p>
|
||||
<p>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. <code>product_properties</code> 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.</p>
|
||||
<p>Problems we're facing are the problems of <em>strong coupling</em>. 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 <strong>low-level entities must not define high-level ones</strong>.</p>
|
||||
<h4>The rule of contexts</h4>
|
||||
<p>To make things worse, let us state that the inverse principle is actually correct either: high-level entities must not define low-level ones, since that simply isn't their responsibility. The exit from this logical labyrinth is: high-level entities must <em>define a context</em>, which other objects are to interpret. To properly design adding new recipe interface 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.</p>
|
||||
<p>We have already found a localization context. There is some set of languages and regions we support in our API, and there are requirements — what exactly the partner must provide to make our API work in a new region. More specifically, there must be some formatting function to represent beverage volume somewhere in our API code:</p>
|
||||
<pre><code>l10n.volume.format(value, language_code, country_code)
|
||||
// l10n.formatVolume('300ml', 'en', 'UK') → '300 ml'
|
||||
// l10n.formatVolume('300ml', 'en', 'US') → '10 fl oz'
|
||||
</code></pre>
|
||||
<p>To make our API work correctly with a new language or region, the partner must either define this function or point which pre-existing implementation to use. Like this:</p>
|
||||
<pre><code>// Add a general formatting rule
|
||||
// for Russian language
|
||||
PUT /formatters/volume/ru
|
||||
{
|
||||
"template": "{volume} мл"
|
||||
}
|
||||
// Add a specific formatting rule
|
||||
// for Russian language in the ‘US’ region
|
||||
PUT /formatters/volume/ru/US
|
||||
{
|
||||
// in US we need to recalculate
|
||||
// the number, then add a postfix
|
||||
"value_preparation": {
|
||||
"action": "divide",
|
||||
"divisor": 30
|
||||
},
|
||||
"template": "{volume} ун."
|
||||
}
|
||||
</code></pre>
|
||||
<p><strong>NB</strong>: we are more than aware that such a simple format isn't enough to cover real-world localization use-cases, and one either rely on existing libraries, or design a sophisticated format for such templating, which takes into account such things as grammatical cases and rules of rounding numbers up, or allow defining formatting rules in a form of function code. The example above is simplified for purely educational purposes.</p>
|
||||
<p>Let us deal with the <code>name</code> and <code>description</code> problem then. To lower the coupling level there we need to formalize (probably just to ourselves) a ‘layout’ concept. We are asking for providing <code>name</code> and <code>description</code> not because we just need them, but for representing them in some specific user interface. This specific UI might have an identifier or a semantic name.</p>
|
||||
<pre><code>GET /v1/layouts/{layout_id}
|
||||
{
|
||||
"id",
|
||||
// We would probably have lots of layouts,
|
||||
// so it's better to enable extensibility
|
||||
// from the beginning
|
||||
"kind": "recipe_search",
|
||||
// Describe every property we require
|
||||
// to have this layout rendered properly
|
||||
"properties": [{
|
||||
// Since we learned that `name`
|
||||
// is actually a title for a search
|
||||
// result snippet, it's much more
|
||||
// convenient to have explicit
|
||||
// `search_title` instead
|
||||
"field": "search_title",
|
||||
"view": {
|
||||
// Machine-readable description
|
||||
// of how this field is rendered
|
||||
"min_length": "5em",
|
||||
"max_length": "20em",
|
||||
"overflow": "ellipsis"
|
||||
}
|
||||
}, …],
|
||||
// Which fields are mandatory
|
||||
"required": [
|
||||
"search_title",
|
||||
"search_description"
|
||||
]
|
||||
}
|
||||
</code></pre>
|
||||
<p>So the partner may decide, which option better suits them. They can provide mandatory fields for the standard layout:</p>
|
||||
<pre><code>PUT /v1/recipes/{id}/properties/l10n/{lang}
|
||||
{
|
||||
"search_title", "search_description"
|
||||
}
|
||||
</code></pre>
|
||||
<p>or create a layout of their own and provide data fields it requires:</p>
|
||||
<pre><code>POST /v1/layouts
|
||||
{
|
||||
"properties"
|
||||
}
|
||||
→
|
||||
{ "id", "properties" }
|
||||
</code></pre>
|
||||
<p>or they may ultimately design their own UI and don't use this functionality at all, defining neither layouts nor data fields.</p>
|
||||
<p>Then our interface would ultimately look like:</p>
|
||||
<pre><code>POST /v1/recipes
|
||||
{ "id" }
|
||||
→
|
||||
{ "id" }
|
||||
</code></pre>
|
||||
<p>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:</p>
|
||||
<pre><code>POST /v1/recipe-builder
|
||||
{
|
||||
"id",
|
||||
// Recipe's fixed properties
|
||||
"product_properties": {
|
||||
"default_volume",
|
||||
"l10n"
|
||||
},
|
||||
// Create all the desirable layouts
|
||||
"layouts": [{
|
||||
"id", "kind", "properties"
|
||||
}],
|
||||
// Add all the formatters needed
|
||||
"formatters": {
|
||||
"volume": [
|
||||
{ "language_code", "template" },
|
||||
{ "language_code", "country_code", "template" }
|
||||
]
|
||||
},
|
||||
// Other actions needed to be done
|
||||
// to register new recipe in the system
|
||||
…
|
||||
}
|
||||
</code></pre>
|
||||
<p>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 <a href="#chapter-11-paragraph-8">Chapter 11</a>.</p>
|
||||
<pre><code>POST /v1/recipes/custom
|
||||
{
|
||||
// First part of the composite
|
||||
// identifier, for example,
|
||||
// the partner's own id
|
||||
"namespace": "my-coffee-company",
|
||||
// Second part of the identifier
|
||||
"id_component": "lungo-customato"
|
||||
}
|
||||
→
|
||||
{
|
||||
"id": "my-coffee-company:lungo-customato"
|
||||
}
|
||||
</code></pre>
|
||||
<p>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 for publishing new recipes for everyone (and that, by the way, would allow us to organize our own backoffice to edit recipes).</p><div class="page-break"></div><h3><a href="#chapter-17" class="anchor" id="chapter-17">Chapter 17. Weak coupling</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. A mindful reader might have noted that this technique was already used in our API study much earlier in <a href="#chapter-9">Chapter 9</a> with regards to ‘program’ and ‘program run’ entities. Indeed, we might do it without <code>program-matcher</code> endpoint and make it this way:</p>
|
||||
<pre><code>GET /v1/recipes/{id}/run-data/{api_type}
|
||||
→
|
||||
{ /* A description, how to
|
||||
execute a specific recipe
|
||||
using a specified API type */ }
|
||||
</code></pre>
|
||||
<p>Then developers would have to make this trick to get coffee prepared:</p>
|
||||
<ul>
|
||||
<li>learn the API type of the specific coffee machine;</li>
|
||||
<li>get the execution description, as stated above;</li>
|
||||
<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>
|
||||
<p>But let us return to the question we have previously mentioned in <a href="#chapter-15">Chapter 15</a>: how should we parametrize the order preparation process implemented via third-party API. In other words, what's this <code>program_execution_endpoint</code> that we ask upon the API type registration?</p>
|
||||
<pre><code>PUT /v1/api-types/{api_type}
|
||||
{
|
||||
"order_execution_endpoint": {
|
||||
// ???
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<p>Out of general considerations, we may assume that every such API would be capable of executing three functions: run a program with specified parameters, return current execution status, and finish (cancel) the order. An obvious way to provide the common interface is to require these three functions being executed via a remote call, for example, like this:</p>
|
||||
<pre><code>// This is an endpoint for partners
|
||||
// to register their coffee machines
|
||||
// in the system
|
||||
PUT /partners/{id}/coffee-machines
|
||||
{
|
||||
"coffee-machines": [{
|
||||
"id",
|
||||
…
|
||||
"order_execution_endpoint": {
|
||||
"program_run_endpoint": {
|
||||
/* Some description of
|
||||
the remote function call */
|
||||
"type": "rpc",
|
||||
"endpoint": <URL>,
|
||||
"format"
|
||||
},
|
||||
"program_state_endpoint",
|
||||
"program_stop_endpoint"
|
||||
}
|
||||
}, …]
|
||||
}
|
||||
</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_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 in the approach. In fact, we simply described what we'd already learned, not even trying to look at a 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 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, <code>program_modify_endpoint</code>, 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 <em>is</em> important is that both endpoint and 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 either: what if we're plugging via our API not a coffee house, but a vending machine? From one side, it means that <code>modify</code> endpoint and all related stuff are simply meaningless: vending machine couldn't sprinkle cinnamon over a coffee cup, and contactless takeout requirement means nothing to it. From the other side, the machine, unlike the people-operated café, requires <em>takeout approval</em>: 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.</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 three endpoints:</p>
|
||||
<ul>
|
||||
<li>to have vending machines integrated a partner must implement the <code>program_takeout_endpoint</code>, but doesn't actually need the <code>program_modify_endpoint</code>;</li>
|
||||
<li>to have regular coffee houses integrated a partner must implement the <code>program_modify_endpoint</code>, but doesn't actually need the <code>program_takeout_endpoint</code>.</li>
|
||||
</ul>
|
||||
<p>Furthermore, we have to describe both endpoints in the docs. It's quite natural that <code>takeout</code> endpoint is very specific; unlike cinnamon sprinkling, which we hid under the pretty general <code>modify</code> 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.</p>
|
||||
<p>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 <em>always</em> 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 when half of the methods <em>isn't actually needed at all</em>, like requesting contactless takeout method.</p>
|
||||
<p>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.</p>
|
||||
<p>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 weak one. How would the <em>ideal</em> solution look from both sides? Something like this:</p>
|
||||
<ul>
|
||||
<li>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;</li>
|
||||
<li>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.</li>
|
||||
</ul>
|
||||
<p>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 <em>informational contexts</em> 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 <a href="#chapter-9">‘The Data Flow’ paragraph of Chapter 9</a>.</p>
|
||||
<p>In our case we need to implement the following mechanisms:</p>
|
||||
<ul>
|
||||
<li>running a program creates a corresponding context comprising all the essential parameters;</li>
|
||||
<li>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.</li>
|
||||
</ul>
|
||||
<p>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:</p>
|
||||
<pre><code>/* Partner's implementation of the program
|
||||
run procedure for a custom API type */
|
||||
registerProgramRunHandler(apiType, (program) => {
|
||||
// Initiating an execution
|
||||
// on partner's side
|
||||
let execution = initExecution(…);
|
||||
// Listen to parent context's changes
|
||||
program.context.on('takeout_requested', () => {
|
||||
// If takeout is requested, initiate
|
||||
// corresponding procedures
|
||||
execution.prepareTakeout(() => {
|
||||
// When the cup is ready for takeout,
|
||||
// emit corresponding event
|
||||
// for higher-level entity to catch it
|
||||
execution.context.emit('takeout_ready');
|
||||
});
|
||||
});
|
||||
|
||||
return execution.context;
|
||||
});
|
||||
</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 queues like <code>GET /program-run/events</code> and <code>GET /partner/{id}/execution/events</code>. 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.</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>
|
||||
<li>instead of a long list of methods that shall be implemented to integrate partner's API, we now have a long list of <code>context</code> objects fields and events they generate;</li>
|
||||
<li>and with regards to technological progress we've changed nothing: now we have deprecated fields and events instead of deprecated methods.</li>
|
||||
</ul>
|
||||
<p>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 <em>are obliged</em> 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.</p>
|
||||
<p>The difference between strong coupling and weak coupling is that field-event mechanism <em>isn't obligatory to both sides</em>. Let us remember what we sought to achieve:</p>
|
||||
<ul>
|
||||
<li>higher-level context doesn't actually know how low-level API works — and it really doesn't; it describes the changes which occurs within the context itself, and reacts only to those events which mean something to it;</li>
|
||||
<li>low-level context doesn't know anything about alternative implementations — and it really doesn't; it handles only those events which mean something at its level, and emits only those events which could actually happen under its specific conditions.</li>
|
||||
</ul>
|
||||
<p>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.</p>
|
||||
<p>Worth mentioning that the number of entities (fields, events), though effectively doubled compared to strong-coupled API design, raised qualitatively, not quantitatively. The <code>program</code> context describes fields and events in its own terms (type of beverage, volume, cinnamon sprinkling), while the <code>execution</code> context must reformulate those terms according to its own subject area (omitting redundant ones, by the way). It is also important that the <code>execution</code> context might concretize these properties for underlying objects according to its own specifics, while the <code>program</code> context must keep its properties general enough to be applicable to any possible underlying technology.</p>
|
||||
<p>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.</p>
|
||||
<h4>The Inversion of Responsibility</h4>
|
||||
<p>It becomes obvious from what was said above that two-way weak coupling means a significant increase of code complexity on both levels, which is often redundant. In many cases, two-way event linking might be replaced with one-way linking without significant loss of design quality. That means allowing a low-level entity to call higher-level methods directly instead of generating events. Let's alter our example:</p>
|
||||
<pre><code>/* Partner's implementation of program
|
||||
run procedure for a custom API type */
|
||||
registerProgramRunHandler(apiType, (program) => {
|
||||
// Initiating an execution
|
||||
// on partner's side
|
||||
let execution = initExecution(…);
|
||||
// Listen to parent context's changes
|
||||
program.context.on('takeout_requested', () => {
|
||||
// If takeout is requested, initiate
|
||||
// corresponding procedures
|
||||
execution.prepareTakeout(() => {
|
||||
/* When the order is ready for takeout,
|
||||
signalize about that, but not
|
||||
with event emitting */
|
||||
// execution.context.emit('takeout_ready')
|
||||
program.context.set('takeout_ready');
|
||||
// Or even more rigidly
|
||||
// program.setTakeoutReady();
|
||||
});
|
||||
});
|
||||
/* Since we're modifying parent context
|
||||
instead of emitting events, we don't
|
||||
actually need to return anything */
|
||||
// return execution.context;
|
||||
});
|
||||
}
|
||||
</code></pre>
|
||||
<p>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 <em>lower</em> abstraction level. Situations with different realizations of <em>higher</em> abstraction levels emerging are, of course, possible, but quite rare. The tree of alternative implementations usually grows top to bottom.</p>
|
||||
<p>Another reason to justify this solution is that major changes occurring at different abstraction levels have different weights:</p>
|
||||
<ul>
|
||||
<li>if the technical level is under change, that must not affect product qualities and the code written by partners;</li>
|
||||
<li>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.</li>
|
||||
</ul>
|
||||
<p>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.</p>
|
||||
<p><strong>NB</strong>: many contemporary frameworks explore a shared state approach, Redux being probably the most notable example. In Redux paradigm the code above would look like this:</p>
|
||||
<pre><code>execution.prepareTakeout(() => {
|
||||
// Instead of generating events
|
||||
// or calling higher-level methods,
|
||||
// an `execution` entity calls
|
||||
// a global or quasi-global
|
||||
// callback to change a global state
|
||||
dispatch(takeoutReady());
|
||||
});
|
||||
</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 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.</p>
|
||||
<pre><code>execution.prepareTakeout(() => {
|
||||
// Instead of initiating global actions
|
||||
// an `execution` entity invokes
|
||||
// its superior's dispatch functionality
|
||||
program.context.dispatch(takeoutReady());
|
||||
});
|
||||
</code></pre>
|
||||
<pre><code>// program.context.dispatch implementation
|
||||
ProgramContext.dispatch = (action) => {
|
||||
// program.context calls its own
|
||||
// superior or global object
|
||||
// if there are no superiors
|
||||
globalContext.dispatch(
|
||||
// The action itself may and
|
||||
// must be reformulated
|
||||
// in appropriate terms
|
||||
this.generateAction(action)
|
||||
);
|
||||
}
|
||||
</code></pre>
|
||||
<h4>Test Yourself</h4>
|
||||
<p>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 <a href="#chapter-9">Chapter 9</a>. In other words, could we start an execution of an order if we operate the low-level API instead of the high-level one?</p>
|
||||
<p>Let us recall that we had proposed the following abstract interfaces to work with arbitrary coffee machine API types:</p>
|
||||
<ul>
|
||||
<li><code>POST /v1/program-matcher</code> returns the id of the program based on the coffee machine and recipe ids;</li>
|
||||
<li><code>POST /v1/programs/{id}/run</code> executes the program.</li>
|
||||
</ul>
|
||||
<p>As we can easily prove, it's quite simple to make these interfaces compatible: we only need to assign a <code>program_id</code> identifier to the (API type, recipe) pair, for example, through returning it in the <code>PUT /coffee-machines</code> method response:</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>So the method we'd developed:</p>
|
||||
<pre><code>POST /v1/programs/{id}/run
|
||||
</code></pre>
|
||||
<p>will work with the partner's coffee machines (like it's a third API type).</p>
|
||||
<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 has 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><strong>NB</strong>. In the <a href="#chapter-9">Chapter 9</a> 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="#chapter-18" class="anchor" id="chapter-18">Chapter 18. Interfaces as a Universal Pattern</a></h3>
|
||||
<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>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, 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>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>
|
||||
<li>
|
||||
<p>when we create an order, we need from the search result to provide those fields which describe the order itself; it might be a structure like:</p>
|
||||
<p><code>{coffee_machine_id, recipe_id, volume, currency_code, price}</code>,</p>
|
||||
<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>
|
||||
</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>
|
||||
<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="#chapter-16">Chapter 16</a>.</p>
|
||||
</li>
|
||||
<li>
|
||||
<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 been appeared 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="#chapter-19" class="anchor" id="chapter-19">Chapter 19. The Serenity Notepad</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>
|
||||
<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 which 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>when the error was fixed, we've 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>
|
||||
<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>
|
||||
</ol>
|
||||
<h5><a href="#chapter-19-paragraph-3" id="chapter-19-paragraph-3" class="anchor">3. Implement your API functionality atop of 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 <a href="#chapter-16">Chapter 16</a> 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>
|
||||
</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>
|
||||
<h5><a href="#chapter-19-paragraph-4" id="chapter-19-paragraph-4" class="anchor">4. 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, 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. to release 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>
|
||||
</div></article>
|
||||
</body></html>
|
BIN
docs/API.en.pdf
BIN
docs/API.en.pdf
Binary file not shown.
BIN
docs/API.ru.epub
BIN
docs/API.ru.epub
Binary file not shown.
708
docs/API.ru.html
708
docs/API.ru.html
@ -288,7 +288,7 @@ h1 {
|
||||
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
|
||||
<path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px" class="octo-arm"></path>
|
||||
<path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a>
|
||||
<div class="page-break"></div><nav><h2 class="toc">Содержание</h2><ul class="table-of-contents"><li><a href="#section-1">Введение</a><ul><li><a href="#chapter-1">Глава 1. О структуре этой книги</a></li><li><a href="#chapter-2">Глава 2. Определение API</a></li><li><a href="#chapter-3">Глава 3. Критерии качества API</a></li><li><a href="#chapter-4">Глава 4. Обратная совместимость</a></li><li><a href="#chapter-5">Глава 5. О версионировании</a></li><li><a href="#chapter-6">Глава 6. Условные обозначения и терминология</a></li></ul></li><li><a href="#section-2">Раздел I. Проектирование API</a><ul><li><a href="#chapter-7">Глава 7. Пирамида контекстов API</a></li><li><a href="#chapter-8">Глава 8. Определение области применения</a></li><li><a href="#chapter-9">Глава 9. Разделение уровней абстракции</a></li><li><a href="#chapter-10">Глава 10. Разграничение областей ответственности</a></li><li><a href="#chapter-11">Глава 11. Описание конечных интерфейсов</a></li><li><a href="#chapter-12">Глава 12. Приложение к разделу I. Модельное API</a></li></ul></li></ul></nav><div style="page-break-after: always;"></div><h2><a href="#section-1" class="anchor" id="section-1">Введение</a></h2><h3><a href="#chapter-1" class="anchor" id="chapter-1">Глава 1. О структуре этой книги</a></h3>
|
||||
<div class="page-break"></div><nav><h2 class="toc">Содержание</h2><ul class="table-of-contents"><li><a href="#section-1">Введение</a><ul><li><a href="#chapter-1">Глава 1. О структуре этой книги</a></li><li><a href="#chapter-2">Глава 2. Определение API</a></li><li><a href="#chapter-3">Глава 3. Критерии качества API</a></li><li><a href="#chapter-4">Глава 4. Обратная совместимость</a></li><li><a href="#chapter-5">Глава 5. О версионировании</a></li><li><a href="#chapter-6">Глава 6. Условные обозначения и терминология</a></li></ul></li><li><a href="#section-2">Раздел I. Проектирование API</a><ul><li><a href="#chapter-7">Глава 7. Пирамида контекстов API</a></li><li><a href="#chapter-8">Глава 8. Определение области применения</a></li><li><a href="#chapter-9">Глава 9. Разделение уровней абстракции</a></li><li><a href="#chapter-10">Глава 10. Разграничение областей ответственности</a></li><li><a href="#chapter-11">Глава 11. Описание конечных интерфейсов</a></li><li><a href="#chapter-12">Глава 12. Приложение к разделу I. Модельное API</a></li></ul></li><li><a href="#section-3">Раздел II. Обратная совместимость</a><ul><li><a href="#chapter-13">Глава 13. Постановка проблемы обратной совместимости</a></li><li><a href="#chapter-14">Глава 14. О ватерлинии айсберга</a></li><li><a href="#chapter-15">Глава 15. Расширение через абстрагирование</a></li><li><a href="#chapter-16">Глава 16. Сильная связность и сопутствующие проблемы</a></li><li><a href="#chapter-17">Глава 17. Слабая связность</a></li><li><a href="#chapter-18">Глава 18. Интерфейсы как универсальный паттерн</a></li><li><a href="#chapter-19">Глава 19. Блокнот душевного покоя</a></li></ul></li></ul></nav><div style="page-break-after: always;"></div><h2><a href="#section-1" class="anchor" id="section-1">Введение</a></h2><h3><a href="#chapter-1" class="anchor" id="chapter-1">Глава 1. О структуре этой книги</a></h3>
|
||||
<p>Книга, которую вы держите в руках, состоит из введения и трех больших разделов.</p>
|
||||
<p>В первом разделе мы поговорим о проектировании API на стадии разработки концепции — как грамотно выстроить архитектуру, от крупноблочного планирования до конечных интерфейсов.</p>
|
||||
<p>Второй раздел будет посвящён жизненному циклу API — как интерфейсы эволюционируют со временем и как развивать продукт так, чтобы отвечать потребностям пользователей.</p>
|
||||
@ -1947,7 +1947,7 @@ POST /v1/orders
|
||||
// Данные о заведении
|
||||
"place": { "name", "location" },
|
||||
// Данные о кофе-машине
|
||||
"coffee-machine": { "id", "brand", "type" },
|
||||
"coffee_machine": { "id", "brand", "type" },
|
||||
// Как добраться
|
||||
"route": { "distance", "duration", "location_tip" },
|
||||
// Предложения напитков
|
||||
@ -2009,7 +2009,7 @@ POST /v1/orders/{id}/cancel
|
||||
// соответствующей указанному рецепту
|
||||
// на указанной кофе-машине
|
||||
POST /v1/program-matcher
|
||||
{ "recipe", "coffee-machine" }
|
||||
{ "recipe", "coffee_machine" }
|
||||
→
|
||||
{ "program_id" }
|
||||
</code></pre>
|
||||
@ -2072,6 +2072,706 @@ GET /v1/runtimes/{runtime_id}/state
|
||||
</code></pre>
|
||||
<pre><code>// Прекращает исполнение рантайма
|
||||
POST /v1/runtimes/{id}/terminate
|
||||
</code></pre><div class="page-break"></div>
|
||||
</code></pre><div class="page-break"></div><h2><a href="#section-3" class="anchor" id="section-3">Раздел II. Обратная совместимость</a></h2><h3><a href="#chapter-13" class="anchor" id="chapter-13">Глава 13. Постановка проблемы обратной совместимости</a></h3>
|
||||
<p>Как обычно, дадим смысловое определение «обратной совместимости», прежде чем начинать изложение.</p>
|
||||
<p>Обратная совместимость — это свойство всей системы API быть стабильной во времени. Это значит следующее: <strong>код, написанный разработчиками с использованием вашего API, продолжает работать функционально корректно в течение длительного времени</strong>. К этому определению есть два больших вопроса, и два уточнения к ним.</p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>Что значит «функционально корректно»?</p>
|
||||
<p>Это значит, что код продолжает выполнять свою функцию — решать какую-то задачу пользователя. Это не означает, что он продолжает работать одинаково: например, если вы предоставляете UI-библиотеку, то изменение функционально несущественных деталей дизайна, типа глубины теней или формы штриха границы, обратную совместимость не нарушит. А вот, например, изменение размеров визуальных компонентов, скорее всего, приведёт к тому, что какие-то пользовательские макеты развалятся.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Что значит «длительное время»?</p>
|
||||
<p>С нашей точки зрения длительность поддержания обратной совместимости следует увязывать с длительностью жизненных циклов приложений в соответствующей предметной области. Хороший ориентир в большинстве случаев — это LTS-периоды платформ. Так как приложение все равно будет переписано в связи с окончанием поддержки платформы, нормально предложить также и переход на новую версию API. В основных предметных областях (десктопные и мобильные операционные системы) этот срок исчисляется несколькими годами.</p>
|
||||
</li>
|
||||
</ol>
|
||||
<p>Почему обратную совместимость необходимо поддерживать (в том числе предпринимать необходимые меры ещё на этапе проектирования API) — понятно из определения. Прекращение работы приложения (полное или частичное) по вине поставщика API — крайне неприятное событие, а то и катастрофа, для любого разработчика, особенно если он платит за это API деньги.</p>
|
||||
<p>Но развернём теперь проблему в другую сторону: а почему вообще возникает проблема с поддержанием обратной совместимости? Почему мы можем <em>хотеть</em> её нарушить? Ответ на этот вопрос, при кажущейся простоте, намного сложнее, чем на предыдущий.</p>
|
||||
<p>Мы могли бы сказать, что <em>обратную совместимость приходится нарушать для расширения функциональности API</em>. Но это лукавство: новая функциональность на то и <em>новая</em>, что она не может затронуть код приложений, который её не использует. Да, конечно, есть ряд сопутствующих проблем, приводящих к стремлению переписать <em>наш</em> код, код самого API, с выпуском новой мажорной версии:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>код банально морально устарел, внесение в него изменений, пусть даже в виде расширения функциональности, нецелесообразно;</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>новая функциональность не была предусмотрена в старом интерфейсе: мы хотели бы наделить уже существующие сущности новыми свойствами, но не можем;</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>наконец, за прошедшее после изначального релиза время мы узнали о предметной области и практике применения нашего API гораздо больше, и сделали бы многие вещи иначе.</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>Эти аргументы можно обобщить как «разработчики API не хотят работать со старым кодом», не сильно покривив душой. Но и это объяснение неполно: даже если вы не собираетесь переписывать код API при добавлении новой функциональности, или вы вовсе её и не собирались добавлять, выпускать новые версии API — мажорные и минорные — всё равно придётся.</p>
|
||||
<p><strong>NB</strong>: в рамках этой главы мы не разделяем минорные версии и патчи: под словами «минорная версия» имеется в виду любой обратно совместимый релиз API.</p>
|
||||
<p>Напомним, что <a href="https://twirl.github.io/The-API-Book/docs/API.ru.html#chapter-2">API — это мост</a>, средство соединения разных программируемых контекстов. И как бы нам ни хотелось зафиксировать конструкцию моста, наши возможности ограничены: мост-то мы можем зафиксировать — да вот края ущелья, как и само ущелье, не можем. В этом корень проблемы: мы не можем оставить <em>свой</em> код без изменений, поэтому нам придётся рано или поздно потребовать, чтобы клиенты изменили <em>свой</em>.</p>
|
||||
<p>Помимо наших собственных поползновений в сторону изменения архитектуры API, три других тектонических процесса происходят одновременно: размывание клиентов, предметной области и нижележащей платформы.</p>
|
||||
<h4>Фрагментация клиентских приложений</h4>
|
||||
<p>В тот момент, когда вы выпустили первую версию API, и первые клиенты начали использовать её — ситуация идеальна. Есть только одна версия, и все клиенты работают с ней. А вот дальше возможны два варианта развития событий:</p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>Если платформа поддерживает on-demand получение кода, как старый-добрый Веб, и вы не поленились это получение кода реализовать (в виде платформенного SDK, например, JS API), то развитие API более или менее находится под вашим контролем. Поддержание обратной совместимости сводится к поддержанию обратной совместимости <em>клиентской библиотеки</em>, а вот в части сервера и клиент-серверного взаимодействия вы свободны.</p>
|
||||
<p>Это не означает, что вы не можете нарушить обратную совместимость — всё ещё можно напортачить с заголовками кэширования SDK или банально допустить баг в коде. Кроме того, даже on-demand системы все равно не обновляются мгновенно — автор сталкивался с ситуацией, когда пользователи намеренно держали вкладку браузера открытой <em>неделями</em>, чтобы не обновляться на новые версии. Тем не менее, вам почти не придётся поддерживать более двух (последней и предпоследней) минорных версий клиентского SDK. Более того, вы можете попытаться в какой-то момент переписать предыдущую мажорную версию библиотеки, имплементировав её на основе API новой версии.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Если поддержка on-demand кода платформой не поддерживается или запрещена условиями, как это произошло с современными мобильными платформами, то ситуация становится гораздо сложнее. По сути, каждый клиент — это «слепок» кода, который работает с вашим API, зафиксированный в том состоянии, в котором он был на момент компиляции. Обновление клиентских приложений по времени растянуто гораздо дольше, нежели Web-приложений; самое неприятное здесь состоит в том, что некоторые клиенты <em>не обновятся вообще никогда</em> — по одной из трёх причин:</p>
|
||||
<ul>
|
||||
<li>разработчики просто не выпускают новую версию приложения, его развитие заморожено;</li>
|
||||
<li>пользователь не хочет обновляться (в том числе потому, что, по мнению пользователя, разработчики приложения его «испортили» в новых версиях);</li>
|
||||
<li>пользователь не может обновиться вообще, потому что его устройство больше не поддерживается.</li>
|
||||
</ul>
|
||||
<p>В современных реалиях все три категории в сумме легко могут составлять десятки процентов аудитории. Это означает, что прекращение поддержки любой версии API является весьма заметным событием — особенно если приложения разработчика поддерживают более широкий спектр версий платформы, нежели ваше API.</p>
|
||||
<p>Вы можете не выпускать вообще никаких SDK, предоставляя только серверное API в виде, например, HTTP эндпойнтов. Вам может показаться, что таким образом, пусть ваше API и стало менее конкурентоспособным на рынке из-за отсутствия SDK, вы облегчили себе задачу поддержания обратной совместимости. На самом деле это совершенно не так: раз вы не предоставляете свой SDK — или разработчики возьмут неофициальный SDK (если кто-то его сделает), или просто каждый из них напишет по фреймворку. Стратегия «ваш фреймворк — ваша ответственность», к счастью или к сожалению, работает плохо: если на вашем API пишут некачественные приложения — значит, ваше API само некачественное. Уж точно по мнению разработчиков, а может и по мнению пользователей, если работа API внутри приложения пользователю видна.</p>
|
||||
</li>
|
||||
</ol>
|
||||
<p>Конечно, если ваше API достаточно stateless и не требует клиентских SDK (или же можно обойтись просто автогенерацией SDK из спецификации), эти проблемы будут гораздо менее заметны, но избежать их полностью можно только одним способом — никогда не выпуская новых версий API. Во всех остальных случаях вы будете иметь дело с какой-то гребёнкой распределения количества пользователей по версиям API и версиям SDK.</p>
|
||||
<h4>Эволюция предметной области</h4>
|
||||
<p>Другая сторона ущелья — та самая нижележащая функциональность, к которой вы предоставляете API. Она, разумеется, тоже не статична и развивается в какую-то сторону:</p>
|
||||
<ul>
|
||||
<li>появляется новая функциональность;</li>
|
||||
<li>старая функциональность перестаёт поддерживаться;</li>
|
||||
<li>меняются интерфейсы.</li>
|
||||
</ul>
|
||||
<p>Как правило, API изначально покрывает только какую-то часть существующей предметной области. В случае нашего <a href="https://twirl.github.io/The-API-Book/docs/API.ru.html#chapter-7">примера с API кофе-машин</a> разумно ожидать, что будут появляться новые модели с новым API, которые нам придётся включать в свою платформу, и гарантировать возможность сохранения того же интерфейса абстракции — весьма непросто. Даже если просто добавлять поддержку новых видов нижележащих устройств, не добавляя ничего во внешний интерфейс — это всё равно изменения в коде, которые могут в итоге привести к несовместимости, пусть и ненамеренно.</p>
|
||||
<p>Стоит также отметить, что далеко не все поставщики API относятся к поддержанию обратной совместимости, да и вообще к качеству своего ПО, так же серьёзно, как и (надеемся) вы. Стоит быть готовым к тому, что заниматься поддержанием вашего API в рабочем состоянии, то есть написанием и поддержкой фасадов к меняющемуся ландшафту предметной области, придётся именно вам, и зачастую довольно внезапно.</p>
|
||||
<h4>Дрифт платформ</h4>
|
||||
<p>Наконец, есть и третья сторона вопроса — «ущелье», через которое вы перекинули свой мост в виде API. Код, который напишут разработчики, исполняется в некоторой среде, которую вы не можете контролировать, и она тоже эволюционирует. Появляются новые версии операционной системы, браузеров, протоколов, языка SDK. Разрабатываются новые стандарты и принимаются новые соглашения, некоторые из которых сами по себе обратно несовместимы, и поделать с этим ничего нельзя.</p>
|
||||
<p>Как и в случае со старыми версиями приложений, старые версии платформ также приводят к фрагментации, поскольку разработчикам (в том числе и разработчикам API) объективно тяжело поддерживать старые платформы, а пользователям столь же объективно тяжело обновляться, так как обновление операционной системы зачастую невозможно без замены самого устройства на более новое.</p>
|
||||
<p>Самое неприятное во всём этом то, что к изменениям в API подталкивает не только поступательный прогресс в виде новых платформ и протоколов, но и банальная мода и вкусовщина. Буквально несколько лет назад были в моде объёмные реалистичные иконки, от которых все отказались в пользу плоских и абстрактных — и большинству разработчиков визуальных компонентов пришлось, вслед за модой, переделывать свои библиотеки, выпуская новые наборы иконок или заменяя старые. Аналогично прямо сейчас повсеместно внедряется поддержка «ночных» тем интерфейсов, что требует изменений в большом количестве API.</p>
|
||||
<h4>Политика обратной совместимости</h4>
|
||||
<p>Итого, если суммировать:</p>
|
||||
<ul>
|
||||
<li>вследствие итерационного развития приложений, платформ и предметной области вы будете вынуждены выпускать новые версии вашего API; в разных предметных областях скорость развития разная, но почти никогда не нулевая;</li>
|
||||
<li>вкупе это приведёт к фрагментации используемой версии API по приложениям и платформам;</li>
|
||||
<li>вам придётся принимать решения, критически влияющие на надёжность вашего API в глазах потребителей.</li>
|
||||
</ul>
|
||||
<p>Опишем кратко эти решения и ключевые принципы их принятия.</p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>Как часто выпускать мажорные версии API.</p>
|
||||
<p>Это в основном <em>продуктовый</em> вопрос. Новая мажорная версия API выпускается, когда накоплена критическая масса функциональности, которую невозможно или слишком дорого поддерживать в рамках предыдущей мажорной версии. В стабильной ситуации такая необходимость возникает, как правило, раз в несколько лет. На динамично развивающихся рынках новые мажорные версии можно выпускать чаще, здесь ограничителем являются только ваши возможности выделить достаточно ресурсов для поддержания зоопарка версий. Однако следует заметить, что выпуск новой мажорной версии раньше, чем была стабилизирована предыдущая (а на это как правило требуется от нескольких месяцев до года), выглядит для разработчиков очень плохим сигналом, означающим риск <em>постоянно</em> сидеть на сырой платформе.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Какое количество <em>мажорных</em> версий поддерживать одновременно.</p>
|
||||
<p>Что касается мажорных версий, то <em>теоретический</em> ответ мы дали выше: в идеальной ситуации жизненный цикл мажорной версии должен быть чуть длиннее жизненного цикла платформы. Для стабильных ниш типа десктопных операционных систем это порядка 5-10 лет, для новых и динамически развивающихся — меньше, но всё равно измеряется в годах. <em>Практически</em> следует смотреть на долю потребителей, реально продолжающих пользоваться версией.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Какое количество <em>минорных</em> версий (в рамках одной мажорной) поддерживать одновременно.</p>
|
||||
<p>Для минорных версий возможны два варианта:</p>
|
||||
<ul>
|
||||
<li>если вы предоставляете только серверное API и компилируемые SDK, вы можете в принципе не поддерживать никакие минорные версии API, помимо актуальной: серверное API находится полностью под вашим контролем, и вы можете оперативно исправить любые проблемы с логикой;</li>
|
||||
<li>если вы предоставляете code-on-demand SDK, то вот здесь хорошим тоном является поддержка предыдущих минорных версий SDK в работающем состоянии на срок, достаточный для того, чтобы разработчики могли протестировать своё приложение с новой версией и внести какие-то правки по необходимости. Так как полностью переписывать приложения при этом не надо, разумно ориентироваться на длину релизных циклов в вашей индустрии, обычно это несколько месяцев в худшем случае.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
<p>Дополнительно в разделе III мы также обсудим, каким образом предупреждать потребителей о выходе новых версий и прекращении поддержки старых, и как стимулировать их переходить на новые версии API.</p><div class="page-break"></div><h3><a href="#chapter-14" class="anchor" id="chapter-14">Глава 14. О ватерлинии айсберга</a></h3>
|
||||
<p>Прежде, чем начинать разговор о принципах проектирования расширяемого API, следует обсудить гигиенический минимум. Огромное количество проблем не случилось бы, если бы разработчики API чуть ответственнее подходили к обозначению зоны своей ответственности.</p>
|
||||
<h5><a href="#chapter-14-paragraph-1" id="chapter-14-paragraph-1" class="anchor">1. Предоставляйте минимальный объём функциональности</a></h5>
|
||||
<p>В любой момент времени ваше API подобно айсбергу: у него есть видимая (документированная) часть и невидимая — недокументированная. В хорошем API эти две части соотносятся друг с другом примерно как надводная и подводная часть настоящего айсберга, 1 к 10. Почему так? Из двух очевидных соображений.</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>Компьютеры существуют, чтобы сложные вещи делать просто, не наоборот. Код, который напишут разработчики поверх вашего API, должен в простых и лаконичных выражениях описывать решение сложной проблемы. Поэтому «внутри» ваш код, скорее всего, будет опираться на мощную номенклатуру непубличной функциональности.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Изъятие функциональности из API невозможно без серьёзных потерь. Если вы пообещали предоставлять какую-то функциональность — вам теперь придётся предоставлять её «вечно» (до окончания поддержки этой мажорной версии API). Объявление функциональности неподдерживаемой — очень сложный и чреватый потенциальными конфликтами с потребителем процесс.</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>Правило №1 самое простое: если какую-то функциональность можно не выставлять наружу — значит, выставлять её не надо. Можно сформулировать и так: каждая сущность, каждое поле, каждый метод в публичном API — это <em>продуктовое</em> решение. Должны существовать веские <em>продуктовые</em> причины, по которым та или иная сущность документирована.</p>
|
||||
<h5><a href="#chapter-14-paragraph-2" id="chapter-14-paragraph-2" class="anchor">2. Избегайте серых зон и недосказанности</a></h5>
|
||||
<p>Ваши обязательства по поддержанию функциональности должны быть оговорены настолько четко, насколько это возможно. Особенно это касается тех сред и платформ, где нет способа нативно ограничить доступ к недокументированной функциональности. К сожалению, разработчики часто считают, что, если они «нашли» какую-то непубличную особенность, то они могут ей пользоваться — а производитель API, соответственно, обязан её поддерживать. Поэтому политика компании относительно таких «находок» должна быть явно сформулирована. Тогда в случае несанкционированного использования скрытой функциональности вы по крайней мере сможете сослаться на документацию и быть формально правы в глазах комьюнити.</p>
|
||||
<p>Однако достаточно часто разработчики API сами легитимизируют такие серые зоны, например:</p>
|
||||
<ul>
|
||||
<li>отдают недокументированные поля в ответах эндпойнтов;</li>
|
||||
<li>используют непубличную функциональность в примерах кода — в документации, в ответ на обращения пользователей, в выступлениях на конференциях и т.д.</li>
|
||||
</ul>
|
||||
<p>Нельзя принять обязательства наполовину. Или вы гарантируете работу этого кода всегда, или не подавайте никаких намеков на то, что такая функциональность существует.</p>
|
||||
<h5><a href="#chapter-14-paragraph-3" id="chapter-14-paragraph-3" class="anchor">3. Фиксируйте неявные договорённости</a></h5>
|
||||
<p>Посмотрите внимательно на код, который предлагаете написать разработчикам: нет ли в нём каких-то условностей, которые считаются очевидными, но при этом нигде не зафиксированы?</p>
|
||||
<p><strong>Пример 1</strong>. Рассмотрим SDK работы с заказами.</p>
|
||||
<pre><code>// Создаёт заказ
|
||||
let order = api.createOrder();
|
||||
// Получает статус заказа
|
||||
let status = api.getStatus(order.id);
|
||||
</code></pre>
|
||||
<p>Предположим, что в какой-то момент при масштабировании вашего сервиса вы пришли к асинхронной репликации базы данных и разрешили чтение из реплики. Это приведёт к тому, что после создания заказа следующее обращение к его статусу по id может вернуть <code>404</code>, если пришлось на асинхронную реплику, до которой ещё не дошли последние изменения из мастера. Фактически, вы сменили <a href="https://en.wikipedia.org/wiki/Consistency_model">политику консистентности</a> со strong на eventual.</p>
|
||||
<p>К чему это приведёт? К тому, что код выше перестанет работать. Разработчик создал заказ, пытается получить его статус — и получает ошибку. Очень тяжело предсказать, какую реакцию на эту ошибку предусмотрят разработчики — вероятнее всего, никакую.</p>
|
||||
<p>Вы можете сказать: «Позвольте, но мы нигде и не обещали строгую консистентность!» — и это будет, конечно, неправдой. Вы можете так сказать если, и только если, вы действительно в документации метода <code>createOrder</code> явно описали нестрогую консистентность, а все ваши примеры использования SDK написаны как-то так:</p>
|
||||
<pre><code>let order = api.createOrder();
|
||||
let status;
|
||||
while (true) {
|
||||
try {
|
||||
status = api.getStatus(order.id);
|
||||
} catch (e) {
|
||||
if (e.httpStatusCode != 404 || timeoutExceeded()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (status) {
|
||||
…
|
||||
}
|
||||
</code></pre>
|
||||
<p>Мы полагаем, что можно не уточнять, что писать код, подобный вышеприведённому, ни в коем случае нельзя. Уж если вы действительно предоставляете нестрого консистентное API, то либо операция <code>createOrder</code> в SDK должна быть асинхронной и возвращать результат только по готовности всех реплик, либо политика перезапросов должна быть скрыта внутри операции <code>getStatus</code>.</p>
|
||||
<p>Если же нестрогая консистентность не была описана с самого начала — вы не можете внести такие изменения в API. Это эффективный слом обратной совместимости, который к тому же приведёт к огромным проблемам ваших потребителей, поскольку проблема будет воспроизводиться случайным образом.</p>
|
||||
<p><strong>Пример 2</strong>. Представьте себе следующий код:</p>
|
||||
<pre><code>let resolve;
|
||||
let promise = new Promise(
|
||||
function (innerResolve) {
|
||||
resolve = innerResolve;
|
||||
}
|
||||
);
|
||||
resolve();
|
||||
</code></pre>
|
||||
<p>Этот код полагается на то, что callback-функция, переданная в <code>new Promise</code> будет выполнена <em>синхронно</em>, и переменная <code>resolve</code> будет инициализирована к моменту вызова <code>resolve()</code>. Однако это конвенция абсолютно ниоткуда не следует: ничто в сигнатуре конструктора <code>new Promise</code> не указывает на синхронный вызов callback-а.</p>
|
||||
<p>Разработчики языка, конечно, могут позволить себе такие фокусы. Однако вы как разработчик API — не можете. Вы должны как минимум задокументировать это поведение и подобрать сигнатуры так, чтобы оно было очевидно; но вообще хорошим советом будет избегать таких конвенций, поскольку они банально неочевидны при прочтении кода, использующего ваше API. Ну и конечно же ни при каких обстоятельствах вы не можете изменить это поведение с синхронного на асинхронное.</p>
|
||||
<p><strong>Пример 3</strong>. Представьте, что вы предоставляете API для анимаций, в котором есть две независимые функции:</p>
|
||||
<pre><code>// Анимирует ширину некоторого объекта
|
||||
// от первого значения до второго
|
||||
// за указанное время
|
||||
object.animateWidth('100px', '500px', '1s');
|
||||
// Наблюдает за изменением размеров объекта
|
||||
object.observe('widthchange', observerFunction);
|
||||
</code></pre>
|
||||
<p>Возникает вопрос: с какой частотой и в каких точках будет вызываться <code>observerFunction</code>? Допустим, в первой версии SDK вы эмулировали анимацию пошагово с частотой 10 кадров в секунду — тогда observerFunction будет вызвана 10 раз и получит значения '140px', '180px' и т.д. вплоть до '500px'. Но затем в новой версии API вы решили воспользоваться системными функциями для обеих операций — и теперь вы попросту не знаете, когда и с какой частотой будет вызвана <code>observerFunction</code>.</p>
|
||||
<p>Даже просто изменение частоты вызовов вполне может сделать чей-то код неработающим — например, если обработчик выполняет на каждом шаге тяжелые вычисления, и разработчик не предусмотрел никакого ограничения частоты выполнения, полагаясь на то, что ваш SDK вызывает его обработчик всего лишь 10 раз в секунду. А вот если, например, <code>observerFunction</code> перестанет вызываться с финальным значением <code>'500px'</code> вследствие каких-то особенностей системных алгоритмов — чей-то код вы сломаете абсолютно точно.</p>
|
||||
<p>В данном случае следует задокументировать конкретный контракт — как и когда вызывается callback — и придерживаться его даже при смене нижележащей технологии.</p>
|
||||
<p><strong>Пример 4</strong>. Представьте, что потребитель совершает заказ, которые проходит через вполне определённую цепочку преобразований:</p>
|
||||
<pre><code>GET /v1/orders/{id}/events/history
|
||||
→
|
||||
{
|
||||
"event_history": [
|
||||
{
|
||||
"iso_datetime": "2020-12-29T00:35:00+03:00",
|
||||
"new_status": "created"
|
||||
},
|
||||
{
|
||||
"iso_datetime": "2020-12-29T00:35:10+03:00",
|
||||
"new_status": "payment_approved"
|
||||
},
|
||||
{
|
||||
"iso_datetime": "2020-12-29T00:35:20+03:00",
|
||||
"new_status": "preparing_started"
|
||||
},
|
||||
{
|
||||
"iso_datetime": "2020-12-29T00:35:30+03:00",
|
||||
"new_status": "ready"
|
||||
}
|
||||
]
|
||||
}
|
||||
</code></pre>
|
||||
<p>Допустим, в какой-то момент вы решили надёжным клиентам с хорошей историей заказов предоставлять кофе «в кредит», не дожидаясь подтверждения платежа. Т.е. заказ перейдёт в статус <code>"preparing_started"</code>, а может и <code>"ready"</code>, вообще без события <code>"payment_approved"</code>. Вам может показаться, что это изменение является обратно-совместимым — в самом деле, вы же и не обещали никакого конкретного порядка событий. Но это, конечно, не так.</p>
|
||||
<p>Предположим, что у разработчика (вероятно, бизнес-партнера вашей компании) написан какой-то код, выполняющий какую-то полезную бизнес функцию поверх этих событий — например, строит аналитику по затратам и доходам. Вполне логично ожидать, что этот код будет оперировать какой-то машиной состояний, которая будет переходить в то или иное состояние в зависимости от получения или неполучения события. Аналитический код наверняка сломается вследствие изменения порядка событий. В лучшем случае разработчик увидит какие-то исключения и будет вынужден разбираться с причиной; в худшем случае партнер будет оперировать неправильной статистикой неопределённое время, пока не найдёт в ней ошибку.</p>
|
||||
<p>Правильным решением было бы во-первых, изначально задокументировать порядок событий и допустимые состояния; во-вторых, продолжать генерировать событие <code>"payment_approved"</code> перед <code>"preparing_started"</code> (если вы приняли решение исполнять такой заказ — значит, по сути, подтвердили платёж) и добавить расширенную информацию о платеже.</p>
|
||||
<p>Этот пример подводит нас к ещё к одному правилу.</p>
|
||||
<h5><a href="#chapter-14-paragraph-4" id="chapter-14-paragraph-4" class="anchor">4. Продуктовая логика тоже должна быть обратно совместимой</a></h5>
|
||||
<p>Такие критичные вещи, как граф переходов между статусами, порядок событий и возможные причины тех или иных изменений — должны быть документированы. Далеко не все детали бизнес-логики можно выразить в форме контрактов на эндпойнты, а некоторые вещи нельзя выразить вовсе.</p>
|
||||
<p>Представьте, что в один прекрасный день вы заводите специальный номер телефона, по которому клиент может позвонить в колл-центр и отменить заказ. Вы даже можете сделать это <em>технически</em> обратно-совместимым образом, добавив новых необязательных полей в сущность «заказ». Но конечный потребитель может просто <em>знать</em> нужный номер телефона, и позвонить по нему, даже если приложение его не показало. При этом код бизнес-аналитика партнера всё так же может сломаться или начать показывать погоду на Марсе, т.к. он был написан когда-то, ничего не зная о возможности отменить заказ, сделанный в приложении партнера, каким-то иным образом, не через самого партнёра же.</p>
|
||||
<p><em>Технически</em> корректным решением в данной ситуации могло бы быть добавление параметра «разрешено отменять через колл-центр» в функцию создания заказа — и, соответственно, запрет операторам колл-центра отменять заказы, если флаг не был указан при их создании. Но это в свою очередь плохое решение <em>с точки зрения продукта</em>. «Хорошее» решение здесь только одно — изначально предусмотреть возможность внешних отмен в API; если же вы её не предвидели — остаётся воспользоваться «блокнотом душевного спокойствия», речь о котором пойдёт в последней главе настоящего раздела.</p><div class="page-break"></div><h3><a href="#chapter-15" class="anchor" id="chapter-15">Глава 15. Расширение через абстрагирование</a></h3>
|
||||
<p>В предыдущих разделах мы старались приводить теоретические правила и иллюстрировать их на практических примерах. Однако понимание принципов проектирования API, устойчивого к изменениям, как ничто другое требует прежде всего практики. Знание о том, куда стоит «постелить соломку» — оно во многом «сын ошибок трудных». Нельзя предусмотреть всего — но можно выработать необходимый уровень технической интуиции.</p>
|
||||
<p>Поэтому в этом разделе мы поступим следующим образом: возьмём наше модельное API из предыдущего раздела, и проверим его на устойчивость в каждой возможной точке — проведём некоторый «вариационный анализ» наших интерфейсов. Ещё более конкретно — к каждой сущности мы подойдём с вопросом «что, если?» — что, если нам потребуется предоставить партнерам возможность написать свою независимую реализацию этого фрагмента логики.</p>
|
||||
<p><strong>NB</strong>: в рассматриваемых нами примерах мы будем выстраивать интерфейсы так, чтобы связывание разных сущностей происходило динамически в реальном времени; на практике такие интеграции будут делаться на стороне сервера путём написания ad hoc кода и формирования конкретных договорённостей с конкретным клиентом, однако мы для целей обучения специально будем идти более сложным и абстрактным путём. Динамическое связывание в реальном времени применимо скорее к сложным программным конструктам типа API операционных систем или встраиваемых библиотек; приводить обучающие примеры на основе систем подобной сложности было бы, однако, чересчур затруднительно.</p>
|
||||
<p>Начнём с базового интерфейса. Предположим, что мы пока что вообще не раскрывали никакой функциональности помимо поиска предложений и заказа, т.е. мы предоставляем API из двух методов — <code>POST /offers/search</code> и <code>POST /orders</code>.</p>
|
||||
<p>Сделаем следующий логический шаг и предположим, что партнёры захотят динамически подключать к нашей платформе свои собственные кофе машины с каким-то новым API. Для этого нам будет необходимо договориться о формате обратного вызова, каким образом мы будем вызывать API партнёра, и предоставить два новых эндпойта для:</p>
|
||||
<ul>
|
||||
<li>регистрации в системе новых типов API;</li>
|
||||
<li>загрузки списка кофе-машин партнёра с указанием типа API.</li>
|
||||
</ul>
|
||||
<p>Например, можно предоставить вот такие методы.</p>
|
||||
<pre><code>// 1. Зарегистрировать новый тип API
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
"order_execution_endpoint": {
|
||||
// Описание функции обратного вызова
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<pre><code>// 2. Предоставить список кофе-машин с разбивкой
|
||||
// по типу API
|
||||
PUT /v1/partners/{partnerId}/coffee-machines
|
||||
{
|
||||
"coffee_machines": [{
|
||||
"id",
|
||||
"api_type",
|
||||
"location",
|
||||
"supported_recipes"
|
||||
}, …]
|
||||
}
|
||||
</code></pre>
|
||||
<p>Таким образом механика следующая:</p>
|
||||
<ul>
|
||||
<li>партнер описывает свои виды API, кофе-машины и поддерживаемые рецепты;</li>
|
||||
<li>при получении заказа, который необходимо выполнить на конкретной кофе машине, наш сервер обратится к функции обратного вызова, передав ей данные о заказе в оговоренном формате.</li>
|
||||
</ul>
|
||||
<p>Теперь партнёры могут динамически подключать свои кофе-машины и обрабатывать заказы. Займёмся теперь, однако, вот каким упражнением:</p>
|
||||
<ul>
|
||||
<li>перечислим все неявные предположения, которые мы допустили;</li>
|
||||
<li>перечислим все неявные механизмы связывания, которые необходимы для функционирования платформы.</li>
|
||||
</ul>
|
||||
<p>Может показаться, что в нашем API нет ни того, ни другого, ведь оно очень просто и по сути просто сводится к вызову какого-то HTTP-метода — но это неправда.</p>
|
||||
<ol>
|
||||
<li>Предполагается, что каждая кофе-машина поддерживает все возможные опции заказа (например, допустимый объём напитка).</li>
|
||||
<li>Нет необходимости показывать пользователю какую-то дополнительную информацию о том, что заказ готовится на новых типах кофе-машин.</li>
|
||||
<li>Цена напитка не зависит ни от партнёра, ни от типа кофе-машины.</li>
|
||||
</ol>
|
||||
<p>Эти пункты мы выписали с одной целью: нам нужно понять, каким конкретно образом мы будем переводить неявные договорённости в явные, если нам это потребуется. Например, если разные кофе-машины предоставляют разный объём функциональности — допустим, в каких-то кофейнях объём кофе фиксирован — что должно измениться в нашем API?</p>
|
||||
<p>Универсальный паттерн внесения подобных изменений таков: мы должны рассмотреть существующий интерфейс как частный случай некоторого более общего, в котором значения некоторых параметров приняты известными по умолчанию, а потому опущены. Таким образом, внесение изменений всегда происходит в три шага.</p>
|
||||
<ol>
|
||||
<li>Явная фиксация программного контракта <em>в том объёме, в котором она действует на текущий момент</em>.</li>
|
||||
<li>Расширение функциональности: добавление нового метода, которые позволяют обойти ограничение, зафиксированное в п. 1.</li>
|
||||
<li>Объявление существующих вызовов (из п. 1) "хелперами" к новому формату (из п. 2), в которых значение новых опций считается равным значению по умолчанию.</li>
|
||||
</ol>
|
||||
<p>На нашем примере с изменением списка доступных опций заказа мы должны поступить следующим образом.</p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>Документируем текущее состояние. Все кофе-машины, подключаемые по API, обязаны поддерживать три опции: посыпку корицей, изменение объёма и бесконтактную выдачу.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Добавляем новый метод <code>with-options</code>:</p>
|
||||
<pre><code>PUT /v1/partners/{partnerId}/coffee-machines-with-options
|
||||
{
|
||||
"coffee_machines": [{
|
||||
"id",
|
||||
"api_type",
|
||||
"location",
|
||||
"supported_recipes",
|
||||
"supported_options": [
|
||||
{"type": "volume_change"}
|
||||
]
|
||||
}, …]
|
||||
}
|
||||
</code></pre>
|
||||
</li>
|
||||
<li>
|
||||
<p>Объявляем, что вызов <code>PUT /coffee-machines</code>, как он представлен сейчас в протоколе, эквивалентен вызову <code>PUT /coffee-machines-with-options</code>, если в последний передать три опции — посыпку корицей, изменение объёма и бесконтактную выдачу, — и, таким образом, является частным случаем — хелпером к более общему вызову.</p>
|
||||
</li>
|
||||
</ol>
|
||||
<p>Часто вместо добавления нового метода можно добавить просто необязательный параметр к существующему интерфейсу — в нашем случае, можно добавить необязательный параметр <code>options</code> к вызову <code>PUT /cofee-machines</code>.</p>
|
||||
<p><strong>NB</strong>. Когда мы говорим о фиксации договоренностей, действующих в настоящий момент — речь идёт о <em>внутренних</em> договорённостях. Мы должны были потребовать от партнеров поддерживать указанный список опций, когда обговаривали формат взаимодействия. Если же мы этого не сделали изначально, а потом решили зафиксировать договорённости в ходе расширения функциональности внешнего API — это очень серьёзная заявка на нарушение обратной совместимости, и так делать ни в коем случае не надо, см. <a href="#chapter-14">главу 14</a>.</p>
|
||||
<h4>Границы применимости</h4>
|
||||
<p>Хотя это упражнение выглядит весьма простым и универсальным, его использование возможно только при наличии хорошо продуманной архитектуры сущностей и, что ещё более важного, понятного вектора дальнейшего развития API. Представим, что через какое-то время к поддерживаемым опциям добавились новые — ну, скажем, добавление сиропа и второго шота эспрессо. Список опций расширить мы можем — а вот изменить соглашение по умолчанию уже нет. Через некоторое время это приведёт к тому, что «дефолтный» интерфейс <code>PUT /coffee-machines</code> окажется никому не нужен, поскольку «дефолтный» список из трёх опций окажется не только редко востребованным, но и просто абсурдным — почему эти три, чем они лучше всех остальных? По сути значения по умолчанию и номенклатура старых методов начнут отражать исторические этапы развития нашего API, а это совершенно не то, чего мы хотели бы от номенклатуры хелперов и значений по умолчанию.</p>
|
||||
<p>Увы, здесь мы сталкиваемся с плохо разрешимым противоречием: мы хотим, с одной стороны, чтобы разработчик писал лаконичный код, следовательно, должны предоставлять хорошие хелперные методы и значения по умолчанию. С другой, знать наперёд какими будут самые частотные наборы опций через несколько лет развития API — очень сложно.</p>
|
||||
<p><strong>NB</strong>. Замаскировать эту проблему можно так: в какой-то момент собрать все эти «странности» в одном месте и переопределить все значения по умолчанию скопом под одним параметром. Условно говоря, вызов одного метода, например, <code>POST /use-defaults {"version": "v2"}</code> переопределяет все значения по умолчанию на более разумные. Это упростит порог входа и уменьшит количество вопросов, но документация от этого станет выглядеть только хуже.</p>
|
||||
<p>В реальной жизни как-то нивелировать проблему помогает лишь слабая связность объектов, речь о которой пойдёт в следующей главе.</p><div class="page-break"></div><h3><a href="#chapter-16" class="anchor" id="chapter-16">Глава 16. Сильная связность и сопутствующие проблемы</a></h3>
|
||||
<p>Для демонстрации проблем сильной связности перейдём теперь к <em>действительно интересным</em> вещам. Продолжим наш «вариационный анализ»: что, если партнёры хотят не просто готовить кофе по стандартным рецептам, но и предлагать свои авторские напитки? Вопрос этот с подвохом: в том виде, как мы описали партнёрское API в предыдущей главе, факт существования партнерской сети никак не отражен в нашем API с точки зрения продукта, предлагаемого пользователю, а потому представляет собой довольно простой кейс. Если же мы пытаемся предоставить не какую-то дополнительную возможность, а модифицировать саму базовую функциональность API, то мы быстро столкнёмся с проблемами совсем другого порядка.</p>
|
||||
<p>Итак, добавим ещё один эндпойнт — для регистрации собственного рецепта партнёра.</p>
|
||||
<pre><code>// Добавляет новый рецепт
|
||||
POST /v1/recipes
|
||||
{
|
||||
"id",
|
||||
"product_properties": {
|
||||
"name",
|
||||
"description",
|
||||
"default_value"
|
||||
// Прочие параметры, описывающие
|
||||
// напиток для пользователя
|
||||
…
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<p>На первый взгляд, вполне разумный и простой интерфейс, который явно декомпозируется согласно уровням абстракции. Попробуем теперь представить, что произойдёт в будущем — как дальнейшее развитие функциональности повлияет на этот интерфейс.</p>
|
||||
<p>Первая проблема очевидна тем, кто внимательно читал <a href="#chapter-11-paragraph-20">главу 11</a>: продуктовые данные должны быть локализованы. Это приведёт нас к первому изменению:</p>
|
||||
<pre><code>"product_properties": {
|
||||
// "l10n" — стандартное сокращение
|
||||
// для "localization"
|
||||
"l10n" : [{
|
||||
"language_code": "en",
|
||||
"country_code": "US",
|
||||
"name",
|
||||
"description"
|
||||
}, /* другие языки и страны */ … ]
|
||||
]
|
||||
</code></pre>
|
||||
<p>И здесь возникает первый большой вопрос — а что делать с <code>default_volume</code>? С одной стороны, это объективная величина, выраженная в стандартизированных единицах измерения, и она используется для запуска программы на исполнение. С другой стороны, для таких стран, как США, мы будем обязаны указать объём не в виде «300 мл», а в виде «10 унций». Мы можем предложить одно из двух решений:</p>
|
||||
<ul>
|
||||
<li>либо партнёр указывает только числовой объём, а числовые представления мы сделаем сами;</li>
|
||||
<li>либо партнёр указывает и объём, и все его локализованные представления.</li>
|
||||
</ul>
|
||||
<p>Первый вариант плох тем, что партнёр с помощью нашего API может как раз захотеть разработать сервис для какой-то новой страны или языка — и не сможет, пока локализация для этого региона не будет поддержана в самом API. Второй вариант плох тем, что сработает только для заранее заданных объёмов — заказать кофе произвольного объёма нельзя. И вот практически первым же действием мы сами загоняем себя в тупик.</p>
|
||||
<p>Проблемами с локализацией, однако, недостатки дизайна этого API не заканчиваются. Следует задать себе вопрос — а <em>зачем</em> вообще здесь нужны <code>name</code> и <code>description</code>? Ведь это по сути просто строки, не имеющие никакой определённой семантики. На первый взгляд — чтобы возвращать их обратно из метода <code>/v1/search</code>, но ведь это тоже не ответ: а зачем эти строки возвращаются из <code>search</code>?</p>
|
||||
<p>Корректный ответ — потому что существует некоторое представление, UI для выбора типа напитка. По-видимому, <code>name</code> и <code>description</code> — это просто два описания напитка, короткое (для показа в общем прейскуранте) и длинное (для показа расширенной информации о продукте). Получается, что мы устанавливаем требования на API исходя из вполне конкретного дизайна. Но что, если партнёр сам делает UI для своего приложения? Мало того, что ему могут быть не нужны два описания, так мы по сути ещё и вводим его в заблуждение: <code>name</code> — это не «какое-то» название, оно предполагает некоторые ограничения. Во-первых, у него есть некоторая рекомендованная длина, оптимальная для конкретного UI; во-вторых, оно должно консистентно выглядеть в одном списке с другими напитками. В самом деле, будет очень странно смотреться, если среди «Капучино», «Лунго» и «Латте» вдруг появится «Бодрящая свежесть» или «Наш самый качественный кофе».</p>
|
||||
<p>Эта проблема разворачивается и в другую сторону — UI (наш или партнера) обязательно будет развиваться, в нём будут появляться новые элементы (картинка для кофе, его пищевая ценность, информация об аллергенах и так далее). <code>product_properties</code> со временем превратится в свалку из большого количества необязательных полей, и выяснить, задание каких из них приведёт к каким эффектам в каком приложении можно будет только методом проб и ошибок.</p>
|
||||
<p>Проблемы, с которыми мы столкнулись — это проблемы <em>сильной связности</em>. Каждый раз, предлагая интерфейс, подобный вышеприведённому, мы фактически описываем имплементацию одной сущности (рецепта) через имплементации других (визуального макета, правил локализации). Этот подход противоречит самому принципу проектирования API «сверху вниз», поскольку <strong>низкоуровневые сущности не должны определять высокоуровневые</strong>.</p>
|
||||
<h4>Правило контекстов</h4>
|
||||
<p>Как бы парадоксально это ни звучало, обратное утверждение тоже верно: высокоуровневые сущности тоже не должны определять низкоуровневые. Это попросту не их ответственность. Выход из этого логического лабиринта таков: высокоуровневые сущности должны <em>определять контекст</em>, который другие объекты будут интерпретировать. Чтобы спроектировать добавление нового рецепта нам нужно не формат данных подобрать — нам нужно понять, какие (возможно, неявные, т.е. не представленные в виде API) контексты существуют в нашей предметной области.</p>
|
||||
<p>Как уже понятно, существует контекст локализации. Есть какой-то набор языков и регионов, которые мы поддерживаем в нашем API, и есть требования — что конкретно необходимо предоставить партнёру, чтобы API заработало на новом языке в новом регионе. Конкретно в случае объёма кофе где-то в недрах нашего API есть функция форматирования строк для отображения объёма напитка:</p>
|
||||
<pre><code>l10n.volume.format(value, language_code, country_code)
|
||||
// l10n.formatVolume('300ml', 'en', 'UK') → '300 ml'
|
||||
// l10n.formatVolume('300ml', 'en', 'US') → '10 fl oz'
|
||||
</code></pre>
|
||||
<p>Чтобы наше API корректно заработал с новым языком или регионом, партнер должен или задать эту функцию, или указать, какую из существующих локализаций необходимо использовать. Для этого мы абстрагируем-и-расширяем API, в соответствии с описанной в предыдущей главе процедурой, и добавляем новый эндпойнт — настройки форматирования:</p>
|
||||
<pre><code>// Добавляем общее правило форматирования
|
||||
// для русского языка
|
||||
PUT /formatters/volume/ru
|
||||
{
|
||||
"template": "{volume} мл"
|
||||
}
|
||||
// Добавляем частное правило форматирования
|
||||
// для русского языка в регионе «США»
|
||||
PUT /formatters/volume/ru/US
|
||||
{
|
||||
// В США требуется сначала пересчитать
|
||||
// объём, потом добавить постфикс
|
||||
"value_preparation": {
|
||||
"action": "divide",
|
||||
"divisor": 30
|
||||
},
|
||||
"template": "{volume} ун."
|
||||
}
|
||||
</code></pre>
|
||||
<p><strong>NB</strong>: мы, разумеется, в курсе, что таким простым форматом локализации единиц измерения в реальной жизни обойтись невозможно, и необходимо либо положиться на существующие библиотеки, либо разработать сложный формат описания (учитывающий, например, падежи слов и необходимую точность округления), либо принимать правила форматирования в императивном виде (т.е. в виде кода функции). Пример выше приведён исключительно в учебных целях.</p>
|
||||
<p>Вернёмся теперь к проблеме <code>name</code> и <code>description</code>. Для того, чтобы снизить связность в этом аспекте, нужно прежде всего формализовать (возможно, для нас самих, необязательно во внешнем API) понятие «макета». Мы требуем <code>name</code> и <code>description</code> не просто так в вакууме, а чтобы представить их во вполне конкретном UI. Этому конкретному UI можно дать идентификатор или значимое имя.</p>
|
||||
<pre><code>GET /v1/layouts/{layout_id}
|
||||
{
|
||||
"id",
|
||||
// Макетов вполне возможно будет много разных,
|
||||
// поэтому имеет смысл сразу заложить
|
||||
// расширяемоесть
|
||||
"kind": "recipe_search",
|
||||
// Описываем каждое свойство рецепта,
|
||||
// которое должно быть задано для
|
||||
// корректной работы макета
|
||||
"properties": [{
|
||||
// Раз уж мы договорились, что `name`
|
||||
// на самом деле нужен как заголовок
|
||||
// в списке результатов поиска —
|
||||
// разумнее его так и назвать `seach_title`
|
||||
"field": "search_title",
|
||||
"view": {
|
||||
// Машиночитаемое описание того,
|
||||
// как будет показано поле
|
||||
"min_length": "5em",
|
||||
"max_length": "20em",
|
||||
"overflow": "ellipsis"
|
||||
}
|
||||
}, …],
|
||||
// Какие поля обязательны
|
||||
"required": ["search_title", "search_description"]
|
||||
}
|
||||
</code></pre>
|
||||
<p>Таким образом, партнёр сможет сам решить, какой вариант ему предпочтителен. Можно задать необходимые поля для стандартного макета:</p>
|
||||
<pre><code>PUT /v1/recipes/{id}/properties/l10n/{lang}
|
||||
{
|
||||
"search_title", "search_description"
|
||||
}
|
||||
</code></pre>
|
||||
<p>Либо создать свой макет и задавать нужные для него поля:</p>
|
||||
<pre><code>POST /v1/layouts
|
||||
{
|
||||
"properties"
|
||||
}
|
||||
→
|
||||
{ "id", "properties" }
|
||||
</code></pre>
|
||||
<p>В конце концов, партнёр может отрисовывать UI самостоятельно и вообще не пользоваться этой техникой, не задавая ни макеты, ни поля.</p>
|
||||
<p>Наш интерфейс добавления рецепта получит в итоге вот такой вид:</p>
|
||||
<pre><code>POST /v1/recipes
|
||||
{ "id" }
|
||||
→
|
||||
{ "id" }
|
||||
</code></pre>
|
||||
<p>Этот вывод может показаться совершенно контринтуитивным, однако отсутствие полей у сущности «рецепт» говорит нам только о том, что сама по себе она не несёт никакой семантики и служит просто способом указания контекста привязки других сущностей. В реальном мире следовало бы, пожалуй, собрать эндпойнт-строитель, который может создавать сразу все нужные контексты одним запросом:</p>
|
||||
<pre><code>POST /v1/recipe-builder
|
||||
{
|
||||
"id",
|
||||
// Задаём свойства рецепта
|
||||
"product_properties": {
|
||||
"default_volume",
|
||||
"l10n"
|
||||
},
|
||||
// Создаём необходимые макеты
|
||||
"layouts": [{
|
||||
"id", "kind", "properties"
|
||||
}],
|
||||
// Добавляем нужные форматтеры
|
||||
"formatters": {
|
||||
"volume": [
|
||||
{ "language_code", "template" },
|
||||
{ "language_code", "country_code", "template" }
|
||||
]
|
||||
},
|
||||
// Прочие действия, которые необходимо
|
||||
// выполнить для корректного заведения
|
||||
// нового рецепта в системе
|
||||
…
|
||||
}
|
||||
</code></pre>
|
||||
<p>Заметим, что передача идентификатора вновь создаваемой сущности клиентом — не лучший паттерн. Но раз уж мы с самого начала решили, что идентификаторы рецептов — не просто случайные наборы символов, а значимые строки, то нам теперь придётся с этим как-то жить. Очевидно, в такой ситуации мы рискуем многочисленными коллизиями между названиями рецептов разных партнёров, поэтому операцию, на самом деле, следует модифицировать: либо для партнерских рецептов всегда пользоваться парой идентификаторов (партнера и рецепта), либо ввести составные идентификаторы, как мы ранее рекомендовали в <a href="#chapter-11-paragraph-8">главе 11</a>.</p>
|
||||
<pre><code>POST /v1/recipes/custom
|
||||
{
|
||||
// Первая часть идентификатора:
|
||||
// например, в виде идентификатора клиента
|
||||
"namespace": "my-coffee-company",
|
||||
// Вторая часть идентификатора
|
||||
"id_component": "lungo-customato"
|
||||
}
|
||||
→
|
||||
{
|
||||
"id": "my-coffee-company:lungo-customato"
|
||||
}
|
||||
</code></pre>
|
||||
<p>Заметим, что в таком формате мы сразу закладываем важное допущение: различные партнёры могут иметь как полностью изолированные неймспейсы, так и разделять их. Более того, мы можем ввести специальные неймспейсы типа "common", которые позволят публиковать новые рецепты для всех. (Это, кстати говоря, хорошо ещё и тем, что такое API мы сможем использовать для организации нашей собственной панели управления контентом.)</p><div class="page-break"></div><h3><a href="#chapter-17" class="anchor" id="chapter-17">Глава 17. Слабая связность</a></h3>
|
||||
<p>В предыдущей главе мы продемонстрировали, как разрыв сильной связанности приводит к декомпозиции сущностей и схлопыванию публичных интерфейсов до минимума. Внимательный читатель может подметить, что этот приём уже был продемонстрирован в нашем учебном API гораздо раньше <a href="#chapter-9">в главе 9</a> на примере сущностей «программа» и «запуск программы». В самом деле, мы могли бы обойтись без программ и без эндпойнта <code>program-matcher</code> и пойти вот таким путём:</p>
|
||||
<pre><code>GET /v1/recipes/{id}/run-data/{api_type}
|
||||
→
|
||||
{ /* описание способа запуска
|
||||
указанного рецепта на
|
||||
машинах с поддержкой
|
||||
указанного типа API */ }
|
||||
</code></pre>
|
||||
<p>Тогда разработчикам пришлось бы сделать примерно следующее для запуска приготовления кофе:</p>
|
||||
<ul>
|
||||
<li>выяснить тип API конкретной кофе-машины;</li>
|
||||
<li>получить описание способа запуска программы выполнения рецепта на машине с API такого типа;</li>
|
||||
<li>в зависимости от типа API выполнить специфические команды запуска.</li>
|
||||
</ul>
|
||||
<p>Очевидно, что такой интерфейс совершенно недопустим — просто потому, что в подавляющем большинстве случаев разработчикам совершенно неинтересно, какого рода API поддерживает та или иная кофе-машина. Для того, чтобы не допустить такого плохого интерфейса мы ввели новую сущность «программа», которая по факту представляет собой не более чем просто идентификатор контекста, как и сущность «рецепт».</p>
|
||||
<p>Аналогичным образом устроена и сущность <code>program_run_id</code>, идентификатор запуска программы. Он также по сути не имеет почти никакого интерфейса и состоит только из идентификатора запуска.</p>
|
||||
<p>Вернёмся теперь к вопросу, который мы вскользь затронули в <a href="#chapter15">главе 15</a> — каким образом нам параметризовать приготовление заказа, если оно исполняется через сторонний API. Иными словами, что такое этот самый <code>program_execution_endpoint</code>, передавать который мы потребовали при регистрации нового типа API?</p>
|
||||
<pre><code>PUT /v1/api-types/{api_type}
|
||||
{
|
||||
"order_execution_endpoint": {
|
||||
// ???
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<p>Исходя из общей логики мы можем предположить, что любое API так или иначе будет выполнять три функции: запускать программы с указанными параметрами, возвращать текущий статус запуска и завершать (отменять) заказ. Самый очевидный подход к реализации такого API — просто потребовать от партнёра имплементировать вызов этих трёх функций удалённо, например следующим образом:</p>
|
||||
<pre><code>// Эндпойнт добавления списка
|
||||
// кофе-машин партнёра
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
"order_execution_endpoint":
|
||||
"program_run_endpoint": {
|
||||
/* Какое-то описание
|
||||
удалённого вызова эндпойнта */
|
||||
"type": "rpc",
|
||||
"endpoint": <URL>,
|
||||
"format"
|
||||
},
|
||||
"program_state_endpoint",
|
||||
"program_stop_endpoint"
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<p><strong>NB</strong>: во многом таким образом мы переносим сложность разработки API в плоскость разработки форматов данных (каким образом мы будем передавать параметры запуска в <code>program_run_endpoint</code>, и в каком формате должен отвечать <code>program_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>
|
||||
<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>Заметим также, что мы невольно начали нарушать принцип изоляции уровней абстракции. На уровне API вендингового автомата вообще не существует понятия «бесконтактная выдача», это по сути продуктовый термин.</p>
|
||||
<p>Каким же образом мы можем решить эту проблему? Одним из двух способов: или досконально изучить предметную область и тренды её развития на несколько лет вперёд, или перейти от сильной связанности к слабой. Как выглядит идеальное решение с точки зрения обеих взаимодействующих сторон? Как-то так:</p>
|
||||
<ul>
|
||||
<li>вышестоящий API программ не знает, как устроен уровень исполнения его команд; он формулирует задание так, как понимает на своём уровне: сварить такой-то кофе такого-то объёма, с корицей, выдать такому-то пользователю;</li>
|
||||
<li>нижележащий API исполнения программ не заботится о том, какие ещё вокруг бывают API того же уровня; он трактует только ту часть задания, которая имеет для него смысл.</li>
|
||||
</ul>
|
||||
<p>Если мы посмотрим на принципы, описанные в предыдущей главе, то обнаружим, что этот принцип мы уже формулировали: нам необходимо задать <em>информационный контекст</em> на каждом из уровней абстракции, и разработать механизм его трансляции. Более того, в общем виде он был сформулирован ещё в <a href="#chapter-9">разделе «Потоки данных»</a>.</p>
|
||||
<p>В нашем конкретном примере нам нужно имплементировать следующие механизмы:</p>
|
||||
<ul>
|
||||
<li>запуск программы создаёт контекст её исполнения, содержащий все существенные параметры;</li>
|
||||
<li>существует способ обмена информацией об изменении данных: исполнитель может читать контекст, узнавать о всех его изменениях и сообщать обратно о изменениях своего состояния.</li>
|
||||
</ul>
|
||||
<p>Организовать и то, и другое можно разными способами, однако по сути мы имеем два описания состояния (верхне- и низкоуровневое) и поток событий между ними. В случае SDK эту идею можно было бы выразить так:</p>
|
||||
<pre><code>/* Имплементация партнёром интерфейса
|
||||
запуска программы на его кофе-машинах */
|
||||
registerProgramRunHandler(apiType, (context) => {
|
||||
// Инициализируем запуск исполнения
|
||||
// программы на стороне партнера
|
||||
let execution = initExecution(context, …);
|
||||
// Подписываемся на события
|
||||
// изменения контекста
|
||||
context.on('takeout_requested', () => {
|
||||
// Если запрошена выдача напитка,
|
||||
// инициализируем выдачу
|
||||
execution.prepareTakeout(() => {
|
||||
// как только напиток готов к выдаче,
|
||||
// сигнализируем об этом
|
||||
execution.context.emit('takeout_ready');
|
||||
});
|
||||
});
|
||||
|
||||
return execution.context;
|
||||
});
|
||||
</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>Внимательный читатель может возразить нам, что фактически, если мы посмотрим на номенклатуру возникающих сущностей, мы ничего не изменили в постановке задачи, и даже усложнили её:</p>
|
||||
<ul>
|
||||
<li>вместо вызова метода <code>takeout</code> мы теперь генерируем пару событий <code>takeout_requested</code>/<code>takeout_ready</code>;</li>
|
||||
<li>вместо длинного списка методов, которые необходимо реализовать для интеграции API партнера, появляются длинные списки полей сущности <code>context</code> и событий, которые она генерирует;</li>
|
||||
<li>проблема устаревания технологии не меняется, вместо устаревших методов мы теперь имеем устаревшие поля и события.</li>
|
||||
</ul>
|
||||
<p>Это замечание совершенно верно. Изменение формата API само по себе не решает проблем, связанных с эволюцией функциональности и нижележащей технологии. Формат API решает другую проблему: как оставить при этом код читаемым и поддерживаемым. Почему в примере с интеграцией через методы код становится нечитаемым? Потому что обе стороны <em>вынуждены</em> имплементировать функциональность, которая в их контексте бессмысленна; и эта имплементация будет состоять из какого-то (хорошо если явного!) способа ответить, что данная функциональность не поддерживается (или, наоборот, поддерживается всегда и безусловно).</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>
|
||||
<h4>Инверсия ответственности</h4>
|
||||
<p>Как несложно понять из вышесказанного, двусторонняя слабая связь означает существенное усложнение имплементации обоих уровней, что во многих ситуациях может оказаться излишним. Часто двустороннюю слабую связь можно без потери качества заменить на одностороннюю, а именно — разрешить нижележащей сущности вместо генерации событий напрямую вызывать методы из интерфейса более высокого уровня. Наш пример изменится примерно вот так:</p>
|
||||
<pre><code>/* Имплементация партнёром интерфейса
|
||||
запуска программы на его кофе-машинах */
|
||||
registerProgramRunHandler(apiType, (context) => {
|
||||
// Инициализируем запуск исполнения
|
||||
// программы на стороне партнера
|
||||
let execution = initExecution(context, …);
|
||||
// Подписываемся на события
|
||||
// изменения контекста
|
||||
context.on('takeout_requested', () => {
|
||||
// Если запрошена выдача напитка,
|
||||
// инициализируем выдачу
|
||||
execution.prepareTakeout(() => {
|
||||
/* как только напиток готов к выдаче,
|
||||
сигнализируем об этом, но не
|
||||
посредством генерации события */
|
||||
// execution.context.emit('takeout_ready')
|
||||
context.set('takeout_ready');
|
||||
// Или ещё более жёстко:
|
||||
// context.setTakeoutReady();
|
||||
});
|
||||
});
|
||||
// Так как мы сами изменяем родительский контекст
|
||||
// нет нужды что-либо возвращать
|
||||
// return execution.context;
|
||||
}
|
||||
</code></pre>
|
||||
<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(() => {
|
||||
// Вместо обращения к вышестоящей сущности
|
||||
// или генерации события на себе,
|
||||
// компонент обращается к глобальному
|
||||
// состоянию и вызывает действия над ним
|
||||
dispatch(takeoutReady());
|
||||
});
|
||||
</code></pre>
|
||||
<p>Надо отметить, что такой подход <em>в принципе</em> не противоречит описанному принципу, но нарушает другой — изоляцию уровней абстракции, а поэтому плохо подходит для написания сложных API, в которых не гарантирована жесткая иерархия компонентов. При этом использовать глобальный (или квази-глобальный) менеджер состояния в таких системах вполне возможно, но требуется имплементировать более сложную пропагацию сообщений по иерархии, а именно: подчинённый объект всегда вызывает методы только ближайшего вышестоящего объекта, а уже тот решает, как и каким образом этот вызов передать выше по иерархии.</p>
|
||||
<pre><code>execution.prepareTakeout(() => {
|
||||
// Вместо обращения к вышестоящей сущности
|
||||
// или генерации события на себе,
|
||||
// компонент обращается к вышестоящему
|
||||
// объекту
|
||||
context.dispatch(takeoutReady());
|
||||
});
|
||||
</code></pre>
|
||||
<pre><code>// Имплементация program.context.dispatch
|
||||
ProgramContext.dispatch = (action) => {
|
||||
// program.context обращается к своему
|
||||
// вышестоящему объекту, или к глобальному
|
||||
// состоянию, если такого объекта нет
|
||||
globalContext.dispatch(
|
||||
// При этом сама суть действия
|
||||
// может и должна быть переформулирована
|
||||
// в терминах соответствующего уровня
|
||||
// абстракции
|
||||
this.generateAction(action)
|
||||
)
|
||||
}
|
||||
</code></pre>
|
||||
<h4>Проверим себя</h4>
|
||||
<p>Описав указанным выше образом взаимодействие со сторонними API, мы можем (и должны) теперь рассмотреть вопрос, совместимы ли эти интерфейсы с нашими собственными абстракциями, которые мы разработали в <a href="#chapter-9">главе 9</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>
|
||||
<p><strong>NB</strong>. В рамках <a href="#chapter-9">главы 9</a> мы именно этот принцип и проиллюстрировали: в рамках API кофе-машин первого типа нет нужды продолжать растить дерево абстракций, можно ограничиться запуском программ; в рамках API второго типа требуется дополнительный промежуточный контекст в виде рантаймов.</p><div class="page-break"></div><h3><a href="#chapter-18" class="anchor" id="chapter-18">Глава 18. Интерфейсы как универсальный паттерн</a></h3>
|
||||
<p>Попробуем кратко суммировать написанное в трёх предыдущих главах.</p>
|
||||
<ol>
|
||||
<li>Расширение функциональности API производится через абстрагирование: необходимо так переосмыслить номенклатуру сущностей, чтобы существующие методы стали частным (желательно — самым частотным) упрощённым случаем реализации.</li>
|
||||
<li>Вышестоящие сущности должны при этом оставаться информационными контекстами для нижестоящих, т.е. не предписывать конкретное поведение, а только сообщать о своём состоянии и предоставлять функциональность для его изменения (прямую через соответствующие методы либо косвенную через получение определённых событий).</li>
|
||||
<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 эндпойнта <code>POST /search</code> мы должны были задать себе вопрос: а «результат поиска» — это абстракция над каким интерфейсом? Для этого нам нужно аккуратно декомпозировать эту сущность, чтобы понять, каким своим срезом она выступает во взаимодействии с каким объектами.</p>
|
||||
<p>Тогда мы придём к пониманию, что результат поиска — это, на самом деле, композиция двух интерфейсов:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>при создании заказа из всего результата поиска необходимы поля, описывающие собственно заказ; это может быть структура вида:</p>
|
||||
<p><code>{coffee_machine_id, recipe_id, volume, currency_code, price}</code>,</p>
|
||||
<p>либо мы можем закодировать все эти данные в одном <code>offer_id</code>;</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>при отображении результата поиска в приложении нам важны другие поля — <code>name</code>, <code>description</code>, а также отформатированная и локализованная цена.</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>Таким образом, наш интерфейс (назовём его <code>ISearchResult</code>) — это композиция двух других интерфейсов: <code>IOrderParameters</code> (сущности, позволяющей сделать заказ) и <code>ISearchItemViewParameters</code> (некоторого абстрактного представления результатов поиска в UI). Подобное разделение должно автоматически подводить нас к ряду вопросов.</p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>Каким образом мы будем связывать одно с другим? Очевидно, что эти два суб-интерфейса зависимы: например, отформатированная человекочитаемая цена должна совпадать с машиночитаемой. Это естественным образом подводит нас к концепции абстрагирования форматирования, описанной в <a href="#chapter15">главе 16</a>.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>А что такое, в свою очередь, «абстрактное представление результатов поиска в UI»? Есть ли у нас какие-то другие виды поисков, не является ли <code>ISearchItemViewParameters</code> сам наследником какого-либо другого интерфейса или композицией других интерфейсов?</p>
|
||||
</li>
|
||||
</ol>
|
||||
<p>Замена конкретных имплементаций интерфейсами позволяет не только точнее ответить на многие вопросы, которые должны были у вас возникнуть в ходе проектирования API, но и наметить множество возможных векторов развития API, что поможет избежать проблем с неконсистентностью API в ходе дальнейшей эволюции программного продукта.</p><div class="page-break"></div><h3><a href="#chapter-19" class="anchor" id="chapter-19">Глава 19. Блокнот душевного покоя</a></h3>
|
||||
<p>Помимо вышеперечисленных абстрактных принципов хотелось бы также привести набор вполне конкретных рекомендаций по внесению изменений в существующее API с поддержанием обратной совместимости.</p>
|
||||
<h5><a href="#chapter-19-paragraph-1" id="chapter-19-paragraph-1" class="anchor">1. Помните о подводной части айсберга</a></h5>
|
||||
<p>То, что вы не давали формальных гарантий и обязательств, совершенно не означает, что эти неформальные гарантии и обязательства можно нарушать. Зачастую даже исправление ошибок в API может привести к неработоспособности чьего-то кода. Можно привести следующий пример из реальной жизни, с которым столкнулся автор этой книги:</p>
|
||||
<ul>
|
||||
<li>существовало некоторое API размещения кнопок в визуальном контейнере; по контракту оно принимало позицию размещаемой кнопки (отступы от углов контейнера) в качестве обязательного параметра;</li>
|
||||
<li>в реализации была допущена ошибка: если позицию не передать, то исключения не происходило — добавленные таким образом кнопки размещались в левом верхнем углу контейнера одна за другой;</li>
|
||||
<li>в день, когда ошибка была исправлена, в техническую поддержку пришло множество обращений от разработчиков, чей код перестал работать; как оказалось, клиенты использовали эту ошибку для того, чтобы последовательно размещать кнопки в левом верхнем углу контейнера.</li>
|
||||
</ul>
|
||||
<p>Если исправления ошибок затрагивают реальных потребителей — вам ничего не остаётся кроме как продолжать эмулировать ошибочное поведение до следующего мажорного релиза. При разработке больших API с широким кругом потребителей такие ситуации встречаются сплошь и рядом — например, разработчики API операционных систем буквально вынуждены портировать старые баги в новые версии ОС.</p>
|
||||
<h5><a href="#chapter-19-paragraph-2" id="chapter-19-paragraph-2" class="anchor">2. Тестируйте формальные интерфейсы</a></h5>
|
||||
<p>Любое программное обеспечение должно тестироваться, и API не исключение. Однако здесь есть свои тонкости: поскольку API предоставляет формальные интерфейсы, тестироваться должны именно они. Это приводит к ошибкам нескольких видов.</p>
|
||||
<ol>
|
||||
<li>Часто требования вида «функция <code>getEntity</code> возвращает значение, установленное вызовом функции <code>setEntity</code>» кажутся и разработчикам, и QA-инженерам самоочевидными и не проверяются. Между тем допустить ошибку в их реализации очень даже возможно, мы встречались с такими случаями на практике.</li>
|
||||
<li>Принцип абстрагирования интерфейсов тоже необходимо проверять. В теории вы может быть и рассматриваете каждую сущность как конкретную имплементацию абстрактного интерфейса — но на практике может оказаться, что вы чего-то не учли и ваш абстрактный интерфейс на деле невозможен. Для целей тестирования очень желательно иметь пусть условную, но отличную от базовой реализацию каждого интерфейса.</li>
|
||||
</ol>
|
||||
<h5><a href="#chapter-19-paragraph-3" id="chapter-19-paragraph-3" class="anchor">3. Реализуйте функциональность своего API поверх публичных интерфейсов</a></h5>
|
||||
<p>Часто можно увидеть антипаттерн: разработчики API используют внутренние непубличные реализации тех или иных методов взамен существующих в их API публичных. Это происходит по двум причинам:</p>
|
||||
<ul>
|
||||
<li>часто публичное API является лишь дополнением к более специализированному внутреннему ПО компании, и наработки, представленные в публичном API, не портируются обратно в непубличную часть проекта, или же разработчики публичного API попросту не знают о существовании аналогичных непубличных функций;</li>
|
||||
<li>в ходе развития API некоторые интерфейсы абстрагируются, но имплементация уже существующих интерфейсов при этом по разным причинам не затрагивается; например, можно представить себе, что при реализации интерфейса <code>PUT /formatters</code>, описанного в <a href="#chapter16">главе 16</a>, разработчики сделали отдельную, более общую, версию функции форматирования объёма для пользовательских языков в API, но не переписали существующую функцию форматирования для известных языков поверх неё.</li>
|
||||
</ul>
|
||||
<p>Помимо очевидных частных проблем, вытекающих из такого подхода (неконсистентность поведения разных функций в API, не найденные при тестировании ошибки), здесь есть и одна глобальная: легко может оказаться, что вашим API попросту невозможно будет пользоваться, если сделать хоть один «шаг в сторону» — попытка воспользоваться любой нестандартной функциональностью может привести к проблемам производительности, многочисленным ошибкам, нестабильной работе и так далее.</p>
|
||||
<p><strong>NB</strong>. Идеальным примером строгого избегания данного антипаттерна следует признать разработку компиляторов — в этой сфере принято компилировать новую версию компилятора при помощи его же предыдущей версии.</p>
|
||||
<h5><a href="#chapter-19-paragraph-4" id="chapter-19-paragraph-4" class="anchor">4. Заведите блокнот</a></h5>
|
||||
<p>Несмотря на все приёмы и принципы, изложенные в настоящем разделе, с большой вероятностью вы <em>ничего</em> не сможете сделать с накапливающейся неконсистентностью вашего API. Да, можно замедлить скорость накопления, предусмотреть какие-то проблемы заранее, заложить запасы устойчивости — но предугадать <em>всё</em> решительно невозможно. На этом этапе многие разработчики склонны принимать скоропалительные решения — т.е. выпускать новые минорные версии API с явным или неявным нарушением обратной совместимости в целях исправления ошибок дизайна.</p>
|
||||
<p>Так делать мы крайне не рекомендуем — поскольку, напомним, API является помимо прочего и мультипликатором ваших ошибок. Что мы рекомендуем — так это завести блокнот душевного покоя, где вы будете записывать выученные уроки, которые потом нужно будет не забыть применить на практике при выпуске новой мажорной версии API.</p><div class="page-break"></div>
|
||||
</div></article>
|
||||
</body></html>
|
BIN
docs/API.ru.pdf
BIN
docs/API.ru.pdf
Binary file not shown.
@ -1,18 +1,18 @@
|
||||
### On the Iceberg's Waterline
|
||||
|
||||
Before we start talking about the extensible API design, we should discuss the hygienic minimum. A huge number of problems would have never happened if API vendors had payed more attention to marking their area of responsibility.
|
||||
Before we start talking about the extensible API design, we should discuss the hygienic minimum. A huge number of problems would have never happened if API vendors had paid more attention to marking their area of responsibility.
|
||||
|
||||
##### 1. Provide a minimal amount of functionality
|
||||
##### Provide a minimal amount of functionality
|
||||
|
||||
At any moment in its lifetime, your API is like an iceberg: it comprises an observable (e.g. documented) part and a hidden one, undocumented. If the API is designed properly, these two parts correspond to each other just like above-water and under-water parts of a real iceberg do, i.e. one to ten. Why so? Because of two obvious reasons.
|
||||
At any moment in its lifetime, your API is like an iceberg: it comprises an observable (e.g. documented) part and a hidden one, undocumented. If the API is designed properly, these two parts correspond to each other just like the above-water and under-water parts of a real iceberg do, i.e. one to ten. Why so? Because of two obvious reasons.
|
||||
|
||||
* Computers exist to make complicated things easy, not vice versa. The code developers write upon your API must describe complicated problem's solution in neat and straightforward sentences. If developers have to write more code than the API itself comprises, then there is something rotten here. Probably, this API simply isn't needed at all.
|
||||
* Computers exist to make complicated things easy, not vice versa. The code developers write upon your API must describe a complicated problem's solution in neat and straightforward sentences. If developers have to write more code than the API itself comprises, then there is something rotten here. Probably, this API simply isn't needed at all.
|
||||
|
||||
* Revoking the API functionality causes losses. If you've promised to provide some functionality, you will have to do so ‘forever’ (until this API version's maintenance period is over). Pronouncing some functionality deprecated is a tricky thing, potentially alienating your customers.
|
||||
|
||||
Rule \#1 is the simplest: if some functionality might be withheld — then never expose it. It might be reformulated like: every entity, every field, every public API method is a *product solution*. There must be solid *product* reasons why some functionality is exposed.
|
||||
|
||||
##### 2. Avoid gray zones and ambiguities
|
||||
##### Avoid gray zones and ambiguities
|
||||
|
||||
Your obligations to maintain some functionality must be stated as clearly as possible. Especially regarding those environments and platforms where no native capability to restrict access to undocumented functionality exists. Unfortunately, developers tend to consider some private features they found to be eligible for use, thus presuming the API vendor shall maintain them intact. Policy on such ‘findings’ must be articulated explicitly. At the very least, in case of such non-authorized usage of undocumented functionality, you might refer to the docs, and be in your own rights in the eyes of the community.
|
||||
|
||||
@ -21,11 +21,11 @@ However, API developers often legitimize such gray zones themselves, for example
|
||||
* returning undocumented fields in endpoints' responses;
|
||||
* using private functionality in code examples — in the docs, responding to support messages, in conference talks, etc.
|
||||
|
||||
One cannot make a partial commitment. Either you guarantee this code will always work, or do not slip a slightest note such a functionality exists.
|
||||
One cannot make a partial commitment. Either you guarantee this code will always work or do not slip a slightest note such functionality exists.
|
||||
|
||||
##### 3. Codify implicit agreements
|
||||
##### Codify implicit agreements
|
||||
|
||||
Third principle is much less obvious. Pay close attention to the code which you're suggesting developers to write: are there any conventions which you consider evident, but never wrote them down?
|
||||
The third principle is much less obvious. Pay close attention to the code which you're suggesting developers to develop: are there any conventions that you consider evident, but never wrote them down?
|
||||
|
||||
**Example \#1**. Let's take a look at this order processing SDK example:
|
||||
```
|
||||
@ -35,9 +35,9 @@ let order = api.createOrder();
|
||||
let status = api.getStatus(order.id);
|
||||
```
|
||||
|
||||
Let's imagine that you're struggling with scaling your service, and at some point moved to the asynchronous replication of the database. This would lead to the situation when querying for the order status right after order creating might return `404`, if an asynchronous replica haven't got the update yet. In fact, thus we abandon strict consistency policy in a favor of an eventual one.
|
||||
Let's imagine that you're struggling with scaling your service, and at some point moved to the asynchronous replication of the database. This would lead to the situation when querying for the order status right after order creating might return `404` if an asynchronous replica hasn't got the update yet. In fact, thus we abandon strict [consistency policy](https://en.wikipedia.org/wiki/Consistency_model) in a favor of an eventual one.
|
||||
|
||||
What would be the result? The code above will stop working. A developer creates an order, tries to get its status — but gets the error. It's very hard to predict what an approach developers would implement to tackle this error. Probably, none at all.
|
||||
What would be the result? The code above will stop working. A developer creates an order, tries to get its status — but gets the error. It's very hard to predict what approach developers would implement to tackle this error. Probably, none at all.
|
||||
|
||||
You may say something like, ‘But we've never promised the strict consistency in the first place’ — and that is obviously not true. You may say that if, and only if, you have really described the eventual consistency in the `createOrder` docs, and all your SDK examples look like:
|
||||
|
||||
@ -58,7 +58,7 @@ if (status) {
|
||||
}
|
||||
```
|
||||
|
||||
We presume we may skip the explanations why such code must never be written in any circumstances. If you're really providing a non-strictly consistent API, then either `createOrder` operation must be asynchronous and return the result when all replicas are synchronized, or the retry policy must be hidden inside `getStatus` operation implementation.
|
||||
We presume we may skip the explanations why such code must never be written under any circumstances. If you're really providing a non-strictly consistent API, then either `createOrder` operation must be asynchronous and return the result when all replicas are synchronized, or the retry policy must be hidden inside `getStatus` operation implementation.
|
||||
|
||||
If you failed to describe the eventual consistency in the first place, then you simply can't make these changes in the API. You will effectively break backwards compatibility, which will lead to huge problems with your customers' apps, intensified by the fact they can't be simply reproduced.
|
||||
|
||||
@ -74,9 +74,9 @@ let promise = new Promise(
|
||||
resolve();
|
||||
```
|
||||
|
||||
This code presumes that callback function passed to `new Promise` will be executed synchronously, and the `resolve` variable will be initialized before the `resolve()` function is called. But this assumption is based on nothing: there are no clues indicating that `new Promise` constructor executes the callback function synchronously.
|
||||
This code presumes that the callback function passed to `new Promise` will be executed synchronously, and the `resolve` variable will be initialized before the `resolve()` function is called. But this assumption is based on nothing: there are no clues indicating that `new Promise` constructor executes the callback function synchronously.
|
||||
|
||||
Of course, the developers of the language standard can afford such tricks; but you as an API developer cannot. You must at least document this behavior and make the signatures point to it; actually, good advice is to avoid such conventions, since they are simply unobvious while reading the code. And of course, under no circumstances you actually change this behavior to asynchronous one.
|
||||
Of course, the developers of the language standard can afford such tricks; but you as an API developer cannot. You must at least document this behavior and make the signatures point to it; actually, good advice is to avoid such conventions, since they are simply unobvious while reading the code. And of course, under no circumstances you can actually change this behavior to an asynchronous one.
|
||||
|
||||
**Example \#3**. Imagine you're providing animations API, which includes two independent functions:
|
||||
|
||||
@ -89,11 +89,11 @@ object.animateWidth('100px', '500px', '1s');
|
||||
object.observe('widthchange', observerFunction);
|
||||
```
|
||||
|
||||
A question arises: how frequently and at what time fractions the `observerFunction` will be called? Let's assume in the first SDK version we emulated step-by-step animation at 10 frames per second: then `observerFunction` will be called 10 times, getting values '140px', '180px', etc., up to '500px'. But then in new API version we moved to implementing both functions atop of system native functionality — and so you're simply don't know, when and how frequently the `observerFunction` will be called.
|
||||
A question arises: how frequently and at what time fractions the `observerFunction` will be called? Let's assume in the first SDK version we emulated step-by-step animation at 10 frames per second: then `observerFunction` will be called 10 times, getting values '140px', '180px', etc., up to '500px'. But then in a new API version, we switched to implementing both functions atop of a system's native functionality — and so you simply don't know, when and how frequently the `observerFunction` will be called.
|
||||
|
||||
Just changing call frequency might result in making some code dysfunctional — for example, if the callback function makes some complex calculations, and no throttling is implemented, since the developer just relied on your SDK built-in throttling. An if `observerFunction` cease to be called when exactly '500px' is reached because of some system algorithms specifics, some code will be broken beyond any doubt.
|
||||
Just changing call frequency might result in making some code dysfunctional — for example, if the callback function makes some complex calculations, and no throttling is implemented since the developer just relied on your SDK's built-in throttling. And if the `observerFunction` ceases to be called when exactly '500px' is reached because of some system algorithms specifics, some code will be broken beyond any doubt.
|
||||
|
||||
In this example you should document the concrete contract (how often the observer function is called) and stick to it even if the underlying technology is changed.
|
||||
In this example, you should document the concrete contract (how often the observer function is called) and stick to it even if the underlying technology is changed.
|
||||
|
||||
**Example \#4**. Imagine that customer orders are passing through a specific pipeline:
|
||||
|
||||
@ -122,18 +122,18 @@ GET /v1/orders/{id}/events/history
|
||||
}
|
||||
```
|
||||
|
||||
Suppose at some moment we decided to allow trustworthy clients to get their coffee in advance, before the payment is confirmed. So an order will jump straight to "preparing_started", or event "ready", without a "payment_approved" event being emitted. It might appear to you that this modification *is* backwards compatible, since you've never really promised any specific event order be maintained, but it is not.
|
||||
Suppose at some moment we decided to allow trustworthy clients to get their coffee in advance before the payment is confirmed. So an order will jump straight to "preparing_started", or event "ready", without a "payment_approved" event being emitted. It might appear to you that this modification *is* backwards compatible since you've never really promised any specific event order being maintained, but it is not.
|
||||
|
||||
Let's assume that a developer (probably, your company's business partner) wrote some code executing some valuable business procedure, for example, gathering income and expenses analytics. It's quite logical to expect this code operates a state machine, which switches from one state to another depending on getting (or getting not) specific events. This analytical code will be broken if the event order changes. In best-case scenario a developer will get some exceptions and have to cope with error's cause; worst-case, partners will operate wrong statistics for an indefinite period of time until they find a mistake.
|
||||
Let's assume that a developer (probably, your company's business partner) wrote some code implementing some valuable business procedure, for example, gathering income and expenses analytics. It's quite logical to expect this code operates a state machine, which switches from one state to another depending on getting (or getting not) specific events. This analytical code will be broken if the event order changes. In the best-case scenario, a developer will get some exceptions and have to cope with the error's cause; worst-case, partners will operate wrong statistics for an indefinite period of time until they find a mistake.
|
||||
|
||||
A proper decision would be, in first, document the event order and allowed states; in second, continue generating "payment_approved" event before "preparing_started" (since you're making a decision to prepare that order, so you're in fact approving the payment) and add extended payment information.
|
||||
A proper decision would be, in first, documenting the event order and allowed states; in second, continuing generating "payment_approved" event before "preparing_started" (since you're making a decision to prepare that order, so you're in fact approving the payment) and add extended payment information.
|
||||
|
||||
This example leads us to the last rule.
|
||||
|
||||
##### 4. Product logic must be backwards compatible as well
|
||||
##### Product logic must be backwards compatible as well
|
||||
|
||||
State transition graph, event order, possible causes of status changes — such critical things must be documented. Not every piece of business logic might be defined in a form of programmatical contract; some cannot be represented at all.
|
||||
State transition graph, event order, possible causes of status changes — such critical things must be documented. Not every piece of business logic might be defined in a form of a programmatical contract; some cannot be represented at all.
|
||||
|
||||
Imagine that one day you start to take phone calls. A client may contact the call center to cancel an order. You might even make this functionality *technically* backwards compatible, introducing new fields to the ‘order’ entity. But end user might simply *know* the number, and call it even if the app wasn't suggesting anything like that. Partner's business analytical code might be broken likewise, or start displaying a weather on Mars, since it was written knowing nothing about the possibility of canceling orders somehow in circumvention of the partner's systems.
|
||||
Imagine that one day you start to take phone calls. A client may contact the call center to cancel an order. You might even make this functionality *technically* backwards compatible, introducing new fields to the ‘order’ entity. But the end-user might simply *know* the number, and call it even if the app wasn't suggesting anything like that. Partner's business analytical code might be broken likewise, or start displaying weather on Mars since it was written knowing nothing about the possibility of canceling orders somehow in circumvention of the partner's systems.
|
||||
|
||||
*Technically* correct decision would be adding ‘canceling via call center allowed’ parameter to the order creation function. Conversely, call center operators may only cancel those orders which were created with this flag set. But that would be a bad decision from a *product* point of view. The only one ‘good’ decision in this situation is to foresee the possibility of external order cancels in the first place. If you haven't foreseen it, your only option is the ‘Serenity Notepad’ to be discussed in the Section III.
|
||||
*Technically* correct decision would be adding ‘canceling via call center allowed’ parameter to the order creation function. Conversely, call center operators may only cancel those orders which were created with this flag set. But that would be a bad decision from a *product* point of view. The only ‘good’ decision in this situation is to foresee the possibility of external order cancels in the first place. If you haven't foreseen it, your only option is the ‘Serenity Notepad’ to be discussed in the last chapter of this Section.
|
@ -0,0 +1,95 @@
|
||||
### Extending through Abstracting
|
||||
|
||||
In previous chapters, we have tried to outline theoretical rules and illustrate them with practical examples. However, understanding the principles of change-proof API design requires practice above all things. An ability to anticipate future growth problems comes from a handful of grave mistakes once made. One cannot foresee everything but can develop certain technical intuition.
|
||||
|
||||
So in the following chapters, we will try to probe our study API from the previous Section, testing its robustness from every possible viewpoint, thus carrying out some ‘variational analysis’ of our interfaces. More specifically, we will apply a ‘What If?’ question to every entity, as if we are to provide a possibility to write an alternate implementation of every piece of logic.
|
||||
|
||||
|
||||
**NB**. In our examples, the interfaces will be constructed in a manner allowing for dynamic real-time linking of different entities. In practice, such integrations usually imply writing an ad hoc server-side code in accordance with specific agreements made with specific partners. But for educational purposes, we will pursue more abstract and complicated ways. Dynamic real-time linking is more typical in complex program constructs like operating system APIs or embeddable libraries; giving educational examples based on such sophisticated systems would be too inconvenient.
|
||||
|
||||
Let's start with the basics. Imagine that we haven't exposed any other functionality but searching for offers and making orders, thus providing an API of two methods: `POST /offers/search` and `POST /orders`.
|
||||
|
||||
Let us make the next logical step there and suppose that partners will wish to dynamically plug their own coffee machines (operating some previously unknown types of API) into our platform. To allow doing so, we have to negotiate a callback format that would allow us to call partners' APIs and expose two new endpoints providing the following capabilities:
|
||||
* registering new API types in the system;
|
||||
* providing the list of the coffee machines and their API types;
|
||||
|
||||
For example, we might provide the following methods.
|
||||
|
||||
```
|
||||
// 1. Register a new API type
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
"order_execution_endpoint": {
|
||||
// Callback function description
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
// 2. Provide a list of coffee machines
|
||||
// with their API types
|
||||
PUT /v1/partners/{partnerId}/coffee-machines
|
||||
{
|
||||
"coffee_machines": [{
|
||||
"api_type",
|
||||
"location",
|
||||
"supported_recipes"
|
||||
}, …]
|
||||
}
|
||||
```
|
||||
|
||||
So the mechanics is like that:
|
||||
* a partner registers their API types, coffee machines, and supported recipes;
|
||||
* with each incoming order, our server will call the callback function, providing the order data in the stipulated format.
|
||||
|
||||
Now the partners might dynamically plug their coffee machines in and get the orders. But we now will do the following exercise:
|
||||
* enumerate all the implicit assumptions we have made;
|
||||
* enumerate all the implicit coupling mechanisms we need to haven the platform functioning properly.
|
||||
|
||||
It may look like there are no such things in our API since it's quite simple and basically just describes making some HTTP call — but that's not true.
|
||||
1. It is implied that every coffee machine supports every order option like varying the beverage volume.
|
||||
2. There is no need to display some additional data to the end-user regarding coffee being brewed on these new coffee machines.
|
||||
3. The price of the beverage doesn't depend on the selected partner or coffee machine type.
|
||||
|
||||
We have written down this list having one purpose in mind: we need to understand, how exactly will we make these implicit arrangements explicit if we need it. For example, if different coffee machines provide different functionality — for example, some of them provide fixed beverage volumes only — what would change in our API?
|
||||
|
||||
The universal approach to making such amendments is: to consider the existing interface as a reduction of some more general one like if some parameters were set to defaults and therefore omitted. So making a change is always a three-step process.
|
||||
|
||||
1. Explicitly define the programmatical contract *as it works right now*.
|
||||
2. Extend the functionality: add a new method allowing for tackling those restrictions set in the previous paragraph.
|
||||
3. Pronounce the existing interfaces (those defined in \#1) being ‘helpers’ to new ones (those defined in \#2) which sets some options to default values.
|
||||
|
||||
More specifically, if we talk about changing available order options, we should do the following.
|
||||
|
||||
1. Describe the current state. All coffee machines, plugged via the API, must support three options: sprinkling with cinnamon, changing the volume, and contactless delivery.
|
||||
|
||||
2. Add new ‘with-options’ endpoint:
|
||||
```
|
||||
PUT /v1/partners/{partnerId}/coffee-machines-with-options
|
||||
{
|
||||
"coffee_machines": [{
|
||||
"api_type",
|
||||
"location",
|
||||
"supported_recipes",
|
||||
"supported_options": [
|
||||
{"type": "volume_change"}
|
||||
]
|
||||
}, …]
|
||||
}
|
||||
```
|
||||
|
||||
3. Pronounce `PUT /coffee-machines` endpoint as it now stands in the protocol being equivalent to calling `PUT /coffee-machines-with-options` if we pass those three options to it (sprinkling with cinnamon, changing the volume, contactless delivery) and therefore being a partial case — a helper to a more general call.
|
||||
|
||||
Usually, just adding a new optional parameter to the existing interface is enough; in our case, adding non-mandatory `options` to the `PUT /coffee-machines` endpoint.
|
||||
|
||||
**NB**. When we talk about defining the contract as it works right now, we're talking about *internal* agreements. We must have asked partners to support those three options while negotiating the interaction format. If we had failed to do so from the very beginning, and now are defining these in a course of expanding the public API, it's a very strong claim to break backwards compatibility, and we should never do that (see [Chapter 14](#chapter-14)).
|
||||
|
||||
#### Limits of Applicability
|
||||
|
||||
Though this exercise looks very simple and universal, its consistent usage is possible only if the hierarchy of entities is well designed from the very beginning and, which is more important, the vector of the further API expansion is clear. Imagine that after some time passed, the options list got new items; let's say, adding syrup or a second espresso shot. We are totally capable of expanding the list — but not the defaults. So the ‘default’ `PUT /coffee-machines` interface will eventually become totally useless because the default set of three options will not only be any longer of use but will also look ridiculously: why these three options, what are the selection criteria? In fact, the defaults and the method list will be reflecting the historical stages of our API development, and that's totally not what you'd expect from the helpers and defaults nomenclature.
|
||||
|
||||
Alas, this dilemma can't be easily resolved. From one side, we want developers to write neat and laconic code, so we must provide useful helpers and defaults. From the other side, we can't know in advance which options sets will be the most frequent after several years of the API expansion.
|
||||
|
||||
**NB**. We might mask this problem in the following manner: one day gather all these oddities and re-define all the defaults with one single parameter. For example, introduce a special method like `POST /use-defaults {"version": "v2"}` which would overwrite all the defaults with more suitable values. That will ease the learning curve, but your documentation will become even worse after that.
|
||||
|
||||
In the real world, the only viable approach to somehow tackle the problem is the weak entity coupling, which we will discuss in the next chapter.
|
204
src/en/clean-copy/03-Section II. Backwards Compatibility/04.md
Normal file
204
src/en/clean-copy/03-Section II. Backwards Compatibility/04.md
Normal file
@ -0,0 +1,204 @@
|
||||
### Strong coupling and related 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? 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.
|
||||
|
||||
So, let us add one more endpoint to register the partner's own recipe:
|
||||
|
||||
```
|
||||
// Adds new recipe
|
||||
POST /v1/recipes
|
||||
{
|
||||
"id",
|
||||
"product_properties": {
|
||||
"name",
|
||||
"description",
|
||||
"default_value"
|
||||
// Other properties, describing
|
||||
// a beverage to end-user
|
||||
…
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
At first glance, again, it looks like a reasonably simple interface, explicitly decomposed into abstraction levels. But let us imagine the future — what would happen with this interface when our system evolves further?
|
||||
|
||||
The first problem is obvious to those who read [chapter 11](#chapter-11-paragraph-20) thoroughly: product properties must be localized. That will lead us to the first change:
|
||||
|
||||
```
|
||||
"product_properties": {
|
||||
// "l10n" is a standard abbreviation
|
||||
// for "localization"
|
||||
"l10n" : [{
|
||||
"language_code": "en",
|
||||
"country_code": "US",
|
||||
"name",
|
||||
"description"
|
||||
}, /* other languages and countries */ … ]
|
||||
]
|
||||
```
|
||||
|
||||
And here the first big question arises: what should we do with the `default_volume` field? From one side, that's an objective quality measured in standardized units, and it's being passed to the program execution engine. On the other side, in countries like the United States, we had to specify beverage volume not like ‘300 ml’, but ‘10 fl oz’. We may propose two solutions:
|
||||
|
||||
* either the partner provides the corresponding number only, and we will make readable descriptions on our own behalf,
|
||||
* or the partner provides both the number and all of its localized representations.
|
||||
|
||||
The flaw in the first option is that a partner might be willing to use the service in some new country or language — and will be unable to do so until the API supports them. The flaw in the second option is that it works with predefined volumes only, so you can't order an arbitrary beverage volume. So the very first step we've made effectively has us trapped.
|
||||
|
||||
The localization flaws are not the only problem of this API. We should ask ourselves a question — *why* do we 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’.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
To make things worse, let us state that the inverse principle is actually correct either: high-level entities must not define low-level ones, since that simply isn't their responsibility. The exit from this logical labyrinth is: high-level entities must *define a context*, which other objects are to interpret. To properly design adding new recipe interface we shouldn't try 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 requirements — what exactly the partner must provide to make our API work in a new region. More specifically, there must be some formatting function to represent beverage volume somewhere in our API code:
|
||||
|
||||
```
|
||||
l10n.volume.format(value, language_code, country_code)
|
||||
// l10n.formatVolume('300ml', 'en', 'UK') → '300 ml'
|
||||
// l10n.formatVolume('300ml', 'en', 'US') → '10 fl oz'
|
||||
```
|
||||
|
||||
To make our API work correctly with a new language or region, the partner must either define this function or point which pre-existing implementation to use. Like this:
|
||||
|
||||
```
|
||||
// Add a general formatting rule
|
||||
// for Russian language
|
||||
PUT /formatters/volume/ru
|
||||
{
|
||||
"template": "{volume} мл"
|
||||
}
|
||||
// Add a specific formatting rule
|
||||
// for Russian language in the ‘US’ region
|
||||
PUT /formatters/volume/ru/US
|
||||
{
|
||||
// in US we need to recalculate
|
||||
// the number, then add a postfix
|
||||
"value_preparation": {
|
||||
"action": "divide",
|
||||
"divisor": 30
|
||||
},
|
||||
"template": "{volume} ун."
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: we are more than aware that such a simple format isn't enough to cover real-world localization use-cases, and one either rely on existing libraries, or design a sophisticated format for such templating, which takes into account such things as grammatical cases and rules of rounding numbers up, or allow defining formatting rules in a form of function code. The example above is simplified for purely educational purposes.
|
||||
|
||||
Let us deal with the `name` and `description` problem then. To lower the coupling level there we need to formalize (probably just to ourselves) a ‘layout’ concept. We are asking for providing `name` and `description` not because we just need them, but for representing them in some specific user interface. This specific UI might have an identifier or a semantic name.
|
||||
|
||||
```
|
||||
GET /v1/layouts/{layout_id}
|
||||
{
|
||||
"id",
|
||||
// We would probably have lots of layouts,
|
||||
// so it's better to enable extensibility
|
||||
// from the beginning
|
||||
"kind": "recipe_search",
|
||||
// Describe every property we require
|
||||
// to have this layout rendered properly
|
||||
"properties": [{
|
||||
// Since we learned that `name`
|
||||
// is actually a title for a search
|
||||
// result snippet, it's much more
|
||||
// convenient to have explicit
|
||||
// `search_title` instead
|
||||
"field": "search_title",
|
||||
"view": {
|
||||
// Machine-readable description
|
||||
// of how this field is rendered
|
||||
"min_length": "5em",
|
||||
"max_length": "20em",
|
||||
"overflow": "ellipsis"
|
||||
}
|
||||
}, …],
|
||||
// Which fields are mandatory
|
||||
"required": [
|
||||
"search_title",
|
||||
"search_description"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
So the partner may decide, which option better suits them. They can provide mandatory fields for the standard layout:
|
||||
|
||||
```
|
||||
PUT /v1/recipes/{id}/properties/l10n/{lang}
|
||||
{
|
||||
"search_title", "search_description"
|
||||
}
|
||||
```
|
||||
|
||||
or create a layout of their own and provide data fields it requires:
|
||||
|
||||
```
|
||||
POST /v1/layouts
|
||||
{
|
||||
"properties"
|
||||
}
|
||||
→
|
||||
{ "id", "properties" }
|
||||
```
|
||||
|
||||
or they may ultimately design their own UI and don't use this functionality at all, defining neither layouts nor data fields.
|
||||
|
||||
Then our interface would ultimately look like:
|
||||
|
||||
```
|
||||
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:
|
||||
|
||||
```
|
||||
POST /v1/recipe-builder
|
||||
{
|
||||
"id",
|
||||
// Recipe's fixed properties
|
||||
"product_properties": {
|
||||
"default_volume",
|
||||
"l10n"
|
||||
},
|
||||
// Create all the desirable layouts
|
||||
"layouts": [{
|
||||
"id", "kind", "properties"
|
||||
}],
|
||||
// Add all the formatters needed
|
||||
"formatters": {
|
||||
"volume": [
|
||||
{ "language_code", "template" },
|
||||
{ "language_code", "country_code", "template" }
|
||||
]
|
||||
},
|
||||
// Other actions needed to be done
|
||||
// to register new recipe in the system
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
We should also note that providing a newly created entity identifier by 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 [Chapter 11](#chapter-11-paragraph-8).
|
||||
|
||||
```
|
||||
POST /v1/recipes/custom
|
||||
{
|
||||
// First part of the composite
|
||||
// identifier, for example,
|
||||
// the partner's own id
|
||||
"namespace": "my-coffee-company",
|
||||
// Second part of the identifier
|
||||
"id_component": "lungo-customato"
|
||||
}
|
||||
→
|
||||
{
|
||||
"id": "my-coffee-company:lungo-customato"
|
||||
}
|
||||
```
|
||||
|
||||
Also note that this format allows us to maintain an important extensibility point: different partners might have totally isolated namespaces, or conversely share them. Furthermore, we might introduce special namespaces (like ‘common’, for example) to allow for publishing new recipes for everyone (and that, by the way, would allow us to organize our own backoffice to edit recipes).
|
262
src/en/clean-copy/03-Section II. Backwards Compatibility/05.md
Normal file
262
src/en/clean-copy/03-Section II. Backwards Compatibility/05.md
Normal file
@ -0,0 +1,262 @@
|
||||
### Weak coupling
|
||||
|
||||
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. A mindful reader might have noted that this technique was already used in our API study much earlier in [Chapter 9](#chapter-9) with regards to ‘program’ and ‘program run’ entities. Indeed, we might do it without `program-matcher` endpoint and make it this way:
|
||||
|
||||
```
|
||||
GET /v1/recipes/{id}/run-data/{api_type}
|
||||
→
|
||||
{ /* A description, how to
|
||||
execute a specific recipe
|
||||
using a specified API type */ }
|
||||
```
|
||||
|
||||
Then developers would have to make this trick to get coffee prepared:
|
||||
* learn the API type of the specific coffee machine;
|
||||
* get the execution description, as stated above;
|
||||
* depending on the API type, run some specific commands.
|
||||
|
||||
Obviously, such 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 [Chapter 15](#chapter-15): 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}
|
||||
{
|
||||
"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 current execution status, and finish (cancel) the order. An obvious way to provide the common interface is to require these three functions being executed via a remote call, for example, like this:
|
||||
|
||||
```
|
||||
// This is an endpoint for partners
|
||||
// to register their coffee machines
|
||||
// in the system
|
||||
PUT /partners/{id}/coffee-machines
|
||||
{
|
||||
"coffee-machines": [{
|
||||
"id",
|
||||
…
|
||||
"order_execution_endpoint": {
|
||||
"program_run_endpoint": {
|
||||
/* Some description of
|
||||
the remote function call */
|
||||
"type": "rpc",
|
||||
"endpoint": <URL>,
|
||||
"format"
|
||||
},
|
||||
"program_state_endpoint",
|
||||
"program_stop_endpoint"
|
||||
}
|
||||
}, …]
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: doing so we're transferring the complexity of developing the API onto a plane of developing appropriate data formats, e.g. how exactly would we send order parameters to the `program_run_endpoint`, and what format the `program_state_endpoint` shall return, etc., but in this chapter, we're focusing on 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.
|
||||
|
||||
1. It describes nicely the integrations we've already implemented (it costs almost nothing to support the API types we already know), but brings no flexibility in the approach. In fact, we simply described what we'd already learned, not even trying to look at a larger picture.
|
||||
2. This design is ultimately based on a single principle: every order preparation might be codified with these three imperative commands.
|
||||
|
||||
We may easily disprove 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.
|
||||
|
||||
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 either: what if we're plugging via our API not a coffee house, but a vending machine? From one side, it means that `modify` endpoint and all related stuff are simply meaningless: vending machine couldn't sprinkle cinnamon over a coffee cup, and contactless takeout requirement means nothing to it. From the other side, the machine, unlike 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.
|
||||
|
||||
Programmable takeout approval requires one more endpoint, let's say, `program_takeout_endpoint`. And so we've lost our way in a forest of three endpoints:
|
||||
* to have vending machines integrated a partner must implement the `program_takeout_endpoint`, but doesn't actually 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`.
|
||||
|
||||
Furthermore, we have to describe both endpoints in the docs. It's quite natural that `takeout` endpoint is very specific; unlike cinnamon sprinkling, which we hid under 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 when half of the methods *isn't actually needed at all*, like requesting contactless takeout method.
|
||||
|
||||
It is also worth mentioning that we unwittingly violated the abstraction levels isolation principle. At 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 weak one. How would the *ideal* solution look from both sides? 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, 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 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 Chapter 9](#chapter-9).
|
||||
|
||||
In our case we need to implement the following mechanisms:
|
||||
* running a program creates a corresponding context comprising all the essential parameters;
|
||||
* there is 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 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:
|
||||
|
||||
```
|
||||
/* Partner's implementation of the program
|
||||
run procedure for a custom API type */
|
||||
registerProgramRunHandler(apiType, (program) => {
|
||||
// Initiating an execution
|
||||
// on partner's side
|
||||
let execution = initExecution(…);
|
||||
// Listen to parent context's changes
|
||||
program.context.on('takeout_requested', () => {
|
||||
// If takeout is requested, initiate
|
||||
// corresponding procedures
|
||||
execution.prepareTakeout(() => {
|
||||
// When the cup is ready for takeout,
|
||||
// emit corresponding event
|
||||
// for higher-level entity to catch it
|
||||
execution.context.emit('takeout_ready');
|
||||
});
|
||||
});
|
||||
|
||||
return execution.context;
|
||||
});
|
||||
```
|
||||
|
||||
**NB**: In 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.
|
||||
|
||||
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 a long list of methods that shall be implemented to integrate partner's API, we now have a long list of `context` objects fields and events they generate;
|
||||
* and with regards to technological progress we'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.
|
||||
|
||||
The difference between strong coupling and weak coupling is that field-event mechanism *isn't obligatory to both sides*. Let us remember what we sought to achieve:
|
||||
|
||||
* higher-level context doesn't actually know how low-level API works — and it really doesn't; it describes the changes which occurs within the context itself, and reacts only to those events which mean something to it;
|
||||
* low-level context doesn't know anything about alternative implementations — and it really doesn't; it handles only those events which mean something at its level, and emits only those events which could actually happen under its specific conditions.
|
||||
|
||||
It's ultimately possible that both sides would know nothing about each other and wouldn't interact at all. This might actually happen at some point in the future with the evolution of underlying technologies.
|
||||
|
||||
Worth mentioning that the number of entities (fields, events), though effectively doubled compared to strong-coupled API design, raised 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 its 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 upcoming ‘SDK’ section of this book.
|
||||
|
||||
#### The Inversion of Responsibility
|
||||
|
||||
It becomes obvious from what was said above that two-way weak coupling means a significant increase of code complexity on both levels, which is often redundant. In many cases, two-way event linking might be replaced with one-way linking without significant loss of design quality. That means allowing a low-level entity to call higher-level methods directly instead of generating events. Let's alter our example:
|
||||
|
||||
```
|
||||
/* Partner's implementation of program
|
||||
run procedure for a custom API type */
|
||||
registerProgramRunHandler(apiType, (program) => {
|
||||
// Initiating an execution
|
||||
// on partner's side
|
||||
let execution = initExecution(…);
|
||||
// Listen to parent context's changes
|
||||
program.context.on('takeout_requested', () => {
|
||||
// If takeout is requested, initiate
|
||||
// corresponding procedures
|
||||
execution.prepareTakeout(() => {
|
||||
/* When the order is ready for takeout,
|
||||
signalize about that, but not
|
||||
with event emitting */
|
||||
// execution.context.emit('takeout_ready')
|
||||
program.context.set('takeout_ready');
|
||||
// Or even more rigidly
|
||||
// program.setTakeoutReady();
|
||||
});
|
||||
});
|
||||
/* Since we're modifying parent context
|
||||
instead of emitting events, we don't
|
||||
actually need to return anything */
|
||||
// return execution.context;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Again, this solution might look counter-intuitive, since we efficiently returned to strong coupling via strictly defined methods. But there is an important difference: we're making all this stuff up because we expect alternative implementations of 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 top to bottom.
|
||||
|
||||
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 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.
|
||||
|
||||
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.
|
||||
|
||||
**NB**: many contemporary frameworks explore a shared state approach, Redux being probably the most notable example. In Redux paradigm the code above would look like this:
|
||||
|
||||
```
|
||||
execution.prepareTakeout(() => {
|
||||
// Instead of generating events
|
||||
// or calling higher-level methods,
|
||||
// an `execution` entity calls
|
||||
// a global or quasi-global
|
||||
// callback to change a global state
|
||||
dispatch(takeoutReady());
|
||||
});
|
||||
```
|
||||
|
||||
Let us note that this approach *in general* doesn't contradict 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 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.
|
||||
|
||||
```
|
||||
execution.prepareTakeout(() => {
|
||||
// Instead of initiating global actions
|
||||
// an `execution` entity invokes
|
||||
// its superior's dispatch functionality
|
||||
program.context.dispatch(takeoutReady());
|
||||
});
|
||||
```
|
||||
```
|
||||
// program.context.dispatch implementation
|
||||
ProgramContext.dispatch = (action) => {
|
||||
// program.context calls its own
|
||||
// superior or global object
|
||||
// if there are no superiors
|
||||
globalContext.dispatch(
|
||||
// The action itself may and
|
||||
// must be reformulated
|
||||
// in appropriate terms
|
||||
this.generateAction(action)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 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 [Chapter 9](#chapter-9). In other words, could we start an execution of an order 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!
|
||||
|
||||
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 has no specific meaning when we talk about some specific hardware context.
|
||||
|
||||
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.
|
||||
|
||||
**NB**. In the [Chapter 9](#chapter-9) 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.
|
@ -0,0 +1,30 @@
|
||||
### Interfaces as a Universal Pattern
|
||||
|
||||
Let us summarize what we have written in the three previous chapters.
|
||||
|
||||
1. 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.
|
||||
2. 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).
|
||||
3. Concrete functionality, e.g. working with ‘bare metal’ hardware, underlying platform APIs, should be delegated to low-level entities.
|
||||
|
||||
**NB**. There is nothing novel about these rules: one might easily recognize them being the [SOLID](https://en.wikipedia.org/wiki/SOLID) 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.
|
||||
|
||||
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.
|
||||
|
||||
For example, we should have asked ourselves a question while designing the `POST /search` 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.
|
||||
|
||||
Then we would have come to the understanding that a ‘search result’ is actually a composition of two interfaces:
|
||||
* when we create an order, we need from the search result to provide those fields which describe the order itself; it might be a structure like:
|
||||
|
||||
`{coffee_machine_id, recipe_id, volume, currency_code, price}`,
|
||||
|
||||
or we can encode this data in the single `offer_id`;
|
||||
|
||||
* to have this search result displayed in the app, we need a different data set: `name`, `description`, formatted and localized price.
|
||||
|
||||
So our interface (let us call it `ISearchResult`) is actually a composition of two other interfaces: `IOrderParameters` (an entity that allows for creating an order) and `ISearchItemViewParameters` (some abstract representation of the search result in the UI). This interface split should automatically lead us to additional questions.
|
||||
|
||||
1. 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 [Chapter 16](#chapter-16).
|
||||
|
||||
2. And what is the ‘abstract representation of the search result in the UI’? Do we have other kinds of search, should the `ISearchItemViewParameters` interface be a subtype of some even more general interface, or maybe a composition of several such ones?
|
||||
|
||||
Replacing specific implementations with interfaces not only allows us to answer more clearly many questions which should have been appeared 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.
|
@ -0,0 +1,35 @@
|
||||
### The Serenity Notepad
|
||||
|
||||
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.
|
||||
|
||||
##### Remember the iceberg's waterline
|
||||
|
||||
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 which the author of this book has actually faced once:
|
||||
* 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;
|
||||
* 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;
|
||||
* when the error was fixed, we've got a bunch of complaints: clients did really use this flaw to stack the buttons in the container's corner.
|
||||
|
||||
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.
|
||||
|
||||
##### Test the formal interface
|
||||
|
||||
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:
|
||||
|
||||
1. Often the requirements like ‘the `getEntity` function returns the value previously being set by the `setEntity` 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.
|
||||
2. 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.
|
||||
|
||||
##### Implement your API functionality atop of public interfaces
|
||||
|
||||
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:
|
||||
* 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;
|
||||
* on a course of extending the API some interfaces become abstract, but the existing functionality isn't affected; imagine that while implementing the `PUT /formatters` interface described in the [Chapter 16](#chapter-16) 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.
|
||||
|
||||
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.
|
||||
|
||||
**NB**. 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.
|
||||
|
||||
##### Keep a notepad
|
||||
|
||||
Whatever tips and tricks that were described in the previous chapters you use, it's often quite probable that you can't do *anything* to prevent the API inconsistencies start piling up. It's possible to reduce the speed of this stockpiling, foresee some problems, have some interface durability reserved for future use. But one can't foresee *everything*. At this stage, many developers tend to make some rash decisions, e.g. to release a backwards incompatible minor version to fix some design flaws.
|
||||
|
||||
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.
|
@ -1,225 +0,0 @@
|
||||
### Strong coupling and related problems
|
||||
|
||||
In previous chapters we tried to outline theoretical rules and principles, and illustrate them with practical examples. However, understanding principles of change-proof API design requires practice like nothing before. An ability to anticipate future growth problems comes from a handful of grave mistakes once made. One cannot foresee everything, but can elaborate a certain technical intuition.
|
||||
|
||||
So in following chapters we will try to probe our study API from the previous Section, testing its robustness from every possible viewpoint, thus carrying out some ‘variational analysis’ of our interfaces. More specifically, we will apply a ‘What If?’ question to every entity, as if we are to provide a possibility to write an alternate implementation of every piece of logic.
|
||||
|
||||
One important remark we're stressing here is that we're talking about alternate realizations of *business* logic, not about *entity implementation* variants. APIs are being changed to make something *usable* in the first place — something missing in the original design. Just re-implementing some interfaces makes no sense to your customers.
|
||||
|
||||
This observation helps narrowing the scope, sparing time on varying interfaces blindly. (Actually, there is always an infinite number of such variations, and it would be a Sisyphean labor to examine all of them.) We need to understand *why* such changes might be desirable, and then we may learn *how* they are to be made.
|
||||
|
||||
A second important remark is that many decisions allowing for such a variability are already incorporated in our API design. Some of them, like determining readiness, we explained in previous chapters in detail; some of them are provided with no comments, so it's now time to explain the logic behind these decisions.
|
||||
|
||||
**NB**. In our examples the interfaces will be constructed in a manner allowing for dynamic real-time linking of different entities. In practice such integrations usually imply writing an ad hoc code at server side with accordance to specific agreements made with specific partner. But for educational purposes we will pursue more abstract and complicated ways. Dynamic real-time linking is more typical to complex program constructions like operating system APIs or embeddable libraries; giving educational examples based on such sophisticated systems would be too inconvenient.
|
||||
|
||||
For the beginning, let us imagine that we decided to give to our partners an opportunity to serve their own unique coffee recipes. What would be the motivation to provide this functionality?
|
||||
* maybe a partner's coffee houses chain seeks to offer their branded beverages to clients;
|
||||
* maybe a partner is building their own application with their branded assortment upon our platform.
|
||||
|
||||
Either way, we need to start with a recipe. What data do we need to allow adding new recipes to the system? Let us remember what contexts the ‘Recipe’ entity is linking: this entity is needed to couple a user's choice with beverage preparation rules. At first glance it looks like we need to describe a ‘Recipe’ in this exact manner:
|
||||
|
||||
```
|
||||
// Adds new recipe
|
||||
POST /v1/recipes
|
||||
{
|
||||
"id",
|
||||
"product_properties": {
|
||||
"name",
|
||||
"description",
|
||||
"default_value"
|
||||
// Other properties, describing
|
||||
// a beverage to end-user
|
||||
…
|
||||
},
|
||||
"execution_properties": {
|
||||
// Program's identifier
|
||||
"program_id",
|
||||
// Program's execution parameters
|
||||
"parameters"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
At first glance, again, it looks like a reasonably simple interface, explicitly decomposed into abstraction levels. But let us imagine the future — what would happen with this interface when our system evolves further?
|
||||
|
||||
The first problem is obvious to those who read [chapter 11](#chapter-11-paragraph-20) thoroughly: product properties must be localized. That will lead us to the first change:
|
||||
|
||||
```
|
||||
"product_properties": {
|
||||
// "l10n" is a standard abbreviation
|
||||
// for "localization"
|
||||
"l10n" : [{
|
||||
"language_code": "en",
|
||||
"country_code": "US",
|
||||
"name",
|
||||
"description"
|
||||
}, /* other languages and countries */ … ]
|
||||
]
|
||||
```
|
||||
|
||||
And here the big question arises: what should we do with the `default_volume` field? From one side, that's an objective quality measured in standardized units, and it's being passed to the program execution engine. From other side, in countries like the United States we had to specify beverage volume not like ‘300 ml’, but ‘10 fl oz’. We may propose two solutions:
|
||||
* either partners provide the corresponding number only, and we will make readable descriptions on our own behalf,
|
||||
* or partners provide both the number and all of its localized representations.
|
||||
|
||||
The flaw in the first option is that a partner might be willing to use the service in some new country or language — and will be unable to do so until the API supports them. The flaw in the second option is that it works with predefined volumes only, so you can't order an arbitrary volume of beverage. So the very first step we've made effectively has us trapped.
|
||||
|
||||
The localization flaws are not the only problem of this API. We should ask ourselves a question — *why* do we really need these `name` and `description`? They are simply non-machine-readable strings with no specific semantics. At first glance we need them to return them back in `/v1/search` method response, but that's not a proper answer: why do we really return these strings from `search`?
|
||||
|
||||
The correct answer lies a way beyond this specific interface. We need them *because some representation exists*. There is a UI for choosing beverage type. Probably `name` and `description` are simply two designations of the beverage type, short one (to be displayed on the search results page) and long one (to be displayed in the extended product specification block). It actually means that we are setting the requirements to the API based on some very specific design. But *what if* a partner is making their own UI for their own app? Not only two descriptions might be of no use for them, but we are also *deceiving* them. `name` is not ‘just a name’ actually, it implies some restrictions: it has recommended length, optimal to some specific UI, and it must look consistently on the search results page. Indeed, ‘our best quality™ coffee’ or ‘Invigorating Morning Freshness®’ designation would look very weird in-between ‘Cappuccino’, ‘Lungo’, and ‘Latte’.
|
||||
|
||||
There is also another side to this story. As UIs (both ours and partner's) tend to evolve, new visual elements will be eventually introduced. For example, a picture of a beverage, its energy value, allergen information, etc. `product_properties` will become a scrapyard for tons of optional fields, and learning how setting what field results in what effects in the UI will be an interesting quest, full of probes and mistakes.
|
||||
|
||||
Problems we're facing are the problems of *strong coupling*. Each time we offer an interface like described above, we're in fact prescript implementing one entity (recipe) basing on implementations of other entities (UI layout, localization rules). This approach disrespects the very basic principle of designing APIs ‘top to bottom’, because **low-level entities must not define high-level ones**. To make things worse, let us mention that revers principle is actually correct either: high-level entities must not define low-level ones, since that simply isn't their responsibility.
|
||||
|
||||
#### The rule of contexts
|
||||
|
||||
The exit from this logical labyrinth is: high-level entities must *define a context*, which other objects are to interpret. To properly design adding new recipe interface we shouldn't try find better data format; we need to understand what contexts, both explicit and implicit, exist in our subject area.
|
||||
|
||||
We have already found a localization context. There is some set of languages and regions we support in our API, and there are requirements — what exactly the partner must provide to make our API work in a new region. More specifically, there must be some formatting function to represent beverage volume somewhere in our API code:
|
||||
|
||||
```
|
||||
l10n.volume.format(value, language_code, country_code)
|
||||
// l10n.formatVolume('300ml', 'en', 'UK') → '300 ml'
|
||||
// l10n.formatVolume('300ml', 'en', 'US') → '10 fl oz'
|
||||
```
|
||||
|
||||
To make our API work correctly with a new language or region, the partner must either define this function or point which pre-existing implementation to use. Like this:
|
||||
|
||||
```
|
||||
// Add a general formatting rule
|
||||
// for Russian language
|
||||
PUT /formatters/volume/ru
|
||||
{
|
||||
"template": "{volume} мл"
|
||||
}
|
||||
// Add a specific formatting rule
|
||||
// for Russian language in the ‘US’ region
|
||||
PUT /formatters/volume/ru/US
|
||||
{
|
||||
// in US we need to recalculate
|
||||
// the number, then add a postfix
|
||||
"value_preparation": {
|
||||
"action": "divide",
|
||||
"divisor": 30
|
||||
},
|
||||
"template": "{volume} ун."
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: we are more than aware that such simple format isn't enough to cover real-world localization use-cases, and one either rely on existing libraries, or design a sophisticated format for such templating, which takes into account such things as grammatical cases and rules of rounding numbers up, or allow defining formatting rules in a form of function code. The example above is simplified in purely educational purposes.
|
||||
|
||||
Let us deal with `name` and `description` problem then. To lower the coupling levels there we need to formalize (probably just to ourselves) a ‘layout’ concept. We are asking for providing `name` and `description` not because we just need them, but for representing them in some specific user interface. This specific UI might have an identifier or a semantic name.
|
||||
|
||||
```
|
||||
GET /v1/layouts/{layout_id}
|
||||
{
|
||||
"id",
|
||||
// We would probably have lots of layouts,
|
||||
// so it's better to enable extensibility
|
||||
// from the beginning
|
||||
"kind": "recipe_search",
|
||||
// Describe every property we require
|
||||
// to have this layout rendered properly
|
||||
"properties": [{
|
||||
// Since we learned that `name`
|
||||
// is actually a title for a search
|
||||
// result snippet, it's much more
|
||||
// convenient to have explicit
|
||||
// `search_title` instead
|
||||
"field": "search_title",
|
||||
"view": {
|
||||
// Machine-readable description
|
||||
// of how this field is rendered
|
||||
"min_length": "5em",
|
||||
"max_length": "20em",
|
||||
"overflow": "ellipsis"
|
||||
}
|
||||
}, …],
|
||||
// Which fields are mandatory
|
||||
"required": [
|
||||
"search_title",
|
||||
"search_description"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
So the partner may decide, which option better suits them. They can provide mandatory fields for the standard layout:
|
||||
|
||||
```
|
||||
PUT /v1/recipes/{id}/properties/l10n/{lang}
|
||||
{
|
||||
"search_title", "search_description"
|
||||
}
|
||||
```
|
||||
|
||||
or create a layout of their own and provide data fields it requires:
|
||||
|
||||
```
|
||||
POST /v1/layouts
|
||||
{
|
||||
"properties"
|
||||
}
|
||||
→
|
||||
{ "id", "properties" }
|
||||
```
|
||||
|
||||
or they may ultimately design their own UI and don't use this functionality at all, defining neither layouts nor data fields.
|
||||
|
||||
The same technique, i.e. defining a specific entity responsible for matching a recipe and its traits for the underlying systems, might be used to detach `execution_properties` from the interface, thus allowing the partner to control how the recipe is being coupled with execution programs. Then our interface would ultimately look like:
|
||||
|
||||
```
|
||||
POST /v1/recipes
|
||||
{ "id" }
|
||||
→
|
||||
{ "id" }
|
||||
```
|
||||
|
||||
This conclusion might look highly counter-intuitive, but lacking any fields in ‘Recipe’ simply tells as that this entity possesses no specific semantics of its own, and is simply an identifier of a context; a method to point out where to look for the data needed by other entities. In the real world we should implement a builder endpoint capable of creating all the related contexts with a single request:
|
||||
|
||||
```
|
||||
POST /v1/recipe-builder
|
||||
{
|
||||
"id",
|
||||
// Recipe's fixed properties
|
||||
"product_properties": {
|
||||
"default_volume",
|
||||
"l10n"
|
||||
},
|
||||
// Execution data
|
||||
"execution_properties"
|
||||
// Create all the desirable layouts
|
||||
"layouts": [{
|
||||
"id", "kind", "properties"
|
||||
}],
|
||||
// Add all the formatters needed
|
||||
"formatters": {
|
||||
"volume": [
|
||||
{ "language_code", "template" },
|
||||
{ "language_code", "country_code", "template" }
|
||||
]
|
||||
},
|
||||
// Other actions needed to be done
|
||||
// to register new recipe in the system
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
We should also note that providing a newly created entity identifier by client isn't exactly the best pattern. However, since we decided from the very beginning to keep recipe identifiers semantically meaningful, we have to live with this convention on. Obviously, we're risking getting lots of collisions on recipe naming used by different partners, so we actually need to modify this operation: either partners must always use a pair of identifiers (i.e. recipe's one plus partner's own id), or we need to introduce composite identifiers, as we recommended earlier in [Chapter 11](#chapter-11-paragraph-8).
|
||||
|
||||
```
|
||||
POST /v1/recipes/custom
|
||||
{
|
||||
// First part of the composite
|
||||
// identifier, for example,
|
||||
// the partner's own id
|
||||
"namespace": "my-coffee-company",
|
||||
// Second part of the identifier
|
||||
"id_component": "lungo-customato"
|
||||
}
|
||||
→
|
||||
{
|
||||
"id": "my-coffee-company:lungo-customato"
|
||||
}
|
||||
```
|
||||
|
||||
Also note that this format allows us to maintain an important extensibility point: different partners might have totally isolated namespaces, or conversely share them. Furthermore, we might introduce special namespaces like ‘common’ to allow publish new recipes for everyone (and that, by the way, would allow us to organize our own backoffice to edit recipes).
|
@ -1,199 +0,0 @@
|
||||
### Weak coupling
|
||||
|
||||
In previous chapter we've demonstrated how breaking strong coupling of components leads to decomposing entities and collapsing their public interfaces down to a reasonable minimum. A mindful reader might have noted that this technique was already used in our API study much earlier in [Chapter 9](#chapter-9) with regards to ‘program’ and ‘program run’ entities. Indeed, we might do it without `program-matcher` endpoint and make it this way:
|
||||
|
||||
```
|
||||
GET /v1/recipes/{id}/run-data/{api_type}
|
||||
→
|
||||
{ /* A description, how to
|
||||
execute a specific recipe
|
||||
using a specified API type */ }
|
||||
```
|
||||
|
||||
Then developers would have to make this trick to get coffee prepared:
|
||||
* learn the API type of the specific coffee-machine;
|
||||
* get the execution description, as stated above;
|
||||
* depending on the API type, run some specific commands.
|
||||
|
||||
Obviously, such interface is absolutely unacceptable, simply because in the majority of use cases developers don't care at all, which API type the specific coffee machine runs. To avoid the necessity of introducing such bad interfaces we created new ‘program’ entity, which constitutes merely a context identifier, just like a ‘recipe’ entity does. A `program_run_id` entity is also organized in this manner, it also possesses no specific properties, being *just* a program run identifier.
|
||||
|
||||
But let us ask ourselves a more interesting question. Our API allows for running programs on coffee machines with a known API. Let us imagine then that we have a partner with their own coffee houses with a plethora of coffee machines running different APIs. How would we allow the partner to get an access to the `programs` API, so they could plug their coffee machines to our system?
|
||||
|
||||
Out of general considerations we may assume that every such API would be capable of executing three functions: run a program with specified parameters, return current execution status, and finish (cancel) the order. An obvious way to provide the common interface is to require partner have this three functions support a remote call, for example, like this:
|
||||
|
||||
```
|
||||
// This is an endpoint for partners
|
||||
// to register their coffee machines
|
||||
// in the system
|
||||
PUT /partners/{id}/coffee-machines
|
||||
{
|
||||
"coffee-machines": [{
|
||||
"id",
|
||||
…
|
||||
"program_api": {
|
||||
"program_run_endpoint": {
|
||||
/* Some description of
|
||||
the remote function call */
|
||||
"type": "rpc",
|
||||
"endpoint": <URL>,
|
||||
"format"
|
||||
},
|
||||
"program_state_endpoint",
|
||||
"program_stop_endpoint"
|
||||
}
|
||||
}, …]
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: doing so we're transferring the complexity of developing the API onto a plane of developing appropriate data formats, e.g. how exactly would we send order parameters to the `program_run_endpoint`, and what format the `program_state_endpoint` shall return, etc., but in this chapter we're focusing on another questions.
|
||||
|
||||
Though this API looks like absolutely universal, it's quite easy to demonstrate how once simple and clear API end up being confusing and convoluted. This design presents two main problems:
|
||||
1. It describes nicely the integrations we've already implemented (it costs almost nothing to support the API types we already know), but brings no flexibility in the approach. In fact, we simply described what we'd already learned, not even trying to look at a larger picture.
|
||||
2. This design is ultimately based on a single principle: every order preparation might be codified with these three imperative commands.
|
||||
|
||||
We may easily disprove No. 2 principle, and that will uncover the implications of No. 1. For the beginning, let us imagine that on a course of further service growth we decided to allow end users to change the order after the execution started. For example, ask for a cinnamon sprinkling or for a contactless takeout. That would lead us to adding a new endpoint, let's say, `program_modify_endpoint`, and new difficulties in data format development (we need to understand in the real time, could we actually sprinkle cinnamon on this specific cup of coffee). What *is* important is that both (endpoint *and* new data fields) would be optional because of backwards compatibility requirement.
|
||||
|
||||
Now let's try to imagine a real world example which doesn't fit into our ‘three imperatives to rule them all’ picture. That's quite easy either: what if we're plugging via our API not a coffee house, but a vending machine? From one side, it means that `modify` endpoint and all related stuff are simply meaningless: vending machine couldn't sprinkle cinnamon over a coffee cup, and contactless takeout requirement means nothing to it. From the other side, the machine, unlike people-operated café, requires *takeout approval*: an end user places an order being somewhere in some other place, then walks to the machine and pushes ‘get the order’ button in the app. We might, of course, require the user to stand in front of the machine when placing an order, but that would contradict the entire product concept of users selecting and ordering beverages and then walking to the takeout point.
|
||||
|
||||
Programmable takeout approval requires one more endpoint, let's say, `program_takeout_endpoint`. And so we've lost our way in a forest of three endpoints:
|
||||
* to have vending machines integrated a partner must implement `program_takeout_endpoint`, but doesn't actually need `program_modify_endpoint`;
|
||||
* to have regular coffee houses integrated a partner must implement `program_modify_endpoint`, but doesn't actually need `program_takeout_endpoint`.
|
||||
|
||||
Furthermore, we have to describe both endpoints in the docs. It's quite natural that `takeout` endpoint is very specific; unlike cinnamon sprinkling, which we hid under pretty general `modify` endpoint, operations like takeout approval will require introducing a new unique method every time. After several iterations we would have a scrapyard, full of similarly looking methods, mostly optional — but you would need to study the docs nonetheless to understand, which methods are needed in your specific situation, and which are not.
|
||||
|
||||
We actually don't know, whether in the real world of coffee machine APIs this problem will really occur or not. But we can say with all confidence regarding ‘bare metal’ integrations that the processes we described *always* happen. The underlying technology shifts; an API which seemed clear and straightforward, becomes a trash bin full of legacy methods, half of which borrows no practical sense under any specific set of conditions. If we add a technical progress to the situation, i.e. imagine that after a while all coffee houses become automated, we will finally end up with the situation when half of methods *aren't actually needed at all*, like requesting contactless takeout method.
|
||||
|
||||
It is also worth mentioning that we unwittingly violated the abstraction levels isolation principle. At a vending machine API level there is no such term as ‘contactless takeout’, that's actually a product concept.
|
||||
|
||||
So, how would we tackle this issue? Using one of two possible approaches: either thoroughly study all the subject area and its upcoming improvements for at least several years ahead, or abandon strong coupling in favor of weak one. How would the *ideal* solution look from both sides? Something like this:
|
||||
* higher-level program API level doesn't actually know how the execution of its commands works; it formulates the tasks at its own level of understanding: brew this recipe, sprinkle with cinnamon, allow this user to take it;
|
||||
* underlying program execution API doesn't care what other same-level implementations exist; it just interprets those parts of the task which make sense to it.
|
||||
|
||||
If we take a look at principles described in previous chapter, we would find that this principle was already formulated: we need to describe *an informational context* at every abstraction level, and design a mechanism to translate it between levels. Furthermore, in more general sense we formulated it as early as in [‘The Data Flow’ paragraph in Chapter 9](#chapter-9).
|
||||
|
||||
In our case we need to implement the following mechanisms:
|
||||
* running a program creates a corresponding context comprising all the essential parameters;
|
||||
* there is method to exchange the information regarding data changes: the execution level may read the context, learn about all the changes and report back the changes of its own.
|
||||
|
||||
There are different techniques to organize this data flow, but basically we always have two context descriptions and two-way event stream in-between. In case of developing an SDK we might express this idea like this:
|
||||
|
||||
```
|
||||
/* Partner's implementation of program
|
||||
run procedure for a custom API type */
|
||||
registerProgramRunHandler(apiType, (program) => {
|
||||
// Initiating an execution
|
||||
// on partner's side
|
||||
let execution = initExecution(…);
|
||||
// Listen to parent context's changes
|
||||
program.context.on('takeout_requested', () => {
|
||||
// If takeout is requested, initiate
|
||||
// corresponding procedures
|
||||
execution.prepareTakeout(() => {
|
||||
// When the cup is ready for takeout,
|
||||
// emit corresponding event
|
||||
// for higher-level entity to catch it
|
||||
execution.context.emit('takeout_ready');
|
||||
});
|
||||
});
|
||||
|
||||
return execution.context;
|
||||
});
|
||||
```
|
||||
|
||||
**NB**: In case of HTTP API corresponding example would look rather bulky as it involves implementing several additional endpoints for message queues like `GET /program-run/events` and `GET /partner/{id}/execution/events`. We would leave this exercise to the reader. Also worth mentioning that in real-world systems such event queues are usually organized using external event message systems like Apache Kafka or Amazon SQS.
|
||||
|
||||
At this point a mindful reader might begin protesting, because if we take a look at the nomenclature of the entities emerged, we will find that nothing changed in the problem statement, it actually became even more complicated:
|
||||
* instead of calling the `takeout` method we're now generating a pair of `takeout_requested`/`takeout_ready` events;
|
||||
* instead of long list of methods which shall be implemented to integrate partner's API, we now have a long list of `context` objects fields and events they generate;
|
||||
* and with regards to technological progress we changed nothing: now we have deprecated fields and events instead of deprecated methods.
|
||||
|
||||
And this remark is absolutely correct. Changing API formats doesn't solve any problems related to the evolution of functionality and underlying technology. Changing API formats solves another problem: how to make the code written by developers stay clean and maintainable. Why would strong-coupled integration (i.e. coupling entities via methods) render the code unreadable? Because both side *are obliged* to implement the functionality which is meaningless in their corresponding subject areas. And these implementations would actually comprise a handful of methods to say that this functionality is either unsupported, or supported always and unconditionally.
|
||||
|
||||
The difference between strong coupling and weak coupling is that field-event mechanism *isn't obligatory to both sides*. Let us remember what we sought to achieve:
|
||||
* higher-level context doesn't actually know how low-level API works — and it really doesn't; it describes the changes which occurs within the context itself, and reacts only to those events which mean something to it;
|
||||
* low-level context doesn't know anything about alternative implementations — and it really doesn't; it handles only those events which mean something at its level, and emits only those events which could actually happen under its specific conditions.
|
||||
|
||||
It's ultimately possible that both sides would know nothing about each other and wouldn't interact at all. This might actually happen at some point in the future with the evolution of underlying technologies.
|
||||
|
||||
Worth mentioning that a number of entities (fields, events), though effectively doubled compared to strong-coupled API design, raises qualitatively, not quantitatively. `program` context describes fields and events in its own terms (type of beverage, volume, cinnamon sprinkling), while `execution` context must reformulate those terms according to its own subject area (omitting redundant ones, by the way). It is also important that `execution` context might concretize these properties for underlying objects according to its own specifics, while `program` context must keep its properties general enough to be applicable to any possible underlying technology.
|
||||
|
||||
One more important feature of event-driven coupling is that it allows an entity to have several higher-level contexts. In typical subject areas such situation would look like an API design flaw, but in complex systems, with several system state-modifying agents present, such design patterns are not that rare. Specifically, you would likely face such situations while developing user-facing UI libraries. We will cover this issue in detail in the ‘SDK’ section of this book.
|
||||
|
||||
#### The Inversion of Responsibility
|
||||
|
||||
It becomes obvious from what said above that two-way weak coupling means significant increase of code complexity on both levels, which is often redundant. In many cases two-way event linking might be replaced with one-way linking without significant loss of design quality. That means allowing low-level entity to call higher-level methods directly instead of generating events. Let's alter our example:
|
||||
|
||||
```
|
||||
/* Partner's implementation of program
|
||||
run procedure for a custom API type */
|
||||
registerProgramRunHandler(apiType, (program) => {
|
||||
// Initiating an execution
|
||||
// on partner's side
|
||||
let execution = initExecution(…);
|
||||
// Listen to parent context's changes
|
||||
program.context.on('takeout_requested', () => {
|
||||
// If takeout is requested, initiate
|
||||
// corresponding procedures
|
||||
execution.prepareTakeout(() => {
|
||||
/* When the order is ready for takeout,
|
||||
signalize about that, but not
|
||||
with event emitting */
|
||||
// execution.context.emit('takeout_ready')
|
||||
program.context.set('takeout_ready');
|
||||
// Or even more rigidly
|
||||
// program.setTakeoutReady();
|
||||
});
|
||||
});
|
||||
/* Since we're modifying parent context
|
||||
instead of emitting events, we don't
|
||||
actually need to return anything */
|
||||
// return execution.context;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Again, this solution might look counter-intuitive, since we efficiently returned to strong coupling via strictly defined methods. But there is an important difference: we're making all this stuff up because we expect alternative implementations of *lower* abstraction level. Situations when different realizations of *higher* abstraction levels emerge are, of course, possible, but quite rare. The tree of alternative implementations usually grows top to bottom.
|
||||
|
||||
Another reason to justify this solution is that major changes occurring at different abstraction levels have different weight:
|
||||
* if the technical level is under change, that must not affect product qualities and the code written by partners;
|
||||
* if the product is changing, i.e. we start selling flight tickets instead of preparing coffee, there is literally no sense to preserve backwards compatibility at technical abstraction levels. Ironically, we may actually make our program run API sell tickets instead of brewing coffee without breaking backwards compatibility, but the partners' code will still become obsolete.
|
||||
|
||||
As a conclusion, because of abovementioned reasons higher-level APIs are evolving more slowly and much more consistently than low-level APIs, which means that reverse strong coupling might often be acceptable or even desirable, at least from the price-quality ratio point of view.
|
||||
|
||||
**NB**: many contemporary frameworks explore a shared state approach, Redux being probably the most notable example. In Redux paradigm the code above would look like:
|
||||
|
||||
```
|
||||
execution.prepareTakeout(() => {
|
||||
// Instead of generating events
|
||||
// or calling higher-level methods,
|
||||
// an `execution` entity calls
|
||||
// a global or quasi-global
|
||||
// callback to change a global state
|
||||
dispatch(takeoutReady());
|
||||
});
|
||||
```
|
||||
|
||||
Let us note that this approach *in general* doesn't contradict to loose coupling principle, but violates another one — of abstraction levels isolation, and therefore isn't suitable for writing branchy APIs with high hierarchy trees. In such system it's still possible to use global or quasi-global state manager, but you need to implement event or method call propagation through the hierarchy, i.e. ensure that low-level entity always interact with its closest higher-level neighbors only, delegating the responsibility of calling high-level or global methods to them.
|
||||
|
||||
```
|
||||
execution.prepareTakeout(() => {
|
||||
// Instead of initiating global actions
|
||||
// an `execution` entity invokes
|
||||
// its superior's dispatch functionality
|
||||
program.context.dispatch(takeoutReady());
|
||||
});
|
||||
```
|
||||
```
|
||||
// program.context.dispatch implementation
|
||||
ProgramContext.dispatch = (action) => {
|
||||
// program.context calls its own
|
||||
// superior or global object
|
||||
// if there are no superiors
|
||||
globalContext.dispatch(
|
||||
// The action itself may and
|
||||
// must be reformulated
|
||||
// in appropriate terms
|
||||
this.generateAction(action)
|
||||
);
|
||||
}
|
||||
```
|
@ -60,7 +60,7 @@
|
||||
* старая функциональность перестаёт поддерживаться;
|
||||
* меняются интерфейсы.
|
||||
|
||||
Как правило, API абстрагирует некоторую более гранулярную предметную область. В случае нашего [примера с API кофе-машин](https://twirl.github.io/The-API-Book/docs/API.ru.html#chapter-7) разумно ожидать, что будут появляться новые модели с новым API, которые нам придётся включать в свою платформу, и гарантировать возможность сохранения того же интерфейса абстракции — весьма непросто. Даже если просто добавлять поддержку новых видов нижележащих устройств, не добавляя ничего во внешний интерфейс — это всё равно изменения в коде, которые могут в итоге привести к несовместимости, пусть и ненамеренно.
|
||||
Как правило, API изначально покрывает только какую-то часть существующей предметной области. В случае нашего [примера с API кофе-машин](https://twirl.github.io/The-API-Book/docs/API.ru.html#chapter-7) разумно ожидать, что будут появляться новые модели с новым API, которые нам придётся включать в свою платформу, и гарантировать возможность сохранения того же интерфейса абстракции — весьма непросто. Даже если просто добавлять поддержку новых видов нижележащих устройств, не добавляя ничего во внешний интерфейс — это всё равно изменения в коде, которые могут в итоге привести к несовместимости, пусть и ненамеренно.
|
||||
|
||||
Стоит также отметить, что далеко не все поставщики API относятся к поддержанию обратной совместимости, да и вообще к качеству своего ПО, так же серьёзно, как и (надеемся) вы. Стоит быть готовым к тому, что заниматься поддержанием вашего API в рабочем состоянии, то есть написанием и поддержкой фасадов к меняющемуся ландшафту предметной области, придётся именно вам, и зачастую довольно внезапно.
|
||||
|
||||
@ -75,7 +75,7 @@
|
||||
#### Политика обратной совместимости
|
||||
|
||||
Итого, если суммировать:
|
||||
* вследствие итерационного развития приложений, платформ и предметной области вы будете вынуждены выпускать новые версии вашего API, рано или поздно; следует отметить, что в разных областях скорость развития разная, но никогда не нулевая;
|
||||
* вследствие итерационного развития приложений, платформ и предметной области вы будете вынуждены выпускать новые версии вашего API; в разных предметных областях скорость развития разная, но почти никогда не нулевая;
|
||||
* вкупе это приведёт к фрагментации используемой версии API по приложениям и платформам;
|
||||
* вам придётся принимать решения, критически влияющие на надёжность вашего API в глазах потребителей.
|
||||
|
||||
@ -87,7 +87,7 @@
|
||||
|
||||
2. Какое количество *мажорных* версий поддерживать одновременно.
|
||||
|
||||
Что касается мажорных версий, то *теоретический* ответ мы дали выше: в идеальной ситуации жизненный цикл мажорной версии должен быть чуть длиннее жизненного цикла платформы. Для стабильных ниш типа десктопных операционных систем это порядка 5-10 лет, для новых и динамически развивающихся — меньше, но всё равно несколько лет. *Практически* следует смотреть на долю потребителей, реально продолжающих пользоваться версией.
|
||||
Что касается мажорных версий, то *теоретический* ответ мы дали выше: в идеальной ситуации жизненный цикл мажорной версии должен быть чуть длиннее жизненного цикла платформы. Для стабильных ниш типа десктопных операционных систем это порядка 5-10 лет, для новых и динамически развивающихся — меньше, но всё равно измеряется в годах. *Практически* следует смотреть на долю потребителей, реально продолжающих пользоваться версией.
|
||||
|
||||
3. Какое количество *минорных* версий (в рамках одной мажорной) поддерживать одновременно.
|
||||
|
||||
@ -96,5 +96,5 @@
|
||||
* если вы предоставляете только серверное API и компилируемые SDK, вы можете в принципе не поддерживать никакие минорные версии API, помимо актуальной: серверное API находится полностью под вашим контролем, и вы можете оперативно исправить любые проблемы с логикой;
|
||||
* если вы предоставляете code-on-demand SDK, то вот здесь хорошим тоном является поддержка предыдущих минорных версий SDK в работающем состоянии на срок, достаточный для того, чтобы разработчики могли протестировать своё приложение с новой версией и внести какие-то правки по необходимости. Так как полностью переписывать приложения при этом не надо, разумно ориентироваться на длину релизных циклов в вашей индустрии, обычно это несколько месяцев в худшем случае.
|
||||
|
||||
Более подробно мы рассмотрим эти вопросы в следующих главах. Дополнительно в разделе III мы также обсудим, каким образом предупреждать потребителей о выходе новых версий и прекращении поддержки старых, и как стимулировать их переходить на новые версии API.
|
||||
Дополнительно в разделе III мы также обсудим, каким образом предупреждать потребителей о выходе новых версий и прекращении поддержки старых, и как стимулировать их переходить на новые версии API.
|
||||
|
@ -2,17 +2,17 @@
|
||||
|
||||
Прежде, чем начинать разговор о принципах проектирования расширяемого API, следует обсудить гигиенический минимум. Огромное количество проблем не случилось бы, если бы разработчики API чуть ответственнее подходили к обозначению зоны своей ответственности.
|
||||
|
||||
##### 1. Предоставляйте минимальный объём функциональности
|
||||
##### Предоставляйте минимальный объём функциональности
|
||||
|
||||
В любой момент времени ваше API подобно айсбергу: у него есть видимая (документированная) часть и невидимая — недокументированная. В хорошем API эти две части соотносятся друг с другом примерно как надводная и подводная часть настоящего айсберга, 1 к 10. Почему так? Из двух очевидных соображений.
|
||||
|
||||
* Компьютеры существуют, чтобы сложные вещи делать просто, не наоборот. Код, который напишут разработчики поверх вашего API, должен в простых и лаконичных выражениях описывать решение сложной проблемы. Если разработчики вынуждены писать больше кода на вашем API, чем вы написали сами — что-то здесь пошло не так. Возможно, такой API просто не нужен.
|
||||
* Компьютеры существуют, чтобы сложные вещи делать просто, не наоборот. Код, который напишут разработчики поверх вашего API, должен в простых и лаконичных выражениях описывать решение сложной проблемы. Поэтому «внутри» ваш код, скорее всего, будет опираться на мощную номенклатуру непубличной функциональности.
|
||||
|
||||
* Изъятие функциональности из API невозможно без серьёзных потерь. Если вы пообещали предоставлять какую-то функциональность — вам теперь придётся предоставлять её «вечно» (до окончания поддержки этой мажорной версии API). Объявление функциональности неподдерживаемой — очень сложный и чреватый потенциальными конфликтами с потребителем процесс.
|
||||
|
||||
Правило №1 самое простое: если какую-то функциональность можно не выставлять наружу — значит, выставлять её не надо. Можно сформулировать и так: каждая сущность, каждое поле, каждый метод в публичном API — это *продуктовое* решение. Должны существовать веские *продуктовые* причины, по которым та или иная сущность документирована.
|
||||
|
||||
##### 2. Избегайте серых зон и недосказанности
|
||||
##### Избегайте серых зон и недосказанности
|
||||
|
||||
Ваши обязательства по поддержанию функциональности должны быть оговорены настолько четко, насколько это возможно. Особенно это касается тех сред и платформ, где нет способа нативно ограничить доступ к недокументированной функциональности. К сожалению, разработчики часто считают, что, если они «нашли» какую-то непубличную особенность, то они могут ей пользоваться — а производитель API, соответственно, обязан её поддерживать. Поэтому политика компании относительно таких «находок» должна быть явно сформулирована. Тогда в случае несанкционированного использования скрытой функциональности вы по крайней мере сможете сослаться на документацию и быть формально правы в глазах комьюнити.
|
||||
|
||||
@ -23,9 +23,9 @@
|
||||
|
||||
Нельзя принять обязательства наполовину. Или вы гарантируете работу этого кода всегда, или не подавайте никаких намеков на то, что такая функциональность существует.
|
||||
|
||||
##### 3. Фиксируйте неявные договорённости
|
||||
##### Фиксируйте неявные договорённости
|
||||
|
||||
Третий принцип гораздо менее явный. Посмотрите внимательно на код, который предлагаете написать разработчикам: нет ли в нём каких-то условностей, которые считаются очевидными, но нигде не зафиксированы при этом?
|
||||
Посмотрите внимательно на код, который предлагаете написать разработчикам: нет ли в нём каких-то условностей, которые считаются очевидными, но при этом нигде не зафиксированы?
|
||||
|
||||
**Пример 1**. Рассмотрим SDK работы с заказами.
|
||||
```
|
||||
@ -35,7 +35,7 @@ let order = api.createOrder();
|
||||
let status = api.getStatus(order.id);
|
||||
```
|
||||
|
||||
Предположим, что в какой-то момент при масштабировании вашего сервиса вы пришли к асинхронной репликации базы данных и разрешили чтение из реплики. Это приведёт к тому, что после создания заказа следующее обращение к его статусу по id может вернуть `404`, если пришлось на асинхронную реплику, до которой ещё не дошли последние изменения из мастера. Фактически, вы сменили политику консистентности со strong на eventual.
|
||||
Предположим, что в какой-то момент при масштабировании вашего сервиса вы пришли к асинхронной репликации базы данных и разрешили чтение из реплики. Это приведёт к тому, что после создания заказа следующее обращение к его статусу по id может вернуть `404`, если пришлось на асинхронную реплику, до которой ещё не дошли последние изменения из мастера. Фактически, вы сменили [политику консистентности](https://en.wikipedia.org/wiki/Consistency_model) со strong на eventual.
|
||||
|
||||
К чему это приведёт? К тому, что код выше перестанет работать. Разработчик создал заказ, пытается получить его статус — и получает ошибку. Очень тяжело предсказать, какую реакцию на эту ошибку предусмотрят разработчики — вероятнее всего, никакую.
|
||||
|
||||
@ -91,7 +91,7 @@ object.observe('widthchange', observerFunction);
|
||||
|
||||
Возникает вопрос: с какой частотой и в каких точках будет вызываться `observerFunction`? Допустим, в первой версии SDK вы эмулировали анимацию пошагово с частотой 10 кадров в секунду — тогда observerFunction будет вызвана 10 раз и получит значения '140px', '180px' и т.д. вплоть до '500px'. Но затем в новой версии API вы решили воспользоваться системными функциями для обеих операций — и теперь вы попросту не знаете, когда и с какой частотой будет вызвана `observerFunction`.
|
||||
|
||||
Даже просто изменение частоты вызовов вполне может сделать чей-то код неработающим — например, если обработчик выполняет на каждом шаге тяжелые вычисления, и разработчик не предусмотрел никакого ограничения частоты выполнения, полагаясь на то, что ваш SDK вызывает его обработчик всего лишь 10 раз в секунду. А вот если, например, `observerFunction` перестанет вызываться с финальным значением '500px' вследствие каких-то особенностей системных алгоритмов — чей-то код вы сломаете абсолютно точно.
|
||||
Даже просто изменение частоты вызовов вполне может сделать чей-то код неработающим — например, если обработчик выполняет на каждом шаге тяжелые вычисления, и разработчик не предусмотрел никакого ограничения частоты выполнения, полагаясь на то, что ваш SDK вызывает его обработчик всего лишь 10 раз в секунду. А вот если, например, `observerFunction` перестанет вызываться с финальным значением `'500px'` вследствие каких-то особенностей системных алгоритмов — чей-то код вы сломаете абсолютно точно.
|
||||
|
||||
В данном случае следует задокументировать конкретный контракт — как и когда вызывается callback — и придерживаться его даже при смене нижележащей технологии.
|
||||
|
||||
@ -121,18 +121,18 @@ GET /v1/orders/{id}/events/history
|
||||
}
|
||||
```
|
||||
|
||||
Допустим, в какой-то момент вы решили надёжным клиентам с хорошей историей заказов предоставлять кофе «в кредит», не дожидаясь подтверждения платежа. Т.е. заказ перейдёт в статус "preparing_started", а может и "ready", вообще без события "payment_approved". Вам может показаться, что это изменение является обратно-совместимым — в самом деле, вы же и не обещали никакого конкретного порядка событий. Но это, конечно, не так.
|
||||
Допустим, в какой-то момент вы решили надёжным клиентам с хорошей историей заказов предоставлять кофе «в кредит», не дожидаясь подтверждения платежа. Т.е. заказ перейдёт в статус `"preparing_started"`, а может и `"ready"`, вообще без события `"payment_approved"`. Вам может показаться, что это изменение является обратно-совместимым — в самом деле, вы же и не обещали никакого конкретного порядка событий. Но это, конечно, не так.
|
||||
|
||||
Предположим, что у разработчика (вероятно, бизнес-партнера вашей компании) написан какой-то код, выполняющий какую-то полезную бизнес функцию поверх этих событий — например, строит аналитику по затратам и доходам. Вполне логично ожидать, что этот код будет оперировать какой-то машиной состояний, которая будет переходить в то или иное состояние в зависимости от получения или неполучения события. Аналитический код наверняка сломается вследствие изменения порядка событий. В лучшем случае разработчик увидит какие-то исключения и будет вынужден разбираться с причиной; в худшем случае партнер будет оперировать неправильной статистикой неопределённое время, пока не найдёт в ней ошибку.
|
||||
|
||||
Правильным решением было бы во-первых, изначально задокументировать порядок событий и допустимые состояния; во-вторых, продолжать генерировать событие "payment_approved" перед "preparing_started" (если вы приняли решение исполнять такой заказ — значит, по сути, подтвердили платёж) и добавить расширенную информацию о платеже.
|
||||
Правильным решением было бы во-первых, изначально задокументировать порядок событий и допустимые состояния; во-вторых, продолжать генерировать событие `"payment_approved"` перед `"preparing_started"` (если вы приняли решение исполнять такой заказ — значит, по сути, подтвердили платёж) и добавить расширенную информацию о платеже.
|
||||
|
||||
Этот пример подводит нас к ещё к одному правилу.
|
||||
|
||||
##### 4. Продуктовая логика тоже должна быть обратно совместимой
|
||||
##### Продуктовая логика тоже должна быть обратно совместимой
|
||||
|
||||
Такие критичные вещи, как граф переходов между статусами, порядок событий и возможные причины тех или иных изменений — должны быть документированы. Далеко не все детали бизнес-логики можно выразить в форме контрактов на эндпойнты, а некоторые вещи нельзя выразить вовсе.
|
||||
|
||||
Представьте, что в один прекрасный день вы заводите специальный номер телефона, по которому клиент может позвонить в колл-центр и отменить заказ. Вы даже можете сделать это *технически* обратно-совместимым образом, добавив новых необязательных полей в сущность «заказ». Но конечный потребитель может просто *знать* нужный номер телефона, и позвонить по нему, даже если приложение его не показало. При этом код бизнес-аналитика партнера всё так же может сломаться или начать показывать погоду на Марсе, т.к. он был написан когда-то, ничего не зная о возможности отменить заказ, сделанный в приложении партнера, каким-то иным образом, не через самого партнёра же.
|
||||
|
||||
*Технически* корректным решением в данной ситуации могло бы быть добавление параметра «разрешено отменять через колл-центр» в функцию создания заказа — и, соответственно, запрет операторам колл-центра отменять заказы, если флаг не был указан при их создании. Но это в свою очередь плохое решение *с точки зрения продукта*. «Хорошее» решение здесь только одно — изначально предусмотреть возможность внешних отмен в API; если же вы её не предвидели — остаётся воспользоваться «блокнотом душевного спокойствия», речь о котором пойдёт в разделе III.
|
||||
*Технически* корректным решением в данной ситуации могло бы быть добавление параметра «разрешено отменять через колл-центр» в функцию создания заказа — и, соответственно, запрет операторам колл-центра отменять заказы, если флаг не был указан при их создании. Но это в свою очередь плохое решение *с точки зрения продукта*. «Хорошее» решение здесь только одно — изначально предусмотреть возможность внешних отмен в API; если же вы её не предвидели — остаётся воспользоваться «блокнотом душевного спокойствия», речь о котором пойдёт в последней главе настоящего раздела.
|
@ -1,33 +1,26 @@
|
||||
### Расширение через абстрагирование
|
||||
|
||||
В предыдущих разделах мы старались приводить теоретические правила и принципы, и иллюстрировать их на практических примерах. Однако понимание принципов проектирования API, устойчивого к изменениям, как ничто другое требует прежде всего практики. Знание о том, куда стоит «постелить соломку» — оно во многом «сын ошибок трудных». Нельзя предусмотреть всего — но можно выработать необходимый уровень технической интуиции.
|
||||
В предыдущих разделах мы старались приводить теоретические правила и иллюстрировать их на практических примерах. Однако понимание принципов проектирования API, устойчивого к изменениям, как ничто другое требует прежде всего практики. Знание о том, куда стоит «постелить соломку» — оно во многом «сын ошибок трудных». Нельзя предусмотреть всего — но можно выработать необходимый уровень технической интуиции.
|
||||
|
||||
Поэтому в этом разделе мы поступим следующим образом: возьмём наше модельное API из предыдущего раздела, и проверим его на устойчивость в каждой возможной точке — проведём некоторый «вариационный анализ» наших интерфейсов. Ещё более конкретно — к каждой сущности мы подойдём с вопросом «что, если?» — что, если нам потребуется предоставить партнерам возможность написать свою независимую реализацию этого фрагмента логики.
|
||||
|
||||
Первый важный момент, на который стоит обратить внимание: мы говорим именно о вариантах реализации _продуктовой логики_. Не о _вариантах реализации сущности_: изменения в API вносятся прежде всего для того, чтобы можно было сделать что-то, не предусмотренное изначальным дизайном — что-то полезное. Заниматься реимплементацией интерфейсов просто так ваши потребители не будут.
|
||||
|
||||
Это соображение вносит определённые ограничения, которые позволяют нам не заниматься варьированием интерфейсов вслепую (в конце концов, таких вариантов бесконечное количество, и предусматривать их все — сизифов труд): нам нужно понять в первую очередь _зачем_ нужны те или иные изменения, и отсюда мы уже поймём _как_ их следует внести.
|
||||
|
||||
Второй важный момент состоит в том, что многие решения, допускающие эту вариативность, _уже заложены_ в дизайне API. Какие-то из них (например, вопрос определения готовности) мы осветили в предыдущих главах подробнее, а какие-то дали без комментариев — настало время объяснить, почему эти решения были приняты.
|
||||
|
||||
**NB**: в рассматриваемых нами примерах мы будем выстраивать интерфейсы так, чтобы связывание разных сущностей происходило динамически в реальном времени; на практике такие интеграции будут делаться на стороне сервера путём написания ad hoc кода и формирования конкретных договорённостей с конкретным клиентом, однако мы для целей обучения специально будем идти более сложным и абстрактным путём. Динамическое связывание в реальном времени применимо скорее к сложным программным конструктам типа API операционных систем или встраиваемых библиотек; приводить обучающие примеры на основе систем подобной сложности было бы, однако, чересчур затруднительно.
|
||||
|
||||
Начнём с базового интерфейса. Предположим, что мы пока что вообще не раскрывали никакой функциональности помимо поиска предложений и заказа, т.е. мы предоставляем API из двух методов — `POST /offers/search` и `POST /orders`.
|
||||
|
||||
Сделаем следующий логический шаг и предположим, что партнёры захотят динамически подключать к нашей платформе свои собственные кофе машины с каким-то новым API. Для этого нам будет необходимо предоставить им три новых эндпойта, посредством которых партнер сможет:
|
||||
* завести в системе словарь новых типов API;
|
||||
* завести в системе список своих кофе-машин с указанием типа;
|
||||
* договориться о формате обратного вызова, каким образом мы будем вызывать API партнёра.
|
||||
Сделаем следующий логический шаг и предположим, что партнёры захотят динамически подключать к нашей платформе свои собственные кофе машины с каким-то новым API. Для этого нам будет необходимо договориться о формате обратного вызова, каким образом мы будем вызывать API партнёра, и предоставить два новых эндпойта для:
|
||||
* регистрации в системе новых типов API;
|
||||
* загрузки списка кофе-машин партнёра с указанием типа API.
|
||||
|
||||
Например, можно предоставить вот такие методы:
|
||||
Например, можно предоставить вот такие методы.
|
||||
|
||||
```
|
||||
// 1. Зарегистрировать новый тип API
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
"order_execution_endpoint": {
|
||||
// Описание функции обратного вызова
|
||||
}
|
||||
"order_execution_endpoint": {
|
||||
// Описание функции обратного вызова
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@ -36,57 +29,60 @@ PUT /v1/api-types/{api_type}
|
||||
// по типу API
|
||||
PUT /v1/partners/{partnerId}/coffee-machines
|
||||
{
|
||||
"coffee_machines": [{
|
||||
"api_type",
|
||||
"location",
|
||||
"supported_recipes"
|
||||
}, …]
|
||||
"coffee_machines": [{
|
||||
"id",
|
||||
"api_type",
|
||||
"location",
|
||||
"supported_recipes"
|
||||
}, …]
|
||||
}
|
||||
```
|
||||
|
||||
Таким образом механика следующая:
|
||||
* партнер описывает свои виды API, кофе-машины и поддерживаемые рецепты;
|
||||
* при получении заказа, который необходимо выполнить на конкретной кофе машине, наш сервер вызывает указанную функцию обратного запуска и передаёт ей параметры в заранее согласованном виде.
|
||||
* при получении заказа, который необходимо выполнить на конкретной кофе машине, наш сервер обратится к функции обратного вызова, передав ей данные о заказе в оговоренном формате.
|
||||
|
||||
Теперь партнёры могут динамически подключать свои кофе-машины и обрабатывать заказы. Займёмся теперь, однако, вот каким упражнением:
|
||||
1. Перечислим все неявные предположения, которые мы допустили.
|
||||
2. Перечислим все неявные механизмы связывания, которые необходимы для функционирования платформы.
|
||||
* перечислим все неявные предположения, которые мы допустили;
|
||||
* перечислим все неявные механизмы связывания, которые необходимы для функционирования платформы.
|
||||
|
||||
Может показаться, что в нашем API нет ни того, ни другого, ведь оно очень просто и по сути просто сводится к вызову какого-то HTTP-метода — но это неправда. Список таких неявностей довольно велик.
|
||||
1. Информации, которую мы передаём на указанный партнёром эндпойнт, достаточно для запуска программы на исполнение.
|
||||
2. Предполагается, что каждая кофе-машина поддерживает все возможные опции заказа (например, допустимый объём напитка).
|
||||
3. Кофе-машины партнёра не требуется как-то специально выделять в списке результатов поиска, они имеют точно такую же карточку результата, как и предложения других партнеров, и ранжируются на общих основаниях.
|
||||
4. Цена напитка не зависит ни от партнёра, ни от типа кофе-машины.
|
||||
Может показаться, что в нашем API нет ни того, ни другого, ведь оно очень просто и по сути просто сводится к вызову какого-то HTTP-метода — но это неправда.
|
||||
1. Предполагается, что каждая кофе-машина поддерживает все возможные опции заказа (например, допустимый объём напитка).
|
||||
2. Нет необходимости показывать пользователю какую-то дополнительную информацию о том, что заказ готовится на новых типах кофе-машин.
|
||||
3. Цена напитка не зависит ни от партнёра, ни от типа кофе-машины.
|
||||
|
||||
Все эти пункты мы выписали с одной целью: нам нужно понять, каким конкретно образом мы будем переводить неявные договорённости в явные, если нам это потребуется. Например, если разные кофе-машины предоставляют разный объём функциональности — допустим, в каких-то кофейнях объём кофе фиксирован — что должно измениться в нашем API?
|
||||
Эти пункты мы выписали с одной целью: нам нужно понять, каким конкретно образом мы будем переводить неявные договорённости в явные, если нам это потребуется. Например, если разные кофе-машины предоставляют разный объём функциональности — допустим, в каких-то кофейнях объём кофе фиксирован — что должно измениться в нашем API?
|
||||
|
||||
Универсальный паттерн внесения подобных изменений таков: мы должны рассмотреть существующий интерфейс как частный случай некоторого более общего, в котором значения некоторых параметров приняты известными по умолчанию, а потому опущены. Таким образом, внесение изменений всегда происходит в три шага:
|
||||
Универсальный паттерн внесения подобных изменений таков: мы должны рассмотреть существующий интерфейс как частный случай некоторого более общего, в котором значения некоторых параметров приняты известными по умолчанию, а потому опущены. Таким образом, внесение изменений всегда происходит в три шага.
|
||||
1. Явная фиксация программного контракта *в том объёме, в котором она действует на текущий момент*.
|
||||
2. Расширение функциональности: добавление нового метода, которые позволяют обойти ограничение, зафиксированное в п. 1.
|
||||
3. Объявление существующих вызовов (из п. 1) "хелперами" к новому формату (из п. 2), в которых значение новых опций считается равным значению по умолчанию.
|
||||
|
||||
На нашем примере с изменением списка доступных опций заказа:
|
||||
На нашем примере с изменением списка доступных опций заказа мы должны поступить следующим образом.
|
||||
|
||||
1. Документируется текущее состояние. Все кофе-машины, подключаемые по API, обязаны поддерживать три опции: посыпку корицей, изменение объёма и бесконтактную выдачу.
|
||||
1. Документируем текущее состояние. Все кофе-машины, подключаемые по API, обязаны поддерживать три опции: посыпку корицей, изменение объёма и бесконтактную выдачу.
|
||||
|
||||
2. Добавляем новый метод `with-options`:
|
||||
```
|
||||
PUT /v1/partners/{partnerId}/coffee-machines-with-options
|
||||
{
|
||||
"coffee_machines": [{
|
||||
"api_type",
|
||||
"location",
|
||||
"supported_recipes",
|
||||
"supported_options": [
|
||||
{"type": "volume_change"}
|
||||
]
|
||||
}, …]
|
||||
"coffee_machines": [{
|
||||
"id",
|
||||
"api_type",
|
||||
"location",
|
||||
"supported_recipes",
|
||||
"supported_options": [
|
||||
{"type": "volume_change"}
|
||||
]
|
||||
}, …]
|
||||
}
|
||||
```
|
||||
|
||||
3. Объявляем, что вызов `PUT /coffee-machines`, как он представлен сейчас в протоколе, эквивалентен вызову `PUT /coffee-machines-with-options`, если в последний передать три опции — посыпку корицей, изменение объёма и бесконтактную выдачу, и, таким образом, является частным случаем — хелпером к более общему вызову.
|
||||
3. Объявляем, что вызов `PUT /coffee-machines`, как он представлен сейчас в протоколе, эквивалентен вызову `PUT /coffee-machines-with-options`, если в последний передать три опции — посыпку корицей, изменение объёма и бесконтактную выдачу, — и, таким образом, является частным случаем — хелпером к более общему вызову.
|
||||
|
||||
Часто вместо добавления нового метода можно добавить просто необязательный параметр к существующему — в нашем случае, можно добавить необязательный параметр `options` к вызову `PUT /cofee-machines`.
|
||||
Часто вместо добавления нового метода можно добавить просто необязательный параметр к существующему интерфейсу — в нашем случае, можно добавить необязательный параметр `options` к вызову `PUT /cofee-machines`.
|
||||
|
||||
**NB**. Когда мы говорим о фиксации договоренностей, действующих в настоящий момент — речь идёт о *внутренних* договорённостях. Мы должны были потребовать от партнеров поддерживать указанный список опций, когда обговаривали формат взаимодействия. Если же мы этого не сделали изначально, а потом решили зафиксировать договорённости в ходе расширения функциональности внешнего API — это очень серьёзная заявка на нарушение обратной совместимости, и так делать ни в коем случае не надо, см. [главу 14](#chapter-14).
|
||||
|
||||
#### Границы применимости
|
||||
|
||||
@ -94,6 +90,6 @@ PUT /v1/partners/{partnerId}/coffee-machines
|
||||
|
||||
Увы, здесь мы сталкиваемся с плохо разрешимым противоречием: мы хотим, с одной стороны, чтобы разработчик писал лаконичный код, следовательно, должны предоставлять хорошие хелперные методы и значения по умолчанию. С другой, знать наперёд какими будут самые частотные наборы опций через несколько лет развития API — очень сложно.
|
||||
|
||||
**NB**. Замаскировать эту проблему можно так: в какой-то момент собрать все эти «странности» в одном месте и переопределить все значения по умолчанию скопом под одним параметром. Условно говоря, вызов одного метода, например, `PUT /defaults {"version": "v2"}` переопределяет все значения по умолчанию на более разумные. Это упростит порог входа и уменьшит количество вопросов, но документация от этого станет выглядеть только хуже.
|
||||
**NB**. Замаскировать эту проблему можно так: в какой-то момент собрать все эти «странности» в одном месте и переопределить все значения по умолчанию скопом под одним параметром. Условно говоря, вызов одного метода, например, `POST /use-defaults {"version": "v2"}` переопределяет все значения по умолчанию на более разумные. Это упростит порог входа и уменьшит количество вопросов, но документация от этого станет выглядеть только хуже.
|
||||
|
||||
В реальной жизни как-то нивелировать проблему помогает лишь слабая связность объектов, речь о которой пойдёт в следующей главе.
|
@ -50,11 +50,11 @@ POST /v1/recipes
|
||||
|
||||
Эта проблема разворачивается и в другую сторону — UI (наш или партнера) обязательно будет развиваться, в нём будут появляться новые элементы (картинка для кофе, его пищевая ценность, информация об аллергенах и так далее). `product_properties` со временем превратится в свалку из большого количества необязательных полей, и выяснить, задание каких из них приведёт к каким эффектам в каком приложении можно будет только методом проб и ошибок.
|
||||
|
||||
Проблемы, с которыми мы столкнулись — это проблемы *сильной связности*. Каждый раз, предлагая интерфейс, подобный вышеприведённому, мы фактически описываем имплементацию одной сущности (рецепта) через имплементации других (визуального макета, правил локализации). Этот подход противоречит самому принципу проектирования API «сверху вниз», поскольку **низкоуровневые сущности не должны определять высокоуровневые**. Как бы парадоксально это ни звучало, обратное утверждение тоже верно: высокоуровневые сущности тоже не должны определять низкоуровневые. Это попросту не их ответственность.
|
||||
Проблемы, с которыми мы столкнулись — это проблемы *сильной связности*. Каждый раз, предлагая интерфейс, подобный вышеприведённому, мы фактически описываем имплементацию одной сущности (рецепта) через имплементации других (визуального макета, правил локализации). Этот подход противоречит самому принципу проектирования API «сверху вниз», поскольку **низкоуровневые сущности не должны определять высокоуровневые**.
|
||||
|
||||
#### Правило контекстов
|
||||
|
||||
Выход из этого логического лабиринта таков: высокоуровневые сущности должны *определять контекст*, который другие объекты будут интерпретировать. Чтобы спроектировать добавление нового рецепта нам нужно не формат данных подобрать — нам нужно понять, какие (возможно, неявные, т.е. не представленные в виде API) контексты существуют в нашей предметной области.
|
||||
Как бы парадоксально это ни звучало, обратное утверждение тоже верно: высокоуровневые сущности тоже не должны определять низкоуровневые. Это попросту не их ответственность. Выход из этого логического лабиринта таков: высокоуровневые сущности должны *определять контекст*, который другие объекты будут интерпретировать. Чтобы спроектировать добавление нового рецепта нам нужно не формат данных подобрать — нам нужно понять, какие (возможно, неявные, т.е. не представленные в виде API) контексты существуют в нашей предметной области.
|
||||
|
||||
Как уже понятно, существует контекст локализации. Есть какой-то набор языков и регионов, которые мы поддерживаем в нашем API, и есть требования — что конкретно необходимо предоставить партнёру, чтобы API заработало на новом языке в новом регионе. Конкретно в случае объёма кофе где-то в недрах нашего API есть функция форматирования строк для отображения объёма напитка:
|
||||
|
@ -20,7 +20,7 @@ GET /v1/recipes/{id}/run-data/{api_type}
|
||||
|
||||
Аналогичным образом устроена и сущность `program_run_id`, идентификатор запуска программы. Он также по сути не имеет почти никакого интерфейса и состоит только из идентификатора запуска.
|
||||
|
||||
Вернёмся теперь к вопросу, который мы вскользь затронули две главы назад — каким образом нам параметризовать приготовление заказа, если оно исполняется через сторонний API. Иными словами, что такое этот самый `program_execution_endpoint`, передавать который мы потребовали при регистрации нового типа API?
|
||||
Вернёмся теперь к вопросу, который мы вскользь затронули в [главе 15](#chapter15) — каким образом нам параметризовать приготовление заказа, если оно исполняется через сторонний API. Иными словами, что такое этот самый `program_execution_endpoint`, передавать который мы потребовали при регистрации нового типа API?
|
||||
|
||||
```
|
||||
PUT /v1/api-types/{api_type}
|
||||
@ -55,6 +55,7 @@ PUT /v1/api-types/{api_type}
|
||||
**NB**: во многом таким образом мы переносим сложность разработки API в плоскость разработки форматов данных (каким образом мы будем передавать параметры запуска в `program_run_endpoint`, и в каком формате должен отвечать `program_state_endpoint`, но в рамках этой главы мы сфокусируемся на других вопросах.)
|
||||
|
||||
Хотя это API и кажется абсолютно универсальным, на его примере можно легко показать, каким образом изначально простые и понятные API превращаются в сложные и запутанные. У этого дизайна есть две основные проблемы.
|
||||
|
||||
1. Он хорошо описывает уже реализованные нами интеграции (т.е. в эту схему легко добавить поддержку известных нам типов API), но не привносит никакой гибкости в подход: по сути мы описали только известные нам способы интеграции, не попытавшись взглянуть на более общую картину.
|
||||
2. Этот дизайн изначально основан на следующем принципе: любое приготовление заказа можно описать этими тремя императивными командами.
|
||||
|
||||
@ -124,7 +125,7 @@ registerProgramRunHandler(apiType, (context) => {
|
||||
|
||||
Важно также отметить, что, хотя количество сущностей (полей, событий) эффективно удваивается по сравнению с сильно связанным API, это удвоение является качественным, а не количественным. Контекст `program` содержит описание задания в своих терминах (вид напитка, объём, посыпка корицей); контекст `execution` должен эти термины переформулировать для своей предметной области (чтобы быть, в свою очередь, таким же информационным контекстом для ещё более низкоуровневого API). Что важно, `execution`-контекст имеет право эти термины конкретизировать, поскольку его нижележащие объекты будут уже работать в рамках какого-то конкретного API, в то время как `program`-контекст обязан выражаться в общих терминах, применимых к любой возможной нижележащей технологии.
|
||||
|
||||
Ещё одним важным свойством такой событийной связности является то, что она позволяет сущности иметь несколько родительских контекстов. В обычных предметных областях такая ситуация выглядела бы ошибкой дизайна API, но в сложных системах, где присутствуют одновременно несколько агентов, влияющих на состояние системы, такая ситуация не является редкостью. В частности, вы почти наверняка столкнётесь с такого рода проблемами при разработке пользовательского UI. Более подробно о подобных двойных иерархиях мы расскажем в разделе, посвященном разработке SDK.
|
||||
Ещё одним важным свойством слабой связности является то, что она позволяет сущности иметь несколько родительских контекстов. В обычных предметных областях такая ситуация выглядела бы ошибкой дизайна API, но в сложных системах, где присутствуют одновременно несколько агентов, влияющих на состояние системы, такая ситуация не является редкостью. В частности, вы почти наверняка столкнётесь с такого рода проблемами при разработке пользовательского UI. Более подробно о подобных двойных иерархиях мы расскажем в разделе, посвященном разработке SDK.
|
||||
|
||||
#### Инверсия ответственности
|
||||
|
||||
@ -204,3 +205,52 @@ ProgramContext.dispatch = (action) => {
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### Проверим себя
|
||||
|
||||
Описав указанным выше образом взаимодействие со сторонними API, мы можем (и должны) теперь рассмотреть вопрос, совместимы ли эти интерфейсы с нашими собственными абстракциями, которые мы разработали в [главе 9](#chapter-9); иными словами, можно ли запустить исполнение такого заказа, оперируя не высокоуровневым, а низкоуровневым 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`, мы быстро придём к ситуации противоречивой номенклатуры методов и протоколов взаимодействия, бо́льшая часть которых в рамках конкретного «железа» не имеет смысла.
|
||||
|
||||
Напротив, применяя парадигму конкретизации контекста на каждом новом уровне абстракции мы рано или поздно спустимся вниз по кроличьей норе достаточно глубоко, чтобы конкретизировать было уже нечего: контекст однозначно соотносится с функциональностью, доступной для программного управления. И вот на этом уровне мы должны отказаться от дальнейшей детализации и непосредственно реализовать нужные алгоритмы. Важно отметить, что глубина абстрагирования будет различной для различных нижележащих платформ.
|
||||
|
||||
**NB**. В рамках [главы 9](#chapter-9) мы именно этот принцип и проиллюстрировали: в рамках API кофе-машин первого типа нет нужды продолжать растить дерево абстракций, можно ограничиться запуском программ; в рамках API второго типа требуется дополнительный промежуточный контекст в виде рантаймов.
|
@ -10,16 +10,21 @@
|
||||
|
||||
Остаётся, однако, неотвеченным вопрос о том, как изначально выстроить номенклатуру сущностей таким образом, чтобы расширение API не превращало её в мешанину из различных неконсистентных методов разных эпох. Впрочем, ответ на него довольно очевиден: чтобы при абстрагировании не возникало неловких ситуаций, подобно рассмотренному нами примеру с поддерживаемыми кофе-машиной опциями, все сущности необходимо *изначально* рассматривать как частную реализацию некоторого более общего интерфейса, даже если никаких альтернативных реализаций в настоящий момент не предвидится.
|
||||
|
||||
**NB**: мы понимаем, что вносим некоторую путаницу, поскольку термин «интерфейс» также используется для обозначения совокупности свойств и методов сущности, да и вообще отвечает за букву «I» в самой аббревиатуре «API»; однако использование других терминов внесёт ещё больше путаницы. Мы могли бы оперировать выражениями «абстрактные типы данных», но это методологически неверно: разработка API, как правило, подразумевают независимость имплементации клиента и сервера, так что никаких «неабстрактных» типов данных в них не существует. Термины типа «виртуальный класс» и «виртуальное наследование» неприменимы по той же причине. Мы могли бы использовать «фасад», но под «фасадом» обычно понимают всё-таки конкретную имплементацию, а не абстракцию. Ближе всего по смыслу подходят «концепты» в том смысле, который вкладывается в них в STL[[ref B. Stroustrup, A. Sutton. A Concept Design for the STL, p. 38]](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2012/n3351.pdf), но термин «интерфейс» нам кажется более понятным.
|
||||
|
||||
Например, разрабатывая API эндпойнта `POST /search` мы должны были задать себе вопрос: а «результат поиска» — это абстракция над каким интерфейсом? Для этого нам нужно аккуратно декомпозировать эту сущность, чтобы понять, каким своим срезом она выступает во взаимодействии с каким объектами.
|
||||
|
||||
Тогда мы придём к пониманию, что результат поиска — это, на самом деле, композиция двух интерфейсов:
|
||||
* при создании заказа из всего результата поиска необходимы поля, описывающие собственно заказ; это может быть структура `{coffee_machine_id, recipe_id, volume, currency_code, price}`, либо мы можем закодировать все эти данные в одном `offer_id`;
|
||||
* при создании заказа из всего результата поиска необходимы поля, описывающие собственно заказ; это может быть структура вида:
|
||||
|
||||
`{coffee_machine_id, recipe_id, volume, currency_code, price}`,
|
||||
|
||||
либо мы можем закодировать все эти данные в одном `offer_id`;
|
||||
|
||||
* при отображении результата поиска в приложении нам важны другие поля — `name`, `description`, а также отформатированная и локализованная цена.
|
||||
|
||||
Таким образом, наш интерфейс (назовём его `ISearchResult`) — это композиция двух других интерфейсов: `IOrderParameters` (сущности, позволяющей сделать заказ) и `ISearchItemViewParameters` (некоторого абстрактного представления результатов поиска в UI). Подобное разделение должно автоматически подводить нас к ряду вопросов.
|
||||
1. Каким образом мы будем связывать одно с другим? Очевидно, что эти два суб-интерфейса зависимы: например, отформатированная человекочитаемая цена должна совпадать с машиночитаемой. Это естественным образом подводит нас к концепции абстрагирования форматирования, описанной в главе «Сильная связность и сопутствующие проблемы»
|
||||
|
||||
1. Каким образом мы будем связывать одно с другим? Очевидно, что эти два суб-интерфейса зависимы: например, отформатированная человекочитаемая цена должна совпадать с машиночитаемой. Это естественным образом подводит нас к концепции абстрагирования форматирования, описанной в [главе 16](#chapter15).
|
||||
|
||||
2. А что такое, в свою очередь, «абстрактное представление результатов поиска в UI»? Есть ли у нас какие-то другие виды поисков, не является ли `ISearchItemViewParameters` сам наследником какого-либо другого интерфейса или композицией других интерфейсов?
|
||||
|
||||
Замена конкретных имплементаций интерфейсами позволяет не только точнее ответить на многие вопросы, которые должны были у вас возникнуть в ходе проектирования API, но и наметить множество возможных векторов развития API, что поможет избежать проблем с неконсистентностью API в ходе дальнейшей эволюции программного продукта.
|
@ -4,25 +4,25 @@
|
||||
|
||||
##### Помните о подводной части айсберга
|
||||
|
||||
То, что вы не давали конкретных гарантий и обязательств, совершенно не означает, что эти неформальные гарантии и обязательства можно нарушать. Зачастую даже исправление багов может нарушить работу чьего-то кода. Можно привести следующий пример из реальной жизни, с которым столкнулся автор этой книги:
|
||||
То, что вы не давали формальных гарантий и обязательств, совершенно не означает, что эти неформальные гарантии и обязательства можно нарушать. Зачастую даже исправление ошибок в API может привести к неработоспособности чьего-то кода. Можно привести следующий пример из реальной жизни, с которым столкнулся автор этой книги:
|
||||
* существовало некоторое API размещения кнопок в визуальном контейнере; по контракту оно принимало позицию размещаемой кнопки (отступы от углов контейнера) в качестве обязательного параметра;
|
||||
* в реализации была допущена ошибка: если позицию не передать, то исключения не происходило — добавленные таким образом кнопки размещались в левом верхнем углу контейнера одна за другой;
|
||||
* в день, когда ошибка была исправлена, в техническую поддержку пришло множество обращений от разработчиков, чей код перестал работать; как оказалось, клиенты использовали эту ошибку для того, чтобы последовательно размещать кнопки в левом верхнем углу контейнера.
|
||||
|
||||
Если исправления ошибок затрагивают реальных потребителей — вам ничего не остаётся кроме как продолжать эмулировать ошибочное поведение но следующего мажорного релиза. При разработке больших API с широким кругом потребителей такие ситуации встречаются сплошь и рядом — например, разработчики API операционных систем буквально вынуждены портировать старые баги в новые версии ОС.
|
||||
Если исправления ошибок затрагивают реальных потребителей — вам ничего не остаётся кроме как продолжать эмулировать ошибочное поведение до следующего мажорного релиза. При разработке больших API с широким кругом потребителей такие ситуации встречаются сплошь и рядом — например, разработчики API операционных систем буквально вынуждены портировать старые баги в новые версии ОС.
|
||||
|
||||
##### Тестируйте формальные интерфейсы
|
||||
|
||||
Любое программное обеспечение должно тестироваться, и API не исключение. Однако здесь есть свои тонкости: поскольку API предоставляет формальные интерфейсы, тестироваться должны именно они. Это приводит к ошибкам нескольких видов.
|
||||
|
||||
1. Часто требования вида «функция `setEntity` возвращает значение, установленное вызовом функции `getEntity`» кажутся и разработчикам, и QA-инженерам самоочевидными и не проверяются. Между тем допустить ошибку в их реализации очень даже возможно, мы встречались с такими случаями на практике.
|
||||
2. Принцип абстрагирования интерфейсов тоже необходимо проверять. В теории вы может быть и рассматриваете каждую сущность как конкретную имплементацию абстрактного интерфейса — но на практике может оказаться, что вы чего-то не учли и ваш абстрактный интерфейс на деле невозможен. Для целей тестирования очень желательно иметь пусть виртуальную, но отличную от базовой реализацию каждого интерфейса.
|
||||
1. Часто требования вида «функция `getEntity` возвращает значение, установленное вызовом функции `setEntity`» кажутся и разработчикам, и QA-инженерам самоочевидными и не проверяются. Между тем допустить ошибку в их реализации очень даже возможно, мы встречались с такими случаями на практике.
|
||||
2. Принцип абстрагирования интерфейсов тоже необходимо проверять. В теории вы может быть и рассматриваете каждую сущность как конкретную имплементацию абстрактного интерфейса — но на практике может оказаться, что вы чего-то не учли и ваш абстрактный интерфейс на деле невозможен. Для целей тестирования очень желательно иметь пусть условную, но отличную от базовой реализацию каждого интерфейса.
|
||||
|
||||
##### Реализуйте функциональность своего API поверх публичных интерфейсов
|
||||
|
||||
Часто можно увидеть антипаттерн: разработчики API используют внутренние непубличные реализации тех или иных методов взамен существующих публичных. Это происходит по двум причинам:
|
||||
Часто можно увидеть антипаттерн: разработчики API используют внутренние непубличные реализации тех или иных методов взамен существующих в их API публичных. Это происходит по двум причинам:
|
||||
* часто публичное API является лишь дополнением к более специализированному внутреннему ПО компании, и наработки, представленные в публичном API, не портируются обратно в непубличную часть проекта, или же разработчики публичного API попросту не знают о существовании аналогичных непубличных функций;
|
||||
* в ходе развития API некоторые интерфейсы абстрагируются, но имплементация уже существующих интерфейсов при этом по разным причинам не затрагивается; например, можно представить себе, что при реализации интерфейса `PUT /formatters`, описанного в главе «Сильная связность и сопутствующие проблемы», разработчики сделали отдельную, более общую, версию функции форматирования объёма для пользовательских языков в API и не переписали функцию форматирования для известных языков поверх неё.
|
||||
* в ходе развития API некоторые интерфейсы абстрагируются, но имплементация уже существующих интерфейсов при этом по разным причинам не затрагивается; например, можно представить себе, что при реализации интерфейса `PUT /formatters`, описанного в [главе 16](#chapter16), разработчики сделали отдельную, более общую, версию функции форматирования объёма для пользовательских языков в API, но не переписали существующую функцию форматирования для известных языков поверх неё.
|
||||
|
||||
Помимо очевидных частных проблем, вытекающих из такого подхода (неконсистентность поведения разных функций в API, не найденные при тестировании ошибки), здесь есть и одна глобальная: легко может оказаться, что вашим API попросту невозможно будет пользоваться, если сделать хоть один «шаг в сторону» — попытка воспользоваться любой нестандартной функциональностью может привести к проблемам производительности, многочисленным ошибкам, нестабильной работе и так далее.
|
||||
|
||||
@ -30,6 +30,6 @@
|
||||
|
||||
##### Заведите блокнот
|
||||
|
||||
Несмотря на всё рассказанное выше в настоящей главе, с большой вероятностью вы *ничего* не сможете сделать с накапливающейся неконсистентностью вашего API. Да, можно замедлить скорость накопления, предусмотреть какие-то проблемы заранее, заложить запасы устойчивости — но предугадать *всё* решительно невозможно. На этом этапе многие разработчики склонные принимать скоропалительные решение — т.е. выпустить новую минорную версию с явным или неявным нарушением обратной совместимости в целях исправления ошибок дизайна.
|
||||
Несмотря на все приёмы и принципы, изложенные в настоящем разделе, с большой вероятностью вы *ничего* не сможете сделать с накапливающейся неконсистентностью вашего API. Да, можно замедлить скорость накопления, предусмотреть какие-то проблемы заранее, заложить запасы устойчивости — но предугадать *всё* решительно невозможно. На этом этапе многие разработчики склонны принимать скоропалительные решения — т.е. выпускать новые минорные версии API с явным или неявным нарушением обратной совместимости в целях исправления ошибок дизайна.
|
||||
|
||||
Так делать мы крайне не рекомендуем — поскольку, напомним, API является помимо прочего и мультипликатором ваших ошибок. Что мы рекомендуем — так это завести блокнот, где вы будете записывать выученные уроки, которые нужно не забыть применить на практике при выпуске новой мажорной версии API.
|
||||
Так делать мы крайне не рекомендуем — поскольку, напомним, API является помимо прочего и мультипликатором ваших ошибок. Что мы рекомендуем — так это завести блокнот душевного покоя, где вы будете записывать выученные уроки, которые потом нужно будет не забыть применить на практике при выпуске новой мажорной версии API.
|
Loading…
x
Reference in New Issue
Block a user