1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-07-12 22:50:21 +02:00

fresh build

This commit is contained in:
Sergey Konstantinov
2023-05-12 09:18:35 +03:00
parent 03de69fc0d
commit db4343550f
7 changed files with 374 additions and 140 deletions

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@ -630,7 +630,7 @@ ul.references li p a.back-anchor {
<li>разработка клиент-серверных приложений;</li>
<li>разработка клиентских SDK.</li>
</ul>
<p>В первом случае мы говорим практически исключительно об API, работающих поверх протокола HTTP. В настоящий момент клиент-серверное взаимодействие, не опирающееся на HTTP, можно встретить разве что в протоколе WebSocket (хотя и он может быть использован совместно с HTTP, что часто и происходит), а также в различных форматах потокового вещания и других узкоспециализированных интерфейсах.</p>
<p>В первом случае мы говорим практически исключительно об API, работающих поверх протокола HTTP. В настоящий момент клиент-серверное взаимодействие, не опирающееся на HTTP, можно встретить разве что в протоколах WebSocket (хотя и он может быть использован совместно с HTTP, что часто и происходит) и MQTT, а также в различных форматах потокового вещания и других узкоспециализированных интерфейсах.</p>
<h4>HTTP API</h4>
<p>Несмотря на кажущуюся гомогенность технологии ввиду использования одного протокола прикладного уровня, в действительности же наблюдается значительное разнообразие подходов к имплементации HTTP API.</p>
<p><strong>Во-первых</strong>, существующие реализации различаются подходом к утилизации собственно протокола HTTP:</p>
@ -1471,40 +1471,37 @@ app.display(offers);
<p>Рассмотрим простой пример: что должна возвращать функция поиска подходящей кофемашины. Для обеспечения хорошего UX приложения необходимо передать довольно значительные объёмы информации.</p>
<pre><code>{
"results": [{
// Данные кофемашины
"coffee_machine_id",
// Тип кофемашины
"coffee_machine_type":
"drip_coffee_maker",
// Марка кофемашины
"coffee_machine_type",
"coffee_machine_brand",
// Название заведения
"place_name": "Кафе «Ромашка»",
// Координаты
// Данные кафе
"place_name": "The Chamomile",
"place_location_latitude",
"place_location_longitude",
// Флаг «открыто сейчас»
"place_open_now",
// Часы работы
"working_hours",
// Сколько идти: время и расстояние
// Как добраться
"walking_distance",
"walking_time",
// Как найти заведение и кофемашину
"place_location_tip",
// Как найти нужное место
"location_tip",
// Предложения
"offers": [{
"recipe": "lungo",
"recipe_name":
"Наш фирменный лунго®™",
// Данные рецепта
"recipe",
"recipe_name",
"recipe_description",
"volume": "800ml",
// Параметры заказа
"volume",
// Данные предложения
"offer_id",
"offer_valid_until",
"localized_price":
"Большая чашка⮠
всего за 19 баксов",
"price": "19.00",
"currency_code": "USD",
"estimated_waiting_time": "20s"
"localized_price":
"Just $19 for a large coffee cup",
"currency_code",
"estimated_waiting_time"
}, …]
}, …]
}
@ -1516,7 +1513,7 @@ app.display(offers);
<li>данные о самой кофемашине;</li>
<li>данные о пути до точки;</li>
<li>данные о рецепте;</li>
<li>особенности рецепта в конкретном заведении;</li>
<li>опции приготовления заказа;</li>
<li>данные о предложении;</li>
<li>данные о цене.</li>
</ul>
@ -1540,9 +1537,7 @@ app.display(offers);
// Рецепт
"recipe":
{ "id", "name", "description" },
// Данные относительно того,
// как рецепт готовят
// на конкретной кофемашине
// Опции заказа
"options":
{ "volume" },
// Метаданные предложения
@ -1561,7 +1556,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="#api-design-describing-interfaces" class="anchor" id="api-design-describing-interfaces">Глава 13. Описание конечных интерфейсов</a><a href="#chapter-13" class="secondary-anchor" id="chapter-13"> </a></h3>
<p>Дополнительно правильная декомпозиция поможет нам в решении задачи расширения и развития API, о чём мы поговорим в разделе III.</p><div class="page-break"></div><h3><a href="#api-design-describing-interfaces" class="anchor" id="api-design-describing-interfaces">Глава 13. Описание конечных интерфейсов</a><a href="#chapter-13" class="secondary-anchor" id="chapter-13"> </a></h3>
<p>Определив все сущности, их ответственность и отношения друг с другом, мы переходим непосредственно к разработке API: нам осталось прописать номенклатуру всех объектов, полей, методов и функций в деталях. В этой главе мы дадим сугубо практические советы, как сделать API удобным и понятным.</p>
<p>Важнейшая задача разработчика API — добиться того, чтобы код, написанный поверх API другими разработчиками, легко читался и поддерживался. Помните, что закон больших чисел работает против вас: если какую-то концепцию или сигнатуру вызова можно понять неправильно, значит, её неизбежно будет понимать неправильно всё большее число партнеров по мере роста популярности API.</p>
<p><strong>NB</strong>: примеры, приведённые в этой главе, прежде всего иллюстрируют проблемы консистентности и читабельности, возникающие при разработке API. Мы не ставим здесь цели дать рекомендации по разработке REST API (такого рода советы будут даны в соответствующем разделе) или стандартных библиотек языков программирования — важен не конкретный синтаксис, а общая идея.</p>
@ -1773,7 +1768,11 @@ PUT /v1/users/{id}
<p>Ограничения должны быть не только на размеры полей, но и на размеры списков или агрегируемых интервалов.</p>
<p><strong>Плохо</strong>: <code>getOrders()</code> — что, если пользователь совершил миллион заказов?</p>
<p><strong>Хорошо</strong>: <code>getOrders({ limit, parameters })</code> — должно существовать ограничение сверху на размер обрабатываемых и возвращаемых данных и, соответственно, возможность уточнить запрос, если партнёру всё-таки требуется большее количество данных, чем разрешено обрабатывать в одном запросе.</p>
<h5><a href="#chapter-13-paragraph-10" id="chapter-13-paragraph-10" class="anchor">10. Считайте трафик</a></h5>
<h5><a href="#chapter-13-paragraph-10" id="chapter-13-paragraph-10" class="anchor">10. Описывайте политику перезапросов</a></h5>
<p>Одна из самых больших проблем с точки зрения производительности, с которой сталкивается почти любой разработчик API, и внутренних, и публичных — это отказ в обслуживании вследствие лавины перезапросов: временные проблемы на бэкенде API (например, повышение времени ответа) могут привести к полной неработоспособности сервера, если клиенты начнут очень быстро повторять запрос, не получив или не дождавшись ответа, сгенерировав, таким образом, кратно большую нагрузку в короткий срок.</p>
<p>Лучшая практика в такой ситуации — это требовать, чтобы клиенты перезапрашивали эндпойнты API с увеличивающимся интервалом (скажем, перевый перезапрос происходит через одну секунду, второй — через две, третий через четыре, и так далее, но не больше одной минуты). Конечно, в случае публичного API такое требование никто не обязан соблюдать, но и хуже от его наличия вам точно не станет: хотя бы часть партнёров прочитает документацию и последует вашим рекомендациям.</p>
<p>Кроме того, вы можете разработать референсную реализацию политики перезапросов в ваших публичных SDK и следить за правильностью имплементации open-source модулей к вашему API.</p>
<h5><a href="#chapter-13-paragraph-11" id="chapter-13-paragraph-11" class="anchor">11. Считайте трафик</a></h5>
<p>В современном мире такой ресурс, как объём переданного трафика, считать уже почти не принято — считается, что Интернет всюду практически безлимитен. Однако он всё-таки не абсолютно безлимитен: всегда можно спроектировать систему так, что объём трафика окажется некомфортным даже и для современных сетей.</p>
<p>Три основные причины раздувания объёма трафика достаточно очевидны:</p>
<ul>
@ -1783,7 +1782,7 @@ PUT /v1/users/{id}
</ul>
<p>Все эти проблемы должны решаться через введения ограничений на размеры полей и правильную декомпозицию эндпойнтов. Если в рамках одной сущности необходимо предоставлять как «лёгкие» (скажем, название и описание рецепта), так и «тяжёлые» данные (скажем, промо-фотография напитка, которая легко может по размеру превышать текстовые поля в сотни раз), лучше разделить эндпойнты и отдавать только ссылку для доступа к «тяжёлым» данным (в нашем случае, ссылку на изображение) — это, как минимум, позволит задавать различные политики кэширования для разных данных.</p>
<p>Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения партнёра (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл. Причиной слишком большого числа запросов / объёма трафика может оказаться ошибка проектирования подсистемы уведомлений об изменениях состояния. Подробнее этот вопрос мы рассмотрим в главе <a href="#api-patterns-push-vs-poll">«Двунаправленные потоки данных»</a> раздела «Паттерны API».</p>
<h5><a href="#chapter-13-paragraph-11" id="chapter-13-paragraph-11" class="anchor">11. Отсутствие результата — тоже результат</a></h5>
<h5><a href="#chapter-13-paragraph-12" id="chapter-13-paragraph-12" class="anchor">12. Отсутствие результата — тоже результат</a></h5>
<p>Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой.</p>
<p><strong>Плохо</strong></p>
<pre><code>POST /v1/coffee-machines/search
@ -1848,7 +1847,7 @@ POST /v1/offers/search
}
</code></pre>
<p>Часто можно столкнуться с ситуацией, когда эндпойнт просто проигнорирует наличие пустого массива <code>recipes</code> и вернёт предложения так, как будто никакого фильтра по рецепту передано не было. В нашем примере это будет означать, что приложение просто проигнорирует требование пользователя показать только напитки без молока, что мы никак не можем счесть приемлемым поведением. Поэтому ответом на такой запрос с пустым массивом в качестве параметра должна быть либо ошибка, либо пустой же массив предложений.</p>
<h5><a href="#chapter-13-paragraph-12" id="chapter-13-paragraph-12" class="anchor">12. Валидируйте ввод</a></h5>
<h5><a href="#chapter-13-paragraph-13" id="chapter-13-paragraph-13" class="anchor">13. Валидируйте ввод</a></h5>
<p>Какой из вариантов действий выбрать в предыдущем примере — исключение или пустой ответ — напрямую зависит от того, что записано в вашем контракте. Если спецификация прямо предписывает, что массив <code>recipes</code> должен быть непустым, то необходимо сгенерировать исключение (иначе вы фактически нарушаете собственную спецификацию).</p>
<p>Это верно не только в случае непустых массивов, но и любых других зафиксированных в контракте ограничений. «Тихое» исправление недопустимых значений почти никогда не имеет никакого практического смысла:</p>
<p><strong>Плохо</strong>:</p>
@ -1927,7 +1926,7 @@ POST /v1/offers/search
strict_mode=true⮠
disable_errors=suspicious_coordinates
</code></pre>
<h5><a href="#chapter-13-paragraph-13" id="chapter-13-paragraph-13" class="anchor">13. Значения по умолчанию должны быть осмысленны</a></h5>
<h5><a href="#chapter-13-paragraph-14" id="chapter-13-paragraph-14" class="anchor">14. Значения по умолчанию должны быть осмысленны</a></h5>
<p>Значения по умолчанию — один из самых ваших сильных инструментов, позволяющих избежать многословности при работе с API. Однако эти умолчания должны помогать разработчикам, а не маскировать их ошибки.</p>
<p><strong>Плохо</strong>:</p>
<pre><code>POST /v1/coffee-machines/search
@ -1955,7 +1954,7 @@ POST /v1/offers/search
// описание ошибки
}
</code></pre>
<h5><a href="#chapter-13-paragraph-14" id="chapter-13-paragraph-14" class="anchor">14. Ошибки должны быть информативными</a></h5>
<h5><a href="#chapter-13-paragraph-15" id="chapter-13-paragraph-15" class="anchor">15. Ошибки должны быть информативными</a></h5>
<p>Недостаточно просто валидировать ввод — необходимо ещё и уметь правильно описать, в чём состоит проблема. В ходе работы над интеграцией партнёры неизбежно будут допускать детские ошибки. Чем понятнее тексты сообщений, возвращаемых вашим API, тем меньше времени разработчик потратит на отладку, и тем приятнее работать с таким API.</p>
<p><strong>Плохо</strong>:</p>
<pre><code>POST /v1/coffee-machines/search
@ -2002,7 +2001,7 @@ POST /v1/offers/search
}
</code></pre>
<p>Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной.</p>
<h5><a href="#chapter-13-paragraph-15" id="chapter-13-paragraph-15" class="anchor">15. Всегда показывайте неразрешимые ошибки прежде разрешимых</a></h5>
<h5><a href="#chapter-13-paragraph-16" id="chapter-13-paragraph-16" class="anchor">16. Всегда показывайте неразрешимые ошибки прежде разрешимых</a></h5>
<p>Рассмотрим пример с заказом кофе</p>
<pre><code>POST /v1/orders
{
@ -2031,7 +2030,7 @@ POST /v1/orders
}
</code></pre>
<p>Какой был смысл получать новый <code>offer</code>, если заказ всё равно не может быть создан? Для пользователя это будет выглядеть как бессмысленные действия (или бессмысленное ожидание), которые всё равно завершатся ошибкой, что бы он ни делал. Да, соблюдение порядка ошибок не изменит результат — заказ всё ещё нельзя сделать — но, во-первых, пользователь потратит меньше времени (а также сделает меньше запросов к бэкенду и внесёт меньший вклад в фон ошибок) и, во-вторых, диагностика проблемы будет гораздо проще читаться.</p>
<h5><a href="#chapter-13-paragraph-16" id="chapter-13-paragraph-16" class="anchor">16. Начинайте исправление ошибок с более глобальных</a></h5>
<h5><a href="#chapter-13-paragraph-17" id="chapter-13-paragraph-17" class="anchor">17. Начинайте исправление ошибок с более глобальных</a></h5>
<p>Если ошибки исправимы (т.е. пользователь может совершить какие-то действия и всё же добиться желаемого), следует в первую очередь сообщать о тех, которые потребуют более глобального изменения состояния.</p>
<p><strong>Плохо</strong>:</p>
<pre><code>POST /v1/orders
@ -2074,7 +2073,7 @@ POST /v1/orders
}
</code></pre>
<p>Какой был смысл показывать пользователю диалог об изменившейся цене, если и с правильной ценой заказ он сделать всё равно не сможет? Пока один из его предыдущих заказов завершится и можно будет сделать следующий заказ, цену, наличие и другие параметры заказа всё равно придётся корректировать ещё раз.</p>
<h5><a href="#chapter-13-paragraph-17" id="chapter-13-paragraph-17" class="anchor">17. Проанализируйте потенциальные взаимные блокировки</a></h5>
<h5><a href="#chapter-13-paragraph-18" id="chapter-13-paragraph-18" class="anchor">18. Проанализируйте потенциальные взаимные блокировки</a></h5>
<p>В сложных системах не редки ситуации, когда исправление одной ошибки приводит к возникновению другой и наоборот.</p>
<pre><code>// Создаём заказ с платной доставкой
POST /v1/orders
@ -2111,7 +2110,7 @@ POST /v1/orders
}
</code></pre>
<p>Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчёта (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса.</p>
<h5><a href="#chapter-13-paragraph-18" id="chapter-13-paragraph-18" class="anchor">18. Указывайте время жизни ресурсов и политики кэширования</a></h5>
<h5><a href="#chapter-13-paragraph-19" id="chapter-13-paragraph-19" class="anchor">19. Указывайте время жизни ресурсов и политики кэширования</a></h5>
<p>В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Поэтому желательно вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации.</p>
<p>Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать достаточно близким к предыдущему запросу, чтобы можно было использовать результат из кэша?</p>
<p><strong>Плохо</strong>:</p>
@ -2152,11 +2151,11 @@ GET /v1/price?recipe=lungo­⮠
}
}
</code></pre>
<h5><a href="#chapter-13-paragraph-19" id="chapter-13-paragraph-19" class="anchor">19. Сохраняйте точность дробных чисел</a></h5>
<h5><a href="#chapter-13-paragraph-20" id="chapter-13-paragraph-20" class="anchor">20. Сохраняйте точность дробных чисел</a></h5>
<p>Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, <code>Decimal</code> или аналогичных.</p>
<p>Если в протоколе нет <code>Decimal</code>-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.</p>
<p>Если конвертация в формат с плавающей запятой заведомо приводит к потере точности (например, если мы переведём 20 минут в часы в виде десятичной дроби), то следует либо предпочесть формат без потери точности (т.е. предпочесть формат <code>00:20</code> формату <code>0.333333…</code>), либо предоставить SDK работы с такими данными, либо (в крайнем случае) описать в документации принципы округления.</p>
<h5><a href="#chapter-13-paragraph-20" id="chapter-13-paragraph-20" class="anchor">20. Все операции должны быть идемпотентны</a></h5>
<h5><a href="#chapter-13-paragraph-21" id="chapter-13-paragraph-21" class="anchor">21. Все операции должны быть идемпотентны</a></h5>
<p>Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни.</p>
<p>Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию.</p>
<p><strong>Плохо</strong>:</p>
@ -2223,12 +2222,12 @@ X-Idempotency-Token: &#x3C;токен>
<li>нельзя полагаться на то, что клиенты генерируют честные случайные токены — они могут иметь одинаковый seed рандомизатора или просто использовать слабый алгоритм или источник энтропии; при проверке токенов нужны слабые ограничения: уникальность токена должна проверяться не глобально, а только применительно к конкретному пользователю и конкретной операции;</li>
<li>клиентские разработчики могут неправильно понимать концепцию — или генерировать новый токен на каждый перезапрос (что на самом деле неопасно, в худшем случае деградирует UX), или, напротив, использовать один токен для разнородных запросов (а вот это опасно и может привести к катастрофически последствиям; ещё одна причина имплементировать совет из предыдущего пункта!); поэтому рекомендуется написать хорошую документацию и/или клиентскую библиотеку для перезапросов.</li>
</ul>
<h5><a href="#chapter-13-paragraph-21" id="chapter-13-paragraph-21" class="anchor">21. Не изобретайте безопасность</a></h5>
<h5><a href="#chapter-13-paragraph-22" id="chapter-13-paragraph-22" class="anchor">22. Не изобретайте безопасность</a></h5>
<p>Если бы автору этой книги давали доллар каждый раз, когда ему приходилось бы имплементировать кем-то придуманный дополнительный протокол безопасности — он бы давно уже был на заслуженной пенсии. Любовь разработчиков API к подписыванию параметров запросов или сложным схемам обмена паролей на токены столь же несомненна, сколько и бессмысленна.</p>
<p><strong>Во-первых</strong>, почти всегда процедуры, обеспечивающие безопасность той или иной операции, <em>уже разработаны</em>. Нет никакой нужды придумывать их заново, просто имплементируйте какой-то из существующих протоколов. Никакие самописные алгоритмы проверки сигнатур запросов не обеспечат вам того же уровня защиты от атаки <a href="https://en.wikipedia.org/wiki/Man-in-the-middle_attack">Man-in-the-Middle</a>, как соединение по протоколу TLS с взаимной проверкой сигнатур сертификатов.</p>
<p><strong>Во-вторых</strong>, чрезвычайно самонадеянно (и опасно) считать, что вы разбираетесь в вопросах безопасности. Новые вектора атаки появляются каждый день, и быть в курсе всех актуальных проблем — это само по себе работа на полный рабочий день. Если же вы полный рабочий день занимаетесь чем-то другим, спроектированная вами система защиты наверняка будет содержать уязвимости, о которых вы просто никогда не слышали — например, ваш алгоритм проверки паролей может быть подвержен <a href="https://en.wikipedia.org/wiki/Timing_attack">атаке по времени</a>, а веб-сервер — <a href="https://capec.mitre.org/data/definitions/105.html">атаке с разделением запросов</a>.</p>
<p>Отдельно уточним: любые API должны предоставляться строго по протоколу TLS версии не ниже 1.2 (лучше 1.3).</p>
<h5><a href="#chapter-13-paragraph-22" id="chapter-13-paragraph-22" class="anchor">22. Помогайте партнёрам не изобретать безопасность</a></h5>
<h5><a href="#chapter-13-paragraph-23" id="chapter-13-paragraph-23" class="anchor">23. Помогайте партнёрам не изобретать безопасность</a></h5>
<p>Не менее важно не только обеспечивать безопасность API как такового, но и предоставить партнёрам такие интерфейсы, которые минимизируют возможные проблемы с безопасностью на их стороне.</p>
<p><strong>Плохо</strong>:</p>
<pre><code>// Позволяет партнёру задать
@ -2286,19 +2285,19 @@ X-Dangerously-Allow-Raw-Value: true
}
</code></pre>
<p>Во втором случае вы сможете централизованно экранировать небезопасный ввод и избежать тем самым SQL-инъекции. Напомним повторно, что делать это необходимо с помощью state-of-the-art инструментов, а не самописных регулярных выражений.</p>
<h5><a href="#chapter-13-paragraph-23" id="chapter-13-paragraph-23" class="anchor">23. Используйте глобально уникальные идентификаторы</a></h5>
<h5><a href="#chapter-13-paragraph-24" id="chapter-13-paragraph-24" class="anchor">24. Используйте глобально уникальные идентификаторы</a></h5>
<p>Хорошим тоном при разработке API будет использование для идентификаторов сущностей глобально уникальных строк, либо семантичных (например, "lungo" для видов напитков), либо случайных (например <a href="https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random)">UUID-4</a>). Это может чрезвычайно пригодиться, если вдруг придётся объединять данные из нескольких источников под одним идентификатором.</p>
<p>Мы вообще склонны порекомендовать использование идентификаторов в urn-подобном формате, т.е. <code>urn:order:&#x3C;uuid></code> (или просто <code>order:&#x3C;uuid></code>), это сильно помогает с отладкой legacy-систем, где по историческим причинам есть несколько разных идентификаторов для одной и той же сущности, в таком случае неймспейсы в urn помогут быстро понять, что это за идентификатор и нет ли здесь ошибки использования.</p>
<p>Отдельное важное следствие: <strong>не используйте инкрементальные номера как внешние идентификаторы</strong>. Помимо вышесказанного, это плохо ещё и тем, что ваши конкуренты легко смогут подсчитать, сколько у вас в системе каких сущностей и тем самым вычислить, например, точное количество заказов за каждый день наблюдений.</p>
<h5><a href="#chapter-13-paragraph-24" id="chapter-13-paragraph-24" class="anchor">24. Предусмотрите ограничения доступа</a></h5>
<h5><a href="#chapter-13-paragraph-25" id="chapter-13-paragraph-25" class="anchor">25. Предусмотрите ограничения доступа</a></h5>
<p>С ростом популярности API вам неизбежно придётся внедрять технические средства защиты от недобросовестного использования — такие, как показ капчи, расстановка приманок-honeypot-ов, возврат ошибок вида «слишком много запросов», постановка прокси-защиты от DDoS перед эндпойнтами и так далее. Всё это невозможно сделать, если вы не предусмотрели такой возможности изначально, а именно — не ввели соответствующей номенклатуры ошибок и предупреждений.</p>
<p>Вы не обязаны с самого начала такие ошибки действительно генерировать — но вы можете предусмотреть их на будущее. Например, вы можете описать ошибку <code>429 Too Many Requests</code> или перенаправление на показ капчи, но не имплементировать возврат таких ответов, пока не возникнет в этом необходимость.</p>
<p>Отдельно необходимо уточнить, что в тех случаях, когда через API можно совершать платежи, ввод дополнительных факторов аутентификации пользователя (через TOTP, SMS или технологии типа 3D-Secure) должен быть предусмотрен обязательно.</p>
<p><strong>NB</strong>: из этого пункта вытекает достаточно очевидное правило, которое, тем не менее, часто нарушают разработчики API — <strong>всегда разделяйте эндпойнты разных семейств API</strong>. Если вы предоставляете и серверное API, и сервисы для конечных пользователей, и виджеты для встраивания в сторонние приложения — эти API должны обслужиться с разных эндпойнтов для того, чтобы вы могли вводить разные меры безопасности (скажем, API-ключи, требование логина и капчу, соответственно).</p>
<h5><a href="#chapter-13-paragraph-25" id="chapter-13-paragraph-25" class="anchor">25. Не предоставляйте endpoint-ов массового получения чувствительных данных</a></h5>
<h5><a href="#chapter-13-paragraph-26" id="chapter-13-paragraph-26" class="anchor">26. Не предоставляйте endpoint-ов массового получения чувствительных данных</a></h5>
<p>Если через API возможно получение персональных данных, номер банковских карт, переписки пользователей и прочей информации, раскрытие которой нанесёт большой ущерб пользователям, партнёрам и/или вам — методов массового получения таких данных в API быть не должно, или, по крайней мере, на них должны быть ограничения на частоту запросов, размер страницы данных, а в идеале ещё и многофакторная аутентификация.</p>
<p>Часто разумной практикой является предоставление таких массовых выгрузок по запросу, т.е. фактически в обход API.</p>
<h5><a href="#chapter-13-paragraph-26" id="chapter-13-paragraph-26" class="anchor">26. Локализация и интернационализация</a></h5>
<h5><a href="#chapter-13-paragraph-27" id="chapter-13-paragraph-27" class="anchor">27. Локализация и интернационализация</a></h5>
<p>Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка <code>Accept-Language</code>), даже если на текущем этапе нужды в локализации нет.</p>
<p>Важно понимать, что язык пользователя и юрисдикция, в которой пользователь находится — разные вещи. Цикл работы вашего API всегда должен хранить локацию пользователя. Либо она задаётся явно (в запросе указываются географические координаты), либо неявно (первый запрос с географическими координатами инициировал создание сессии, в которой сохранена локация) — но без локации корректная локализация невозможна. В большинстве случаев локацию допустимо редуцировать до кода страны.</p>
<p>Дело в том, что множество параметров, потенциально влияющих на работу API, зависят не от языка, а именно от расположения пользователя. В частности, правила форматирования чисел (разделители целой и дробной частей, разделители разрядов) и дат, первый день недели, раскладка клавиатуры, система единиц измерения (которая к тому же может оказаться не десятичной!) и так далее. В некоторых ситуациях необходимо хранить две локации: та, в которой пользователь находится, и та, которую пользователь сейчас просматривает. Например, если пользователь из США планирует туристическую поездку в Европу, то цены ему желательно показывать в местной валюте, но отформатированными согласно правилам американского письма.</p>
@ -2775,15 +2774,15 @@ const pendingOrders = await api.
</code></pre>
<p><strong>NB</strong>: отметим также, что в формате асинхронного взаимодействия можно передавать не только бинарный статус (выполнено задание или нет), но и прогресс выполнения в процентах, если это возможно.</p><div class="page-break"></div><h3><a href="#api-patterns-lists" class="anchor" id="api-patterns-lists">Глава 20. Списки и организация доступа к ним</a><a href="#chapter-20" class="secondary-anchor" id="chapter-20"> </a></h3>
<p>В предыдущей главе мы пришли вот к такому интерфейсу, позволяющему минимизировать коллизии при создании заказов:</p>
<pre><code>const pendingOrders = await api.
getOngoingOrders();
<pre><code>const pendingOrders = await api
.getOngoingOrders();
{ orders: [{
order_id: &#x3C;идентификатор задания>,
status: "new"
}, …]}
</code></pre>
<p>Внимательный читатель может подметить, что этот интерфейс нарушает нашу же рекомендацию, данную в главе «Описание конечных интерфейсов»: количество возвращаемых данных в любом ответе должно быть ограничено, но в нашем интерфейсе отсутствуют какие-либо лимиты. Эта проблема существовала и в предыдущих версиях этого эндпойнта, но отказ от синхронного создания заказа её усугубил: операция создания задания должна работать максимально быстро, и, следовательно, почти все проверки лимитов мы должны проводить асинхронно — а значит, клиент потенциально может создать очень много заданий, что может многократно увеличить размер ответа функции <code>getOngoingOrders</code>.</p>
<p>Внимательный читатель может подметить, что этот интерфейс нарушает нашу же рекомендацию, данную в главе <a href="#api-design-describing-interfaces">«Описание конечных интерфейсов»</a>: количество возвращаемых данных в любом ответе должно быть ограничено, но в нашем интерфейсе отсутствуют какие-либо лимиты. Эта проблема существовала и в предыдущих версиях этого эндпойнта, но отказ от синхронного создания заказа её усугубил: операция создания задания должна работать максимально быстро, и, следовательно, почти все проверки лимитов мы должны проводить асинхронно — а значит, клиент потенциально может создать очень много заданий, что может многократно увеличить размер ответа функции <code>getOngoingOrders</code>.</p>
<p><strong>NB</strong>: конечно, не иметь <em>вообще никакого</em> ограничения на создание заданий — не самое мудрое решение; какие-то легковесные проверки лимитов должны быть в API. Тем не менее, в рамках этой главы мы фокусируемся именно на проблеме размера ответа сервера.</p>
<p>Исправить эту проблему достаточно просто — можно ввести лимит записей и параметры фильтрации и сортировки, например так:</p>
<pre><code>api.getOngoingOrders({
@ -2955,7 +2954,7 @@ GET /v1/partners/{id}/offers/history⮠
older_than=&#x3C;item_id>&#x26;limit=&#x3C;limit>
</code></pre>
<p>Первый формат запроса позволяет решить задачу (1), т.е. получить все элементы списка, появившиеся позднее последнего известного; второй формат — задачу (2), т.е. перебрать нужно количество записей в истории запросов. Важно, что первый запрос при этом ещё и кэшируемый.</p>
<p><strong>NB</strong>: отметим, что в главе <a href="#api-design-describing-interfaces">«Описание конечных интерфейсов»</a> мы давали рекомендацию не давать доступ во внешнем API к инкрементальным id. Однако, схема этого и не требует: внешние идентификаторы могут быть произвольными (не обязательно монотонными) — достаточно, чтобы они однозначно конвертировались во внутренние монотонные идентификаторы.</p>
<p><strong>NB</strong>: отметим, что в главе <a href="#api-design-describing-interfaces">«Описание конечных интерфейсов»</a> мы давали рекомендацию не предоставлять доступ во внешнем API к инкрементальным id. Однако, схема этого и не требует: внешние идентификаторы могут быть произвольными (не обязательно монотонными) — достаточно, чтобы они однозначно конвертировались во внутренние монотонные идентификаторы.</p>
<p>Другим способом организации такого перебора может быть дата создания записи, но этот способ чуть сложнее в имплементации:</p>
<ul>
<li>дата создания двух записей может полностью совпадать, особенно если записи могут массово генерироваться программно; в худшем случае может получиться так, что в один момент времени было создано больше записей, чем максимальный лимит их извлечения, и тогда часть записей вообще нельзя будет перебрать;</li>
@ -3058,7 +3057,7 @@ POST /v1/partners/{id}/offers/history⮠
// заказа, более старые,
// чем запись с указанным id
GET /v1/orders/created-history⮠
older_than=&#x3C;item_id>&#x26;limit=&#x3C;limit>
?older_than=&#x3C;item_id>&#x26;limit=&#x3C;limit>
{
"orders_created_events": [{
@ -3070,7 +3069,121 @@ GET /v1/orders/created-history⮠
}
</code></pre>
<p>События иммутабельны, и их список только пополняется, следовательно, организовать перебор этого списка вполне возможно. Да, событие — это не то же самое, что и сам заказ: к моменту прочтения партнёром события, заказ уже давно может изменить статус. Но, тем не менее, мы предоставили возможность перебрать <em>все</em> новые заказы, пусть и не самым оптимальным образом.</p>
<p><strong>NB</strong>: в вышеприведённых фрагментах кода мы опустили метаданные ответа — такие как общее число элементов в списке, флаг типа <code>has_more_items</code> для индикации необходимости продолжить перебор и т.д. Хотя эти метаданные необязательны (клиент узнает размер списка, когда переберёт его полностью), их наличие повышает удобство работы с API для разработчиков, и мы рекомендуем их добавлять.</p><div class="page-break"></div><h3><a href="#api-patterns-push-vs-poll" class="anchor" id="api-patterns-push-vs-poll">Глава 21. Двунаправленные потоки данных. Push и poll-модели</a><a href="#chapter-21" class="secondary-anchor" id="chapter-21"> </a></h3><div class="page-break"></div><h3><a href="#chapter-22" class="anchor" id="chapter-22">Глава 22. Варианты организации системы нотификаций</a></h3><div class="page-break"></div><h3><a href="#chapter-23" class="anchor" id="chapter-23">Глава 23. Атомарность</a></h3><div class="page-break"></div><h3><a href="#chapter-24" class="anchor" id="chapter-24">Глава 24. Частичные обновления</a></h3><div class="page-break"></div><h3><a href="#chapter-25" class="anchor" id="chapter-25">Глава 25. Деградация и предсказуемость</a></h3><div class="page-break"></div><h2><a href="#section-4" class="anchor" id="section-4">Раздел III. Обратная совместимость</a></h2><h3><a href="#back-compat-statement" class="anchor" id="back-compat-statement">Глава 26. Постановка проблемы обратной совместимости</a><a href="#chapter-26" class="secondary-anchor" id="chapter-26"> </a></h3>
<p><strong>NB</strong>: в вышеприведённых фрагментах кода мы опустили метаданные ответа — такие как общее число элементов в списке, флаг типа <code>has_more_items</code> для индикации необходимости продолжить перебор и т.д. Хотя эти метаданные необязательны (клиент узнает размер списка, когда переберёт его полностью), их наличие повышает удобство работы с API для разработчиков, и мы рекомендуем их добавлять.</p><div class="page-break"></div><h3><a href="#api-patterns-push-vs-poll" class="anchor" id="api-patterns-push-vs-poll">Глава 21. Двунаправленные потоки данных. Push и poll-модели</a><a href="#chapter-21" class="secondary-anchor" id="chapter-21"> </a></h3>
<p>В предыдущей главе мы рассмотрели следующий кейс: партнёр получает информацию о новых событиях, произошедших в системе, периодически опрашивая эндпойнт, поддерживающий отдачу упорядоченных списков.</p>
<pre><code>GET /v1/orders/created-history⮠
older_than=&#x3C;item_id>&#x26;limit=&#x3C;limit>
{
"orders_created_events": [{
"id",
"occured_at",
"order_id"
}, …]
}
</code></pre>
<p>Подобный паттерн (известный как <a href="https://en.wikipedia.org/wiki/Polling_(computer_science)"><em>поллинг</em></a>) — наиболее часто встречающийся способ организации двунаправленной связи в API, когда партнёру требуется не только отправлять какие-то данные на сервер, но и получать оповещения от сервера об изменении какого-то состояния.</p>
<p>При всей простоте, поллинг всегда заставляет искать компромисс между отзывчивостью, производительностью и пропускной способностью системы:</p>
<ul>
<li>чем длиннее интервал между последовательными запросами, тем больше будет задержка между изменением состояния на сервере и получением информации об этом на клиенте, и тем потенциально большим будет объём данных, которые необходимо будет передать за одну итерацию;</li>
<li>с другой стороны, чем этот интервал короче, чем большее количество запросов будет совершаться зря, т.к. никаких изменений в системе за прошедшее время не произошло.</li>
</ul>
<p>Иными словами, поллинг всегда создаёт какой-то фоновый трафик в системе, но никогда не гарантирует максимальной отзывчивости. Иногда эту проблему решают с помощью «долгого поллинга» (<a href="https://en.wikipedia.org/wiki/Push_technology#Long_polling">long polling</a>) — т.е. целенаправленно замедляют отдачу сервером ответа на длительное (секунды, десятки секунд) время до тех пор, пока на сервере не появится сообщение для передачи — однако мы не рекомендуем использовать этот подход в современных системах из-за связанных технических проблем (в частности, в условиях ненадёжной сети у клиента нет способа понять, что соединение на самом деле потеряно, и нужно отправить новый запрос, а не ожидать ответа на текущий).</p>
<p>Если оказывается, что обычного поллинга для решения пользовательских задач недостаточно, то можно перейти к обратной модели (<em>push</em>): сервер <em>сам</em> сообщает клиенту, что в системе произошли изменения.</p>
<p>Хотя и проблема, и способы её решения выглядят похоже, в настоящий момент применяются совершенно разные технологии для доставки сообщений от бэкенда к бэкенду и от бэкенда к клиентскому устройству.</p>
<h4>Доставка сообщений на клиентское устройство</h4>
<p>Поскольку разнообразные мобильные платформы и «умные устройства» (Internet of Things, IoT) сейчас составляют значительную долю всех клиентских устройств, на технологии взаимного обмена данных между сервером и конечным пользователем накладываются значительные ограничения с точки зрения экономии заряда батареи (и отчасти трафика). Многие производители платформ и устройств следят за потребляемыми приложением ресурсами, и могут отправлять приложение в фон или вовсе закрывать открытые соединения. В такой ситуации частый поллинг стоит применять только в активных фазах работы приложения (т.е. когда пользователь непосредственно взаимодействует с UI) либо если приложение работает в контролируемой среде (например, используется сотрудниками компании-партнера непосредственно в работе, и может быть добавлено в системные исключения).</p>
<p>Альтернатив поллингу на данный момент можно предложить три:</p>
<h5><a href="#chapter-21-paragraph-1" id="chapter-21-paragraph-1" class="anchor">1. Дуплексные соединения</a></h5>
<p>Самый очевидный вариант — использование технологий, позволяющих передавать по одному соединению сообщения в обе стороны. Наиболее известной из таких технологий является <a href="https://websockets.spec.whatwg.org/">WebSockets</a>. Иногда для организации полнодуплексного соединения применяется <a href="https://datatracker.ietf.org/doc/html/rfc7540#section-8.2">Server Push, предусмотренный протоколом HTTP/2</a>, однако надо отметить, что формально спецификация не предусматривает такого использования. Также существует протокол <a href="https://www.w3.org/TR/webrtc/">WebRTC</a>, но он, в основном, используется для обмена медиа-данными между клиентами, редко для клиент-серверного взаимодействия.</p>
<p>Несмотря на то, что идея в целом выглядит достаточно простой и привлекательной, в реальности её использование довольно ограничено. Поддержки инициирования <em>сервером</em> отправки сообщения обратно на клиент практически нет в популярном серверном ПО и фреймворках (gRPC поддерживает потоки сообщений с сервера, но их всё равно должен инициировать клиент; использование потоков для пересылки сообщений по мере их возникновения — то же самое использование HTTP/2 Server Push в обход спецификации, что, фактически, работает как тот же самый long polling, только чуть более современный), и существующие стандарты спецификаций API также не поддерживают такой обмен данными: WebSockets является низкоуровневым протоколом, и формат взаимодействия придётся разработать самостоятельно.</p>
<p>Дуплексные соединения по-прежнему страдают от ненадёжной сети и требуют дополнительных ухищрений для того, чтобы отличить сетевую проблему от отсутствия новых сообщений. Всё это приводит к тому, что данная технология используется в основном веб-приложениями.</p>
<h5><a href="#chapter-21-paragraph-2" id="chapter-21-paragraph-2" class="anchor">2. Раздельный канал обратного вызова</a></h5>
<p>Вместо дуплексных соединений можно использовать два раздельных канала — один для отправки сообщений на сервер, другой для получения сообщений с сервера. Наиболее популярной технологией такого рода является <a href="https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html">MQTT</a>. Хотя эта технология считается максимально эффективной в силу использования низкоуровневых протоколов, её достоинства порождают и её недостатки:</p>
<ul>
<li>технология в первую очередь предназначена для имплементации паттерна pub/sub и ценна наличием соответствующего серверного ПО (MQTT Broker); применить её для других задач, особенно для двунаправленного обмена данными, может быть сложно;</li>
<li>низкоуровневый протокол диктует необходимость разработки собственного формата данных.</li>
</ul>
<p>Существует также веб-стандарт отправки серверных сообщений <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html">Server-Sent Events</a> (SSE). Однако по сравнению с WebSocket он менее функциональный (только текстовые данные, однонаправленный поток сообщений) и поэтому используется редко.</p>
<h5><a href="#chapter-21-paragraph-3" id="chapter-21-paragraph-3" class="anchor">3. Сторонние сервисы отправки push-уведомлений</a></h5>
<p>Одна из неприятных особенностей технологии типа long polling / WebSocket / SSE / MQTT — необходимость поддерживать открытое соединение между клиентом и сервером, что для мобильных приложений может быть проблемой с точки зрения производительности и энергопотребления. Один из вариантов решения этой проблемы — делегирование отправки уведомлений стороннему сервису (самым популярным выбором на сегодня является Firebase Cloud Messaging от Google), который в свою очередь доставит уведомление через встроенные механизмы платформы. Использование встроенных в платформу сервисов получения уведомлений снимает с разработчика головную боль по написанию кода, поддерживающего открытое соединение, и снижает риски неполучения сообщения. Недостатками third-party серверов сообщений является необходимость платить за них и ограничения на размер сообщения.</p>
<p>Кроме того, отправка push-уведомлений на устройство конечного пользователя страдает от одной большой проблемы: процент успешной доставки уведомлений никогда не равен 100; потери сообщений могут достигать десятков процентов. С учётом ограничений на размер контента, скорее правильно говорить не о push-модели, а о комбинированной: приложение продолжает периодически опрашивать сервер, а пуши являются триггером для внеочередного опроса. (На самом деле, это соображение в той или иной мере применимо к любой технологии доставки сообщений на клиент. Низкоуровневые протоколы предоставляют больше возможностей управлять гарантиями доставки, но, с учётом ситуации с принудительным закрытием соединений системой, иметь в качестве страховки низкочастотный поллинг в приложении почти никогда не бывает лишним.)</p>
<h4>Использование push-технологий в публичном API</h4>
<p>Следствием описанной выше фрагментации клиентских технологий является фактическая невозможность использовать любую из них кроме обычного поллинга в публичном API. Требование к партнёрам реализовать получение сообщений через WebSocket / MQTT / SSE каналы значительно повышает порог входа в API, т.к. работа с низкоуровневыми протоколами, к тому же плохо покрытыми существующими IDL и кодогенерацией, требует значительных ресурсов и чревата ошибками имплементации. Если же вы решите предоставлять готовый SDK к такому API, то вам придётся самостоятельно разработать его под каждую целевую платформу (что, повторимся, само по себе трудоёмко). Учитывая, что HTTP-поллинг кратно проще в реализации, а его недостатки проявляются только там, где <em>действительно</em> нужно экономить трафик и вычислительные ресурсы, мы склонны рекомендовать предоставлять альтернативные каналы получения сообщений только <em>в дополнение</em> к поллингу, но никак не вместо него.</p>
<p>Хорошим решением для публичного API могли бы стать системные пуши, но здесь возникает другая проблема: разработчики приложений не склонны давать сторонним сервисам право на отсылку push-уведомлений, и на то есть большой список причин, начиная от расходов на отправку и заканчивая проблемами безопасности.</p>
<p>Фактически самый удобный способ организовать доставку сообщений от бэкенда публичного API пользователю партнёрского сервиса — это доставить сообщение с бэкенда на бэкенд, чтобы сервис партнёра сам транслировал сообщение на клиенты через push-уведомления или любую другую технологию, которую партнёр выбрал для разработки своего приложения.</p>
<h4>Доставка сообщений backend-to-backend</h4>
<p>В отличие от клиентских приложений, серверные API практически безальтернативно используют единственный подход для организации двустороннего взаимодействия [помимо поллинга, который работает на сервере точно так же, как и на клиенте, и имеет те же достоинства и недостатки] — отдельный канал связи для обратных вызовов. В случае публичных API практически безальтернативно такой технологией является использование URL обратного вызова (т.н. «webhook»).</p>
<p>Хотя long polling, WebSocket, MQTT и HTTP/2 Push тоже вполне применимы для backend-2-backend взаимодействия, мы сходу затрудняемся назвать примеры популярных API, которые использовали бы эти технологии. Главными причинами такого положения дел нам видятся:</p>
<ul>
<li>меньшая критичность к проблемам производительности (у сервера фактически нет ограничений по расходу трафика, и поддержание открытых соединений тоже не является проблемой);</li>
<li>бо́льшая требовательность к гарантиям доставки;</li>
<li>широкий выбор готовых компонентов для разработки webhook-ов (поскольку, фактически, это просто обычный веб-сервер);</li>
<li>возможность описать такое взаимодействие спецификацией и использовать кодогенерацию.</li>
</ul>
<p>При интеграции через webhook, партнёр указывает URL своего собственного сервера обработки сообщений, и сервер API вызывает этот эндпойнт для оповещения о произошедшем событии.</p>
<p>Предположим, что в нашем кофейном примере партнёр располагает некоторым бэкендом, готовым принимать оповещения о новых заказах, поступивших в его кофейни, и нам нужно договориться о формате взаимодействия. Решение этой задачи декомпозируется на несколько шагов:</p>
<h5><a href="#chapter-21-paragraph-4" id="chapter-21-paragraph-4" class="anchor">4. Договоренность о контракте</a></h5>
<p>В зависимости от важности партнёра для вашего бизнеса здесь возможны разные варианты:</p>
<ul>
<li>производитель API может реализовать возможность вызова webhook-а в формате, предложенном партнёром;</li>
<li>наоборот, партнёр должен разработать эндпойнт в стандартном формате, предлагаемом производителем API;</li>
<li>любой промежуточный вариант.</li>
</ul>
<p>Важно, что в любом случае должен существовать формальный контракт (очень желательно — в виде спецификации) на форматы запросов и ответов эндпойнта-webhook-а и возникающие ошибки.</p>
<h5><a href="#chapter-21-paragraph-5" id="chapter-21-paragraph-5" class="anchor">5. Договорённость о авторизации и аутентификации</a></h5>
<p>Так как webhook-и представляют собой обратный канал взаимодействия, для него придётся разработать отдельный способ авторизации — это партнёр должен проверить, что запрос исходит от нашего бэкенда, а не наоборот. Мы повторяем здесь настоятельную рекомендацию не изобретать безопасность и использовать существующие стандартные механизмы, например, <a href="https://en.wikipedia.org/wiki/Mutual_authentication#mTLS">mTLS</a>, хотя в реальном мире с большой долей вероятности придётся использовать архаичные техники типа фиксации IP-адреса вызывающего сервера.</p>
<h5><a href="#chapter-21-paragraph-6" id="chapter-21-paragraph-6" class="anchor">6. API для задания адреса webhook-а</a></h5>
<p>Так как callback-эндпойнт разрабатывается партнёром, его URL нам априори неизвестен. Должен существовать интерфейс (возможно, в виде кабинета партнёра) для задания URL webhook-а (и публичных ключей авторизации).</p>
<p><strong>Важно</strong>. К операции задания адреса callback-а нужно подходить с максимально возможной серьёзностью (очень желательно требовать второй фактор авторизации для подтверждения этой операции), поскольку, получив доступ к такой функциональности, злоумышленник может совершить множество весьма неприятных атак:</p>
<ul>
<li>если указать в качестве приёмника сторонний URL, можно получить доступ к потоку всех заказов партнёра и при этом вызвать перебои в его работе;</li>
<li>такая уязвимость может также эксплуатироваться с целью организации DoS-атаки на сторонние сервисы;</li>
<li>если указать в качестве webhook-а URL интранет-сервисов компании-провайдера API, можно осуществить <a href="https://en.wikipedia.org/wiki/SSRF">SSRF-атаку</a> на инфраструктуру самой компании.</li>
</ul>
<h4>Типичные проблемы интеграции через webhook</h4>
<p>Двунаправленные интеграции (и клиентские, и серверные — хотя последние в большей степени) несут в себе очень неприятные риски для провайдера API. Если в общем случае качество работы API зависит в первую очередь от самого разработчика API, то в случае обратных вызовов всё в точности наоборот: качество работы интеграции напрямую зависит от того, как код webhook-эндпойнта написан партнёром. Мы можем столкнуться здесь с самыми различными видами проблем в партнёрском коде:</p>
<ul>
<li>webhook может возвращать false-positive ответы, когда сообщение не было обработано, но сервер партнёра тем не менее ошибочно вернул код успеха;</li>
<li>и наоборот, возможны false-negative ответы, когда сообщение было обработано, но эндпойнт почему-то вернул ошибку (или просто ответил в неправильном формате);</li>
<li>webhook может обрабатывать входящие запросы очень долго — возможно, настолько долго, что сервер сообщений просто не будет успевать их отправить;</li>
<li>могут быть допущены ошибки в реализации идемпотентости, и повторная обработка одного и того же сообщения партнёром может приводить к ошибкам или некорректности данных в системе партнёра;</li>
<li>размер тела сообщение может превысить лимит, выставленный на веб-сервере партнёра;</li>
<li>авторизация на стороне партнёра может не проверяться или проверяться некорректно, и злоумышленник легко может отправить какие-то выгодные ему запросы, представившись сервером API;</li>
<li>наконец, эндпойнт может быть просто недоступен по множеству различных причин, от проблем в дата-центре, где расположены сервера партнёра, до банальной человеческой ошибки при смене URL webhook-а.</li>
</ul>
<p>Очевидно, вы никак не можете гарантировать, что партнёр не совершил какую-то из перечисленных ошибок. Но вы можете попытаться минимизировать возможный ущерб:</p>
<ol>
<li>Состояние системы должно быть восстановимо. Даже если партнёр неправильно обработал сообщения, всегда должна быть возможность реабилитироваться и получить список последних событий и/или полное состояние системы, чтобы исправить случившиеся ошибки.</li>
<li>Помогите партнёру написать правильный код, зафиксировав в документации неочевидные моменты, с которыми могут быть незнакомы неопытные разработчики:
<ul>
<li>ключи идемпотентности каждой операции;</li>
<li>гарантии доставки (exactly once, at least once; <a href="https://docs.confluent.io/kafka/design/delivery-semantics.html">см. описание гарантий доставки</a> на примере технологии Apache Kafka);</li>
<li>будет ли сервер генерировать параллельные запросы к webhook-у и, если да, каково максимальное количество одновременных запросов;</li>
<li>гарантирует ли сервер строгий порядок сообщений (запросы всегда доставляются в порядке от самого старого к самому новому)</li>
<li>размеры полей и сообщений в байтах;</li>
<li>политика перезапросов при получении ошибки.</li>
</ul>
</li>
<li>Должна быть реализована система мониторинга состояния партнёрских эндпойнтов:
<ul>
<li>при появлении большого числа ошибок (таймаутов) должно срабатывать оповещение (в т.ч. оповещение партнёра о проблеме), возможно, с несколькими уровнями эскалации;</li>
<li>если в очереди скапливается большое количество необработанных событий, должен существовать механизм деградации (ограничения количества запросов в адрес партнёра — возможно в виде срезания спроса, т.е. частичного отказа в обслуживании конечных пользователей) и полного аварийного отключения партнёра.</li>
</ul>
</li>
</ol>
<h4>Очереди сообщений</h4>
<p>Для внутренних API технология webhook-ов (то есть наличия программной возможности задавать URL обратного вызова) либо вовсе не нужна, либо решается с помощью протоколов <a href="https://en.wikipedia.org/wiki/Web_Services_Discovery">Service Discovery</a>, поскольку сервисы в составе одного бэкенда как правило равноправны — если сервис А может вызывать сервис Б, то и сервис Б может вызывать сервис А.</p>
<p>Однако все проблемы Webhook-ов, описанные нами выше, для таких обратных вызовов всё ещё актуальны. Вызов внутреннего сервиса всё ещё может окончиться false negative-ошибкой, внутренние клиенты могут не ожидать нарушения порядка пересылки сообщений и так далее.</p>
<p>Для решения этих проблем, а также для большей горизонтальной масштабируемости технологий обратного вызова, были созданы <a href="https://en.wikipedia.org/wiki/Message_queue">сервисы очередей сообщений</a> и, в частности, различные серверные реализации паттерна pub/sub. В настоящий момент pub/sub-архитектуры пользуются большой популярностью среди разработчиков, вплоть до перевода любого межсервисного взаимодействия на очереди событий.</p>
<p><strong>NB</strong>: отметим, что ничего бесплатного в мире не бывает, и за эти гарантии доставки и горизонтальную масштабируемость необходимо платить:</p>
<ul>
<li>межсерверное взаимодействие становится событийно-консистентным со всеми вытекающими отсюда проблемами;</li>
<li>хорошая горизонтальная масштабируемость и дешевизна использования очередей достигается при использовании политик at least once/at most once и отсутствии гарантии строгого порядка событий;</li>
<li>в очереди могут скапливаться необработанные сообщения, внося нарастающие задержки, и решение этой проблемы на стороне подписчика может оказаться отнюдь не тривиальным.</li>
</ul>
<p>Отметим также, что в публичных API зачастую используются обе технологии в связке — бэкенд API отправляет задание на вызов webhook-а в виде публикации события, которое специально предназначенный для этого внутренний сервис будет пытаться обработать путём вызова webhook-а.</p>
<p>Теоретически можно представить себе и такую интеграцию, в которой разработчик API даёт партнёрам непосредственно прямой доступ к внутренней очереди сообщений, однако примеры таких API нам неизвестны.</p><div class="page-break"></div><h3><a href="#chapter-22" class="anchor" id="chapter-22">Глава 22. Варианты организации системы нотификаций</a></h3><div class="page-break"></div><h3><a href="#chapter-23" class="anchor" id="chapter-23">Глава 23. Атомарность</a></h3><div class="page-break"></div><h3><a href="#chapter-24" class="anchor" id="chapter-24">Глава 24. Частичные обновления</a></h3><div class="page-break"></div><h3><a href="#chapter-25" class="anchor" id="chapter-25">Глава 25. Деградация и предсказуемость</a></h3><div class="page-break"></div><h2><a href="#section-4" class="anchor" id="section-4">Раздел III. Обратная совместимость</a></h2><h3><a href="#back-compat-statement" class="anchor" id="back-compat-statement">Глава 26. Постановка проблемы обратной совместимости</a><a href="#chapter-26" class="secondary-anchor" id="chapter-26"> </a></h3>
<p>Как обычно, дадим смысловое определение «обратной совместимости», прежде чем начинать изложение.</p>
<p>Обратная совместимость — это свойство всей системы API быть стабильной во времени. Это значит следующее: <strong>код, написанный разработчиками с использованием вашего API, продолжает работать функционально корректно в течение длительного времени</strong>. К этому определению есть два больших вопроса, и два уточнения к ним.</p>
<ol>

Binary file not shown.

View File

@ -87,7 +87,7 @@
<li><a href="API.en.html#api-patterns-weak-consistency">Chapter 18. Eventual Consistency</a></li>
<li><a href="API.en.html#api-patterns-async">Chapter 19. Asynchronicity and Time Management</a></li>
<li><a href="API.en.html#api-patterns-lists">Chapter 20. Lists and Accessing Them</a></li>
<li><a href="API.en.html#chapter-21">Chapter 21. Bidirectional Data Flows. Push and Poll Models</a></li>
<li><a href="API.en.html#api-patterns-push-vs-poll">Chapter 21. Bidirectional Data Flows. Push and Poll Models</a></li>
<li><a href="API.en.html#chapter-22">Chapter 22. Organization of Notification Systems</a></li>
<li><a href="API.en.html#chapter-23">Chapter 23. Atomicity</a></li>
<li><a href="API.en.html#chapter-24">Chapter 24. Partial Updates</a></li>