mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-01-05 10:20:22 +02:00
Идемпотетнтость, кэширование, пагинация
This commit is contained in:
parent
7c544d9274
commit
6dfa58adbd
246
docs/API.ru.html
246
docs/API.ru.html
@ -531,50 +531,63 @@ GET /sensors
|
||||
<li><p>С точки зрения верхнеуровневого API отмена заказа является терминальным действием, т.е. никаких последующих операций уже быть не может; а с точки зрения низкоуровневого API обработка заказа продолжается, т.к. нужно дождаться, когда стакан будет утилизирован, и после этого освободить кофе-машину (т.е. разрешить создание новых рантаймов на ней). Это вторая задача для уровня исполнения: связывать оба статуса, внешний (заказ отменён) и внутренний (исполнение продолжается).</p></li>
|
||||
</ol>
|
||||
<p>Может показаться, что соблюдение правила изоляции уровней абстракции является избыточным и заставляет усложнять интерфейс. И это в действительности так: важно понимать, что никакая гибкость, логичность, читабельность и расширяемость не бывает бесплатной. Можно построить API так, чтобы оно выполняло свою функцию с минимальными накладными расходами, по сути — дать интерфейс к микроконтроллерам кофе-машины. Однако пользоваться им будет крайне неудобно, и расширяемость такого API будет нулевой.</p>
|
||||
<p>Выделение уровней абстракции — прежде всего <em>логическая</em> процедура: как мы объясняем себе и разработчику, из чего состоит наш API. <strong>Абстрагируемая дистанция между сущностями существует объективно</strong>, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни <em>явно</em>. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.</p><div class="page-break"></div><h3 id="10">Глава 10. Разграничение областей ответственности</h3>
|
||||
<p>Выделение уровней абстракции — прежде всего <em>логическая</em> процедура: как мы объясняем себе и разработчику, из чего состоит наш API. <strong>Абстрагируемая дистанция между сущностями существует объективно</strong>, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни <em>явно</em>. Чем более неявно разведены (или, хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API, и тем хуже будет написан использующий его код.</p>
|
||||
<h4 id="-1">Потоки данных</h4>
|
||||
<p>Полезное упражнение, позволяющее рассмотреть иерархию уровней абстракции API — исключить из рассмотрения все частности и построить — в голове или на бумаге — дерево потоков данных: какие данные протекают через объекты вашего API и как видоизменяются на каждом этапе.</p>
|
||||
<p>Это упражнение не только полезно для проектирования, но и, помимо прочего, является единственным способом развивать большие (в смысле номенклатуры объектов) API. Человеческая память не безгранична, и любой активно развивающийся проект достаточно быстро станет настолько большим, что удержать в голове всю иерархию сущностей со всеми полями и методами станет невозможно. Но вот держать в уме схему потоков данных обычно вполне возможно — или, во всяком случае, получается держать в уме на порядок больший фрагмент дерева сущностей API.</p>
|
||||
<p>Какие потоки данных мы имеем в нашем кофейном API?</p>
|
||||
<ol>
|
||||
<li><p>Данные с сенсоров — объёмы кофе / воды / стакана. Это низший из доступных нам уровней данных, здесь мы не можем ничего изменить или переформулировать.</p></li>
|
||||
<li><p>Непрерывный поток данных сенсоров мы преобразуем в дискретные статусы исполнения команд, вводя в него понятия, не существующие в предметной области. API кофе-машины не предоставляет нам понятий «кофе наливается» или «стакан ставится» — это наше программное обеспечение трактует поступающие потоки данных от сенсоров, вводя новые понятия: если наблюдаемый объём (кофе или воды) меньше целевого — значит, процесс не закончен; если объём достигнут — значит, необходимо сменить статус исполнения и выполнить следующее действие.<br />
|
||||
Важно отметить, что мы не просто вычисляем какие-то новые параметры из имеющихся данных сенсоров: мы сначала создаём новый кортеж данных более высокого уровня — «программа исполнения» как последовательность шагов и условий — и инициализируем его начальные значения. Без этого контекста определить, что собственно происходит с кофе-машиной невозможно.</p></li>
|
||||
<li><p>Обладая логическими данными о состоянии исполнения программы, мы можем (вновь через создание нового, более высокоуровневого контекста данных!) свести данные от двух типов API к единому формату: исполнение операции создания напитка и её логические параметры: целевой рецепт, объём, готов ли заказ.</p></li>
|
||||
</ol>
|
||||
<p>Таким образом, каждый уровень абстракции нашего API соответствует какому-то обобщению и обогащению потока данных, преобразованию его из терминов нижележащего (и вообще говоря бесполезного для потребителя) контекста в термины вышестоящего контекста.</p>
|
||||
<p>Дерево можно развернуть и в обратную сторону:</p>
|
||||
<ol>
|
||||
<li><p>На уровне заказа мы задаём его логические параметры: рецепт, объём, место исполнения и набор допустимых статусов заказа.</p></li>
|
||||
<li><p>На уровне исполнения мы обращаемся к данным уровня заказа и создаём более низкоуровневый контекст: программа исполнения в виде последовательности шагов, их параметров и условий перехода от одного шага к другому и начальное состояние (с какого шага начать исполнение и с какими параметрами).</p></li>
|
||||
<li><p>На уровне рантайма мы обращаемся к целевым значениям (какую операцию выполнить и какой целевой объём) и преобразуем её в набор микрокоманд API кофе-машины и набор статусов исполнения каждой команды.</p></li>
|
||||
</ol>
|
||||
<p>Если обратиться к описанному в начале главы «плохому» решению (предполагающему самостоятельное определение факта готовности заказа разработчиком), то мы увидим, что и с точки зрения потоков данных происходит смешение понятий: </p>
|
||||
<ul>
|
||||
<li><p>с одной стороны, в контексте заказа оказываются данные (объём кофе), «просочившиеся» откуда-то с физического уровня; тем самым, уровни абстракции непоправимо смешиваются без возможности их разделить;</p></li>
|
||||
<li><p>с другой стороны, сам контекст заказа неполноценный: он не задаёт новых мета-переменных, которые отсутствуют на более низких уровнях абстракции (статус заказа), не инициализирует их и не предоставляет правил работы.</p>
|
||||
<p>Более подробно о контекстах данных мы поговорим в разделе II. Здесь же ограничимся следующим выводом: потоки данных и их преобразования можно и нужно рассматривать как некоторый срез, который, с одной стороны, помогает нам правильно разделить уровни абстракции, а с другой — проверить, что наши теоретические построения действительно работают так, как нужно.</p></li>
|
||||
</ul><div class="page-break"></div><h3 id="10">Глава 10. Разграничение областей ответственности</h3>
|
||||
<p>Исходя из описанного в предыдущей главе, мы понимаем, что иерархия абстракций в нашем гипотетическом проекте должна выглядеть примерно так:</p>
|
||||
<ul>
|
||||
<li>пользовательский уровень (те сущности, с которыми непосредственно взаимодействует пользователь и сформулированы в понятных для него терминах; например, заказы и виды кофе);</li>
|
||||
<li>исполнительный уровень (те сущности, которые отвечают за переформулирование заказа в машинные термины);</li>
|
||||
<li>физический уровень (непосредственно сами датчики машины).</li>
|
||||
<li>уровень исполнения программ (те сущности, которые отвечают за преобразование заказа в машинные термины);</li>
|
||||
<li>уровень рантайма для API второго типа (сущности, отвечающие за state-машину выполнения заказа).</li>
|
||||
</ul>
|
||||
<p>Теперь нам необходимо определить ответственность каждой сущности: в чём смысл её существования в рамках нашего API, какие действия можно выполнять с самой сущностью, а какие — делегировать другим объектам. Фактически, нам нужно применить «зачем-принцип» к каждой отдельной сущности нашего API.</p>
|
||||
<p>Для этого нам нужно пройти по нашему API и сформулировать в терминах предметной области, что представляет из себя каждый объект. Напомню, что из концепции уровней абстракции следует, что каждый уровень иерархии — это некоторая собственная промежуточная предметная область, ступенька, по которой мы переходим от описания задачи в терминах одного связываемого контекста («заказанный пользователем лунго») к описанию в терминах второго («задание кофе-машине на выполнение указанной программы»).</p>
|
||||
<p>В нашем умозрительном примере получится примерно так:</p>
|
||||
<ol>
|
||||
<li>Сущности уровня пользователя (те, работая с которыми, разработчик непосредственно решает задачи пользователя).<ul>
|
||||
<li>Заказ <code>order</code> — описывает некоторую логическую единицу взаимодействия с пользователем. Заказ можно:<ul>
|
||||
<li>создавать</li>
|
||||
<li>проверять статус</li>
|
||||
<li>получать или отменять</li></ul></li>
|
||||
<li>Рецепт <code>recipe</code> — описывает «идеальную модель» вида кофе, его потребительские свойства. Рецепт в данном контексте для нас неизменяемая сущность, которую можно только просмотреть и выбрать;</li>
|
||||
<li>Задание <code>task</code> — описывает некоторые задачи, на которые декомпозируются заказ;</li>
|
||||
<li>Кофе-машина <code>cofee-machine</code> — модель объекта реального мира. Мы можем:<ul>
|
||||
<li>получать статусы датчиков</li>
|
||||
<li>отправлять команды и проверять их исполнение</li></ul></li>
|
||||
<li>создавать;</li>
|
||||
<li>проверять статус;</li>
|
||||
<li>получать или отменять.</li></ul></li>
|
||||
<li>Рецепт <code>recipe</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> — конкретный факт исполнения программы на конкретной кофе-машине. Запуски можно:<ul>
|
||||
<li>инициировать (создавать);</li>
|
||||
<li>отменять;</li>
|
||||
<li>проверять состояние запуска.</li></ul></li></ul></li>
|
||||
<li>Сущности уровня программ исполнения (те, работая с которыми, можно непосредственно управлять состоянием кофе-машины через API второго типа).<ul>
|
||||
<li>Рантайм <code>runtime</code> — контекст исполнения программы, т.е. состояние всех переменных. Рантаймы можно:<ul>
|
||||
<li>создавать;</li>
|
||||
<li>проверять статус;</li>
|
||||
<li>терминировать.</li></ul></li></ul></li>
|
||||
</ol>
|
||||
<p>Если внимательно посмотреть на каждый объект, то мы увидим, что, в итоге, каждый объект оказался в смысле своей ответственности составным. Например, <code>coffee-machine</code> будет частично оперировать реальными командами кофе-машины <em>и</em> представлять их состояние в каком-то машиночитаемом виде.</p>
|
||||
<h4 id="">Декомпозиция интерфейсов</h4>
|
||||
<p>На этапе разделения уровней абстракции мы упомянули, что одна из целей такого разделения — добиться возможности безболезненно сменить нижележащие уровни абстракции при добавлении новых технологий или изменении нижележащих протоколов. Декомпозиция интерфейсов — основной инструмент, позволяющий добиться такой гибкости.</p>
|
||||
<p>На этом этапе нам предстоит отделить частное от общего: понять, какие свойства сущностей фиксированы в нашей модели, а какие будут зависеть от реализации. В нашем кофе-примере таким сильно зависящим от имплементации объектом, очевидно, будут сами кофе-машины. Попробуем ещё немного развить наше API: каким образом нам нужно описать модели кофе-машин для того, чтобы предоставить интерфейсы для работы с задачами заказа?</p>
|
||||
<h4 id="-1">Интерфейсы как универсальный паттерн</h4>
|
||||
<p>Как мы убедились в предыдущей главе, выделение интерфейсов крайне важно с точки зрения удобства написания кода. Однако, интерфейсы играют и другую важную роль в проектировании: они позволяют уложить в голове архитектуру API целиком.</p>
|
||||
<p>Любой сколько-нибудь крупный API рано или поздно обрастает разнообразной номенклатурой сущностей, их свойст и методов, как в силу того, что в одном объекте «сходятся» несколько предметных областей, так и в силу появления со временем разнообразной вспомогательной и дополнительной функциональности. Особенно сложной номенклатура объектов и их методов становится в случае появления альтернативных реализаций одного и того же интерфейса.</p>
|
||||
<p>Человеческие возможности небезграничны: невозможно держать в голове всю номенклатуру объектов. Это осложняет и проектирование API, и рефакторинг, и просто решение возникающих задач по реализации той или иной бизнес-логики.</p>
|
||||
<p>Держать же в голове схему взаимодействия интерфейсов гораздо проще - как в силу исключения из рассмотрения разнообразных вспомогательных и специфических методов, так и в силу того, что интерфейсы позволяют отделить существенное (в чем смысл конкретной сущности) от несущественного (деталей реализации).</p>
|
||||
<p>Поскольку задача выделения интерфейсов есть задача удобного манипулирования сущностями в голове разработчика, мы рекомендуем при проектировании интерфейсов руководствоваться, прежде всего, здравым смыслом: интерфейсы должны быть ровно настолько сложны, насколько это удобно для человеческого восприятия (а лучше даже чуть проще). В простейших случаях это просто означает, что интерфейс должен содержать семь плюс-минуса два свойства/метода. Более сложные интерфейсы должны декомпозироваться в несколько простых.</p>
|
||||
<p>Это правило существенно важно не только при проектировании api - не забывайте, что ваши пользователи неизбежно столкнутся с той же проблемой - понять примерную архитектуру вашего api, запомнить, что с чем связано в вашей системе. Правильно выделенные интерфейсы помогут и здесь, причём сразу в двух смыслах - как непосредственно работающему с вашим кодом программисту, так и документатору, которому будет гораздо проще описать структуру вашего api, опираясь на дерево интерфейсов.</p>
|
||||
<p>С другой стороны надо вновь напомнить, что бесплатно ничего не бывает, и выделение интерфейсов - самая «небесплатная» часть процесса разработки API, поскольку в чистом виде приносится в жертву удобство разработки ради построения «правильной» архитектуры: разумеется, код писать куда проще, когда имеешь доступ ко всем объектам API со всей их богатой номенклатурой методов, нежели когда из каждого объекта доступны только пара непосредственно примыкающих интерфейсов, притом с максимально общими методами.</p>
|
||||
<p>Помимо прочего, это означает, что интерфейсы необходимо выделять там, где это актуально решаемой задаче - прежде всего, в точках будущего роста и там, где возможны альтернативные реализации. Чем проще API, тем меньше нужда в интерфейсах, и наоборот: сложное API требует интерфейсов практически всюду просто для того, чтобы ограничить разрастание излишне сильной связанности и при этом не сойти с ума.</p>
|
||||
<p>В пределе в сложном api должна сложиться ситуация, при которой все объекты взаимодействуют друг с другом только как интерфейсы — нет ни одной публичной сигнатуры, принимающей конкретный объект, а не его интерфейс. Разумеется, достичь такого уровня абстракции практически невозможно - почти в любой системе есть глобальные объекты, разнообразные технические сущности (имплементации стандартных структур данных, например); наконец, невозможно «спрятать» за интерфейсы системные объекты или сущности физического уровня.</p>
|
||||
<h4 id="-2">Информационные контексты</h4>
|
||||
<p>При выделении интерфейсов и вообще при проектировании api бывает полезно взглянуть на иерархию абстракций с другой точки зрения, а именно: каким образом информация «протекает» через нашу иерархию.</p>
|
||||
<p>Вспомним, что одним из критериев отделения уровней абстракции является переход от структур данных одной предметной области к структурам данных другой. В рамках нашего примера через иерархию наших объектов происходит трансляция данных реального мира — «железные» кофе-машины, которые готовят реально существующие напитки — в виртуальные интерфейсы «заказов».</p>
|
||||
<p>Если обратиться к правилу «неперепрыгивания» через уровни абстракции, то с точки зрения потоков данных оно формулируется так:</p>
|
||||
<ul>
|
||||
<li>каждый объект в иерархии абстракций должен оперировать данными согласно своему уровню иерархии;</li>
|
||||
<li>преобразованием данных имеют право заниматься только те объекты, в чьи непосредственные обязанности это входит.</li>
|
||||
</ul>
|
||||
<p>Дерево информационных контекстов (какой объект обладает какой информацией, и кто является транслятором из одного контекста в другой), по сути, представляет собой «срез» нашего дерева иерархии интерфейсов; выделение такого среза позволяет проще и удобнее удерживать в голове всю архитектуру проекта.</p>
|
||||
<p>Если внимательно посмотреть на каждый объект, то мы увидим, что, в итоге, каждый объект оказался в смысле своей ответственности составным. Например, <code>program</code> будет оперировать данными высшего уровня (рецепт и кофе-машина), дополняя их терминами своего уровня (идентификатор запуска). Это совершенно нормально: API должно связывать контексты.</p>
|
||||
<h4 id="">Сценарии использования</h4>
|
||||
<p>На этом уровне, когда наше API уже в целом понятно устроено и спроектированы, мы должны поставить себя на место разработчика и попробовать написать код.</p>
|
||||
<p>// TODO
|
||||
// Хелперы, бойлерплейт</p><div class="page-break"></div><h3 id="11">Глава 11. Описание конечных интерфейсов</h3>
|
||||
<p>Определив все сущности, их ответственность и отношения друг с другом, мы переходим непосредственно к разработке API: нам осталось прописать номенклатуру всех объектов, полей, методов и функций в деталях. В этой главе мы дадим сугубо практические советы, как сделать API удобным и понятным.</p>
|
||||
@ -603,16 +616,16 @@ GET /orders/statistics
|
||||
<p>Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.</p>
|
||||
<p><strong>Хорошо</strong>:</p>
|
||||
<pre><code>// Возвращает агрегированную статистику заказов за указанный период
|
||||
POST /orders/statistics/aggregate
|
||||
POST /v1/orders/statistics/aggregate
|
||||
{ "start_date", "end_date" }
|
||||
</code></pre></li>
|
||||
</ul>
|
||||
<p><strong>Стремитесь к тому, чтобы из сигнатуры функции было абсолютно ясно, что она делает, что принимает на вход и что возвращает</strong>. Вообще, при прочтении кода, работающего с вашим API, должно быть сразу понятно, что, собственно, он делает — без подглядывания в документацию.</p>
|
||||
<p>Два важных следствия:</p>
|
||||
<p><strong>1.1.</strong> Если операция модифицирующая, это должно быть очевидно из сигнатуры. В частности, не может быть модифицирующих операций за <code>GET</code>.</p>
|
||||
<p><strong>1.2.</strong> Если операция асинхронная, это должно быть очевидно из сигнатуры, <strong>либо</strong> должна существовать конвенция именования, позволяющая отличаться синхронные операции от асинхронных.</p>
|
||||
<p><strong>1.2.</strong> Если в номенклатуре вашего API есть как синхронные операции, так и асинхронные, то (а)синхронность должна быть очевидна из сигнатур, <strong>либо</strong> должна существовать конвенция именования, позволяющая отличать синхронные операции от асинхронных.</p>
|
||||
<h4 id="2">2. Использованные стандарты указывайте явно</h4>
|
||||
<p>К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «начинать ли неделю с понедельника или с воскресенья», что уж говорить о каких-то более сложных стандартах.</p>
|
||||
<p>К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя», что уж говорить о каких-то более сложных стандартах.</p>
|
||||
<p>Поэтому <em>всегда</em> указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе:</p>
|
||||
<ul>
|
||||
<li><strong>плохо</strong>: <code>"date":"11/12/2020"</code> — стандартов записи дат существует огромное количество, плюс из этой записи невозможно даже понять, что здесь число, а что месяц;<br />
|
||||
@ -626,7 +639,7 @@ POST /orders/statistics/aggregate
|
||||
<code>"duration":{"unit":"ms","value":5000}</code>.</li>
|
||||
</ul>
|
||||
<p>Отдельное следствие из этого правила — денежные величины <em>всегда</em> должны сопровождаться указанием кода валюты.</p>
|
||||
<p>Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что как ни сделай — кто-то останется недовольным. Классический пример такого рода — порядок геокоординат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.</p>
|
||||
<p>Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что как ни сделай — кто-то останется недовольным. Классический пример такого рода — порядок географических координат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.</p>
|
||||
<h4 id="3">3. Сохраняйте точность дробных чисел</h4>
|
||||
<p>Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.</p>
|
||||
<p>Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.</p>
|
||||
@ -648,7 +661,7 @@ POST /orders/statistics/aggregate
|
||||
strpbrk (str1, str2)
|
||||
</code>
|
||||
Возможно, автору этого API казалось, что аббревиатура <code>pbrk</code> что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк <code>str1</code>, <code>str2</code> является набором символов для поиска.<br />
|
||||
<strong>Хорошо</strong>: <code>str_search_for_characters(lookup_character_set, str)</code><br />
|
||||
<strong>Хорошо</strong>: <code>str_search_for_characters (lookup_character_set, str)</code><br />
|
||||
Однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение <code>string</code> до <code>str</code> выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.</li>
|
||||
</ul>
|
||||
<h4 id="6">6. Тип поля должен быть ясен из его названия</h4>
|
||||
@ -672,7 +685,7 @@ strpbrk (str1, str2)
|
||||
GET /coffee-machines/functions
|
||||
</code>
|
||||
Слово "functions" многозначное; может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).<br />
|
||||
<strong>Хорошо</strong>: <code>GET /coffee-machines/builtin-functions-list</code></li>
|
||||
<strong>Хорошо</strong>: <code>GET /v1/coffee-machines/builtin-functions-list</code></li>
|
||||
</ul>
|
||||
<h4 id="7">7. Подобные сущности должны называться подобно и вести себя подобным образом</h4>
|
||||
<ul>
|
||||
@ -722,14 +735,14 @@ GET /comments/{id}
|
||||
<strong>Хорошо</strong>:
|
||||
<code>
|
||||
// Создаёт комментарий и возвращает его
|
||||
POST /comments
|
||||
POST /v1/comments
|
||||
{ "content" }
|
||||
→
|
||||
{ "comment_id", "published", "action_required", "content" }
|
||||
</code>
|
||||
<code>
|
||||
// Возвращает комментарий по его id
|
||||
GET /comments/{id}
|
||||
GET /v1/comments/{id}
|
||||
→
|
||||
{ /* в точности тот же формат,
|
||||
что и в ответе POST /comments */
|
||||
@ -737,5 +750,154 @@ GET /comments/{id}
|
||||
}
|
||||
</code>
|
||||
Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа невелик) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение.</li>
|
||||
</ul>
|
||||
<h4 id="9">9. Идемпотентность</h4>
|
||||
<p>Все эндпойнты должны быть идемпотентны. Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни.</p>
|
||||
<p>Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию.</p>
|
||||
<ul>
|
||||
<li><strong>Плохо</strong>
|
||||
<code>
|
||||
// Создаёт заказ
|
||||
POST /orders
|
||||
</code>
|
||||
Повтор запроса создаст два заказа!</li>
|
||||
<li><strong>Хорошо</strong>
|
||||
<code>
|
||||
// Создаёт заказ
|
||||
POST /v1/orders
|
||||
X-Idempotency-Token: <случайная строка>
|
||||
</code>
|
||||
Клиент на своей стороне запоминает X-Idempotency-Token, и, в случае автоматического повторного перезапроса, обязан его сохранить. Сервер на своей стороне проверяет токен и, если заказ с таким токеном уже существует для этого клиента, не даёт создать заказ повторно.<br />
|
||||
Альтернатива:
|
||||
<code>
|
||||
// Создаёт черновик заказа
|
||||
POST /v1/orders
|
||||
→
|
||||
{ "draft_id" }
|
||||
</code>
|
||||
<code>
|
||||
// Подтверждает черновик заказа
|
||||
PUT /v1/orders/drafts/{draft_id}
|
||||
{ "confirmed": true }
|
||||
</code>
|
||||
Создание черновика заказа — необязывающая операция, которая не приводит ни к каким последствиям, поэтому допустимо создавать черновики без токена идемпотентности.
|
||||
Операция подтверждения заказа — уже естественным образом идемпотентна, для неё <code>draft_id</code> играет роль ключа идемпотентности.</li>
|
||||
</ul>
|
||||
<h4 id="10">10. Кэширование</h4>
|
||||
<p>В клиент-серверном API, как правило, сеть и ресурс сервера не бесконечны, поэтому кэширование на клиенте результатов операции является стандартным действием.</p>
|
||||
<p>Желательно в такой ситуации внести ясность; если не из сигнатур операций, то хотя бы из документации должно быть понятно, каким образом можно кэшировать результат.</p>
|
||||
<ul>
|
||||
<li><p><strong>Плохо</strong></p>
|
||||
<pre><code>// Возвращает цену лунго в кафе,
|
||||
// ближайшем к указанной точке
|
||||
GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
→
|
||||
{ "currency_code", "price" }
|
||||
</code></pre>
|
||||
<p>Возникает два вопроса:</p>
|
||||
<ul>
|
||||
<li>в течение какого времени эта цена действительна?</li>
|
||||
<li>на каком расстоянии от указанной точки цена всё ещё действительна? </li></ul>
|
||||
<p>Если на первый вопрос легко ответить введением стандартных заголовков Cache-Control, то для второго вопроса готовых решений нет. В ситуации, когда кэш нужен и по временной, и по пространственной координате следует поступить примерно так:</p>
|
||||
<pre><code>// Возвращает предложение: за какую сумму
|
||||
// наш сервис готов приготовить лунго
|
||||
GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
→
|
||||
{
|
||||
"offer": {
|
||||
"id",
|
||||
"currency_code",
|
||||
"price",
|
||||
"terms": {
|
||||
// До какого времени валидно предложение
|
||||
"valid_until",
|
||||
// Где валидно предложение:
|
||||
// * город
|
||||
// * географический объект
|
||||
// * …
|
||||
"valid_within"
|
||||
}
|
||||
}
|
||||
}
|
||||
</code></pre></li>
|
||||
</ul>
|
||||
<h4 id="11-1">11. Пагинация, фильтрация и курсоры</h4>
|
||||
<p>Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может.</p>
|
||||
<p>Любой эндпойнт, возвращающий изменяемые данные постранично, должен обеспечивать возможность эти данные перебрать.</p>
|
||||
<ul>
|
||||
<li><p><strong>Плохо</strong>:</p>
|
||||
<pre><code>// Возвращает указанный limit записей,
|
||||
// отсортированных по дате создания
|
||||
// начиная с записи с номером offset
|
||||
GET /records?limit=10&offset=100
|
||||
</code></pre>
|
||||
<p>На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса:</p>
|
||||
<ol>
|
||||
<li>Каким образом клиент узнает о появлении новых записей в начале списка?<br />
|
||||
Легко заметить, что клиент может только попытаться повторить первый запрос и сличить идентификаторы с запомненным началом списка. Но что делать, если добавленное количество записей превышает limit? Представим себе ситуацию:<ul>
|
||||
<li>клиент обрабатывает записи в порядке поступления;</li>
|
||||
<li>произошла какая-то проблема, и накопилось большое количество необработанных записей;</li>
|
||||
<li>клиент запрашивает новые записи (offset=0), однако не находит на первой странице известных идентификаторов — новых записей накопилось больше, чем limit;</li>
|
||||
<li>клиент вынужден продолжить перебирать записи (увеличивая offset), пока не доберётся до последней известной ему; всё это время клиент простаивает;</li>
|
||||
<li>таким образом может сложиться ситуация, когда клиент вообще никогда не обработает всю очередь, т.к. будет занят беспорядочным линейным перебором.</li></ul></li>
|
||||
<li>Что произойдёт, если при переборе списка одна из записей в уже перебранной части будет удалена?<br />
|
||||
Произойдёт следующее: клиент пропустит одну запись и никогда не сможет об этом узнать.</li>
|
||||
<li>Какие параметры кэширования мы можем выставить на этот эндпойнт?<br />
|
||||
Никакие: повторяя запрос с теми же limit-offset мы каждый раз получаем новый набор записей.</li></ol>
|
||||
<p><strong>Хорошо</strong>: в таких однонаправленных списках пагинация должна быть организована по тому ключу, порядок которых фиксирован. Например, вот так:</p>
|
||||
<pre><code>// Возвращает указанный limit записей,
|
||||
// отсортированных по дате создания,
|
||||
// начиная с первой записи, созданной позднее,
|
||||
// чем запись с указанным id
|
||||
GET /records?older_than={record_id}&limit=10
|
||||
// Возвращает указанный limit записей,
|
||||
// отсортированных по дате создания,
|
||||
// начиная с первой записи, созданной раньше,
|
||||
// чем запись с указанным id
|
||||
GET /records?newer_than={record_id}&limit=10
|
||||
</code></pre>
|
||||
<p>При такой организации клиенту не надо заботиться об удалении или добавлении записей в уже перебранной части списка: он продолжает перебор по идентификатору известной записи — первой известной, если надо получить новые записи; последней известной, если надо продолжить перебор.
|
||||
Если операции удаления записей нет, то такие запросы можно свободно кэшировать — по одному и тому же URL будет всегда возвращаться один и тот же набор записей.<br />
|
||||
Другой вариант организации таких списков — возврат курсора <code>cursor</code>, который используется вместо <code>record_id</code>, что делает интерфейсы универсальнее.</p></li>
|
||||
<li><p><strong>Плохо</strong>:</p>
|
||||
<pre><code>// Возвращает указанный limit записей,
|
||||
// отсортированных по полю sort_by
|
||||
// в порядке sort_order,
|
||||
// начиная с записи с номером offset
|
||||
GET /records?sort_by=date_modified&sort_order=desc&limit=10&offset=100
|
||||
</code></pre>
|
||||
<p>Сортировка по дате модификации обычно означает, что данные могут меняться. Иными словами, между запросом первой порции данных и запросом второй порции данных какая-то запись может измениться; она просто пропадёт из перечисления, т.к. автоматически попадает на первую страницу. Клиент никогда не получит те записи, которые менялись во время перебора, и у него даже нет способа узнать о самом факте такого пропуска. Помимо этого отметим, что такое API нерасширяемо — невозможно добавить сортировку по двум или более полям.</p>
|
||||
<p><strong>Хорошо</strong>: в представленной постановке задача, вообще говоря, не решается. Список записей по дате изменения всегда будет непредсказуемо изменяться, поэтому необходимо изменить сам подход к формированию данных, одним из двух способов.</p>
|
||||
<ul>
|
||||
<li><p>Фиксировать порядок в момент обработки запроса; т.е. сервер формирует полный список и сохраняет его в неизменяемом виде:</p>
|
||||
<pre><code>// Создаёт представление по указанным параметрам
|
||||
POST /v1/record-views
|
||||
{
|
||||
sort_by: [
|
||||
{ "field": "date_modified", "order": "desc" }
|
||||
]
|
||||
}
|
||||
→
|
||||
{ "id", "cursor" }
|
||||
</code></pre>
|
||||
<pre><code>// Позволяет получить часть представления
|
||||
GET /v1/record-views/{id}?cursor={cursor}
|
||||
</code></pre>
|
||||
<p>Т.к. созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offest, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков может получиться так, что порядок будет нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).</p></li>
|
||||
<li><p>Гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:</p>
|
||||
<pre><code>POST /v1/records/modified/list
|
||||
{
|
||||
// Опционально
|
||||
"cursor"
|
||||
}
|
||||
→
|
||||
{
|
||||
"modified": [
|
||||
{ "date", "record_id" }
|
||||
],
|
||||
"cursor"
|
||||
}
|
||||
</code></pre>
|
||||
<p>Недостатком этой схемы является необходимость заводить отдельные списки под каждый вид сортировки.</p></li></ul></li>
|
||||
</ul><div class="page-break"></div></article>
|
||||
</body></html>
|
BIN
docs/API.ru.pdf
BIN
docs/API.ru.pdf
Binary file not shown.
@ -361,5 +361,35 @@ POST /v1/runtimes
|
||||
|
||||
Может показаться, что соблюдение правила изоляции уровней абстракции является избыточным и заставляет усложнять интерфейс. И это в действительности так: важно понимать, что никакая гибкость, логичность, читабельность и расширяемость не бывает бесплатной. Можно построить API так, чтобы оно выполняло свою функцию с минимальными накладными расходами, по сути — дать интерфейс к микроконтроллерам кофе-машины. Однако пользоваться им будет крайне неудобно, и расширяемость такого API будет нулевой.
|
||||
|
||||
Выделение уровней абстракции — прежде всего _логическая_ процедура: как мы объясняем себе и разработчику, из чего состоит наш API. **Абстрагируемая дистанция между сущностями существует объективно**, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни _явно_. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.
|
||||
Выделение уровней абстракции — прежде всего _логическая_ процедура: как мы объясняем себе и разработчику, из чего состоит наш API. **Абстрагируемая дистанция между сущностями существует объективно**, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни _явно_. Чем более неявно разведены (или, хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API, и тем хуже будет написан использующий его код.
|
||||
|
||||
#### Потоки данных
|
||||
|
||||
Полезное упражнение, позволяющее рассмотреть иерархию уровней абстракции API — исключить из рассмотрения все частности и построить — в голове или на бумаге — дерево потоков данных: какие данные протекают через объекты вашего API и как видоизменяются на каждом этапе.
|
||||
|
||||
Это упражнение не только полезно для проектирования, но и, помимо прочего, является единственным способом развивать большие (в смысле номенклатуры объектов) API. Человеческая память не безгранична, и любой активно развивающийся проект достаточно быстро станет настолько большим, что удержать в голове всю иерархию сущностей со всеми полями и методами станет невозможно. Но вот держать в уме схему потоков данных обычно вполне возможно — или, во всяком случае, получается держать в уме на порядок больший фрагмент дерева сущностей API.
|
||||
|
||||
Какие потоки данных мы имеем в нашем кофейном API?
|
||||
|
||||
1. Данные с сенсоров — объёмы кофе / воды / стакана. Это низший из доступных нам уровней данных, здесь мы не можем ничего изменить или переформулировать.
|
||||
|
||||
2. Непрерывный поток данных сенсоров мы преобразуем в дискретные статусы исполнения команд, вводя в него понятия, не существующие в предметной области. API кофе-машины не предоставляет нам понятий «кофе наливается» или «стакан ставится» — это наше программное обеспечение трактует поступающие потоки данных от сенсоров, вводя новые понятия: если наблюдаемый объём (кофе или воды) меньше целевого — значит, процесс не закончен; если объём достигнут — значит, необходимо сменить статус исполнения и выполнить следующее действие.
|
||||
Важно отметить, что мы не просто вычисляем какие-то новые параметры из имеющихся данных сенсоров: мы сначала создаём новый кортеж данных более высокого уровня — «программа исполнения» как последовательность шагов и условий — и инициализируем его начальные значения. Без этого контекста определить, что собственно происходит с кофе-машиной невозможно.
|
||||
|
||||
3. Обладая логическими данными о состоянии исполнения программы, мы можем (вновь через создание нового, более высокоуровневого контекста данных!) свести данные от двух типов API к единому формату: исполнение операции создания напитка и её логические параметры: целевой рецепт, объём, готов ли заказ.
|
||||
|
||||
Таким образом, каждый уровень абстракции нашего API соответствует какому-то обобщению и обогащению потока данных, преобразованию его из терминов нижележащего (и вообще говоря бесполезного для потребителя) контекста в термины вышестоящего контекста.
|
||||
|
||||
Дерево можно развернуть и в обратную сторону:
|
||||
|
||||
1. На уровне заказа мы задаём его логические параметры: рецепт, объём, место исполнения и набор допустимых статусов заказа.
|
||||
|
||||
2. На уровне исполнения мы обращаемся к данным уровня заказа и создаём более низкоуровневый контекст: программа исполнения в виде последовательности шагов, их параметров и условий перехода от одного шага к другому и начальное состояние (с какого шага начать исполнение и с какими параметрами).
|
||||
|
||||
3. На уровне рантайма мы обращаемся к целевым значениям (какую операцию выполнить и какой целевой объём) и преобразуем её в набор микрокоманд API кофе-машины и набор статусов исполнения каждой команды.
|
||||
|
||||
Если обратиться к описанному в начале главы «плохому» решению (предполагающему самостоятельное определение факта готовности заказа разработчиком), то мы увидим, что и с точки зрения потоков данных происходит смешение понятий:
|
||||
* с одной стороны, в контексте заказа оказываются данные (объём кофе), «просочившиеся» откуда-то с физического уровня; тем самым, уровни абстракции непоправимо смешиваются без возможности их разделить;
|
||||
* с другой стороны, сам контекст заказа неполноценный: он не задаёт новых мета-переменных, которые отсутствуют на более низких уровнях абстракции (статус заказа), не инициализирует их и не предоставляет правил работы.
|
||||
|
||||
Более подробно о контекстах данных мы поговорим в разделе II. Здесь же ограничимся следующим выводом: потоки данных и их преобразования можно и нужно рассматривать как некоторый срез, который, с одной стороны, помогает нам правильно разделить уровни абстракции, а с другой — проверить, что наши теоретические построения действительно работают так, как нужно.
|
@ -3,8 +3,8 @@
|
||||
Исходя из описанного в предыдущей главе, мы понимаем, что иерархия абстракций в нашем гипотетическом проекте должна выглядеть примерно так:
|
||||
|
||||
* пользовательский уровень (те сущности, с которыми непосредственно взаимодействует пользователь и сформулированы в понятных для него терминах; например, заказы и виды кофе);
|
||||
* исполнительный уровень (те сущности, которые отвечают за переформулирование заказа в машинные термины);
|
||||
* физический уровень (непосредственно сами датчики машины).
|
||||
* уровень исполнения программ (те сущности, которые отвечают за преобразование заказа в машинные термины);
|
||||
* уровень рантайма для API второго типа (сущности, отвечающие за state-машину выполнения заказа).
|
||||
|
||||
Теперь нам необходимо определить ответственность каждой сущности: в чём смысл её существования в рамках нашего API, какие действия можно выполнять с самой сущностью, а какие — делегировать другим объектам. Фактически, нам нужно применить «зачем-принцип» к каждой отдельной сущности нашего API.
|
||||
|
||||
@ -12,56 +12,31 @@
|
||||
|
||||
В нашем умозрительном примере получится примерно так:
|
||||
|
||||
1. Заказ `order` — описывает некоторую логическую единицу взаимодействия с пользователем. Заказ можно:
|
||||
* создавать
|
||||
* проверять статус
|
||||
* получать или отменять
|
||||
2. Рецепт `recipe` — описывает «идеальную модель» вида кофе, его потребительские свойства. Рецепт в данном контексте для нас неизменяемая сущность, которую можно только просмотреть и выбрать;
|
||||
3. Задание `task` — описывает некоторые задачи, на которые декомпозируются заказ;
|
||||
4. Кофе-машина `cofee-machine` — модель объекта реального мира. Мы можем:
|
||||
* получать статусы датчиков
|
||||
* отправлять команды и проверять их исполнение
|
||||
1. Сущности уровня пользователя (те, работая с которыми, разработчик непосредственно решает задачи пользователя).
|
||||
* Заказ `order` — описывает некоторую логическую единицу взаимодействия с пользователем. Заказ можно:
|
||||
* создавать;
|
||||
* проверять статус;
|
||||
* получать или отменять.
|
||||
* Рецепт `recipe` — описывает «идеальную модель» вида кофе, его потребительские свойства. Рецепт в данном контексте для нас неизменяемая сущность, которую можно только просмотреть и выбрать.
|
||||
* Кофе-машина `coffee-machine` — модель объекта реального мира. Из описания кофе-машины мы, в частности, должны извлечь её положение в пространстве и предоставляемые опции (о чём подробнее поговорим ниже).
|
||||
2. Сущности уровня управления исполнением (те, работая с которыми, можно непосредственно исполнить заказ):
|
||||
* Программа `program` — описывает доступные возможности конкретной кофе-машины. Программы можно только просмотреть.
|
||||
* Селектор программ `programs/matcher` — позволяет связать рецепт и программу исполнения, т.е. фактически выяснить набор данных, необходимых для приготовления конкретного рецепта на конкретной кофе-машине. Селектор работает только на выбор нужной программы.
|
||||
* Запуск программы `programs/run` — конкретный факт исполнения программы на конкретной кофе-машине. Запуски можно:
|
||||
* инициировать (создавать);
|
||||
* отменять;
|
||||
* проверять состояние запуска.
|
||||
3. Сущности уровня программ исполнения (те, работая с которыми, можно непосредственно управлять состоянием кофе-машины через API второго типа).
|
||||
* Рантайм `runtime` — контекст исполнения программы, т.е. состояние всех переменных. Рантаймы можно:
|
||||
* создавать;
|
||||
* проверять статус;
|
||||
* терминировать.
|
||||
|
||||
Если внимательно посмотреть на каждый объект, то мы увидим, что, в итоге, каждый объект оказался в смысле своей ответственности составным. Например, `coffee-machine` будет частично оперировать реальными командами кофе-машины *и* представлять их состояние в каком-то машиночитаемом виде.
|
||||
Если внимательно посмотреть на каждый объект, то мы увидим, что, в итоге, каждый объект оказался в смысле своей ответственности составным. Например, `program` будет оперировать данными высшего уровня (рецепт и кофе-машина), дополняя их терминами своего уровня (идентификатор запуска). Это совершенно нормально: API должно связывать контексты.
|
||||
|
||||
#### Декомпозиция интерфейсов
|
||||
#### Сценарии использования
|
||||
|
||||
На этапе разделения уровней абстракции мы упомянули, что одна из целей такого разделения — добиться возможности безболезненно сменить нижележащие уровни абстракции при добавлении новых технологий или изменении нижележащих протоколов. Декомпозиция интерфейсов — основной инструмент, позволяющий добиться такой гибкости.
|
||||
|
||||
На этом этапе нам предстоит отделить частное от общего: понять, какие свойства сущностей фиксированы в нашей модели, а какие будут зависеть от реализации. В нашем кофе-примере таким сильно зависящим от имплементации объектом, очевидно, будут сами кофе-машины. Попробуем ещё немного развить наше API: каким образом нам нужно описать модели кофе-машин для того, чтобы предоставить интерфейсы для работы с задачами заказа?
|
||||
|
||||
#### Интерфейсы как универсальный паттерн
|
||||
|
||||
Как мы убедились в предыдущей главе, выделение интерфейсов крайне важно с точки зрения удобства написания кода. Однако, интерфейсы играют и другую важную роль в проектировании: они позволяют уложить в голове архитектуру API целиком.
|
||||
|
||||
Любой сколько-нибудь крупный API рано или поздно обрастает разнообразной номенклатурой сущностей, их свойст и методов, как в силу того, что в одном объекте «сходятся» несколько предметных областей, так и в силу появления со временем разнообразной вспомогательной и дополнительной функциональности. Особенно сложной номенклатура объектов и их методов становится в случае появления альтернативных реализаций одного и того же интерфейса.
|
||||
|
||||
Человеческие возможности небезграничны: невозможно держать в голове всю номенклатуру объектов. Это осложняет и проектирование API, и рефакторинг, и просто решение возникающих задач по реализации той или иной бизнес-логики.
|
||||
|
||||
Держать же в голове схему взаимодействия интерфейсов гораздо проще - как в силу исключения из рассмотрения разнообразных вспомогательных и специфических методов, так и в силу того, что интерфейсы позволяют отделить существенное (в чем смысл конкретной сущности) от несущественного (деталей реализации).
|
||||
|
||||
Поскольку задача выделения интерфейсов есть задача удобного манипулирования сущностями в голове разработчика, мы рекомендуем при проектировании интерфейсов руководствоваться, прежде всего, здравым смыслом: интерфейсы должны быть ровно настолько сложны, насколько это удобно для человеческого восприятия (а лучше даже чуть проще). В простейших случаях это просто означает, что интерфейс должен содержать семь плюс-минуса два свойства/метода. Более сложные интерфейсы должны декомпозироваться в несколько простых.
|
||||
|
||||
Это правило существенно важно не только при проектировании api - не забывайте, что ваши пользователи неизбежно столкнутся с той же проблемой - понять примерную архитектуру вашего api, запомнить, что с чем связано в вашей системе. Правильно выделенные интерфейсы помогут и здесь, причём сразу в двух смыслах - как непосредственно работающему с вашим кодом программисту, так и документатору, которому будет гораздо проще описать структуру вашего api, опираясь на дерево интерфейсов.
|
||||
|
||||
С другой стороны надо вновь напомнить, что бесплатно ничего не бывает, и выделение интерфейсов - самая «небесплатная» часть процесса разработки API, поскольку в чистом виде приносится в жертву удобство разработки ради построения «правильной» архитектуры: разумеется, код писать куда проще, когда имеешь доступ ко всем объектам API со всей их богатой номенклатурой методов, нежели когда из каждого объекта доступны только пара непосредственно примыкающих интерфейсов, притом с максимально общими методами.
|
||||
|
||||
Помимо прочего, это означает, что интерфейсы необходимо выделять там, где это актуально решаемой задаче - прежде всего, в точках будущего роста и там, где возможны альтернативные реализации. Чем проще API, тем меньше нужда в интерфейсах, и наоборот: сложное API требует интерфейсов практически всюду просто для того, чтобы ограничить разрастание излишне сильной связанности и при этом не сойти с ума.
|
||||
|
||||
В пределе в сложном api должна сложиться ситуация, при которой все объекты взаимодействуют друг с другом только как интерфейсы — нет ни одной публичной сигнатуры, принимающей конкретный объект, а не его интерфейс. Разумеется, достичь такого уровня абстракции практически невозможно - почти в любой системе есть глобальные объекты, разнообразные технические сущности (имплементации стандартных структур данных, например); наконец, невозможно «спрятать» за интерфейсы системные объекты или сущности физического уровня.
|
||||
|
||||
#### Информационные контексты
|
||||
|
||||
При выделении интерфейсов и вообще при проектировании api бывает полезно взглянуть на иерархию абстракций с другой точки зрения, а именно: каким образом информация «протекает» через нашу иерархию.
|
||||
|
||||
Вспомним, что одним из критериев отделения уровней абстракции является переход от структур данных одной предметной области к структурам данных другой. В рамках нашего примера через иерархию наших объектов происходит трансляция данных реального мира — «железные» кофе-машины, которые готовят реально существующие напитки — в виртуальные интерфейсы «заказов».
|
||||
|
||||
Если обратиться к правилу «неперепрыгивания» через уровни абстракции, то с точки зрения потоков данных оно формулируется так:
|
||||
|
||||
* каждый объект в иерархии абстракций должен оперировать данными согласно своему уровню иерархии;
|
||||
* преобразованием данных имеют право заниматься только те объекты, в чьи непосредственные обязанности это входит.
|
||||
|
||||
Дерево информационных контекстов (какой объект обладает какой информацией, и кто является транслятором из одного контекста в другой), по сути, представляет собой «срез» нашего дерева иерархии интерфейсов; выделение такого среза позволяет проще и удобнее удерживать в голове всю архитектуру проекта.
|
||||
На этом уровне, когда наше API уже в целом понятно устроено и спроектированы, мы должны поставить себя на место разработчика и попробовать написать код.
|
||||
|
||||
// TODO
|
||||
// Хелперы, бойлерплейт
|
@ -40,7 +40,7 @@
|
||||
**Хорошо**:
|
||||
```
|
||||
// Возвращает агрегированную статистику заказов за указанный период
|
||||
POST /orders/statistics/aggregate
|
||||
POST /v1/orders/statistics/aggregate
|
||||
{ "start_date", "end_date" }
|
||||
```
|
||||
|
||||
@ -50,11 +50,11 @@
|
||||
|
||||
**1.1.** Если операция модифицирующая, это должно быть очевидно из сигнатуры. В частности, не может быть модифицирующих операций за `GET`.
|
||||
|
||||
**1.2.** Если операция асинхронная, это должно быть очевидно из сигнатуры, **либо** должна существовать конвенция именования, позволяющая отличаться синхронные операции от асинхронных.
|
||||
**1.2.** Если в номенклатуре вашего API есть как синхронные операции, так и асинхронные, то (а)синхронность должна быть очевидна из сигнатур, **либо** должна существовать конвенция именования, позволяющая отличать синхронные операции от асинхронных.
|
||||
|
||||
#### 2. Использованные стандарты указывайте явно
|
||||
|
||||
К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «начинать ли неделю с понедельника или с воскресенья», что уж говорить о каких-то более сложных стандартах.
|
||||
К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя», что уж говорить о каких-то более сложных стандартах.
|
||||
|
||||
Поэтому _всегда_ указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе:
|
||||
|
||||
@ -70,7 +70,7 @@
|
||||
|
||||
Отдельное следствие из этого правила — денежные величины *всегда* должны сопровождаться указанием кода валюты.
|
||||
|
||||
Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что как ни сделай — кто-то останется недовольным. Классический пример такого рода — порядок геокоординат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.
|
||||
Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что как ни сделай — кто-то останется недовольным. Классический пример такого рода — порядок географических координат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.
|
||||
|
||||
#### 3. Сохраняйте точность дробных чисел
|
||||
|
||||
@ -97,7 +97,7 @@
|
||||
strpbrk (str1, str2)
|
||||
```
|
||||
Возможно, автору этого API казалось, что аббревиатура `pbrk` что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк `str1`, `str2` является набором символов для поиска.
|
||||
**Хорошо**: `str_search_for_characters(lookup_character_set, str)`
|
||||
**Хорошо**: `str_search_for_characters (lookup_character_set, str)`
|
||||
Однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение `string` до `str` выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.
|
||||
|
||||
#### 6. Тип поля должен быть ясен из его названия
|
||||
@ -122,7 +122,7 @@
|
||||
GET /coffee-machines/functions
|
||||
```
|
||||
Слово "functions" многозначное; может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).
|
||||
**Хорошо**: `GET /coffee-machines/builtin-functions-list`
|
||||
**Хорошо**: `GET /v1/coffee-machines/builtin-functions-list`
|
||||
|
||||
#### 7. Подобные сущности должны называться подобно и вести себя подобным образом
|
||||
|
||||
@ -175,14 +175,14 @@
|
||||
**Хорошо**:
|
||||
```
|
||||
// Создаёт комментарий и возвращает его
|
||||
POST /comments
|
||||
POST /v1/comments
|
||||
{ "content" }
|
||||
→
|
||||
{ "comment_id", "published", "action_required", "content" }
|
||||
```
|
||||
```
|
||||
// Возвращает комментарий по его id
|
||||
GET /comments/{id}
|
||||
GET /v1/comments/{id}
|
||||
→
|
||||
{ /* в точности тот же формат,
|
||||
что и в ответе POST /comments */
|
||||
@ -191,3 +191,171 @@
|
||||
```
|
||||
Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа невелик) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение.
|
||||
|
||||
#### 9. Идемпотентность
|
||||
|
||||
Все эндпойнты должны быть идемпотентны. Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни.
|
||||
|
||||
Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию.
|
||||
|
||||
* **Плохо**
|
||||
```
|
||||
// Создаёт заказ
|
||||
POST /orders
|
||||
```
|
||||
Повтор запроса создаст два заказа!
|
||||
* **Хорошо**
|
||||
```
|
||||
// Создаёт заказ
|
||||
POST /v1/orders
|
||||
X-Idempotency-Token: <случайная строка>
|
||||
```
|
||||
Клиент на своей стороне запоминает X-Idempotency-Token, и, в случае автоматического повторного перезапроса, обязан его сохранить. Сервер на своей стороне проверяет токен и, если заказ с таким токеном уже существует для этого клиента, не даёт создать заказ повторно.
|
||||
Альтернатива:
|
||||
```
|
||||
// Создаёт черновик заказа
|
||||
POST /v1/orders
|
||||
→
|
||||
{ "draft_id" }
|
||||
```
|
||||
```
|
||||
// Подтверждает черновик заказа
|
||||
PUT /v1/orders/drafts/{draft_id}
|
||||
{ "confirmed": true }
|
||||
```
|
||||
Создание черновика заказа — необязывающая операция, которая не приводит ни к каким последствиям, поэтому допустимо создавать черновики без токена идемпотентности.
|
||||
Операция подтверждения заказа — уже естественным образом идемпотентна, для неё `draft_id` играет роль ключа идемпотентности.
|
||||
|
||||
#### 10. Кэширование
|
||||
|
||||
В клиент-серверном API, как правило, сеть и ресурс сервера не бесконечны, поэтому кэширование на клиенте результатов операции является стандартным действием.
|
||||
|
||||
Желательно в такой ситуации внести ясность; если не из сигнатур операций, то хотя бы из документации должно быть понятно, каким образом можно кэшировать результат.
|
||||
|
||||
* **Плохо**
|
||||
```
|
||||
// Возвращает цену лунго в кафе,
|
||||
// ближайшем к указанной точке
|
||||
GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
→
|
||||
{ "currency_code", "price" }
|
||||
```
|
||||
Возникает два вопроса:
|
||||
* в течение какого времени эта цена действительна?
|
||||
* на каком расстоянии от указанной точки цена всё ещё действительна?
|
||||
|
||||
Если на первый вопрос легко ответить введением стандартных заголовков Cache-Control, то для второго вопроса готовых решений нет. В ситуации, когда кэш нужен и по временной, и по пространственной координате следует поступить примерно так:
|
||||
```
|
||||
// Возвращает предложение: за какую сумму
|
||||
// наш сервис готов приготовить лунго
|
||||
GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
→
|
||||
{
|
||||
"offer": {
|
||||
"id",
|
||||
"currency_code",
|
||||
"price",
|
||||
"terms": {
|
||||
// До какого времени валидно предложение
|
||||
"valid_until",
|
||||
// Где валидно предложение:
|
||||
// * город
|
||||
// * географический объект
|
||||
// * …
|
||||
"valid_within"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 11. Пагинация, фильтрация и курсоры
|
||||
|
||||
Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может.
|
||||
|
||||
Любой эндпойнт, возвращающий изменяемые данные постранично, должен обеспечивать возможность эти данные перебрать.
|
||||
* **Плохо**:
|
||||
```
|
||||
// Возвращает указанный limit записей,
|
||||
// отсортированных по дате создания
|
||||
// начиная с записи с номером offset
|
||||
GET /records?limit=10&offset=100
|
||||
```
|
||||
На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса:
|
||||
1. Каким образом клиент узнает о появлении новых записей в начале списка?
|
||||
Легко заметить, что клиент может только попытаться повторить первый запрос и сличить идентификаторы с запомненным началом списка. Но что делать, если добавленное количество записей превышает limit? Представим себе ситуацию:
|
||||
* клиент обрабатывает записи в порядке поступления;
|
||||
* произошла какая-то проблема, и накопилось большое количество необработанных записей;
|
||||
* клиент запрашивает новые записи (offset=0), однако не находит на первой странице известных идентификаторов — новых записей накопилось больше, чем limit;
|
||||
* клиент вынужден продолжить перебирать записи (увеличивая offset), пока не доберётся до последней известной ему; всё это время клиент простаивает;
|
||||
* таким образом может сложиться ситуация, когда клиент вообще никогда не обработает всю очередь, т.к. будет занят беспорядочным линейным перебором.
|
||||
2. Что произойдёт, если при переборе списка одна из записей в уже перебранной части будет удалена?
|
||||
Произойдёт следующее: клиент пропустит одну запись и никогда не сможет об этом узнать.
|
||||
3. Какие параметры кэширования мы можем выставить на этот эндпойнт?
|
||||
Никакие: повторяя запрос с теми же limit-offset мы каждый раз получаем новый набор записей.
|
||||
|
||||
**Хорошо**: в таких однонаправленных списках пагинация должна быть организована по тому ключу, порядок которых фиксирован. Например, вот так:
|
||||
```
|
||||
// Возвращает указанный limit записей,
|
||||
// отсортированных по дате создания,
|
||||
// начиная с первой записи, созданной позднее,
|
||||
// чем запись с указанным id
|
||||
GET /records?older_than={record_id}&limit=10
|
||||
// Возвращает указанный limit записей,
|
||||
// отсортированных по дате создания,
|
||||
// начиная с первой записи, созданной раньше,
|
||||
// чем запись с указанным id
|
||||
GET /records?newer_than={record_id}&limit=10
|
||||
```
|
||||
При такой организации клиенту не надо заботиться об удалении или добавлении записей в уже перебранной части списка: он продолжает перебор по идентификатору известной записи — первой известной, если надо получить новые записи; последней известной, если надо продолжить перебор.
|
||||
Если операции удаления записей нет, то такие запросы можно свободно кэшировать — по одному и тому же URL будет всегда возвращаться один и тот же набор записей.
|
||||
Другой вариант организации таких списков — возврат курсора `cursor`, который используется вместо `record_id`, что делает интерфейсы универсальнее.
|
||||
|
||||
* **Плохо**:
|
||||
```
|
||||
// Возвращает указанный limit записей,
|
||||
// отсортированных по полю sort_by
|
||||
// в порядке sort_order,
|
||||
// начиная с записи с номером offset
|
||||
GET /records?sort_by=date_modified&sort_order=desc&limit=10&offset=100
|
||||
```
|
||||
Сортировка по дате модификации обычно означает, что данные могут меняться. Иными словами, между запросом первой порции данных и запросом второй порции данных какая-то запись может измениться; она просто пропадёт из перечисления, т.к. автоматически попадает на первую страницу. Клиент никогда не получит те записи, которые менялись во время перебора, и у него даже нет способа узнать о самом факте такого пропуска. Помимо этого отметим, что такое API нерасширяемо — невозможно добавить сортировку по двум или более полям.
|
||||
|
||||
**Хорошо**: в представленной постановке задача, вообще говоря, не решается. Список записей по дате изменения всегда будет непредсказуемо изменяться, поэтому необходимо изменить сам подход к формированию данных, одним из двух способов.
|
||||
* Фиксировать порядок в момент обработки запроса; т.е. сервер формирует полный список и сохраняет его в неизменяемом виде:
|
||||
|
||||
```
|
||||
// Создаёт представление по указанным параметрам
|
||||
POST /v1/record-views
|
||||
{
|
||||
sort_by: [
|
||||
{ "field": "date_modified", "order": "desc" }
|
||||
]
|
||||
}
|
||||
→
|
||||
{ "id", "cursor" }
|
||||
```
|
||||
```
|
||||
// Позволяет получить часть представления
|
||||
GET /v1/record-views/{id}?cursor={cursor}
|
||||
```
|
||||
|
||||
Т.к. созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offest, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков может получиться так, что порядок будет нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).
|
||||
|
||||
* Гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:
|
||||
|
||||
```
|
||||
POST /v1/records/modified/list
|
||||
{
|
||||
// Опционально
|
||||
"cursor"
|
||||
}
|
||||
→
|
||||
{
|
||||
"modified": [
|
||||
{ "date", "record_id" }
|
||||
],
|
||||
"cursor"
|
||||
}
|
||||
```
|
||||
|
||||
Недостатком этой схемы является необходимость заводить отдельные списки под каждый вид сортировки.
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user