1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-05-31 22:09:37 +02:00

Fresh build

This commit is contained in:
Sergey Konstantinov 2022-06-09 23:40:45 +03:00
parent cce49bd98e
commit 9a04c6770b
6 changed files with 126 additions and 126 deletions

Binary file not shown.

View File

@ -503,7 +503,7 @@ Cache-Control: no-cache
<h4>Why an API?</h4>
<p>Since our book is dedicated not to software development per se, but to developing APIs, we should look at all those questions from a different angle: why solving those problems specifically requires an API, not simply a specialized software application? In terms of our fictional example, we should ask ourselves: why provide a service to developers, allowing for brewing coffee to end users, instead of just making an app?</p>
<p>In other words, there must be a solid reason to split two software development domains: there are the operators which provide APIs, and there are the operators which develop services for end users. Their interests are somehow different to such an extent, that coupling these two roles in one entity is undesirable. We will talk about the motivation to specifically provide APIs in more detail in Section III.</p>
<p>We should also note, that you should try making an API when and only when you wrote ‘because that's our area of expertise’ in question 2. Developing APIs is a sort of meta-engineering: you're writing some software to allow other companies to develop software to solve users' problems. You must possess expertise in both domains (APIs and user products) to design your API well.</p>
<p>We should also note that you should try making an API when, and only when, your answer is "because that's our area of expertise" to question 3. Developing APIs is a sort of meta-engineering: you're writing some software to allow other companies to develop software to solve users' problems. You must possess expertise in both domains (APIs and user products) to design your API well.</p>
<p>As for our speculative example, let us imagine that in the near future some tectonic shift happened within the coffee brewing market. Two distinct player groups took shape: some companies provide ‘hardware’, i.e. coffee machines; other companies have access to customer auditory. Something like the flights market looks like: there are air companies, which actually transport passengers; and there are trip planning services where users are choosing between trip variants the system generates for them. We're aggregating hardware access to allow app vendors for ordering freshly brewed coffee.</p>
<h4>What and How</h4>
<p>After finishing all these theoretical exercises, we should proceed right to designing and developing the API, having a decent understanding of two things:</p>
@ -858,7 +858,7 @@ GET /sensors
<li>finds the <code>program_run_id</code> identifier and calls the <code>runs/{program_run_id}/cancel</code> method;</li>
</ul>
</li>
<li>the <code>rides/cancel</code> handler completes operations on its level of responsibility and, depending on the coffee machine API kind, proceeds with one of two possible execution branches:
<li>the <code>runs/cancel</code> handler completes operations on its level of responsibility and, depending on the coffee machine API kind, proceeds with one of two possible execution branches:
<ul>
<li>either calls the <code>POST /execution/cancel</code> method of a physical coffee machine API;</li>
<li>or invokes the <code>POST /v1/runtimes/{id}/terminate</code> method;</li>
@ -2845,7 +2845,7 @@ ProgramContext.dispatch = (action) => {
<p>In the case of a gateway API that provides access to some underlying API or aggregates several APIs behind a single façade, there is a strong temptation to proxy the original interface as is, thus not introducing any changes to it and making a life much simpler by sparing an effort needed to implement the weak-coupled interaction between services. For example, while developing program execution interfaces as described in the <a href="#chapter-9">Chapter 9</a> we might have taken the existing first-kind coffee-machine API as a role model and provided it in our API by just proxying the requests and responses as is. Doing so is highly undesirable because of several reasons:</p>
<ul>
<li>usually, you have no guarantees that the partner will maintain backwards compatibility or at least keep new versions more or less conceptually akin to the older ones;</li>
<li>nay partner's problems will automatically ricochet into your customers.</li>
<li>any partner's problem will automatically ricochet into your customers.</li>
</ul>
<p>The best practice is quite the opposite: isolate the third-party API usage, e.g. develop an abstraction level that will allow for:</p>
<ul>

Binary file not shown.

Binary file not shown.

View File

@ -422,7 +422,7 @@ h1 {
<p>Рассмотрим следующую запись:</p>
<pre><code>// Описание метода
POST /v1/bucket/{id}/some-resource
X-Idempotency-Token: &#x3C;токен идемпотентости>
X-Idempotency-Token: &#x3C;токен идемпотентности>
{
// Это однострочный комментарий
@ -459,7 +459,7 @@ Cache-Control: no-cache
<li>разграничение областей ответственности;</li>
<li>описание конечных интерфейсов.</li>
</ul>
<p>Этот алгоритм строит API сверху вниз, от общих требований и сценариев использования до конкретной номенклатуры сущностей; фактически, двигаясь этим путем, вы получите на выходе готовый API — чем этот подход и ценен.</p>
<p>Этот алгоритм строит API сверху вниз, от общих требований и сценариев использования до конкретной номенклатуры сущностей; фактически, двигаясь этим путём, вы получите на выходе готовый API — чем этот подход и ценен.</p>
<p>Может показаться, что наиболее полезные советы приведены в последнем разделе, однако это не так; цена ошибки, допущенной на разных уровнях весьма различна. Если исправить плохое именование довольно просто, то исправить неверное понимание того, зачем вообще нужен API, практически невозможно.</p>
<p><strong>NB</strong>. Здесь и далее мы будем рассматривать концепции разработки API на примере некоторого гипотетического API заказа кофе в городских кофейнях. На всякий случай сразу уточним, что пример является синтетическим; в реальной ситуации, если бы такой API пришлось проектировать, он, вероятно, был бы совсем не похож на наш выдуманный пример.</p><div class="page-break"></div><h3><a href="#chapter-8" class="anchor" id="chapter-8">Глава 8. Определение области применения</a></h3>
<p>Ключевой вопрос, который вы должны задать себе четыре раза, выглядит так: какую проблему мы решаем? Задать его следует четыре раза с ударением на каждом из четырёх слов.</p>
@ -480,11 +480,11 @@ Cache-Control: no-cache
<p>Итак, предположим, что мы хотим предоставить API автоматического заказа кофе в городских кофейнях. Попробуем применить к нему этот принцип.</p>
<ol>
<li>
<p>Зачем кому-то может потребоваться API для приготовления кофе? В чем неудобство заказа кофе через интерфейс, человек-человек или человек-машина? Зачем нужна возможность заказа машина-машина?</p>
<p>Зачем кому-то может потребоваться API для приготовления кофе? В чём неудобство заказа кофе через интерфейс, человек-человек или человек-машина? Зачем нужна возможность заказа машина-машина?</p>
<ul>
<li>Возможно, мы хотим решить проблему выбора и знания? Чтобы человек наиболее полно знал о доступных ему здесь и сейчас опциях.</li>
<li>Возможно, мы оптимизируем время ожидания? Чтобы человеку не пришлось ждать, пока его заказ готовится.</li>
<li>Возможно, мы хотим минимизировать ошибки? Чтобы человек получил именно то, что хотел заказать, не потеряв информацию при разговорном общении либо при настройке незнакомого интерфейса кофе-машины.</li>
<li>Возможно, мы хотим минимизировать ошибки? Чтобы человек получил именно то, что хотел заказать, не потеряв информацию при разговорном общении либо при настройке незнакомого интерфейса кофемашины.</li>
</ul>
<p>Вопрос «зачем» — самый важный из тех вопросов, которые вы должны задавать себе. Не только глобально в отношении целей всего проекта, но и локально в отношении каждого кусочка функциональности. <strong>Если вы не можете коротко и понятно ответить на вопрос «зачем эта сущность нужна» — значит, она не нужна</strong>.</p>
<p>Здесь и далее предположим (в целях придания нашему примеру глубины и некоторой упоротости), что мы оптимизируем все три фактора в порядке убывания важности.</p>
@ -493,16 +493,16 @@ Cache-Control: no-cache
<p>Правда ли решаемая проблема существует? Действительно ли мы наблюдаем неравномерную загрузку кофейных автоматов по утрам? Правда ли люди страдают от того, что не могут найти поблизости нужный им латте с ореховым сиропом? Действительно ли людям важны те минуты, которые они теряют, стоя в очередях?</p>
</li>
<li>
<p>Действительно ли мы обладаем достаточным ресурсом, чтобы решить эту проблему? Есть ли у нас доступ к достаточному количеству кофе-машин и клиентов, чтобы обеспечить работоспособность системы?</p>
<p>Действительно ли мы обладаем достаточным ресурсом, чтобы решить эту проблему? Есть ли у нас доступ к достаточному количеству кофемашин и клиентов, чтобы обеспечить работоспособность системы?</p>
</li>
<li>
<p>Наконец, правда ли мы решим проблему? Как мы поймём, что оптимизировали перечисленные факторы?</p>
</li>
</ol>
<p>На все эти вопросы, в общем случае, простого ответа нет. В идеале ответы на эти вопросы должны даваться с цифрами в руках. Сколько конкретно времени тратится неоптимально, и какого значения мы рассчитываем добиться, располагая какой плотностью кофе-машин? Заметим также, что в реальной жизни просчитать такого рода цифры можно в основном для проектов, которые пытаются влезть на уже устоявшийся рынок; если вы пытаетесь сделать что-то новое, то, вероятно, вам придётся ориентироваться в основном на свою интуицию.</p>
<p>На все эти вопросы, в общем случае, простого ответа нет. В идеале ответы на эти вопросы должны даваться с цифрами в руках. Сколько конкретно времени тратится неоптимально, и какого значения мы рассчитываем добиться, располагая какой плотностью кофемашин? Заметим также, что в реальной жизни просчитать такого рода цифры можно в основном для проектов, которые пытаются влезть на уже устоявшийся рынок; если вы пытаетесь сделать что-то новое, то, вероятно, вам придётся ориентироваться в основном на свою интуицию.</p>
<h4>Почему API?</h4>
<p>Поскольку наша книга посвящена не просто разработке программного обеспечения, а разработке API, то на все эти вопросы мы должны взглянуть под другим ракурсом: а почему для решения этих задач требуется именно API, а не просто программное обеспечение? В нашем вымышленном примере мы должны спросить себя: зачем нам нужно предоставлять сервис для других разработчиков, чтобы они могли готовить кофе своим клиентам, а не сделать своё приложение для конечного потребителя?</p>
<p>Иными словами, должна иметься веская причина, по которой два домена разработки ПО должны быть разделены: есть оператор(ы), предоставляющий API; есть оператор(ы), предоставляющий сервисы пользователям. Их интересы в чем-то различны настолько, что объединение этих двух ролей в одном лице нежелательно. Более подробно мы изложим причины и мотивации делать именно API в разделе III.</p>
<p>Иными словами, должна иметься веская причина, по которой два домена разработки ПО должны быть разделены: есть оператор(ы), предоставляющий API; есть оператор(ы), предоставляющий сервисы пользователям. Их интересы в чём-то различны настолько, что объединение этих двух ролей в одном лице нежелательно. Более подробно мы изложим причины и мотивации делать именно API в разделе III.</p>
<p>Заметим также следующее: вы должны браться делать API тогда и только тогда, когда в ответе на второй вопрос написали «потому что в этом состоит наша экспертиза». Разрабатывая API, вы занимаетесь некоторой мета-разработкой: вы пишете ПО для того, чтобы другие могли разрабатывать ПО для решения задачи пользователя. Не обладая экспертизой в обоих этих доменах (API и конечные продукты) написать хороший API сложно.</p>
<p>Для нашего умозрительного примера предположим, что в недалеком будущем произошло разделение рынка кофе на две группы игроков: одни предоставляют само железо, кофейные аппараты, а другие имеют доступ к потребителю — примерно как это произошло, например, с рынком авиабилетов, где есть собственно авиакомпании, осуществляющие перевозку, и сервисы планирования путешествий, где люди выбирают варианты перелётов. Мы хотим агрегировать доступ к железу, чтобы владельцы приложений могли встраивать заказ кофе.</p>
<h4>Что и как</h4>
@ -516,13 +516,13 @@ Cache-Control: no-cache
<li>Предоставляем сервисам с большой пользовательской аудиторией API для того, чтобы их потребители могли максимально удобно для себя заказать кофе.</li>
<li>Для этого мы абстрагируем за нашим HTTP API доступ к «железу» и предоставим методы для выбора вида напитка и места его приготовления и для непосредственно исполнения заказа.</li>
</ol><div class="page-break"></div><h3><a href="#chapter-9" class="anchor" id="chapter-9">Глава 9. Разделение уровней абстракции</a></h3>
<p>«Разделите свой код на уровни абстракции» - пожалуй, самый общий совет для разработчиков программного обеспечения. Однако будет вовсе не преувеличением сказать, что изоляция уровней абстракции — самая сложная задача, стоящая перед разработчиком API.</p>
<p>«Разделите свой код на уровни абстракции» пожалуй, самый общий совет для разработчиков программного обеспечения. Однако будет вовсе не преувеличением сказать, что изоляция уровней абстракции — самая сложная задача, стоящая перед разработчиком API.</p>
<p>Прежде чем переходить к теории, следует чётко сформулировать, <em>зачем</em> нужны уровни абстракции и каких целей мы хотим достичь их выделением.</p>
<p>Вспомним, что программный продукт - это средство связи контекстов, средство преобразования терминов и операций одной предметной области в другую. Чем дальше друг от друга эти области отстоят - тем большее число промежуточных передаточных звеньев нам придётся ввести. Вернёмся к нашему примеру с кофейнями. Какие уровни сущностей мы видим?</p>
<p>Вспомним, что программный продукт это средство связи контекстов, средство преобразования терминов и операций одной предметной области в другую. Чем дальше друг от друга эти области отстоят тем большее число промежуточных передаточных звеньев нам придётся ввести. Вернёмся к нашему примеру с кофейнями. Какие уровни сущностей мы видим?</p>
<ol>
<li>Мы готовим с помощью нашего API <em>заказ</em> — один или несколько стаканов кофе — и взымаем за это плату.</li>
<li>Мы готовим с помощью нашего API <em>заказ</em> — один или несколько стаканов кофе — и взимаем за это плату.</li>
<li>Каждый стакан кофе приготовлен по определённому <em>рецепту</em>, что подразумевает наличие разных ингредиентов и последовательности выполнения шагов приготовления.</li>
<li>Напиток готовится на конкретной физической <em>кофе-машине</em>, располагающейся в какой-то точке пространства.</li>
<li>Напиток готовится на конкретной физической <em>кофемашине</em>, располагающейся в какой-то точке пространства.</li>
</ol>
<p>Каждый из этих уровней задаёт некоторый срез нашего API, с которым будет работать потребитель. Выделяя иерархию абстракций мы, прежде всего, стремимся снизить связность различных сущностей нашего API. Это позволит нам добиться нескольких целей.</p>
<ol>
@ -540,7 +540,7 @@ Cache-Control: no-cache
<pre><code>// возвращает рецепт лунго
GET /v1/recipes/lungo
</code></pre>
<pre><code>// размещает на указанной кофе-машине
<pre><code>// размещает на указанной кофемашине
// заказ на приготовление лунго
// и возвращает идентификатор заказа
POST /v1/orders
@ -570,24 +570,24 @@ GET /v1/orders/{id}
<li>разработчик, которому придётся поддержать эту функциональность, имеет высокие шансы сделать ошибку: добавив поддержку произвольного объёма кофе в код, работающий с <code>POST /v1/orders</code> нужно не забыть переписать код проверки готовности заказа;</li>
<li>мы получим классическую ситуацию, когда одно и то же поле (объём кофе) значит разные вещи в разных интерфейсах. В <code>GET /v1/recipes</code> поле «объём» теперь значит «объём, который будет запрошен, если не передать его явно в <code>POST /v1/orders</code>»; переименовать его в «объём по умолчанию» уже не получится, с этой проблемой теперь придётся жить.</li>
</ul>
<p><strong>В-третьих</strong>, вся эта схема полностью неработоспособна, если разные модели кофе-машин производят лунго разного объёма. Для решения задачи «объём лунго зависит от вида машины» нам придётся сделать совсем неприятную вещь: сделать рецепт зависимым от id машины. Тем самым мы начнём активно смешивать уровни абстракции: одной частью нашего API (рецептов) станет невозможно пользоваться без другой части (информации о кофе-машинах). Что немаловажно, от разработчиков потребуется изменить логику своего приложения: если раньше они могли предлагать сначала выбрать объём, а потом кофе-машину, то теперь им придётся полностью изменить этот шаг.</p>
<p><strong>В-третьих</strong>, вся эта схема полностью неработоспособна, если разные модели кофемашин производят лунго разного объёма. Для решения задачи «объём лунго зависит от вида машины» нам придётся сделать совсем неприятную вещь: сделать рецепт зависимым от id машины. Тем самым мы начнём активно смешивать уровни абстракции: одной частью нашего API (рецептов) станет невозможно пользоваться без другой части (информации о кофемашинах). Что немаловажно, от разработчиков потребуется изменить логику своего приложения: если раньше они могли предлагать сначала выбрать объём, а потом кофемашину, то теперь им придётся полностью изменить этот шаг.</p>
<p>Хорошо, допустим, мы поняли, как сделать плохо. Но как же тогда сделать <em>хорошо</em>? Разделение уровней абстракции должно происходить вдоль трёх направлений:</p>
<ol>
<li>
<p>От сценариев использования к их внутренней реализации: высокоуровневые сущности и номенклатура их методов должны напрямую отражать сценарии использования API; низкоуровневый - отражать декомпозицию сценариев на составные части.</p>
<p>От сценариев использования к их внутренней реализации: высокоуровневые сущности и номенклатура их методов должны напрямую отражать сценарии использования API; низкоуровневый отражать декомпозицию сценариев на составные части.</p>
</li>
<li>
<p>От терминов предметной области пользователя к терминам предметной области исходных данных — в нашем случае от высокоуровневых понятий «рецепт», «заказ», «кофейня» к низкоуровневым «температура напитка» и «координаты кофе-машины»</p>
<p>От терминов предметной области пользователя к терминам предметной области исходных данных — в нашем случае от высокоуровневых понятий «рецепт», «заказ», «кофейня» к низкоуровневым «температура напитка» и «координаты кофемашины»</p>
</li>
<li>
<p>Наконец, от структур данных, в которых удобно оперировать пользователю к структурам данных, максимально приближенных к «сырым» - в нашем случае от «лунго» и «сети кофеен "Ромашка"» - к сырым байтовый данным, описывающим состояние кофе-машины марки «Доброе утро» в процессе приготовления напитка.</p>
<p>Наконец, от структур данных, в которых удобно оперировать пользователю к структурам данных, максимально приближенных к «сырым» в нашем случае от «лунго» и «сети кофеен "Ромашка"» к сырым байтовый данным, описывающим состояние кофемашины марки «Доброе утро» в процессе приготовления напитка.</p>
</li>
</ol>
<p>Чем дальше находятся друг от друга программные контексты, которые соединяет наш API - тем более глубокая иерархия сущностей должна получиться у нас в итоге.</p>
<p>Чем дальше находятся друг от друга программные контексты, которые соединяет наш API тем более глубокая иерархия сущностей должна получиться у нас в итоге.</p>
<p>В нашем примере с определением готовности кофе мы явно пришли к тому, что нам требуется промежуточный уровень абстракции:</p>
<ul>
<li>с одной стороны, «заказ» не должен содержать информацию о датчиках и сенсорах кофе-машины;</li>
<li>с другой стороны, кофе-машина не должна хранить информацию о свойствах заказа (да и вероятно её API такой возможности и не предоставляет).</li>
<li>с одной стороны, «заказ» не должен содержать информацию о датчиках и сенсорах кофемашины;</li>
<li>с другой стороны, кофемашина не должна хранить информацию о свойствах заказа (да и вероятно её API такой возможности и не предоставляет).</li>
</ul>
<p>Наивный подход в такой ситуации — искусственно ввести некий промежуточный уровень абстракции, «передаточное звено», который переформулирует задачи одного уровня абстракции в другой. Например, введём сущность <code>task</code> вида:</p>
<pre><code>{
@ -600,18 +600,18 @@ GET /v1/orders/{id}
"status": "executing",
"operations": [
// описание операций, запущенных на
// физической кофе-машине
// физической кофемашине
]
}
}
</code></pre>
<p>Мы называем этот подход «наивным» не потому, что он неправильный; напротив, это вполне логичное решение «по умолчанию», если вы на данном этапе ещё не знаете или не понимаете, как будет выглядеть ваш API. Проблема его в том, что он умозрительный: он не добавляет понимания того, как устроена предметная область.</p>
<p>Хороший разработчик в нашем примере должен спросить: хорошо, а какие вообще говоря существуют варианты? Как можно определять готовность напитка? Если вдруг окажется, что сравнение объёмов — единственный способ определения готовности во всех без исключения кофе-машинах, то почти все рассуждения выше — неверны: можно совершенно спокойно включать в интерфейсы определение готовности кофе по объёму, т.к. никакого другого и не существует. Прежде, чем что-то абстрагировать — надо представлять, <em>что</em> мы, собственно, абстрагируем.</p>
<p>Для нашего примера допустим, что мы сели изучать спецификации API кофе-машин и выяснили, что существует принципиально два класса устройств:</p>
<p>Хороший разработчик в нашем примере должен спросить: хорошо, а какие вообще говоря существуют варианты? Как можно определять готовность напитка? Если вдруг окажется, что сравнение объёмов — единственный способ определения готовности во всех без исключения кофемашинах, то почти все рассуждения выше — неверны: можно совершенно спокойно включать в интерфейсы определение готовности кофе по объёму, т.к. никакого другого и не существует. Прежде, чем что-то абстрагировать — надо представлять, <em>что</em> мы, собственно, абстрагируем.</p>
<p>Для нашего примера допустим, что мы сели изучать спецификации API кофемашин и выяснили, что существует принципиально два класса устройств:</p>
<ul>
<li>кофе-машины с предустановленными программами, которые умеют готовить заранее прошитые N видов напитков, и мы можем управлять только какими-то параметрами напитка (скажем, объёмом напитка, вкусом сиропа и видом молока); у таких машин отсутствует доступ к внутренним функциям и датчикам, но зато машина умеет через API сама отдавать статус приготовления напитка;</li>
<li>кофе-машины с предустановленными функциями типа «смолоть такой-то объём кофе», «пролить N миллилитров воды», «взбить молочную пену» и т.д.: у таких машин отсутствует понятие «программа приготовления», но есть доступ к микрокомандам и датчикам.</li>
<li>кофемашины с предустановленными программами, которые умеют готовить заранее прошитые N видов напитков, и мы можем управлять только какими-то параметрами напитка (скажем, объёмом напитка, вкусом сиропа и видом молока); у таких машин отсутствует доступ к внутренним функциям и датчикам, но зато машина умеет через API сама отдавать статус приготовления напитка;</li>
<li>кофемашины с предустановленными функциями типа «смолоть такой-то объём кофе», «пролить N миллилитров воды», «взбить молочную пену» и т.д.: у таких машин отсутствует понятие «программа приготовления», но есть доступ к микрокомандам и датчикам.</li>
</ul>
<p>Предположим, для большей конкретности, что эти два класса устройств поставляются вот с таким физическим API.</p>
<ul>
@ -651,7 +651,7 @@ POST /cancel
// Формат аналогичен формату ответа `POST /execute`
GET /execution/status
</code></pre>
<p><strong>NB</strong>. На всякий случай отметим, что данный API нарушает множество описанных нами принципов проектирования, начиная с отсутствия версионирования; он приведен в таком виде по двум причинам: (1) чтобы мы могли показать, как спроектировать API более удачно; (2) скорее всего, в реальной жизни вы получите именно такой API от производителей кофе-машин, и это ещё довольно вменяемый вариант.</p>
<p><strong>NB</strong>. На всякий случай отметим, что данный API нарушает множество описанных нами принципов проектирования, начиная с отсутствия версионирования; он приведен в таком виде по двум причинам: (1) чтобы мы могли показать, как спроектировать API более удачно; (2) скорее всего, в реальной жизни вы получите именно такой API от производителей кофемашин, и это ещё довольно вменяемый вариант.</p>
</li>
<li>
<p>Машины с предустановленными функциями:</p>
@ -704,7 +704,7 @@ GET /sensors
<p><strong>NB</strong>. Пример нарочно сделан умозрительным для моделирования ситуации, описанной в начале главы: для определения готовности напитка нужно сличить объём налитого с эталоном.</p>
</li>
</ul>
<p>Теперь картина становится более явной: нам нужно абстрагировать работу с кофе-машиной так, чтобы наш «уровень исполнения» в API предоставлял общие функции (такие, как определение готовности напитка) в унифицированном виде. Важно отметить, что с точки зрения разделения абстракций два этих вида кофе-машин сами находятся на разных уровнях: первые предоставляют API более высокого уровня, нежели вторые; следовательно, и «ветка» нашего API, работающая со вторым видом машин, будет более «развесистой».</p>
<p>Теперь картина становится более явной: нам нужно абстрагировать работу с кофемашиной так, чтобы наш «уровень исполнения» в API предоставлял общие функции (такие, как определение готовности напитка) в унифицированном виде. Важно отметить, что с точки зрения разделения абстракций два этих вида кофемашин сами находятся на разных уровнях: первые предоставляют API более высокого уровня, нежели вторые; следовательно, и «ветка» нашего API, работающая со вторым видом машин, будет более «развесистой».</p>
<p>Следующий шаг, необходимый для отделения уровней абстракции — необходимо понять, какую функциональность нам, собственно, необходимо абстрагировать. Для этого нам необходимо обратиться к задачам, которые решает разработчик на уровне работы с заказами, и понять, какие проблемы у него возникнут в случае отсутствия нашего слоя абстракции.</p>
<ol>
<li>Очевидно, что разработчику хочется создавать заказ унифицированным образом — перечислить высокоуровневые параметры заказа (вид напитка, объём и специальные требования, такие как вид сиропа или молока) — и не думать о том, как на конкретной машине исполнить этот заказ.</li>
@ -721,7 +721,7 @@ GET /sensors
<p>Таким образом, нам нужно внедрить два новых уровня абстракции.</p>
<ol>
<li>
<p>Уровень управления исполнением, предоставляющий унифицированный интерфейс к атомарным программам. «Унифицированный интерфейс» в данном случае означает, что, независимо от того, на какого рода кофе-машине готовится заказ, разработчик может рассчитывать на:</p>
<p>Уровень управления исполнением, предоставляющий унифицированный интерфейс к атомарным программам. «Унифицированный интерфейс» в данном случае означает, что, независимо от того, на какого рода кофемашине готовится заказ, разработчик может рассчитывать на:</p>
<ul>
<li>единую номенклатуру статусов и других высокоуровневых параметров исполнения (например, ожидаемого времени готовности заказа или возможных ошибок исполнения);</li>
<li>единую номенклатуру доступных методов (например, отмены заказа) и их одинаковое поведение.</li>
@ -762,14 +762,14 @@ GET /sensors
{ "program_run_id" }
</code></pre>
<p>Обратите внимание, что во всей этой цепочке вообще никак не участвует тип API кофе-машины — собственно, ровно для этого мы и абстрагировали. Мы могли бы сделать интерфейсы более конкретными, разделив функциональность <code>run</code> и <code>match</code> для разных API, т.е. ввести раздельные endpoint-ы:</p>
<p>Обратите внимание, что во всей этой цепочке вообще никак не участвует тип API кофемашины — собственно, ровно для этого мы и абстрагировали. Мы могли бы сделать интерфейсы более конкретными, разделив функциональность <code>run</code> и <code>match</code> для разных API, т.е. ввести раздельные endpoint-ы:</p>
<ul>
<li><code>POST /v1/program-matcher/{api_type}</code></li>
<li><code>POST /v1/programs/{api_type}/{program_id}/run</code></li>
</ul>
<p>Достоинством такого подхода была бы возможность передавать в match и run не унифицированные наборы параметров, а только те, которые имеют значение в контексте указанного типа API. Однако в нашем дизайне API такой необходимости не прослеживается. Обработчик <code>run</code> сам может извлечь нужные параметры из мета-информации о программе и выполнить одно из двух действий:</p>
<ul>
<li>вызвать <code>POST /execute</code> физического API кофе-машины с передачей внутреннего идентификатора программы — для машин, поддерживающих API первого типа;</li>
<li>вызвать <code>POST /execute</code> физического API кофемашины с передачей внутреннего идентификатора программы — для машин, поддерживающих API первого типа;</li>
<li>инициировать создание рантайма для работы с API второго типа.</li>
</ul>
<p>Уровень рантаймов API второго типа, исходя из общих соображений, будет скорее всего непубличным, и мы плюс-минус свободны в его имплементации. Самым простым решением будет реализовать виртуальную state-машину, которая создаёт «рантайм» (т.е. stateful контекст исполнения) для выполнения программы и следит за его состоянием.</p>
@ -817,7 +817,7 @@ GET /sensors
</code></pre>
<p><strong>NB</strong>: в имплементации связки <code>orders</code><code>match</code><code>run</code><code>runtimes</code> можно пойти одним из двух путей:</p>
<ul>
<li>либо обработчик <code>POST /orders</code> сам обращается к доступной информации о рецепте, кофе-машине и программе и формирует stateless-запрос, в котором указаны все нужные данные (тип API кофе-машины и список команд в частности);</li>
<li>либо обработчик <code>POST /orders</code> сам обращается к доступной информации о рецепте, кофемашине и программе и формирует stateless-запрос, в котором указаны все нужные данные (тип API кофемашины и список команд в частности);</li>
<li>либо в запросе содержатся только идентификаторы, и следующие обработчики в цепочке сами обратятся за нужными данными через какие-то внутренние API.</li>
</ul>
<p>Оба варианта имеют право на жизнь; какой из них выбрать зависит от деталей реализации.</p>
@ -827,9 +827,9 @@ GET /sensors
<ul>
<li>пользователь вызовет метод <code>GET /v1/orders</code>;</li>
<li>обработчик <code>orders</code> выполнит операции своего уровня ответственности (проверку авторизации, в частности), найдёт идентификатор <code>program_run_id</code> и обратится к API программ <code>runs/{program_run_id}</code>;</li>
<li>обработчик <code>runs</code> в свою очередь выполнит операции своего уровня (в частности, проверит тип API кофе-машины) и в зависимости от типа API пойдёт по одной из двух веток исполнения:
<li>обработчик <code>runs</code> в свою очередь выполнит операции своего уровня (в частности, проверит тип API кофемашины) и в зависимости от типа API пойдёт по одной из двух веток исполнения:
<ul>
<li>либо вызовет <code>GET /execution/status</code> физического API кофе-машины, получит объём кофе и сличит с эталонным;</li>
<li>либо вызовет <code>GET /execution/status</code> физического API кофемашины, получит объём кофе и сличит с эталонным;</li>
<li>либо обратится к <code>GET /v1/runtimes/{runtime_id}</code>, получит <code>state.status</code> и преобразует его к статусу заказа;</li>
</ul>
</li>
@ -852,9 +852,9 @@ GET /sensors
<li>найдёт идентификатор <code>program_run_id</code> и обратится к обработчику <code>runs/{program_run_id}/cancel</code>;</li>
</ul>
</li>
<li>обработчик <code>runs/cancel</code> произведёт операции своего уровня (в частности, установит тип API кофе-машины) и в зависимости от типа API пойдёт по одной из двух веток исполнения:
<li>обработчик <code>runs/cancel</code> произведёт операции своего уровня (в частности, установит тип API кофемашины) и в зависимости от типа API пойдёт по одной из двух веток исполнения:
<ul>
<li>либо вызовет <code>POST /execution/cancel</code> физического API кофе-машины;</li>
<li>либо вызовет <code>POST /execution/cancel</code> физического API кофемашины;</li>
<li>либо вызовет <code>POST /v1/runtimes/{id}/terminate</code>;</li>
</ul>
</li>
@ -876,10 +876,10 @@ GET /sensors
</ul>
</li>
<li>
<p>С точки зрения верхнеуровневого API отмена заказа является терминальным действием, т.е. никаких последующих операций уже быть не может; а с точки зрения низкоуровневого API обработка заказа продолжается, т.к. нужно дождаться, когда стакан будет утилизирован, и после этого освободить кофе-машину (т.е. разрешить создание новых рантаймов на ней). Это вторая задача для уровня исполнения: связывать оба статуса, внешний (заказ отменён) и внутренний (исполнение продолжается).</p>
<p>С точки зрения верхнеуровневого API отмена заказа является терминальным действием, т.е. никаких последующих операций уже быть не может; а с точки зрения низкоуровневого API обработка заказа продолжается, т.к. нужно дождаться, когда стакан будет утилизирован, и после этого освободить кофемашину (т.е. разрешить создание новых рантаймов на ней). Это вторая задача для уровня исполнения: связывать оба статуса, внешний (заказ отменён) и внутренний (исполнение продолжается).</p>
</li>
</ol>
<p>Может показаться, что соблюдение правила изоляции уровней абстракции является избыточным и заставляет усложнять интерфейс. И это в действительности так: важно понимать, что никакая гибкость, логичность, читабельность и расширяемость не бывает бесплатной. Можно построить API так, чтобы он выполнял свою функцию с минимальными накладными расходами, по сути — дать интерфейс к микроконтроллерам кофе-машины. Однако пользоваться им будет крайне неудобно, и расширяемость такого API будет нулевой.</p>
<p>Может показаться, что соблюдение правила изоляции уровней абстракции является избыточным и заставляет усложнять интерфейс. И это в действительности так: важно понимать, что никакая гибкость, логичность, читабельность и расширяемость не бывает бесплатной. Можно построить API так, чтобы он выполнял свою функцию с минимальными накладными расходами, по сути — дать интерфейс к микроконтроллерам кофемашины. Однако пользоваться им будет крайне неудобно, и расширяемость такого API будет нулевой.</p>
<p>Выделение уровней абстракции, прежде всего, <em>логическая</em> процедура: как мы объясняем себе и разработчику, из чего состоит наш API. <strong>Абстрагируемая дистанция между сущностями существует объективно</strong>, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни <em>явно</em>. Чем более неявно разведены (или, хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API, и тем хуже будет написан использующий его код.</p>
<h4>Потоки данных</h4>
<p>Полезное упражнение, позволяющее рассмотреть иерархию уровней абстракции API — исключить из рассмотрения все частности и построить — в голове или на бумаге — дерево потоков данных: какие данные протекают через объекты вашего API и как видоизменяются на каждом этапе.</p>
@ -890,8 +890,8 @@ GET /sensors
<p>Данные с сенсоров — объёмы кофе / воды / стакана. Это низший из доступных нам уровней данных, здесь мы не можем ничего изменить или переформулировать.</p>
</li>
<li>
<p>Непрерывный поток данных сенсоров мы преобразуем в дискретные статусы исполнения команд, вводя в него понятия, не существующие в предметной области. API кофе-машины не предоставляет нам понятий «кофе наливается» или «стакан ставится» — это наше программное обеспечение трактует поступающие потоки данных от сенсоров, вводя новые понятия: если наблюдаемый объём (кофе или воды) меньше целевого — значит, процесс не закончен; если объём достигнут — значит, необходимо сменить статус исполнения и выполнить следующее действие.<br>
Важно отметить, что мы не просто вычисляем какие-то новые параметры из имеющихся данных сенсоров: мы сначала создаём новый кортеж данных более высокого уровня — «программа исполнения» как последовательность шагов и условий — и инициализируем его начальные значения. Без этого контекста определить, что собственно происходит с кофе-машиной невозможно.</p>
<p>Непрерывный поток данных сенсоров мы преобразуем в дискретные статусы исполнения команд, вводя в него понятия, не существующие в предметной области. API кофемашины не предоставляет нам понятий «кофе наливается» или «стакан ставится» — это наше программное обеспечение трактует поступающие потоки данных от сенсоров, вводя новые понятия: если наблюдаемый объём (кофе или воды) меньше целевого — значит, процесс не закончен; если объём достигнут — значит, необходимо сменить статус исполнения и выполнить следующее действие.<br>
Важно отметить, что мы не просто вычисляем какие-то новые параметры из имеющихся данных сенсоров: мы сначала создаём новый кортеж данных более высокого уровня — «программа исполнения» как последовательность шагов и условий — и инициализируем его начальные значения. Без этого контекста определить, что собственно происходит с кофемашиной невозможно.</p>
</li>
<li>
<p>Обладая логическими данными о состоянии исполнения программы, мы можем (вновь через создание нового, более высокоуровневого контекста данных!) свести данные от двух типов API к единому формату исполнения операции создания напитка и её логических параметров: целевой рецепт, объём, статус готовности.</p>
@ -907,7 +907,7 @@ GET /sensors
<p>На уровне исполнения мы обращаемся к данным уровня заказа и создаём более низкоуровневый контекст: программа исполнения в виде последовательности шагов, их параметров и условий перехода от одного шага к другому и начальное состояние.</p>
</li>
<li>
<p>На уровне рантайма мы обращаемся к целевым значениям (какую операцию выполнить и какой целевой объём) и преобразуем их в набор микрокоманд API кофе-машины и набор статусов исполнения каждой команды.</p>
<p>На уровне рантайма мы обращаемся к целевым значениям (какую операцию выполнить и какой целевой объём) и преобразуем их в набор микрокоманд API кофемашины и набор статусов исполнения каждой команды.</p>
</li>
</ol>
<p>Если обратиться к описанному в начале главы «плохому» решению (предполагающему самостоятельное определение факта готовности заказа разработчиком), то мы увидим, что и с точки зрения потоков данных происходит смешение понятий:</p>
@ -923,7 +923,7 @@ GET /sensors
<li>уровень рантайма для API второго типа (сущности, отвечающие за state-машину выполнения заказа).</li>
</ul>
<p>Теперь нам необходимо определить ответственность каждой сущности: в чём смысл её существования в рамках нашего API, какие действия можно выполнять с самой сущностью, а какие — делегировать другим объектам. Фактически, нам нужно применить «зачем-принцип» к каждой отдельной сущности нашего API.</p>
<p>Для этого нам нужно пройти по нашему API и сформулировать в терминах предметной области, что представляет из себя каждый объект. Напомню, что из концепции уровней абстракции следует, что каждый уровень иерархии — это некоторая собственная промежуточная предметная область, ступенька, по которой мы переходим от описания задачи в терминах одного связываемого контекста («заказанный пользователем лунго») к описанию в терминах второго («задание кофе-машине на выполнение указанной программы»).</p>
<p>Для этого нам нужно пройти по нашему API и сформулировать в терминах предметной области, что представляет из себя каждый объект. Напомню, что из концепции уровней абстракции следует, что каждый уровень иерархии — это некоторая собственная промежуточная предметная область, ступенька, по которой мы переходим от описания задачи в терминах одного связываемого контекста («заказанный пользователем лунго») к описанию в терминах второго («задание кофемашине на выполнение указанной программы»).</p>
<p>В нашем умозрительном примере получится примерно так:</p>
<ol>
<li>Сущности уровня пользователя (те сущности, работая с которыми, разработчик непосредственно решает задачи пользователя).
@ -937,14 +937,14 @@ GET /sensors
</ul>
</li>
<li>Рецепт <code>recipe</code> — описывает «идеальную модель» вида кофе, его потребительские свойства. Рецепт в данном контексте для нас неизменяемая сущность, которую можно только просмотреть и выбрать.</li>
<li>Кофе-машина <code>coffee-machine</code> — модель объекта реального мира. Из описания кофе-машины мы, в частности, должны извлечь её положение в пространстве и предоставляемые опции (о чём подробнее поговорим ниже).</li>
<li>Кофемашина <code>coffee-machine</code> — модель объекта реального мира. Из описания кофемашины мы, в частности, должны извлечь её положение в пространстве и предоставляемые опции (о чём подробнее поговорим ниже).</li>
</ul>
</li>
<li>Сущности уровня управления исполнением (те сущности, работая с которыми, можно непосредственно исполнить заказ).
<ul>
<li>Программа <code>program</code> — описывает некоторый план исполнения для конкретной кофе-машины. Программы можно только просмотреть.</li>
<li>Селектор программ <code>programs/matcher</code> — позволяет связать рецепт и программу исполнения, т.е. фактически выяснить набор данных, необходимых для приготовления конкретного рецепта на конкретной кофе-машине. Селектор работает только на выбор нужной программы.</li>
<li>Запуск программы <code>programs/run</code> — конкретный факт исполнения программы на конкретной кофе-машине. Запуски можно:
<li>Программа <code>program</code> — описывает некоторый план исполнения для конкретной кофемашины. Программы можно только просмотреть.</li>
<li>Селектор программ <code>programs/matcher</code> — позволяет связать рецепт и программу исполнения, т.е. фактически выяснить набор данных, необходимых для приготовления конкретного рецепта на конкретной кофемашине. Селектор работает только на выбор нужной программы.</li>
<li>Запуск программы <code>programs/run</code> — конкретный факт исполнения программы на конкретной кофемашине. Запуски можно:
<ul>
<li>инициировать (создавать);</li>
<li>проверять состояние запуска;</li>
@ -953,7 +953,7 @@ GET /sensors
</li>
</ul>
</li>
<li>Сущности уровня программ исполнения (те сущности, работая с которыми, можно непосредственно управлять состоянием кофе-машины через API второго типа).
<li>Сущности уровня программ исполнения (те сущности, работая с которыми, можно непосредственно управлять состоянием кофемашины через API второго типа).
<ul>
<li>Рантайм <code>runtime</code> — контекст исполнения программы, т.е. состояние всех переменных. Рантаймы можно:
<ul>
@ -965,24 +965,24 @@ GET /sensors
</ul>
</li>
</ol>
<p>Если внимательно посмотреть на каждый объект, то мы увидим, что, в итоге, каждый объект оказался в смысле своей ответственности составным. Например, <code>program</code> будет оперировать данными высшего уровня (рецепт и кофе-машина), дополняя их терминами своего уровня (идентификатор запуска). Это совершенно нормально: API должен связывать контексты.</p>
<p>Если внимательно посмотреть на каждый объект, то мы увидим, что, в итоге, каждый объект оказался в смысле своей ответственности составным. Например, <code>program</code> будет оперировать данными высшего уровня (рецепт и кофемашина), дополняя их терминами своего уровня (идентификатор запуска). Это совершенно нормально: API должен связывать контексты.</p>
<h4>Сценарии использования</h4>
<p>На этом уровне, когда наш API уже в целом понятно устроен и спроектирован, мы должны поставить себя на место разработчика и попробовать написать код. Наша задача: взглянуть на номенклатуру сущностей и понять, как ими будут пользоваться.</p>
<p>Представим, что нам поставили задачу, пользуясь нашим кофейным API, разработать приложение для заказа кофе. Какой код мы напишем?</p>
<p>Очевидно, первый шаг — нужно предоставить пользователю возможность выбора, чего он, собственно хочет. И первый же шаг обнажает неудобство использования нашего API: никаких методов, позволяющих пользователю что-то выбрать в нашем API нет. Разработчику придётся сделать что-то типа такого:</p>
<ul>
<li>получить все доступные рецепты из <code>GET /v1/recipes</code>;</li>
<li>получить список всех кофе-машин из <code>GET /v1/coffee-machines</code>;</li>
<li>получить список всех кофемашин из <code>GET /v1/coffee-machines</code>;</li>
<li>самостоятельно выбрать нужные данные.</li>
</ul>
<p>В псевдокоде это будет выглядеть примерно вот так:</p>
<pre><code>// Получить все доступные рецепты
let recipes = api.getRecipes();
// Получить все доступные кофе-машины
// Получить все доступные кофемашины
let coffeeMachines = api.getCoffeeMachines();
// Построить пространственный индекс
let coffeeMachineRecipesIndex = buildGeoIndex(recipes, coffeeMachines);
// Выбрать кофе-машины, соответствующие запросу пользователя
// Выбрать кофемашины, соответствующие запросу пользователя
let matchingCoffeeMachines = coffeeMachineRecipesIndex.query(
parameters,
{ "sort_by": "distance" }
@ -990,7 +990,7 @@ let matchingCoffeeMachines = coffeeMachineRecipesIndex.query(
// Наконец, показать предложения пользователю
app.display(coffeeMachines);
</code></pre>
<p>Как видите, разработчику придётся написать немало лишнего кода (это не упоминая о сложности имплементации геопространственных индексов!). Притом, учитывая наши наполеоновские планы по покрытию нашим API всех кофе-машин мира, такой алгоритм выглядит заведомо бессмысленной тратой ресурсов на получение списков и поиск по ним.</p>
<p>Как видите, разработчику придётся написать немало лишнего кода (это не упоминая о сложности имплементации геопространственных индексов!). Притом, учитывая наши наполеоновские планы по покрытию нашим API всех кофемашин мира, такой алгоритм выглядит заведомо бессмысленной тратой ресурсов на получение списков и поиск по ним.</p>
<p>Напрашивается добавление нового эндпойнта поиска. Для того, чтобы разработать этот интерфейс, нам придётся самим встать на место UX-дизайнера и подумать, каким образом приложение будет пытаться заинтересовать пользователя. Два сценария довольно очевидны:</p>
<ul>
<li>показать ближайшие кофейни и виды предлагаемого кофе в них («service discovery»-сценарий) — для пользователей-новичков, или просто людей без определённых предпочтений;</li>
@ -1018,9 +1018,9 @@ app.display(coffeeMachines);
<p>Здесь:</p>
<ul>
<li><code>offer</code> — некоторое «предложение»: на каких условиях можно заказать запрошенные виды кофе, если они были указаны, либо какое-то маркетинговое предложение — цены на самые популярные / интересные напитки, если пользователь не указал конкретные рецепты для поиска;</li>
<li><code>place</code> — место (кафе, автомат, ресторан), где находится машина; мы не вводили эту сущность ранее, но, очевидно, пользователю потребуются какие-то более понятные ориентиры, нежели географические координаты, чтобы найти нужную кофе-машину.</li>
<li><code>place</code> — место (кафе, автомат, ресторан), где находится машина; мы не вводили эту сущность ранее, но, очевидно, пользователю потребуются какие-то более понятные ориентиры, нежели географические координаты, чтобы найти нужную кофемашину.</li>
</ul>
<p><strong>NB</strong>. Мы могли бы не добавлять новый эндпойнт, а обогатить существующий <code>/coffee-machines</code>. Однако такое решение выглядит менее семантично: не стоит в рамках одного интерфейса смешивать способ перечисления объектов по порядку и по релевантности запросу, поскольку эти два вида ранжирования обладают существенно разными свойствами и сценариями использования. К тому же, обогащение поиска «предложениями» скорее выводит эту функциональность из неймспейса «кофе-машины»: для пользователя всё-таки первичен факт получения предложения приготовить напиток на конкретных условиях, и кофе-машина — лишь одно из них. <code>/v1/offers/search</code> — более логичное имя для такого эндпойнта.</p>
<p><strong>NB</strong>. Мы могли бы не добавлять новый эндпойнт, а обогатить существующий <code>/coffee-machines</code>. Однако такое решение выглядит менее семантично: не стоит в рамках одного интерфейса смешивать способ перечисления объектов по порядку и по релевантности запросу, поскольку эти два вида ранжирования обладают существенно разными свойствами и сценариями использования. К тому же, обогащение поиска «предложениями» скорее выводит эту функциональность из неймспейса «кофемашины»: для пользователя всё-таки первичен факт получения предложения приготовить напиток на конкретных условиях, и кофемашина — лишь одно из них. <code>/v1/offers/search</code> — более логичное имя для такого эндпойнта.</p>
<p>Вернёмся к коду, который напишет разработчик. Теперь он будет выглядеть примерно так:</p>
<pre><code>// Ищем предложения,
// соответствующие запросу пользователя
@ -1028,8 +1028,8 @@ let offers = api.offerSearch(parameters);
// Показываем пользователю
app.display(offers);
</code></pre>
<h4>Хэлперы</h4>
<p>Методы, подобные только что изобретённому нами <code>offers/search</code>, принято называть <em>хэлперами</em>. Цель их существования — обобщить понятные сценарии использования API и облегчить их. Под «облегчить» мы имеем в виду не только сократить многословность («бойлерплейт»), но и помочь разработчику избежать частых проблем и ошибок.</p>
<h4>Хелперы</h4>
<p>Методы, подобные только что изобретённому нами <code>offers/search</code>, принято называть <em>хелперами</em>. Цель их существования — обобщить понятные сценарии использования API и облегчить их. Под «облегчить» мы имеем в виду не только сократить многословность («бойлерплейт»), но и помочь разработчику избежать частых проблем и ошибок.</p>
<p>Рассмотрим, например, вопрос стоимости заказа. Наша функция поиска возвращает какие-то «предложения» с ценой. Но ведь цена может меняться: в «счастливый час» кофе может стоить меньше. Разработчик может ошибиться в имплементации этой функциональности трижды:</p>
<ul>
<li>кэшировать на клиентском устройстве результаты поиска слишком долго (в результате цена всегда будет неактуальна),</li>
@ -1098,19 +1098,19 @@ app.display(offers);
}
</code></pre>
<p>Получив такую ошибку, клиент должен проверить её род (что-то с предложением), проверить конкретную причину ошибки (срок жизни оффера истёк) и отправить повторный запрос цены. При этом если бы <code>checks_failed</code> показал другую причину ошибки — например, указанный <code>offer_id</code> не принадлежит данному пользователю — действия клиента были бы иными (отправить пользователя повторно авторизоваться, а затем перезапросить цену). Если же обработка такого рода ошибок в коде не предусмотрена — следует показать пользователю сообщение <code>localized_message</code> и вернуться к обработке ошибок по умолчанию.</p>
<p>Важно также отметить, что неустранимые ошибки в моменте для клиента бесполезны (не зная причины ошибки клиент не может ничего разумного предложить пользователю), но это не значит, что у них не должно быть расширенной информации: их все равно будет просматривать разработчик, когда будет исправлять эту проблему в коде. Подробнее об этом в пп. 12-13 следующей главы.</p>
<p>Важно также отметить, что неустранимые ошибки в моменте для клиента бесполезны (не зная причины ошибки клиент не может ничего разумного предложить пользователю), но это не значит, что у них не должно быть расширенной информации: их всё равно будет просматривать разработчик, когда будет исправлять эту проблему в коде. Подробнее об этом в пп. 12-13 следующей главы.</p>
<h4>Декомпозиция интерфейсов. Правило «7±2»</h4>
<p>Исходя из нашего собственного опыта использования разных API, мы можем, не колеблясь, сказать, что самая большая ошибка проектирования сущностей в API (и, соответственно, головная боль разработчиков) — чрезмерная перегруженность интерфейсов полями, методами, событиями, параметрами и прочими атрибутами сущностей.</p>
<p>При этом существует «золотое правило», применимое не только к API, но ко множеству других областей проектирования: человек комфортно удерживает в краткосрочной памяти 7±2 различных объекта. Манипулировать большим числом сущностей человеку уже сложно. Это правило также известно как <a href="https://ru.wikipedia.org/wiki/%D0%A0%D0%B0%D0%B1%D0%BE%D1%87%D0%B0%D1%8F_%D0%BF%D0%B0%D0%BC%D1%8F%D1%82%D1%8C#%D0%9E%D1%86%D0%B5%D0%BD%D0%BA%D0%B0_%D0%B5%D0%BC%D0%BA%D0%BE%D1%81%D1%82%D0%B8_%D1%80%D0%B0%D0%B1%D0%BE%D1%87%D0%B5%D0%B9_%D0%BF%D0%B0%D0%BC%D1%8F%D1%82%D0%B8">«закон Миллера»</a>.</p>
<p>Бороться с этим законом можно только одним способом: декомпозицией. На каждом уровне работы с вашим API нужно стремиться логически группировать сущности под одним именем там, где это возможно и таким образом, чтобы разработчику никогда не приходилось оперировать более чем 10 сущностями одновременно.</p>
<p>Рассмотрим простой пример: что должна возвращать функция поиска подходящей кофе-машины. Для обеспечения хорошего UX приложения необходимо передать довольно значительные объёмы информации.</p>
<p>Рассмотрим простой пример: что должна возвращать функция поиска подходящей кофемашины. Для обеспечения хорошего UX приложения необходимо передать довольно значительные объёмы информации.</p>
<pre><code>{
"results": [
{
"coffee_machine_id",
// Тип кофе-машины
// Тип кофемашины
"coffee_machine_type": "drip_coffee_maker",
// Марка кофе-машины
// Марка кофемашины
"coffee_machine_brand",
// Название заведения
"place_name": "Кафе «Ромашка»",
@ -1124,7 +1124,7 @@ app.display(offers);
// Сколько идти: время и расстояние
"walking_distance",
"walking_time",
// Как найти заведение и кофе-машину
// Как найти заведение и кофемашину
"place_location_tip",
"offers": [
{
@ -1149,8 +1149,8 @@ app.display(offers);
<p>Подход, увы, совершенно стандартный, его можно встретить практически в любом API. Как мы видим, количество полей сущностей вышло далеко за рекомендованные 7, и даже 9. При этом набор полей идёт плоским списком вперемешку, часто с одинаковыми префиксами.</p>
<p>В такой ситуации мы должны выделить в структуре информационные домены: какие поля логически относятся к одной предметной области. В данном случае мы можем выделить как минимум следующие виды данных:</p>
<ul>
<li>данные о заведении, в котором находится кофе машины;</li>
<li>данные о самой кофе-машине;</li>
<li>данные о заведении, в котором находится кофемашины;</li>
<li>данные о самой кофемашине;</li>
<li>данные о пути до точки;</li>
<li>данные о рецепте;</li>
<li>особенности рецепта в конкретном заведении;</li>
@ -1162,7 +1162,7 @@ app.display(offers);
"results": [{
// Данные о заведении
"place": { "name", "location" },
// Данные о кофе-машине
// Данные о кофемашине
"coffee-machine": { "id", "brand", "type" },
// Как добраться
"route": { "distance", "duration", "location_tip" },
@ -1171,7 +1171,7 @@ app.display(offers);
// Рецепт
"recipe": { "id", "name", "description" },
// Данные относительно того,
// как рецепт готовят на конкретной кофе-машине
// как рецепт готовят на конкретной кофемашине
"options": { "volume" },
// Метаданные предложения
"offer": { "id", "valid_until" },
@ -1184,7 +1184,7 @@ app.display(offers);
</code></pre>
<p>Такой API читать и воспринимать гораздо удобнее, нежели сплошную простыню различных атрибутов. Более того, возможно, стоит на будущее сразу дополнительно сгруппировать, например, <code>place</code> и <code>route</code> в одну структуру <code>location</code>, или <code>offer</code> и <code>pricing</code> в одну более общую структуру.</p>
<p>Важно, что читабельность достигается не просто снижением количества сущностей на одном уровне. Декомпозиция должна производиться таким образом, чтобы разработчик при чтении интерфейса сразу понимал: так, вот здесь находится описание заведения, оно мне пока неинтересно и углубляться в эту ветку я пока не буду. Если перемешать данные, которые нужны в моменте одновременно для выполнения действия по разным композитам — это только ухудшит читабельность, а не улучшит.</p>
<p>Дополнительно правильная декомпозиция поможет нам в решении задачи расширения и развития API, о чем мы поговорим в разделе II.</p><div class="page-break"></div><h3><a href="#chapter-11" class="anchor" id="chapter-11">Глава 11. Описание конечных интерфейсов</a></h3>
<p>Дополнительно правильная декомпозиция поможет нам в решении задачи расширения и развития API, о чём мы поговорим в разделе II.</p><div class="page-break"></div><h3><a href="#chapter-11" class="anchor" id="chapter-11">Глава 11. Описание конечных интерфейсов</a></h3>
<p>Определив все сущности, их ответственность и отношения друг с другом, мы переходим непосредственно к разработке API: нам осталось прописать номенклатуру всех объектов, полей, методов и функций в деталях. В этой главе мы дадим сугубо практические советы, как сделать API удобным и понятным.</p>
<p>Важное уточнение под номером ноль:</p>
<h5><a href="#chapter-11-paragraph-0" id="chapter-11-paragraph-0" class="anchor">0. Правила — это всего лишь обобщения</a></h5>
@ -1258,10 +1258,10 @@ strpbrk (str1, str2)
<p>Аналогично, если ожидается булево значение, то это должно быть очевидно из названия, т.е. именование должно описывать некоторое качественное состояние, например, <code>is_ready</code>, <code>open_now</code>.</p>
<p><strong>Плохо</strong>: <code>"task.status": true</code> — неочевидно, что статус бинарен, к тому же такой API будет нерасширяемым.</p>
<p><strong>Хорошо</strong>: <code>"task.is_finished": true</code>.</p>
<p>Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учетом специфики first-class citizen-типов. Например, объекты типа <code>Date</code>, если таковые имеются, разумно индицировать с помощью, например, постфикса <code>_at</code> (<code>created_at</code>, <code>occurred_at</code> и т.д.) или <code>_date</code>.</p>
<p>Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учётом специфики first-class citizen-типов. Например, объекты типа <code>Date</code>, если таковые имеются, разумно индицировать с помощью, например, постфикса <code>_at</code> (<code>created_at</code>, <code>occurred_at</code> и т.д.) или <code>_date</code>.</p>
<p>Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс во избежание непонимания.</p>
<p><strong>Плохо</strong>:</p>
<pre><code>// Возвращает список встроенных функций кофе-машины
<pre><code>// Возвращает список встроенных функций кофемашины
GET /coffee-machines/{id}/functions
</code></pre>
<p>Слово "functions" многозначное: оно может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).</p>
@ -1281,7 +1281,7 @@ str_replace(needle, replace, haystack)
</code></pre>
<p>Здесь нарушены сразу несколько правил:</p>
<ul>
<li>написание неконсистентно в части знака подчеркивания;</li>
<li>написание неконсистентно в части знака подчёркивания;</li>
<li>близкие по смыслу методы имеют разный порядок аргументов <code>needle</code>/<code>haystack</code>;</li>
<li>первый из методов находит только первое вхождение строки <code>needle</code>, а другой — все вхождения, и об этом поведении никак нельзя узнать из сигнатуры функций.</li>
</ul>
@ -1338,7 +1338,7 @@ GET /v1/users/{id}/orders
<p><strong>Плохо</strong>: <code>"dont_call_me": false</code><br>
— люди в целом плохо считывают двойные отрицания. Это провоцирует ошибки.</p>
<p><strong>Лучше</strong>: <code>"prohibit_calling": true</code> или <code>"avoid_calling": true</code><br>
— читается лучше, хотя обольщаться все равно не следует. Насколько это возможно откажитесь от семантически двойных отрицаний, даже если вы придумали «негативное» слово без явной приставки «не».</p>
— читается лучше, хотя обольщаться всё равно не следует. Насколько это возможно откажитесь от семантически двойных отрицаний, даже если вы придумали «негативное» слово без явной приставки «не».</p>
<p>Стоит также отметить, что в использовании <a href="https://ru.wikipedia.org/wiki/%D0%97%D0%B0%D0%BA%D0%BE%D0%BD%D1%8B_%D0%B4%D0%B5_%D0%9C%D0%BE%D1%80%D0%B3%D0%B0%D0%BD%D0%B0">законов де Моргана</a> ошибиться ещё проще, чем в двойных отрицаниях. Предположим, что у вас есть два флага:</p>
<pre><code>GET /coffee-machines/{id}/stocks
@ -1747,7 +1747,7 @@ GET /v1/records?limit=10&#x26;offset=100
</code></pre>
<p>На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса.</p>
<ol>
<li>Каким образом клиент узнает о появлении новых записей в начале списка?<br>
<li>Каким образом клиент узнает о появлении новых записей в начале списка?
Легко заметить, что клиент может только попытаться повторить первый запрос и сверить идентификаторы с запомненным началом списка. Но что делать, если добавленное количество записей превышает <code>limit</code>? Представим себе ситуацию:
<ul>
<li>клиент обрабатывает записи в порядке поступления;</li>
@ -1859,7 +1859,7 @@ GET /v1/record-views/{id}?cursor={cursor}
→ 400 Bad Request
{}
</code></pre>
<p>— да, конечно, допущенные ошибки (опечатка в <code>"lngo"</code> и неправильные координаты) очевидны. Но раз наш сервер все равно их проверяет, почему не вернуть описание ошибок в читаемом виде?</p>
<p>— да, конечно, допущенные ошибки (опечатка в <code>"lngo"</code> и неправильные координаты) очевидны. Но раз наш сервер всё равно их проверяет, почему не вернуть описание ошибок в читаемом виде?</p>
<p><strong>Хорошо</strong>:</p>
<pre><code>{
"reason": "wrong_parameter_value",
@ -1911,7 +1911,7 @@ POST /v1/orders
"reason": "recipe_unknown"
}
</code></pre>
<p>— какой был смысл получать новый <code>offer</code>, если заказ все равно не может быть создан?</p>
<p>— какой был смысл получать новый <code>offer</code>, если заказ всё равно не может быть создан?</p>
<p><strong>Во-вторых</strong>, соблюдайте такой порядок разрешимых ошибок, который приводит к наименьшему раздражению пользователя и разработчика. В частности, следует начинать с более значимых ошибок, решение которых требует более глобальных изменений.</p>
<p><strong>Плохо</strong>:</p>
<pre><code>POST /v1/orders
@ -1937,7 +1937,7 @@ POST /v1/orders
"localized_message": "Лимит заказов превышен"
}
</code></pre>
<p>— какой был смысл показывать пользователю диалог об изменившейся цене, если и с правильной ценой заказ он сделать все равно не сможет? Пока один из его предыдущих заказов завершится и можно будет сделать следующий заказ, цену, наличие и другие параметры заказа всё равно придётся корректировать ещё раз.</p>
<p>— какой был смысл показывать пользователю диалог об изменившейся цене, если и с правильной ценой заказ он сделать всё равно не сможет? Пока один из его предыдущих заказов завершится и можно будет сделать следующий заказ, цену, наличие и другие параметры заказа всё равно придётся корректировать ещё раз.</p>
<p><strong>В-третьих</strong>, постройте таблицу: разрешение какой ошибки может привести к появлению другой, иначе вы можете показать одну и ту же ошибку несколько раз, а то и вовсе зациклить разрешение ошибок.</p>
<pre><code>// Создаём заказ с платной доставкой
POST /v1/orders
@ -1973,7 +1973,7 @@ POST /v1/orders
"minimal_sum": "10000.00"
}
</code></pre>
<p>Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчета (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса.</p>
<p>Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчёта (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса.</p>
<h5><a href="#chapter-11-paragraph-19" id="chapter-11-paragraph-19" class="anchor">19. Отсутствие результата — тоже результат</a></h5>
<p>Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой.</p>
<p><strong>Плохо</strong></p>
@ -2006,7 +2006,7 @@ POST /v1/orders
<p>Важно понимать, что язык пользователя и юрисдикция, в которой пользователь находится — разные вещи. Цикл работы вашего API всегда должен хранить локацию пользователя. Либо она задаётся явно (в запросе указываются географические координаты), либо неявно (первый запрос с географическими координатами инициировал создание сессии, в которой сохранена локация) — но без локации корректная локализация невозможна. В большинстве случаев локацию допустимо редуцировать до кода страны.</p>
<p>Дело в том, что множество параметров, потенциально влияющих на работу API, зависят не от языка, а именно от расположения пользователя. В частности, правила форматирования чисел (разделители целой и дробной частей, разделители разрядов) и дат, первый день недели, раскладка клавиатуры, система единиц измерения (которая к тому же может оказаться не десятичной!) и так далее. В некоторых ситуациях необходимо хранить две локации: та, в которой пользователь находится, и та, которую пользователь сейчас просматривает. Например, если пользователь из США планирует туристическую поездку в Европу, то цены ему желательно показывать в местной валюте, но отформатированными согласно правилам американского письма.</p>
<p>Следует иметь в виду, что явной передачи локации может оказаться недостаточно, поскольку в мире существуют территориальные конфликты и спорные территории. Каким образом API должен себя вести при попадании координат пользователя на такие территории — вопрос, к сожалению, в первую очередь юридический. Автору этой книги приходилось как-то разрабатывать API, в котором пришлось вводить концепцию «территория государства A по мнению официальных органов государства Б».</p>
<p><strong>Важно</strong>: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 19 сообщение <code>localized_message</code> адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение <code>details.checks_failed[].message</code> написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де факто является стандартом в мире разработки программного обеспечения.</p>
<p><strong>Важно</strong>: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 19 сообщение <code>localized_message</code> адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение <code>details.checks_failed[].message</code> написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де-факто является стандартом в мире разработки программного обеспечения.</p>
<p>Следует отметить, что индикация, какие сообщения следует показать пользователю, а какие написаны для разработчика, должна, разумеется, быть явной конвенцией вашего API. В примере для этого используется префикс <code>localized_</code>.</p>
<p>И ещё одна вещь: все строки должны быть в кодировке UTF-8 и никакой другой.</p><div class="page-break"></div><h3><a href="#chapter-12" class="anchor" id="chapter-12">Глава 12. Приложение к разделу I. Модельный API</a></h3>
<p>Суммируем текущее состояние нашего учебного API.</p>
@ -2026,7 +2026,7 @@ POST /v1/orders
"results": [{
// Данные о заведении
"place": { "name", "location" },
// Данные о кофе-машине
// Данные о кофемашине
"coffee_machine": { "id", "brand", "type" },
// Как добраться
"route": { "distance", "duration", "location_tip" },
@ -2035,7 +2035,7 @@ POST /v1/orders
// Рецепт
"recipe": { "id", "name", "description" },
// Данные относительно того,
// как рецепт готовят на конкретной кофе-машине
// как рецепт готовят на конкретной кофемашине
"options": { "volume" },
// Метаданные предложения
"offer": { "id", "valid_until" },
@ -2087,7 +2087,7 @@ POST /v1/orders/{id}/cancel
<h5><a href="#chapter-12-paragraph-4" id="chapter-12-paragraph-4" class="anchor">4. Работа с программами</a></h5>
<pre><code>// Возвращает идентификатор программы,
// соответствующей указанному рецепту
// на указанной кофе-машине
// на указанной кофемашине
POST /v1/program-matcher
{ "recipe", "coffee_machine" }
@ -2162,7 +2162,7 @@ POST /v1/runtimes/{id}/terminate
</li>
<li>
<p>Что значит «длительное время»?</p>
<p>С нашей точки зрения длительность поддержания обратной совместимости следует увязывать с длительностью жизненных циклов приложений в соответствующей предметной области. Хороший ориентир в большинстве случаев — это LTS-периоды платформ. Так как приложение все равно будет переписано в связи с окончанием поддержки платформы, нормально предложить также и переход на новую версию API. В основных предметных областях (десктопные и мобильные операционные системы) этот срок исчисляется несколькими годами.</p>
<p>С нашей точки зрения длительность поддержания обратной совместимости следует увязывать с длительностью жизненных циклов приложений в соответствующей предметной области. Хороший ориентир в большинстве случаев — это LTS-периоды платформ. Так как приложение всё равно будет переписано в связи с окончанием поддержки платформы, нормально предложить также и переход на новую версию API. В основных предметных областях (десктопные и мобильные операционные системы) этот срок исчисляется несколькими годами.</p>
</li>
</ol>
<p>Почему обратную совместимость необходимо поддерживать (в том числе предпринимать необходимые меры ещё на этапе проектирования API) — понятно из определения. Прекращение работы приложения (полное или частичное) по вине поставщика API — крайне неприятное событие, а то и катастрофа, для любого разработчика, особенно если он платит за этот API деньги.</p>
@ -2188,7 +2188,7 @@ POST /v1/runtimes/{id}/terminate
<ol>
<li>
<p>Если платформа поддерживает on-demand получение кода, как старый-добрый Веб, и вы не поленились это получение кода реализовать (в виде платформенного SDK, например, JS API), то развитие API более или менее находится под вашим контролем. Поддержание обратной совместимости сводится к поддержанию обратной совместимости <em>клиентской библиотеки</em>, а вот в части сервера и клиент-серверного взаимодействия вы свободны.</p>
<p>Это не означает, что вы не можете нарушить обратную совместимость — всё ещё можно напортачить с заголовками кэширования SDK или банально допустить баг в коде. Кроме того, даже on-demand системы все равно не обновляются мгновенно — автор сталкивался с ситуацией, когда пользователи намеренно держали вкладку браузера открытой <em>неделями</em>, чтобы не обновляться на новые версии. Тем не менее, вам почти не придётся поддерживать более двух (последней и предпоследней) минорных версий клиентского SDK. Более того, вы можете попытаться в какой-то момент переписать предыдущую мажорную версию библиотеки, имплементировав её на основе API новой версии.</p>
<p>Это не означает, что вы не можете нарушить обратную совместимость — всё ещё можно напортачить с заголовками кэширования SDK или банально допустить баг в коде. Кроме того, даже on-demand системы всё равно не обновляются мгновенно — автор сталкивался с ситуацией, когда пользователи намеренно держали вкладку браузера открытой <em>неделями</em>, чтобы не обновляться на новые версии. Тем не менее, вам почти не придётся поддерживать более двух (последней и предпоследней) минорных версий клиентского SDK. Более того, вы можете попытаться в какой-то момент переписать предыдущую мажорную версию библиотеки, имплементировав её на основе API новой версии.</p>
</li>
<li>
<p>Если поддержка on-demand кода платформой не поддерживается или запрещена условиями, как это произошло с современными мобильными платформами, то ситуация становится гораздо сложнее. По сути, каждый клиент — это «слепок» кода, который работает с вашим API, зафиксированный в том состоянии, в котором он был на момент компиляции. Обновление клиентских приложений по времени растянуто гораздо дольше, нежели Web-приложений; самое неприятное здесь состоит в том, что некоторые клиенты <em>не обновятся вообще никогда</em> — по одной из трёх причин:</p>
@ -2209,7 +2209,7 @@ POST /v1/runtimes/{id}/terminate
<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 изначально покрывает только какую-то часть существующей предметной области. В случае нашего <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>
@ -2226,7 +2226,7 @@ POST /v1/runtimes/{id}/terminate
<ol>
<li>
<p>Как часто выпускать мажорные версии API.</p>
<p>Это в основном <em>продуктовый</em> вопрос. Новая мажорная версия API выпускается, когда накоплена критическая масса функциональности, которую невозможно или слишком дорого поддерживать в рамках предыдущей мажорной версии. В стабильной ситуации такая необходимость возникает, как правило, раз в несколько лет. На динамично развивающихся рынках новые мажорные версии можно выпускать чаще, здесь ограничителем являются только ваши возможности выделить достаточно ресурсов для поддержания зоопарка версий. Однако следует заметить, что выпуск новой мажорной версии раньше, чем была стабилизирована предыдущая (а на это как правило требуется от нескольких месяцев до года), выглядит для разработчиков очень плохим сигналом, означающим риск <em>постоянно</em> сидеть на сырой платформе.</p>
<p>Это в основном <em>продуктовый</em> вопрос. Новая мажорная версия API выпускается, когда накоплена критическая масса функциональности, которую невозможно или слишком дорого поддерживать в рамках предыдущей мажорной версии. В стабильной ситуации такая необходимость возникает, как правило, раз в несколько лет. На динамично развивающихся рынках новые мажорные версии можно выпускать чаще, здесь ограничителем являются только ваши возможности выделить достаточно ресурсов для поддержания зоопарка версий. Однако следует заметить, что выпуск новой мажорной версии раньше, чем была стабилизирована предыдущая (а на это, как правило, требуется от нескольких месяцев до года), выглядит для разработчиков очень плохим сигналом, означающим риск <em>постоянно</em> сидеть на сырой платформе.</p>
</li>
<li>
<p>Какое количество <em>мажорных</em> версий поддерживать одновременно.</p>
@ -2255,13 +2255,13 @@ POST /v1/runtimes/{id}/terminate
</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>
<p>Однако достаточно часто разработчики API сами легитимизируют такие серые зоны, например:</p>
<ul>
<li>отдают недокументированные поля в ответах эндпойнтов;</li>
<li>используют непубличную функциональность в примерах кода — в документации, в ответ на обращения пользователей, в выступлениях на конференциях и т.д.</li>
</ul>
<p>Нельзя принять обязательства наполовину. Или вы гарантируете работу этого кода всегда, или не подавайте никаких намеков на то, что такая функциональность существует.</p>
<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>
@ -2337,21 +2337,21 @@ object.observe('widthchange', observerFunction);
}
</code></pre>
<p>Допустим, в какой-то момент вы решили надёжным клиентам с хорошей историей заказов предоставлять кофе «в кредит», не дожидаясь подтверждения платежа. Т.е. заказ перейдёт в статус <code>"preparing_started"</code>, а может и <code>"ready"</code>, вообще без события <code>"payment_approved"</code>. Вам может показаться, что это изменение является обратно-совместимым — в самом деле, вы же и не обещали никакого конкретного порядка событий. Но это, конечно, не так.</p>
<p>Предположим, что у разработчика (вероятно, бизнес-партнера вашей компании) написан какой-то код, выполняющий какую-то полезную бизнес функцию поверх этих событий — например, строит аналитику по затратам и доходам. Вполне логично ожидать, что этот код будет оперировать какой-то машиной состояний, которая будет переходить в то или иное состояние в зависимости от получения или неполучения события. Аналитический код наверняка сломается вследствие изменения порядка событий. В лучшем случае разработчик увидит какие-то исключения и будет вынужден разбираться с причиной; в худшем случае партнер будет оперировать неправильной статистикой неопределённое время, пока не найдёт в ней ошибку.</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> нужный номер телефона, и позвонить по нему, даже если приложение его не показало. При этом код бизнес-аналитика партнёра всё так же может сломаться или начать показывать погоду на Марсе, т.к. он был написан когда-то, ничего не зная о возможности отменить заказ, сделанный в приложении партнёра, каким-то иным образом, не через самого партнёра же.</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>Поэтому в этом разделе мы поступим следующим образом: возьмём наш <a href="#chapter-12">модельный API</a> из предыдущего раздела, и проверим его на устойчивость в каждой возможной точке — проведём некоторый «вариационный анализ» наших интерфейсов. Ещё более конкретно — к каждой сущности мы подойдём с вопросом «что, если?» — что, если нам потребуется предоставить партнерам возможность написать свою независимую реализацию этого фрагмента логики.</p>
<p>Поэтому в этом разделе мы поступим следующим образом: возьмём наш <a href="#chapter-12">модельный API</a> из предыдущего раздела, и проверим его на устойчивость в каждой возможной точке — проведём некоторый «вариационный анализ» наших интерфейсов. Ещё более конкретно — к каждой сущности мы подойдём с вопросом «что, если?» — что, если нам потребуется предоставить партнёрам возможность написать свою независимую реализацию этого фрагмента логики.</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>
<p>Сделаем следующий логический шаг и предположим, что партнёры захотят динамически подключать к нашей платформе свои собственные кофемашины с каким-то новым API. Для этого нам будет необходимо договориться о формате обратного вызова, каким образом мы будем вызывать API партнёра, и предоставить два новых эндпойнта для:</p>
<ul>
<li>регистрации в системе новых типов API;</li>
<li>загрузки списка кофе-машин партнёра с указанием типа API.</li>
<li>загрузки списка кофемашин партнёра с указанием типа API.</li>
</ul>
<p>Например, можно предоставить вот такие методы.</p>
<pre><code>// 1. Зарегистрировать новый тип API
@ -2362,7 +2362,7 @@ PUT /v1/api-types/{api_type}
}
}
</code></pre>
<pre><code>// 2. Предоставить список кофе-машин с разбивкой
<pre><code>// 2. Предоставить список кофемашин с разбивкой
// по типу API
PUT /v1/partners/{partnerId}/coffee-machines
{
@ -2376,21 +2376,21 @@ PUT /v1/partners/{partnerId}/coffee-machines
</code></pre>
<p>Таким образом механика следующая:</p>
<ul>
<li>партнер описывает свои виды API, кофе-машины и поддерживаемые рецепты;</li>
<li>при получении заказа, который необходимо выполнить на конкретной кофе машине, наш сервер обратится к функции обратного вызова, передав ей данные о заказе в оговоренном формате.</li>
<li>партнёр описывает свои виды API, кофемашины и поддерживаемые рецепты;</li>
<li>при получении заказа, который необходимо выполнить на конкретной кофемашине, наш сервер обратится к функции обратного вызова, передав ей данные о заказе в оговорённом формате.</li>
</ul>
<p>Теперь партнёры могут динамически подключать свои кофе-машины и обрабатывать заказы. Займёмся теперь, однако, вот каким упражнением:</p>
<p>Теперь партнёры могут динамически подключать свои кофемашины и обрабатывать заказы. Займёмся теперь, однако, вот каким упражнением:</p>
<ul>
<li>перечислим все неявные предположения, которые мы допустили;</li>
<li>перечислим все неявные механизмы связывания, которые необходимы для функционирования платформы.</li>
</ul>
<p>Может показаться, что в нашем API нет ни того, ни другого, ведь он очень прост и по сути просто сводится к вызову какого-то HTTP-метода — но это неправда.</p>
<ol>
<li>Предполагается, что каждая кофе-машина поддерживает все возможные опции заказа (например, допустимый объём напитка).</li>
<li>Нет необходимости показывать пользователю какую-то дополнительную информацию о том, что заказ готовится на новых типах кофе-машин.</li>
<li>Цена напитка не зависит ни от партнёра, ни от типа кофе-машины.</li>
<li>Предполагается, что каждая кофемашина поддерживает все возможные опции заказа (например, допустимый объём напитка).</li>
<li>Нет необходимости показывать пользователю какую-то дополнительную информацию о том, что заказ готовится на новых типах кофемашин.</li>
<li>Цена напитка не зависит ни от партнёра, ни от типа кофемашины.</li>
</ol>
<p>Эти пункты мы выписали с одной целью: нам нужно понять, каким конкретно образом мы будем переводить неявные договорённости в явные, если нам это потребуется. Например, если разные кофе-машины предоставляют разный объём функциональности — допустим, в каких-то кофейнях объём кофе фиксирован — что должно измениться в нашем API?</p>
<p>Эти пункты мы выписали с одной целью: нам нужно понять, каким конкретно образом мы будем переводить неявные договорённости в явные, если нам это потребуется. Например, если разные кофемашины предоставляют разный объём функциональности — допустим, в каких-то кофейнях объём кофе фиксирован — что должно измениться в нашем API?</p>
<p>Универсальный паттерн внесения подобных изменений таков: мы должны рассмотреть существующий интерфейс как частный случай некоторого более общего, в котором значения некоторых параметров приняты известными по умолчанию, а потому опущены. Таким образом, внесение изменений всегда происходит в три шага.</p>
<ol>
<li>Явная фиксация программного контракта <em>в том объёме, в котором она действует на текущий момент</em>.</li>
@ -2400,7 +2400,7 @@ PUT /v1/partners/{partnerId}/coffee-machines
<p>На нашем примере с изменением списка доступных опций заказа мы должны поступить следующим образом.</p>
<ol>
<li>
<p>Документируем текущее состояние. Все кофе-машины, подключаемые по API, обязаны поддерживать три опции: посыпку корицей, изменение объёма и бесконтактную выдачу.</p>
<p>Документируем текущее состояние. Все кофемашины, подключаемые по API, обязаны поддерживать три опции: посыпку корицей, изменение объёма и бесконтактную выдачу.</p>
</li>
<li>
<p>Добавляем новый метод <code>with-options</code>:</p>
@ -2423,13 +2423,13 @@ PUT /v1/partners/{partnerId}/coffee-machines
</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>
<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. Представим, что через какое-то время к поддерживаемым опциям добавились новые — ну, скажем, добавление сиропа и второго шота эспрессо. Список опций расширить мы можем — а вот изменить соглашение по умолчанию уже нет. Через некоторое время это приведёт к тому, что «дефолтный» интерфейс <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>Для демонстрации проблем сильной связности перейдём теперь к <em>действительно интересным</em> вещам. Продолжим наш «вариационный анализ»: что, если партнёры хотят не просто готовить кофе по стандартным рецептам, но и предлагать свои авторские напитки? Вопрос этот с подвохом: в том виде, как мы описали партнёрскый API в предыдущей главе, факт существования партнёрской сети никак не отражён в нашем API с точки зрения продукта, предлагаемого пользователю, а потому представляет собой довольно простой кейс. Если же мы пытаемся предоставить не какую-то дополнительную возможность, а модифицировать саму базовую функциональность API, то мы быстро столкнёмся с проблемами совсем другого порядка.</p>
<p>Итак, добавим ещё один эндпойнт — для регистрации собственного рецепта партнёра.</p>
<pre><code>// Добавляет новый рецепт
POST /v1/recipes
@ -2466,7 +2466,7 @@ POST /v1/recipes
<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>Эта проблема разворачивается и в другую сторону — UI (наш или партнёра) обязательно будет развиваться, в нём будут появляться новые элементы (картинка для кофе, его пищевая ценность, информация об аллергенах и так далее). <code>product_properties</code> со временем превратится в свалку из большого количества необязательных полей, и выяснить, задание каких из них приведёт к каким эффектам в каком приложении можно будет только методом проб и ошибок.</p>
<p>Проблемы, с которыми мы столкнулись — это проблемы <em>сильной связности</em>. Каждый раз, предлагая интерфейс, подобный вышеприведённому, мы фактически описываем имплементацию одной сущности (рецепта) через имплементации других (визуального макета, правил локализации). Этот подход противоречит самому принципу проектирования API «сверху вниз», поскольку <strong>низкоуровневые сущности не должны определять высокоуровневые</strong>.</p>
<h4>Правило контекстов</h4>
<p>Как бы парадоксально это ни звучало, обратное утверждение тоже верно: высокоуровневые сущности тоже не должны определять низкоуровневые. Это попросту не их ответственность. Выход из этого логического лабиринта таков: высокоуровневые сущности должны <em>определять контекст</em>, который другие объекты будут интерпретировать. Чтобы спроектировать добавление нового рецепта нам нужно не формат данных подобрать — нам нужно понять, какие (возможно, неявные, т.е. не представленные в виде API) контексты существуют в нашей предметной области.</p>
@ -2475,7 +2475,7 @@ POST /v1/recipes
// l10n.formatVolume('300ml', 'en', 'UK') → '300 ml'
// l10n.formatVolume('300ml', 'en', 'US') → '10 fl oz'
</code></pre>
<p>Чтобы наш API корректно заработал с новым языком или регионом, партнер должен или задать эту функцию, или указать, какую из существующих локализаций необходимо использовать. Для этого мы абстрагируем-и-расширяем API, в соответствии с описанной в предыдущей главе процедурой, и добавляем новый эндпойнт — настройки форматирования:</p>
<p>Чтобы наш API корректно заработал с новым языком или регионом, партнёр должен или задать эту функцию, или указать, какую из существующих локализаций необходимо использовать. Для этого мы абстрагируем-и-расширяем API, в соответствии с описанной в предыдущей главе процедурой, и добавляем новый эндпойнт — настройки форматирования:</p>
<pre><code>// Добавляем общее правило форматирования
// для русского языка
PUT /formatters/volume/ru
@ -2502,7 +2502,7 @@ PUT /formatters/volume/ru/US
"id",
// Макетов вполне возможно будет много разных,
// поэтому имеет смысл сразу заложить
// расширяемоесть
// расширяемость
"kind": "recipe_search",
// Описываем каждое свойство рецепта,
// которое должно быть задано для
@ -2572,7 +2572,7 @@ PUT /formatters/volume/ru/US
}
</code></pre>
<p>Заметим, что передача идентификатора вновь создаваемой сущности клиентом — не лучший паттерн. Но раз уж мы с самого начала решили, что идентификаторы рецептов — не просто случайные наборы символов, а значимые строки, то нам теперь придётся с этим как-то жить. Очевидно, в такой ситуации мы рискуем многочисленными коллизиями между названиями рецептов разных партнёров, поэтому операцию, на самом деле, следует модифицировать: либо для партнерских рецептов всегда пользоваться парой идентификаторов (партнера и рецепта), либо ввести составные идентификаторы, как мы ранее рекомендовали в <a href="#chapter-11-paragraph-8">главе 11</a>.</p>
<p>Заметим, что передача идентификатора вновь создаваемой сущности клиентом — не лучший паттерн. Но раз уж мы с самого начала решили, что идентификаторы рецептов — не просто случайные наборы символов, а значимые строки, то нам теперь придётся с этим как-то жить. Очевидно, в такой ситуации мы рискуем многочисленными коллизиями между названиями рецептов разных партнёров, поэтому операцию, на самом деле, следует модифицировать: либо для партнёрских рецептов всегда пользоваться парой идентификаторов (партнёра и рецепта), либо ввести составные идентификаторы, как мы ранее рекомендовали в <a href="#chapter-11-paragraph-8">главе 11</a>.</p>
<pre><code>POST /v1/recipes/custom
{
// Первая часть идентификатора:
@ -2597,11 +2597,11 @@ PUT /formatters/volume/ru/US
</code></pre>
<p>Тогда разработчикам пришлось бы сделать примерно следующее для запуска приготовления кофе:</p>
<ul>
<li>выяснить тип API конкретной кофе-машины;</li>
<li>выяснить тип API конкретной кофемашины;</li>
<li>получить описание способа запуска программы выполнения рецепта на машине с API такого типа;</li>
<li>в зависимости от типа API выполнить специфические команды запуска.</li>
</ul>
<p>Очевидно, что такой интерфейс совершенно недопустим — просто потому, что в подавляющем большинстве случаев разработчикам совершенно неинтересно, какого рода API поддерживает та или иная кофе-машина. Для того, чтобы не допустить такого плохого интерфейса мы ввели новую сущность «программа», которая по факту представляет собой не более чем просто идентификатор контекста, как и сущность «рецепт».</p>
<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}
@ -2613,7 +2613,7 @@ PUT /formatters/volume/ru/US
</code></pre>
<p>Исходя из общей логики мы можем предположить, что любой API так или иначе будет выполнять три функции: запускать программы с указанными параметрами, возвращать текущий статус запуска и завершать (отменять) заказ. Самый очевидный подход к реализации такого API — просто потребовать от партнёра имплементировать вызов этих трёх функций удалённо, например следующим образом:</p>
<pre><code>// Эндпойнт добавления списка
// кофе-машин партнёра
// кофемашин партнёра
PUT /v1/api-types/{api_type}
{
"order_execution_endpoint":
@ -2658,10 +2658,10 @@ PUT /v1/api-types/{api_type}
</ul>
<p>Организовать и то, и другое можно разными способами, однако по сути мы имеем два описания состояния (верхне- и низкоуровневое) и поток событий между ними. В случае SDK эту идею можно было бы выразить так:</p>
<pre><code>/* Имплементация партнёром интерфейса
запуска программы на его кофе-машинах */
запуска программы на его кофемашинах */
registerProgramRunHandler(apiType, (context) => {
// Инициализируем запуск исполнения
// программы на стороне партнера
// программы на стороне партнёра
let execution = initExecution(context, …);
// Подписываемся на события
// изменения контекста
@ -2682,7 +2682,7 @@ registerProgramRunHandler(apiType, (context) => {
<p>Внимательный читатель может возразить нам, что фактически, если мы посмотрим на номенклатуру возникающих сущностей, мы ничего не изменили в постановке задачи, и даже усложнили её:</p>
<ul>
<li>вместо вызова метода <code>takeout</code> мы теперь генерируем пару событий <code>takeout_requested</code>/<code>takeout_ready</code>;</li>
<li>вместо длинного списка методов, которые необходимо реализовать для интеграции API партнера, появляются длинные списки полей сущности <code>context</code> и событий, которые она генерирует;</li>
<li>вместо длинного списка методов, которые необходимо реализовать для интеграции API партнёра, появляются длинные списки полей сущности <code>context</code> и событий, которые она генерирует;</li>
<li>проблема устаревания технологии не меняется, вместо устаревших методов мы теперь имеем устаревшие поля и события.</li>
</ul>
<p>Это замечание совершенно верно. Изменение формата API само по себе не решает проблем, связанных с эволюцией функциональности и нижележащей технологии. Формат API решает другую проблему: как оставить при этом код читаемым и поддерживаемым. Почему в примере с интеграцией через методы код становится нечитаемым? Потому что обе стороны <em>вынуждены</em> имплементировать функциональность, которая в их контексте бессмысленна; и эта имплементация будет состоять из какого-то (хорошо если явного!) способа ответить, что данная функциональность не поддерживается (или, наоборот, поддерживается всегда и безусловно).</p>
@ -2693,14 +2693,14 @@ registerProgramRunHandler(apiType, (context) => {
</ul>
<p>В пределе может вообще оказаться так, что обе стороны вообще ничего не знают друг о друге и никак не взаимодействуют — не исключаем, что на каком-то этапе развития технологии именно так и произойдёт.</p>
<p>Важно также отметить, что, хотя количество сущностей (полей, событий) эффективно удваивается по сравнению с сильно связанным API, это удвоение является качественным, а не количественным. Контекст <code>program</code> содержит описание задания в своих терминах (вид напитка, объём, посыпка корицей); контекст <code>execution</code> должен эти термины переформулировать для своей предметной области (чтобы быть, в свою очередь, таким же информационным контекстом для ещё более низкоуровневого API). Что важно, <code>execution</code>-контекст имеет право эти термины конкретизировать, поскольку его нижележащие объекты будут уже работать в рамках какого-то конкретного API, в то время как <code>program</code>-контекст обязан выражаться в общих терминах, применимых к любой возможной нижележащей технологии.</p>
<p>Ещё одним важным свойством слабой связности является то, что она позволяет сущности иметь несколько родительских контекстов. В обычных предметных областях такая ситуация выглядела бы ошибкой дизайна API, но в сложных системах, где присутствуют одновременно несколько агентов, влияющих на состояние системы, такая ситуация не является редкостью. В частности, вы почти наверняка столкнётесь с такого рода проблемами при разработке пользовательского UI. Более подробно о подобных двойных иерархиях мы расскажем в разделе, посвященном разработке SDK.</p>
<p>Ещё одним важным свойством слабой связности является то, что она позволяет сущности иметь несколько родительских контекстов. В обычных предметных областях такая ситуация выглядела бы ошибкой дизайна API, но в сложных системах, где присутствуют одновременно несколько агентов, влияющих на состояние системы, такая ситуация не является редкостью. В частности, вы почти наверняка столкнётесь с такого рода проблемами при разработке пользовательского UI. Более подробно о подобных двойных иерархиях мы расскажем в разделе, посвящённом разработке SDK.</p>
<h4>Инверсия ответственности</h4>
<p>Как несложно понять из вышесказанного, двусторонняя слабая связь означает существенное усложнение имплементации обоих уровней, что во многих ситуациях может оказаться излишним. Часто двустороннюю слабую связь можно без потери качества заменить на одностороннюю, а именно — разрешить нижележащей сущности вместо генерации событий напрямую вызывать методы из интерфейса более высокого уровня. Наш пример изменится примерно вот так:</p>
<pre><code>/* Имплементация партнёром интерфейса
запуска программы на его кофе-машинах */
запуска программы на его кофемашинах */
registerProgramRunHandler(apiType, (context) => {
// Инициализируем запуск исполнения
// программы на стороне партнера
// программы на стороне партнёра
let execution = initExecution(context, …);
// Подписываемся на события
// изменения контекста
@ -2722,10 +2722,10 @@ registerProgramRunHandler(apiType, (context) => {
// return execution.context;
}
</code></pre>
<p>Вновь такое решение выглядит контринтуитивным, ведь мы снова вернулись к сильной связи двух уровней через жестко определённые методы. Однако здесь есть важный момент: мы городим весь этот огород потому, что ожидаем появления альтернативных реализаций <em>нижележащего</em> уровня абстракции. Ситуации, когда появляются альтернативные реализации <em>вышележащего</em> уровня абстракции, конечно, возможны, но крайне редки. Обычно дерево альтернативных реализаций растёт сверху вниз.</p>
<p>Вновь такое решение выглядит контринтуитивным, ведь мы снова вернулись к сильной связи двух уровней через жёстко определённые методы. Однако здесь есть важный момент: мы городим весь этот огород потому, что ожидаем появления альтернативных реализаций <em>нижележащего</em> уровня абстракции. Ситуации, когда появляются альтернативные реализации <em>вышележащего</em> уровня абстракции, конечно, возможны, но крайне редки. Обычно дерево альтернативных реализаций растёт сверху вниз.</p>
<p>Другой аспект заключается в том, что, хотя серьёзные изменения концепции возможны на любом из уровней абстракции, их вес принципиально разный:</p>
<ul>
<li>если меняется технический уровень, это не должно существенно влиять на продукт, а значит — на написанный партнерами код;</li>
<li>если меняется технический уровень, это не должно существенно влиять на продукт, а значит — на написанный партнёрами код;</li>
<li>если меняется сам продукт, ну например мы начинаем продавать билеты на самолёт вместо приготовления кофе на заказ, сохранять обратную совместимость на промежуточных уровнях API <em>бесполезно</em>. Мы вполне можем продавать билеты на самолёт тем же самым API программ и контекстов, да только написанный партнёрами код всё равно надо будет полностью переписывать с нуля.</li>
</ul>
<p>В конечном итоге это приводит к тому, что API вышележащих сущностей меняется медленнее и более последовательно по сравнению с API нижележащих уровней, а значит подобного рода «обратная» жёсткая связь зачастую вполне допустима и даже желательна исходя из соотношения «цена-качество».</p>
@ -2738,7 +2738,7 @@ registerProgramRunHandler(apiType, (context) => {
dispatch(takeoutReady());
});
</code></pre>
<p>Надо отметить, что такой подход <em>в принципе</em> не противоречит описанному принципу, но нарушает другой — изоляцию уровней абстракции, а поэтому плохо подходит для написания сложных API, в которых не гарантирована жесткая иерархия компонентов. При этом использовать глобальный (или квази-глобальный) менеджер состояния в таких системах вполне возможно, но требуется имплементировать более сложную пропагацию сообщений по иерархии, а именно: подчинённый объект всегда вызывает методы только ближайшего вышестоящего объекта, а уже тот решает, как и каким образом этот вызов передать выше по иерархии.</p>
<p>Надо отметить, что такой подход <em>в принципе</em> не противоречит описанному принципу, но нарушает другой — изоляцию уровней абстракции, а поэтому плохо подходит для написания сложных API, в которых не гарантирована жёсткая иерархия компонентов. При этом использовать глобальный (или квази-глобальный) менеджер состояния в таких системах вполне возможно, но требуется имплементировать более сложную пропагацию сообщений по иерархии, а именно: подчинённый объект всегда вызывает методы только ближайшего вышестоящего объекта, а уже тот решает, как и каким образом этот вызов передать выше по иерархии.</p>
<pre><code>execution.prepareTakeout(() => {
// Вместо обращения к вышестоящей сущности
// или генерации события на себе,
@ -2763,9 +2763,9 @@ ProgramContext.dispatch = (action) => {
</code></pre>
<h4>Проверим себя</h4>
<p>Описав указанным выше образом взаимодействие со сторонними API, мы можем (и должны) теперь рассмотреть вопрос, совместимы ли эти интерфейсы с нашими собственными абстракциями, которые мы разработали в <a href="#chapter-9">главе 9</a>; иными словами, можно ли запустить исполнение такого заказа, оперируя не высокоуровневым, а низкоуровневым API.</p>
<p>Напомним, что мы предложили вот такие абстрактные интерфейсы для работы с произвольными типами API кофе-машин:</p>
<p>Напомним, что мы предложили вот такие абстрактные интерфейсы для работы с произвольными типами API кофемашин:</p>
<ul>
<li><code>POST /v1/program-matcher</code> возвращает идентификатор программы по идентификатору кофе-машины и рецепта;</li>
<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>
@ -2792,11 +2792,11 @@ ProgramContext.dispatch = (action) => {
<p>И разработанный нами метод</p>
<pre><code>POST /v1/programs/{id}/run
</code></pre>
<p>будет работать и с партнерскими кофе-машинами (читай, с третьим видом API).</p>
<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><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>
@ -2804,8 +2804,8 @@ ProgramContext.dispatch = (action) => {
<li>Конкретная функциональность, т.е. работа непосредственно с «железом», нижележащим API платформы, должна быть делегирована сущностям самого низкого уровня.</li>
</ol>
<p><strong>NB</strong>. В этих правилах нет ничего особенно нового: в них легко опознаются принципы архитектуры <a href="https://en.wikipedia.org/wiki/SOLID">SOLID</a> — что неудивительно, поскольку SOLID концентрируется на контрактно-ориентированном подходе к разработке, а API по определению и есть контракт. Мы лишь добавляем в эти принципы понятие уровней абстракции и информационных контекстов.</p>
<p>Остаётся, однако, неотвеченным вопрос о том, как изначально выстроить номенклатуру сущностей таким образом, чтобы расширение API не превращало её в мешанину из различных неконсистентных методов разных эпох. Впрочем, ответ на него довольно очевиден: чтобы при абстрагировании не возникало неловких ситуаций, подобно рассмотренному нами примеру с поддерживаемыми кофе-машиной опциями, все сущности необходимо <em>изначально</em> рассматривать как частную реализацию некоторого более общего интерфейса, даже если никаких альтернативных реализаций в настоящий момент не предвидится.</p>
<p>Например, разрабатывая API эндпойнта <code>POST /search</code> мы должны были задать себе вопрос: а «результат поиска» — это абстракция над каким интерфейсом? Для этого нам нужно аккуратно декомпозировать эту сущность, чтобы понять, каким своим срезом она выступает во взаимодействии с каким объектами.</p>
<p>Остаётся, однако, неотвеченным вопрос о том, как изначально выстроить номенклатуру сущностей таким образом, чтобы расширение API не превращало её в мешанину из различных неконсистентных методов разных эпох. Впрочем, ответ на него довольно очевиден: чтобы при абстрагировании не возникало неловких ситуаций, подобно рассмотренному нами примеру с поддерживаемыми кофемашиной опциями, все сущности необходимо <em>изначально</em> рассматривать как частную реализацию некоторого более общего интерфейса, даже если никаких альтернативных реализаций в настоящий момент не предвидится.</p>
<p>Например, разрабатывая API эндпойнта <code>POST /search</code> мы должны были задать себе вопрос: а «результат поиска» — это абстракция над каким интерфейсом? Для этого нам нужно аккуратно декомпозировать эту сущность, чтобы понять, каким своим срезом она выступает во взаимодействии с какими объектами.</p>
<p>Тогда мы придём к пониманию, что результат поиска — это, на самом деле, композиция двух интерфейсов:</p>
<ul>
<li>
@ -2843,7 +2843,7 @@ ProgramContext.dispatch = (action) => {
<li>Принцип абстрагирования интерфейсов тоже необходимо проверять. В теории вы может быть и рассматриваете каждую сущность как конкретную имплементацию абстрактного интерфейса — но на практике может оказаться, что вы чего-то не учли и ваш абстрактный интерфейс на деле невозможен. Для целей тестирования очень желательно иметь пусть условную, но отличную от базовой реализацию каждого интерфейса.</li>
</ol>
<h5><a href="#chapter-19-paragraph-3" id="chapter-19-paragraph-3" class="anchor">3. Изолируйте зависимости</a></h5>
<p>В случае, если API является гейтвеем, предоставляющим доступ к какому-то нижележащему API или агрегирующим несколько различных API за одним фасадом, велик соблазн предоставить оригинальный интерфейс as is, не внося в него изменений и не усложняя себя жизнь разработкой слабо связанного взаимодействия. Например, разрабатывая интерфейс для запуска программ, описанный в <a href="#chapter-9">главе 9</a>, мы могли бы взять за основу интерфейс кофе-машин первого типа и предоставить его в виде API, проксируя запросы и ответы как есть. Делать так ни в коем случае нельзя по нескольким причинам:</p>
<p>В случае, если API является гейтвеем, предоставляющим доступ к какому-то нижележащему API или агрегирующим несколько различных API за одним фасадом, велик соблазн предоставить оригинальный интерфейс as is, не внося в него изменений и не усложняя себя жизнь разработкой слабо связанного взаимодействия. Например, разрабатывая интерфейс для запуска программ, описанный в <a href="#chapter-9">главе 9</a>, мы могли бы взять за основу интерфейс кофемашин первого типа и предоставить его в виде API, проксируя запросы и ответы как есть. Делать так ни в коем случае нельзя по нескольким причинам:</p>
<ul>
<li>как правило, у вас нет никаких гарантий, что партнёр будет поддерживать свой API в обратно-совместимом или хотя бы концептуально похожем виде;</li>
<li>любые проблемы партнёра будут автоматически отражаться на ваших клиентах.</li>

Binary file not shown.