1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-01-23 17:53:04 +02:00

WIP: глава разделение уровней абстракции перерабатывается

This commit is contained in:
Sergey Konstantinov 2020-11-18 19:26:13 +03:00
parent 272d792aaf
commit 3f78ca456d
14 changed files with 405 additions and 155 deletions

View File

@ -1 +0,0 @@
{}

View File

@ -42,7 +42,8 @@ function getParts ({ path, l10n: { chapter }, pageBreak}) {
const md = fs.readFileSync(`${subdir}${file}`, 'utf-8');
parts.push(
mdHtml.makeHtml(
md.trim().replace(/^### /, `### ${chapter} ${counter++}. `)
md.trim()
.replace(/^### /, `### ${chapter} ${counter++}. `)
) + pageBreak
);
});

View File

@ -27,11 +27,21 @@
padding-left: 92px;
}
code {
code, pre {
font-family: Inconsolata, sans-serif;
font-size: 12pt;
}
pre {
margin: 12pt 0;
padding: 12pt;
border-radius: .25em;
border-top: 1px solid rgba(0,0,0,.45);
border-left: 1px solid rgba(0,0,0,.45);
box-shadow: .1em .1em .1em rgba(0,0,0,.45);
page-break-inside: avoid;
}
.page-break {
page-break-after: always;
}
@ -106,14 +116,14 @@ h4, h5 {
<li>API должно быть консистентно: при разработке новой функциональности, т.е. при обращении к каким-то незнакомым сущностям в API, разработчик может действовать по аналогии с уже известными ему концепциями API, и его код будет работать.</li>
</ul>
<p>Однако статическое удобство и понятность API — это простая часть. В конце концов, никто не стремится специально сделать API нелогичным и нечитаемым — всегда при разработке мы начинаем с каких-то понятных базовых концепций. При минимальном опыте проектирования сложно сделать ядро API, не удовлетворяющее критериям очевидности, читаемости и консистентности.</p>
<p>Проблемы начинаются, когда мы начинаем API развивать. Добавление новой фунциональности рано или поздно приводит к тому, что некогда простое и понятное API становится наслоением разных концепций, а попытки сохранить обратную совместимость приводят к нелогичным, неочевидным и попросту плохим решениям. Отчасти это связано так же и с тем, что невозможно обладать полным знанием о будущем: ваше понимание о «правильном» API тоже будет меняться со временем, как в объективной части (какие задачи решает API и как лучше это сделать), так и в субъективной — что такое очевидность, читабельность и консистентность для вашего API.</p>
<p>Проблемы начинаются, когда мы начинаем API развивать. Добавление новой функциональности рано или поздно приводит к тому, что некогда простое и понятное API становится наслоением разных концепций, а попытки сохранить обратную совместимость приводят к нелогичным, неочевидным и попросту плохим решениям. Отчасти это связано так же и с тем, что невозможно обладать полным знанием о будущем: ваше понимание о «правильном» API тоже будет меняться со временем, как в объективной части (какие задачи решает API и как лучше это сделать), так и в субъективной — что такое очевидность, читабельность и консистентность для вашего API.</p>
<p>Принципы, которые я буду излагать ниже, во многом ориентированы именно на то, чтобы API правильно развивалось во времени и не превращалось в нагромождение разнородных неконсистентных интерфейсов. Важно понимать, что такой подход тоже небесплатен: необходимость держать в голове варианты развития событий и закладывать возможность изменений в API означает избыточность интерфейсов и возможно излишнее абстрагирование. И то, и другое, помимо прочего, усложняет и работу программиста, пользующегося вашим API. <strong>Закладывание перспектив «на будущее» имеет смысл, только если это будущее у API есть, иначе это попросту оверинжиниринг</strong>.</p><div class="page-break"></div><h3 id="4">Глава 4. Обратная совместимость</h3>
<p>Обратная совместимость — это некоторая <em>временна́я</em> характеристика качества вашего API. Именно необходимость поддержания обратной совместимости отличает разработку API от разработки программного обеспечения вообще.</p>
<p>Разумеется, обратная совместимость не абсолютна. В некоторых предметных областях выпуск новых обратно несовместимых версий API является вполне рутинной процедурой. Тем не менее, каждый раз, когда выпускается новая обратно несовместимая версия API, всем разработчикам приходится инвестировать какое-то ненулевое количество усилий, чтобы адаптировать свой код к новой версии. В этом плане выпуск новых версий API является некоторого рода «налогом» на потребителей — им нужно тратить вполне осязаемые деньги только для того, чтобы их продукт продолжал работать.</p>
<p>Конечно, крупные компании с прочным положением на рынке могут позволить себе такой налог взымать. Более того, они могут вводить какие-то санкции за отказ от перехода на новые версии API, вплоть до отключения приложений.</p>
<p>С нашей точки зрения, подобное поведение ничем не может быть оправдано. Избегайте скрытых налогов на своих пользователей. Если вы можете не ломать обратную совсемстимость — не ломайте её.</p>
<p>С нашей точки зрения, подобное поведение ничем не может быть оправдано. Избегайте скрытых налогов на своих пользователей. Если вы можете не ломать обратную совместимость — не ломайте её.</p>
<p>Да, безусловно, поддержка старых версий API — это тоже своего рода налог. Технологии меняются, и, как бы хорошо ни было спроектировано ваше API, всего предусмотреть невозможно. В какой-то момент ценой поддержки старых версий становится невозможность предоставлять новую функциональность и поддерживать новые платформы, и выпустить новую версию всё равно придётся. Однако вы по крайней мере сможете убедить своих потребителей в необходимости перехода.</p>
<p>Более подробно о политиках версионирования будет рассказано в разделе II.</p><div class="page-break"></div><h3 id="5">Глава 5. О версионировании</h3>
<p>Более подробно о жизненном цикле API и политиках выпуска новых версий будет рассказано в разделе II.</p><div class="page-break"></div><h3 id="5">Глава 5. О версионировании</h3>
<p>Здесь и далее мы будем придерживаться принципов версионирования <a href="https://semver.org/">semver</a>:</p>
<ol>
<li>Версия API задаётся тремя цифрами, вида <code>1.2.3</code></li>
@ -121,10 +131,11 @@ h4, h5 {
<li>Вторая цифра (минорная версия) увеличивается при добавлении новой функциональности с сохранением обратной совместимости</li>
<li>Третья цифра (патч) увеличивается при выпуске новых версий, содержащих только исправление ошибок</li>
</ol>
<p>Выражения «мажорная версия API» и «версия API, содержащая обратно несовместимые изменения функциональности» тем самым следует считать эквивалентными.</p><div class="page-break"></div><h3 id="6">Глава 6. Условные обозначения и терминология</h3>
<p>Выражения «мажорная версия API» и «версия API, содержащая обратно несовместимые изменения функциональности» тем самым следует считать эквивалентными.</p>
<p>Более подробно о политиках версионирования будет рассказано в разделе II. В разделе I мы ограничимся лишь указанием версии API в формате <code>v1</code>, <code>v2</code>, etc.</p><div class="page-break"></div><h3 id="6">Глава 6. Условные обозначения и терминология</h3>
<p>Разработка программного обеспечения характеризуется, помимо прочего, существованием множества различных парадигм разработки, адепты которых зачастую настроены весьма воинственно по отношению к адептам других парадигм. Поэтому при написании этой книги мы намеренно избегаем слов «метод», «объект», «функция» и так далее, используя нейтральный термин «сущность». Под «сущностью» понимается некоторая атомарная единица функциональности — класс, метод, объект, монада, прототип (нужное подчеркнуть).</p>
<p>Для составных частей сущности, к сожалению, достаточно нейтрального термина нам придумать не удалось, поэтому мы используем слова «поля» и «методы».</p>
<p>Большинство примеров API в общих разделах будут даны в виде JSON-over-HTTP-эндпойтов. Это некоторая условность, которая помогает описать концепции, как нам кажется, максимально понятно. Вместо <code>GET /orders</code> вполне может быть вызов метода <code>orders.get()</code>, локальный или удалённый; вместо JSON может быть любой другой формат данных. Смысл утверждений от этого не меняется.</p>
<p>Большинство примеров API в общих разделах будут даны в виде JSON-over-HTTP-эндпойтов. Это некоторая условность, которая помогает описать концепции, как нам кажется, максимально понятно. Вместо <code>GET /v1/orders</code> вполне может быть вызов метода <code>orders.get()</code>, локальный или удалённый; вместо JSON может быть любой другой формат данных. Смысл утверждений от этого не меняется.</p>
<p>Также в примерах часто применяется следующая конвенция. Запись <code>{ "begin_date" }</code> (т.е. отсутствие значения у поля в JSON-объекте) означает, что в поле находится именно то, что ожидается — т.е. в данном примере какая-то дата начала.</p><div class="page-break"></div><h2>I. Проектирование API</h2><h3 id="7api">Глава 7. Пирамида контекстов API</h3>
<p>Подход, который мы используем для проектирования, состоит из четырёх шагов:</p>
<ul>
@ -134,14 +145,14 @@ h4, h5 {
<li>описание конечных интерфейсов.</li>
</ul>
<p>Этот алгоритм строит API сверху вниз, от общих требований и сценариев использования до конкретной номенклатуры сущностей; фактически, двигаясь этим путем, вы получите на выходе готовое API — чем этот подход и ценен.</p>
<p>Может показаться, что наиболее полезные советы и best practice приведены в последнем разделе, однако это не так; цена ошибки, допущенной на разных уровнях весьма различна. Если исправить плохое именование довольно просто, то исправить неверное понимание того, зачем вообще нужно API, практически невозможно.</p>
<p>Может показаться, что наиболее полезные советы приведены в последнем разделе, однако это не так; цена ошибки, допущенной на разных уровнях весьма различна. Если исправить плохое именование довольно просто, то исправить неверное понимание того, зачем вообще нужно API, практически невозможно.</p>
<p><em>NB</em>. Здесь и далее мы будем рассматривать концепции разработки API на примере некоторого гипотетического API заказа кофе в городских кофейнях. На всякий случай сразу уточним, что пример является синтетическим; в реальной ситуации, если бы такое API пришлось проектировать, оно вероятно было бы совсем не похоже на наш выдуманный пример.</p><div class="page-break"></div><h3 id="8">Глава 8. Определение области применения</h3>
<p>Ключевой вопрос, который вы должны задать себе четыре раза, выглядит так: какую проблему мы решаем? Задать его следует четыре раза с ударением на каждом из четырёх слов.</p>
<ol>
<li><p><em>Какую</em> проблему мы решаем? Можем ли мы чётко описать, в какой ситуации гипотетическим потребителям-разработчикам нужно наше API?</p></li>
<li><p>Какую <em>проблему</em> мы решаем? А мы правда уверены, что описанная выше ситуация — проблема? Действительно ли кто-то готов платить (в прямом и переносном смысле) за то, что ситуация будет как-то автоматизирована?</p></li>
<li><p>Какую проблему <em>мы</em> решаем? Действительно ли решение этой проблемы находится в нашей компетенции? Действительно ли мы находимся в той позиции, чтобы решить эту проблему?</p></li>
<li><p>Какую проблему мы <em>решаем</em>? Правда ли, что решение, которое мы предлагаем, действильно решает проблему? Не создаём ли мы на её месте другую проблему, более сложную?</p></li>
<li><p>Какую проблему мы <em>решаем</em>? Правда ли, что решение, которое мы предлагаем, действительно решает проблему? Не создаём ли мы на её месте другую проблему, более сложную?</p></li>
</ol>
<p>Итак, предположим, что мы хотим предоставить API автоматического заказа кофе в городских кофейнях. Попробуем применить к ней этот принцип.</p>
<ol>
@ -149,14 +160,14 @@ h4, h5 {
<ul>
<li>Возможно, мы хотим решить проблему выбора и знания? Чтобы человек наиболее полно знал о доступных ему здесь и сейчас опциях.</li>
<li>Возможно, мы оптимизируем время ожидания? Чтобы человеку не пришлось ждать, пока его заказ готовится.</li>
<li>Возможно, мы хотим минимизировать ошибки? Чтобы человек получил именно то, что хотел заказть, не потеряв информацию при разговорном общении либо при настройке незнакомого интерфейса кофе-машины.</li></ul>
<li>Возможно, мы хотим минимизировать ошибки? Чтобы человек получил именно то, что хотел заказать, не потеряв информацию при разговорном общении либо при настройке незнакомого интерфейса кофе-машины.</li></ul>
<p>Вопрос «зачем» — самый важный из тех вопросов, которые вы должны задавать себе. Не только глобально в отношении целей всего проекта, но и локально в отношении каждого кусочка функциональности. <strong>Если вы не можете коротко и понятно ответить на вопрос «зачем эта сущность нужна» — значит, она не нужна</strong>.</p>
<p>Здесь и далее предположим (в целях придания нашему примеру глубины и некоторой упоротости), что мы оптимизируем все три фактора в порядке убывания важности.</p></li>
<li><p>Правда ли решаемая проблема существует? Дейсвительно ли мы наблюдаем неравномерную загрузку кофейных автоматов по утрам? Правда ли люди страдают от того, что не могут найти поблизости нужный им латте с ореховым сиропом? Действительно ли людям важны те минуты, которые они теряют, стоя в очередях?</p></li>
<li><p>Действительно ли мы обладаем достаточным ресурсом, чтобы решить эту проблему? Есть ли у нас доступ к достаточному количеству кофемашин и клиентов, чтобы обеспечить работоспособность системы?</p></li>
<li><p>Правда ли решаемая проблема существует? Действительно ли мы наблюдаем неравномерную загрузку кофейных автоматов по утрам? Правда ли люди страдают от того, что не могут найти поблизости нужный им латте с ореховым сиропом? Действительно ли людям важны те минуты, которые они теряют, стоя в очередях?</p></li>
<li><p>Действительно ли мы обладаем достаточным ресурсом, чтобы решить эту проблему? Есть ли у нас доступ к достаточному количеству кофе-машин и клиентов, чтобы обеспечить работоспособность системы?</p></li>
<li><p>Наконец, правда ли мы решим проблему? Как мы поймём, что оптимизировали перечисленные факторы?</p></li>
</ol>
<p>На все эти вопросы, в общем случае, простого ответа нет. В идеале ответы на эти вопросы должны даваться с цифрами в руках. Сколько конкретно времени тратится неоптимально, и какого значения мы рассчитываем добиться, располагая какой плотностью кофемашин? Заметим также, что в реальной жизни просчитать такого рода цифры можно в основном для проектов, которые пытаются влезть на уже устоявшийся рынок; если вы пытаетесь сделать что-то новое, то, вероятно, вам придётся ориентироваться в основном на свою интуицию.</p>
<p>На все эти вопросы, в общем случае, простого ответа нет. В идеале ответы на эти вопросы должны даваться с цифрами в руках. Сколько конкретно времени тратится неоптимально, и какого значения мы рассчитываем добиться, располагая какой плотностью кофе-машин? Заметим также, что в реальной жизни просчитать такого рода цифры можно в основном для проектов, которые пытаются влезть на уже устоявшийся рынок; если вы пытаетесь сделать что-то новое, то, вероятно, вам придётся ориентироваться в основном на свою интуицию.</p>
<h4 id="api">Почему API?</h4>
<p>Т.к. наша книга посвящена не просто разработке программного обеспечения, а разработке API, то на все эти вопросы мы должны взглянуть под другим ракурсом: а почему для решения этих задач требуется именно API, а не просто программное обеспечение? В нашем вымышленном примере мы должны спросить себя: зачем нам нужно предоставлять сервис для других разработчиков, чтобы они могли готовить кофе своим клиентам, а не сделать своё приложение для конечного потребителя?</p>
<p>Иными словами, должна иметься веская причина, по которой два домена разработки ПО должны быть разделены: есть оператор(ы), предоставляющий API; есть оператор(ы), предоставляющий сервисы пользователям. Их интересы в чем-то различны настолько, что объединение этих двух ролей в одном лице нежелательно. Более подробно мы изложим причины и мотивации делать именно API в разделе II.</p>
@ -168,17 +179,17 @@ h4, h5 {
<li>Что конкретно мы делаем</li>
<li>Как мы это делаем</li>
</ol>
<p>В случае нашего кофепримера мы:</p>
<p>В случае нашего кофе-примера мы:</p>
<ol>
<li>Предоставляем сервисам с большой пользовательской аудиторией API для того, чтобы их потребители могли максимально удобно для себя заказать кофе.</li>
<li>Для этого мы абстрагируем за нашим HTTP API доступ к «железу» и предоставим методы для выбора вида напитка и места его приготовления и для непосредственно исполнения заказа.</li>
</ol><div class="page-break"></div><h3 id="9">Глава 9. Разделение уровней абстракции</h3>
<p>«Разделите свой код на уровни абстракции» - пожалуй, самый общий совет для разработчиков программного обеспечения. Однако будет вовсе не преувеличением сказать, что изоляция уровней абстрации — самая сложная задача, стоящая перед разработчиком API.</p>
<p>«Разделите свой код на уровни абстракции» - пожалуй, самый общий совет для разработчиков программного обеспечения. Однако будет вовсе не преувеличением сказать, что изоляция уровней абстракции — самая сложная задача, стоящая перед разработчиком API.</p>
<p>Прежде чем переходить к теории, следует чётко сформулировать, <em>зачем</em> нужны уровни абстракции и каких целей мы хотим достичь их выделением.</p>
<p>Вспомним, что программный продукт - это средство связи контекстов, средство преобразования терминов и операций одной предметной области в другую. Чем дальше друг от друга эти области отстоят - тем большее число промежуточных передаточных звеньев нам придётся ввести. Вернёмся к нашему примеру с кофейнями. Какие уровни сущностей мы видим?</p>
<ol>
<li>Непосредственно состояние кофе-машины и шаги приготовления кофе. Температура, давление, объём воды.</li>
<li>У кофе есть мета-характерстики: сорт, вкус, вид напитка.</li>
<li>У кофе есть мета-характеристики: сорт, вкус, вид напитка.</li>
<li>Мы готовим с помощью нашего API <em>заказ</em> — один или несколько стаканов кофе с определенной стоимостью.</li>
<li>Наши кофе-машины как-то распределены в пространстве (и времени).</li>
<li>Кофе-машина принадлежит какой-то сети кофеен, каждая из которых обладает какой-то айдентикой и специальными возможностями.</li>
@ -191,12 +202,12 @@ h4, h5 {
</ol>
<p>Допустим, мы имеем следующий интерфейс:</p>
<ul>
<li><code>GET /recipes/lungo</code><br />
<li><code>GET /v1/recipes/lungo</code><br />
— возвращает рецепт лунго;</li>
<li><code>POST /coffee-machines/orders?machine_id={id}</code><br />
<li><code>POST /v1/coffee-machines/orders?machine_id={id}</code><br />
<code>{recipe:"lungo"}</code><br />
— размещает на указанной кофе-машине заказ на приготовление лунго и возвращает идентификатор заказа;</li>
<li><code>GET /orders?order_id={id}</code><br />
<li><code>GET /v1/orders?order_id={id}</code><br />
— возвращает состояние заказа;</li>
</ul>
<p>И зададимся вопросом, каким образом разработчик определит, что заказ клиента готов. Допустим, мы сделаем так: добавим в рецепт лунго эталонный объём, а в состояние заказа — количество уже налитого кофе. Тогда разработчику нужно будет проверить совпадение этих двух цифр, чтобы убедиться, что кофе готов.</p>
@ -207,17 +218,16 @@ h4, h5 {
<ul>
<li>или мы фиксируем список допустимых объёмов и заводим фиктивные рецепты типа <code>/recipes/small-lungo</code>, <code>recipes/large-lungo</code>. Почему фиктивные? Потому что рецепт один и тот же, меняется только объём. Нам придётся либо тиражировать одинаковые рецепты, отличающиеся только объёмом, либо вводить какое-то «наследование» рецептов, чтобы можно было указать базовый рецепт и только переопределить объём;</li>
<li>или мы модифицируем интерфейс, объявляя объём кофе, указанный в рецепте, значением по умолчанию; при размещении заказа мы разрешаем указать объём, отличный от эталонного:<br />
<code>POST /coffee-machines/orders?machine_id={id}</code><br />
<code>POST /v1/coffee-machines/orders?machine_id={id}</code><br />
<code>{recipe:"lungo","volume":"800ml"}</code><br />
Для таких кофе произвольного объёма нужно будет получать требуемый объём не из <code>GET /recipes</code>, а из <code>GET /orders</code>. Сделав так, мы сразу получаем клубок из связанных проблем:</li>
<li>разработчик, которому придётся поддержать эту функциональность, имеет высокие шансы сделать ошибку: добавив поддержку произвольного объёма кофе в код, работающий с <code>POST /coffee-machines/orders</code> нужно не забыть переписать код проверки готовности заказа;</li>
<li>мы получим классическую ситуацию, когда одно и то же поле (объём кофе) значит разные вещи в разных интерфейсах. В <code>GET /recipes</code> поле «объём» теперь значит «объём, который будет запрошен, если не передать его явно в <code>POST /coffee-machines/orders</code>»; переименовать его в «объём по умолчанию» уже не получиться, с этой проблемой теперь придётся жить.</li></ul></li>
Для таких кофе произвольного объёма нужно будет получать требуемый объём не из <code>GET /v1/recipes</code>, а из <code>GET /v1/orders</code>. Сделав так, мы сразу получаем клубок из связанных проблем:</li>
<li>разработчик, которому придётся поддержать эту функциональность, имеет высокие шансы сделать ошибку: добавив поддержку произвольного объёма кофе в код, работающий с <code>POST /v1/coffee-machines/orders</code> нужно не забыть переписать код проверки готовности заказа;</li>
<li>мы получим классическую ситуацию, когда одно и то же поле (объём кофе) значит разные вещи в разных интерфейсах. В <code>GET /v1/recipes</code> поле «объём» теперь значит «объём, который будет запрошен, если не передать его явно в <code>POST /v1/coffee-machines/orders</code>»; переименовать его в «объём по умолчанию» уже не получится, с этой проблемой теперь придётся жить.</li></ul></li>
<li><p>Вся эта схема полностью неработоспособна, если разные модели кофе-машин производят лунго разного объёма. Для решения задачи «объём лунго зависит от вида машины» нам придётся сделать совсем неприятную вещь: сделать рецепт зависимым от id машины. Тем самым мы начнём активно смешивать уровни абстракции: одной частью нашего API (рецептов) станет невозможно пользоваться без другой части (информации о кофе-машинах). Что немаловажно, от разработчиков потребуется изменить логику своего приложения: если раньше они могли предлагать сначала выбрать объём, а потом кофе-машину, то теперь им придётся полностью изменить этот шаг.</p></li>
</ol>
<p>Хорошо, допустим, мы поняли, как сделать плохо. Но как же тогда сделать <em>хорошо</em>? Разделение уровней абстракции должно происходить вдоль трёх направлений:</p>
<ol>
<li><p>От сценариев использования к их внутренней реализации: высокоуровневые сущности и номенклатура их методов должны напрямую отражать сценарии использования API; низкоуровневый - отражать декомпозицию сценариев на составные части.</p>
<p>Здесь мы должны явно обратиться к выписанному нами ранее «что» и «как». В идеальном мире высший уровень абстракции вашего API должен быть просто переводом записанной человекочитаемой фразы на машинный язык. Если нужно узнать, готов ли заказ — значит, должен быть метод <code>is-order-ready</code> (если мы считаем эту операцию действительно важной и частотной) или хотя бы <code>GET /orders/{id}/status</code> для того, чтобы явно узнать статус заказа. Эту логику требуется прорастить вниз до самых мелких и частных сценариев типа определения температуры напитка или наличия у исполнителя картонного держателя нужного размера.</p></li>
<li><p>От сценариев использования к их внутренней реализации: высокоуровневые сущности и номенклатура их методов должны напрямую отражать сценарии использования API; низкоуровневый - отражать декомпозицию сценариев на составные части.</p></li>
<li><p>От терминов предметной области пользователя к терминам предметной области исходных данных — в нашем случае от высокоуровневых понятий «рецепт», «заказ», «бренд», «кофейня» к низкоуровневым «температура напитка» и «координаты кофе-машины»</p></li>
<li><p>Наконец, от структур данных, в которых удобно оперировать пользователю к структурам данных, максимально приближенных к «сырым» - в нашем случае от «лунго» и «сети кофеен "Ромашка"» - к сырым байтовый данным, описывающим состояние кофе-машины марки «Доброе утро» в процессе приготовления напитка.</p></li>
</ol>
@ -227,43 +237,141 @@ h4, h5 {
<li>с одной стороны, «заказ» не должен содержать информацию о датчиках и сенсорах кофе-машины;</li>
<li>с другой стороны, кофе-машина не должна хранить информацию о свойствах заказа (да и вероятно её API такой возможности и не предоставляет).</li>
</ul>
<p>Введём промежуточный уровень: нам нужно звено, которое одновременно знает о заказе, рецепте и кофе-машине. Назовём его «уровнем исполнения»: его ответственностью является интерпретация заказа, превращение его в набор команд кофе-машине. Самый простой вариант — ввести абстрактную сущность «задание» <code>task</code>:</p>
<p>Наивный подход в такой ситуации — искусственно ввести некий промежуточный уровень абстракции, «передаточное звено», который переформулирует задачи одного уровня абстракции в другой. Например, введём сущность <code>task</code> вида:</p>
<pre><code>{
"volume_requested": "800ml",
"volume_prepared": "200ml",
"readiness_policy": "check_volume",
"ready": false,
"operation_state": {
"status": "executing",
"operations": [
// описание операций, запущенных на
// физической кофе-машине
]
}
}
</code></pre>
<p>Я называю этот подход «наивным» не потому, что он неправильный; напротив, это вполне логичное решение «по умолчанию», если вы на данном этапе ещё не знаете или не понимаете, как будет выглядеть ваше API. Проблема его в том, что он умозрительный: он не добавляет понимания того, как устроена предметная область.</p>
<p>Хороший разработчик в нашем примере должен спросить: хорошо, а какие вообще говоря существуют варианты? Как можно определять готовность напитка? Если вдруг окажется, что сравнение объёмов — единственный способ определения готовности во всех без исключения кофе-машинах, то почти все рассуждения выше — неверны: можно совершенно спокойно включать в интерфейсы определение готовности кофе по объёму, т.к. никакого другого и не существует. Прежде, чем что-то абстрагировать — надо представлять, <em>что</em> мы, собственно, абстрагируем.</p>
<p>Для нашего примера допустим, что мы сели изучать спецификации API кофе-машин и выяснили, что существует принципиально два класса устройств:</p>
<ul>
<li>заказ порождает одно или несколько заданий, указывая для задания конкретный рецепт и кофе-машину;</li>
<li>задание в свою очередь оперирует командами кофе-машины и отвечает за интерпретацию состояния датчиков.</li>
<li>кофе-машины с предустановленными программами, которые умеют готовить заранее прошитые N видов напитков, и мы можем управлять только какими-то параметрами напитка (скажем, объёмом напитка, вкусом сиропа и видом молока); у таких машин отсутствует доступ к внутренним функциям и датчикам, но зато машина умеет через API сама отдавать статус приготовления напитка;</li>
<li>кофе-машины с предустановленными функциями типа «смолоть такой-то объём кофе», «пролить N миллилитров воды», «взбить молочную пену» и т.д.: у таких машин отсутствует понятие «программа приготовления», но есть доступ к микрокомандам и датчикам.</li>
</ul>
<p>Таким образом, наше API будет выглядеть примерно так:</p>
<p>Предположим, для большей конкретности, что эти два класса устройств поставляются вот с таким физическим API:</p>
<ul>
<li><code>POST /orders</code> — создаёт заказ;</li>
<li><code>GET /tasks?order_id={order_id}</code> — позволяет получить список заданий по заказу.</li>
<li><p>Машины с предустановленными программами:</p>
<pre><code>GET /programs
// Возвращает список предустановленных программ
{
// идентификатор программы
"program": "01",
// вид кофе
"type": "lungo"
}
</code></pre>
<pre><code>POST /execute
{
"program": 1,
"volume": "200ml"
}
// Запускает указанную программу на исполнение
// и возвращает статус исполнения
{
// Уникальный идентификатор задания
"execution_id": "01-01",
// Идентификатор исполняемой программы
"program": 1,
// Запрошенный объём напитка
"volume": "200ml",
// Ожидаемое время приготовления
"preparation_time": "20s",
// Готовность
"ready": false
}
</code></pre>
<pre><code>POST /cancel
// Отменяет текущую программу
</code></pre>
<pre><code>GET /execution/{execution_id}/status
// Возвращает статус исполнения
// Формат аналогичен формату ответа `POST /execute`
</code></pre>
<p><em>NB</em>. На всякий случай отметим, что данное API нарушает множество описанных нами принципов проектирования, начиная с отсутствия версионирования; оно приведено в таком виде по двум причинам: (1) чтобы мы могли показать, как спроектировать API более удачно; (2) скорее всего, в реальной жизни вы получите именно такое API от производителей кофе-машин, и это ещё довольно вменяемый вариант.</p></li>
<li><p>Машины с предустановленными функциями:</p>
<pre><code>GET /functions
// Возвращает список доступных функций
{
"functions": [
{
// Тип операции
// * set_cup — поставить стакан
// * grind_coffee — смолоть кофе
// * shed_water — пролить воду
// * discard_cup — утилизировать стакан
"type": "set_cup",
// Допустимые аргументы для каждой операции
// Для простоты ограничимся одним аргументом:
// * volume — объём стакана, кофе или воды
"arguments": ["volume"]
},
]
}
</code></pre>
<pre><code>POST /functions
{
"type": "set_cup",
"arguments": [{ "name": "volume", "value": "300ml" }]
}
// Запускает на исполнение функцию
// с передачей указанных значений аргументов
</code></pre>
<pre><code>GET /sensors
// Возвращает статусы датчиков
{
"sensors": [
{
// Допустимые значения
// * cup_volume — объём установленного стакана
// * ground_coffee_volume — объём смолотого кофе
// * cup_filled_volume — объём напитка в стакане
"type": "cup_volume",
"value": "200ml"
},
]
}
</code></pre>
<p><em>NB</em>. Пример нарочно сделан умозрительным для моделирования ситуации, описанной в начале главы: для определения готовности напитка нужно сличить объём налитого с эталоном.</p></li>
</ul>
<p>Внимательный читатель может здесь поинтересоваться, а в чём, собственно разница по сравнению с наивным подходом? Напомню, мы рассмотрели выше примерно такой вариант:</p>
<p>Теперь картина становится более явной: нам нужно абстрагировать работу с кофе-машиной так, чтобы наш «уровень исполнения» в API предоставлял общие функции (такие, как определение готовности напитка) в унифицированном виде. Важно отметить, что с точки зрения разделения абстракций два этих вида кофе-машин сами находятся на разных уровнях: первые предоставляют API более высокого уровня, нежели вторые; следовательно, и «ветка» нашего API, работающая со вторым видом машин, будет более «развесистой».</p>
<p>Следующий шаг, необходимый для отделения уровней абстракции — необходимо понять, какую функциональность нам, собственно, необходимо абстрагировать. Для этого нам необходимо обратиться к задачам, которые решает разработчик на уровне работы с заказами, и понять, какие проблемы у него возникнут в случае отсутствия нашего слоя абстракции.</p>
<ol>
<li>Очевидно, что разработчику хочется создавать заказ унифицированным образом — перечислить высокоуровневые параметры заказа (вид напитка, объём и специальные требования, такие как вид сиропа или молока) — и не думать о том, как на конкретной машине исполнить этот заказ.</li>
<li>Разработчику надо понимать состояние исполнения — готов ли заказ или нет; если не готов — когда ожидать готовность (и надо ли её ожидать вообще в случае ошибки исполнения).</li>
<li>Разработчику нужно уметь соотносить заказ с его положением в пространстве и времени — чтобы показать потребителю, когда и как нужно заказ забрать.</li>
<li>Наконец, разработчику нужно выполнять атомарные операции — прежде всего, отменять заказ.</li>
</ol>
<p>Таким образом, наш промежуточный уровень абстракции должен:</p>
<ul>
<li><code>POST /coffee-machines/orders?machine_id={id}</code><br />
<code>{recipe:"lungo","volume":"800ml"}</code><br />
— создаёт заказ указанного объёма</li>
<li><code>GET /orders/{id}</code><br />
<code>{…"volume_requested":"800ml","volume_prepared":"120ml"…}</code><br />
— состояние исполнения заказа (налито 120 мл из запрошенных 800).</li>
<li>скрывать параметры создания задания на приготовление напитка — их вычисление должно производиться в нашем коде;</li>
<li>обобщать состояние исполнения в единый набор статусов, не зависящий от физического уровня приготовления напитка;</li>
<li>связывать параметры заказа (его уникальный идентификатор) с конкретным процессом приготовления напитка;</li>
<li>предоставлять атомарный интерфейс логических операций, скрывая за фасадом различия в физической имплементации этих операций.</li>
</ul>
<p>По сути пара <code>volume_requested</code> / <code>volume_prepared</code> и является аналогом дополнительной сущности <code>task</code>, зачем мы тогда усложняли?</p>
<p>Во-первых, в схеме с дополнительным уровнем абстракции мы скрываем конструирование самого объекта <code>task</code>. Если от <code>GET /orders/{id}</code> ожидается, что он вернёт хотя бы логически те же параметры заказа, что были переданы в <code>POST /coffee-machines/orders</code>, то при конструировании <code>task</code> сформировать нужный набор параметров — уже наша ответственность, спрятанная внутри обработчика создания заказа. Мы можем переформулировать параметры заказа в более удобные для исполнения на кофе-машине термины — например, возвращаясь к вопросу проверки готовности, явно сформулировать политику определения готовности кофе:</p>
<ul>
<li><code>POST /tasks/?order_id={order_id}</code><br />
<code>{…"volume_requested":"800ml","readiness_policy":"check_volume"…}</code><br />
— внутри обработчика создания заказа мы обратились к спецификации кофе-машины и поставили задачу в соответствии с ней. (Здесь мы предполагаем, что <code>POST /tasks</code> — внутренний метод создания задач; он может и не существовать в виде API.)</li>
<li><code>GET /tasks/{id}/status</code><br />
<code>{…"volume_prepared":"200ml","ready":false}</code><br />
— в публичном интерфейсе </li>
</ul>
<p>На это (совершенно верное!) замечаниемы ответим, что выделение уровней абстракции — прежде всего <em>логическая</em> процедура: как мы объясняем себе и разработчику, из чего состоит наш API. Мы могли бы просто ограничиться выделением секции <code>task</code> в ответе <code>GET /orders/{id}</code> — или вовсе сказать, что <code>task</code> — это просто четверка полей (<code>ready</code>, <code>volume_requested</code>, <code>volume_prepared</code>, <code>readiness_policy</code>) и есть. <strong>Абстрагируемая дистанция между сущностями существует объективно</strong>, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни <em>явно</em>. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.</p>
<p>Первая из проблем, которую мы видим на данном этапе — это отсутствие на физическом уровне машин второго типа самого понятия «заказ» или «напиток»: машина выполняет какой-то набор операций</p>
<p>Выделение уровней абстракции — прежде всего <em>логическая</em> процедура: как мы объясняем себе и разработчику, из чего состоит наш API. Мы могли бы просто ограничиться выделением секции <code>task</code> в ответе <code>GET /orders/{id}</code> — или вовсе сказать, что <code>task</code> — это просто четверка полей (<code>ready</code>, <code>volume_requested</code>, <code>volume_prepared</code>, <code>readiness_policy</code>) и есть. <strong>Абстрагируемая дистанция между сущностями существует объективно</strong>, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни <em>явно</em>. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.</p>
<p>NB: важно заметить, что с дальнейшей проработкой уровень исполнения, скорее всего, сам должен будет разделиться на два и более уровня, т.к. «задача» по сути — просто сущность-зонтик, связывающая в рамках заказа несколько высокоуровневых сущностей. Идея определения параметров кофе-машины на этапе создания заказов не очень удобна, да и до манипуляции командами кофе-машины и состоянием сенсоров всё ещё далеко с точки зрения абстрагирования. Но мы пока оставим в таком виде, для удобства дальнейшего изложения.</p>
<h4 id="">Изоляция уровней абстракции</h4>
<p>Важное свойство правильно подобранных уровней абстракции, и отсюда требование к их проектированию — это требование изоляции: <strong>взамодействие возможно только между сущностями соседних уровней абстракции</strong>. Если при проектировании выясняется, что для выполнения того или иного действия требуется «перепрыгнуть» уровень абстракции, это явный признак того, что в проекте допущены ошибки.</p>
<p>Возвращаясь к нашему примеру с готовностью кофе: проблемы с определением готовности кофе исходя из объёма возникают именно потому, что мы не можем ожидать от пользователя, создающего заказ, знания о необходимости проверки объёма налитого реальной кофе-машиной объёма кофе. Мы вводим дополнительный уровень абстракции именно для того, чтобы на нём переформулировать, что такое «заказ готов».</p>
<p>Важным следствием этого принципа является то, что информацию о готовности заказа нам придётся «прорастить» через все уровни абстракции:</p>
<ol>
<li>На физическом уровне мы будем оперировать состоянием кофе-машины, её сенсоров;</li>
<li>На физическом уровне мы будем оперировать состоянием кофе-машины, её сенсоров; с точки зрения физических сенсоров нет никакой «готовности заказа», есть только состояние выполнения команд;</li>
<li>На уровне исполнения статус готовности означает, что состояние сенсоров приведено к эталонному (в случае политики "check_volume" — что налит именно тот объём кофе, который был запрошен);</li>
<li>На пользовательском уровне статус готовности заказа означает, что все ассоциированные задачи выполнены.</li>
</ol>
@ -272,8 +380,8 @@ h4, h5 {
<ul>
<li><code>POST /orders/{id}/cancel</code> работает с высокоуровневыми данными о заказе:<ul>
<li>проверяет авторизацию, т.е. имеет ли право этот пользователь отменять этот заказ;</li>
<li>решает денежные вопросы — нужно ли делать рефанд</li>
<li>находит все незавершённые задачи и отменяет их</li></ul></li>
<li>решает денежные вопросы — нужно ли делать рефанд;</li>
<li>находит все незавершённые задачи и отменяет их;</li></ul></li>
<li><code>POST /tasks/{id}/cancel</code> работает с исполнением заказа:<ul>
<li>определяет, возможно ли физически отменить исполнение, есть ли такая функция у кофе-машины;</li>
<li>генерирует последовательность действий отмены (возможно, не только непосредственно для самой машины — вполне вероятно, необходимо будет поставить задание сотруднику кофейни утилизировать невостребованный напиток);</li></ul></li>
@ -285,10 +393,8 @@ h4, h5 {
<code>{"status":"canceled","operation_state":{"status":"canceling","operations":[…]}</code><br />
— с т.з. высокоуровневого кода задача завершена (<code>canceled</code>), но с точки зрения низкоуровневого кода список исполняемых операций непуст, т.е. задача продолжает работать.</li>
</ul>
<p>NB: так как <code>task</code> связывает два разных уровня абстракции, то и статусов у неё два: внешний <code>canceled</code> и внутренний <code>canceling</code>. Мы могли бы опустить второй статус и предложить ориентироваться на содержание <code>operations</code>, но это вновь (а) неявно, (б) предполагает необходимость разбираться в более низкоуровневом интерфейсе <code>operation_state</code>, что, быть может, разработчику вовсе и не нужно.</p>
<p>Может показаться, что соблюдение правила изоляции уровней абстракции является избыточным и заставляет усложнять интерфейс. И это в действительности так: важно понимать, что никакая гибкость, логичность, читабельность и расширяемость не бывает бесплатной. Можно построить API так, чтобы оно выполняло свою функцию с минимальными накладными расходами, по сути — дать интерфейс к микроконтроллерам кофе-машины. Однако пользоваться им будет крайне неудобно, и расширяемость такого API будет нулевой.</p>
<p>Дублирование функций на каждом уровне абстракций позволяет добиться важной вещи: возможности сменить нижележащие уровни без необходимости переписывать верхнеуровневый код. Мы можем добавить другие виды кофе-машин с принципиально другими физическими способами определения готовности напитка, и наш метод <code>GET /orders?order_id={id}</code> продолжит работать, как работал.</p>
<p>Да, код, который работал с физическим уровнем, придётся переписать. Но, во-первых, это неизбежно: изменение принципов работы физического уровня автоматически означает необходимость переписать код. Во-вторых, такое разделение ставит перед нами четкий вопрос: до какого момента API должно предоставлять публичный доступ? Стоило ли предоставлять пользователю методы физического уровня?</p><div class="page-break"></div><h3 id="10">Глава 10. Разграничение областей ответственности</h3>
<p><strong>NB</strong>: так как <code>task</code> связывает два разных уровня абстракции, то и статусов у неё два: внешний <code>canceled</code> и внутренний <code>canceling</code>. Мы могли бы опустить второй статус и предложить ориентироваться на содержание <code>operations</code>, но это (а) неявно, (б) предполагает необходимость разбираться в более низкоуровневом интерфейсе <code>operation_state</code>, что, быть может, разработчику вовсе и не нужно.</p>
<p>Может показаться, что соблюдение правила изоляции уровней абстракции является избыточным и заставляет усложнять интерфейс. И это в действительности так: важно понимать, что никакая гибкость, логичность, читабельность и расширяемость не бывает бесплатной. Можно построить API так, чтобы оно выполняло свою функцию с минимальными накладными расходами, по сути — дать интерфейс к микроконтроллерам кофе-машины. Однако пользоваться им будет крайне неудобно, и расширяемость такого API будет нулевой.</p><div class="page-break"></div><h3 id="10">Глава 10. Разграничение областей ответственности</h3>
<p>Исходя из описанного в предыдущей главе, мы понимаем, что иерархия абстракций в нашем гипотетическом проекте должна выглядеть примерно так:</p>
<ul>
<li>пользовательский уровень (те сущности, с которыми непосредственно взаимодействует пользователь и сформулированы в понятных для него терминах; например, заказы и виды кофе);</li>
@ -311,10 +417,8 @@ h4, h5 {
</ol>
<p>Если внимательно посмотреть на каждый объект, то мы увидим, что, в итоге, каждый объект оказался в смысле своей ответственности составным. Например, <code>coffee-machine</code> будет частично оперировать реальными командами кофе-машины <em>и</em> представлять их состояние в каком-то машиночитаемом виде.</p>
<h4 id="">Декомпозиция интерфейсов</h4>
<p>// TODO</p>
<p>NB. Во многих языках программирования нет поддержки интерфейсов, абстрактных классов и/или множественного наследования. Однако выделять интерфейсы нам это не мешает, поскольку мы всегда можем "договориться", что объект source имеет право пользоваться только вот этим набором свойств и методов. Конечно, контролировать соблюдение этой договоренности в достаточно развесистом API довольно сложно, но, поверьте автору, вполне возможно, тем более, что тестами и/или статическим анализом кода соблюдение договоренностей об интерфейсах можно проверить почти всегда.</p>
<p>Разделение контекстов - не единственная причина, по которой выделение интерфейсов критически важно при проектировании API. Предъявление к входящим параметрам требования только удовлетворять интерфейсу существенно упрощает создание альтернативных реализаций ваших объектов, в том числе в целях тестирования. Теперь чтобы протестировать объект source достаточно написать mock на IGeoContext, а не весь класс map целиком. Аналогично, если мы захотим использовать наши оверлеи для показа их, скажем, в качестве какой-то инфографики или на абстрактном плане местности, нам не придётся переделывать для этого класс Map - достаточно будет альтернативной реализации IGraphicalContext.</p>
<p>При выделении интерфейсов важно также понимать, что интерфейс, в отличие от его реализации, должен быть минимально достаточным и не должен включать в себя вспомогательные методы. Например, если класс map имеет как метод для получения всей области картографирования в виде четырехугольника getBBox, так и методы получения каждого из углов по отдельности - getLeftBottom, getRightTop, например, — то интерфейс IGeoContext должен содержать что-то одно. Нет никакого смысла загромождать интерфейс альтернативными реализациями одной и той же функциональности — это затрудняет чтение и усложняет написание собственных реализаций. Если только нет каких-то показаний с точки зрения производительности, следует отдать предпочтение максимально общему методу - в нашем случае getBBox.</p>
<p>На этапе разделения уровней абстракции мы упомянули, что одна из целей такого разделения — добиться возможности безболезненно сменить нижележащие уровни абстракции при добавлении новых технологий или изменении нижележащих протоколов. Декомпозиция интерфейсов — основной инструмент, позволяющий добиться такой гибкости.</p>
<p>На этом этапе нам предстоит отделить частное от общего: понять, какие свойства сущностей фиксированы в нашей модели, а какие будут зависеть от реализации. В нашем кофе-примере таким сильно зависящим от имплементации объектом, очевидно, будут сами кофе-машины. Попробуем ещё немного развить наше API: каким образом нам нужно описать модели кофе-машин для того, чтобы предоставить интерфейсы для работы с задачами заказа?</p>
<h4 id="-1">Интерфейсы как универсальный паттерн</h4>
<p>Как мы убедились в предыдущей главе, выделение интерфейсов крайне важно с точки зрения удобства написания кода. Однако, интерфейсы играют и другую важную роль в проектировании: они позволяют уложить в голове архитектуру API целиком.</p>
<p>Любой сколько-нибудь крупный API рано или поздно обрастает разнообразной номенклатурой сущностей, их свойст и методов, как в силу того, что в одном объекте «сходятся» несколько предметных областей, так и в силу появления со временем разнообразной вспомогательной и дополнительной функциональности. Особенно сложной номенклатура объектов и их методов становится в случае появления альтернативных реализаций одного и того же интерфейса.</p>
@ -334,7 +438,8 @@ h4, h5 {
<li>преобразованием данных имеют право заниматься только те объекты, в чьи непосредственные обязанности это входит.</li>
</ul>
<p>Дерево информационных контекстов (какой объект обладает какой информацией, и кто является транслятором из одного контекста в другой), по сути, представляет собой «срез» нашего дерева иерархии интерфейсов; выделение такого среза позволяет проще и удобнее удерживать в голове всю архитектуру проекта.</p>
<p>// TODO: простые вещи делаются просто без бойлерплейта</p><div class="page-break"></div><h3 id="11">Глава 11. Описание конечных интерфейсов</h3>
<p>// TODO
// Хелперы, бойлерплейт</p><div class="page-break"></div><h3 id="11">Глава 11. Описание конечных интерфейсов</h3>
<p>Определив все сущности, их ответственность и отношения друг с другом, мы переходим непосредственно к разработке API: нам осталось прописать номенклатуру всех объектов, полей, методов и функций в деталях. В этой главе мы дадим сугубо практические советы, как сделать API удобным и понятным.</p>
<p>Важное уточнение под номером ноль:</p>
<h4 id="0">0. Правила — это всего лишь обобщения</h4>
@ -347,25 +452,29 @@ h4, h5 {
<ul>
<li><p>плохо: </p>
<pre><code>GET /orders/cancellation
отменяет заказ
// отменяет заказ
</code></pre>
<p>Неочевидно, что достаточно просто обращения к сущности <code>cancellation</code> (что это?), тем более немодифицирующим методом <code>GET</code>, чтобы отменить заказ; </p>
<p>Хорошо: </p>
<pre><code> POST /orders/cancel
отменяет заказ
</code></pre></li>
<li><p>плохо:</p>
<pre><code>POST /orders/cancel
отменяет заказ
</code></pre>
<ul>
<li>плохо:</li></ul>
<pre><code>GET /orders/statistics
Возвращает агрегированную статистику заказов за всё время
// Возвращает агрегированную статистику заказов за всё время
</code></pre>
<p>Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.</p>
<p>Хорошо:</p>
<pre><code>POST /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>
<h4 id="2">2. Сущности должны именоваться конкретно</h4>
<p>Избегайте слов-«амёб» без определённой семантики, таких как get, apply, make. Сущности должны именоваться конкретно:</p>
<ul>
@ -380,13 +489,11 @@ h4, h5 {
<li>плохо:
<code>
strpbrk (str1, str2)
возвращает положение первого вхождения в строку str2 любого символа из строки str2
// возвращает положение первого вхождения в строку str2
// любого символа из строки str2
</code>
Возможно, автору этого API казалось, что аббревиатура <code>pbrk</code> что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк <code>str1</code>, <code>str2</code> является набором символов для поиска.
Хорошо:
<code>
str_search_for_characters(lookup_character_set, str)
</code>
Хорошо: <code>str_search_for_characters(lookup_character_set, str)</code><br />
Однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение <code>string</code> до <code>str</code> выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.</li>
</ul>
<h4 id="4">4. Тип поля должен быть ясен из его названия</h4>
@ -401,7 +508,17 @@ str_search_for_characters(lookup_character_set, str)
<li>плохо: <code>"task.status": true</code> — неочевидно, что статус бинарен, плюс такое API будет нерасширяемым;<br />
хорошо: <code>"task.is_finished": true</code></li>
</ul>
<p>Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учетом специфики first-class citizen-типов. Например, объекты типа <code>Date</code>, если таковые имеются, разумно индицировать с помощью, например, постфикса <code>_at</code> (<code>created_at</code>, <code>occurred_at</code>, etc).</p>
<p>Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учетом специфики first-class citizen-типов. Например, объекты типа <code>Date</code>, если таковые имеются, разумно индицировать с помощью, например, постфикса <code>_at</code> (<code>created_at</code>, <code>occurred_at</code>, etc) или <code>_date</code>.</p>
<p>Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс, чтобы избежать непонимания.</p>
<ul>
<li>Плохо:
<code>
GET /coffee-machines/functions
// Возвращает список встроенных функций кофе-машины
</code>
Слово "functions" многозначное; может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).<br />
Хорошо: <code>GET /coffee-machines/builtin-functions-list</code></li>
</ul>
<h4 id="5">5. Подобные сущности должны называться подобно и вести себя подобным образом</h4>
<ul>
<li><p>плохо: <code>begin_transition</code> / <code>stop_transition</code><br />
@ -409,10 +526,12 @@ str_search_for_characters(lookup_character_set, str)
хорошо: <code>begin_transition</code> / <code>end_transition</code> либо <code>start_transition</code> / <code>stop_transition</code>;</p></li>
<li><p>плохо: </p>
<pre><code>strpos(haystack, needle)
Находит первую позицию позицию строки `needle` внутри строки `haystack`
// Находит первую позицию позицию строки `needle`
// внутри строки `haystack`
</code></pre>
<pre><code>str_replace(needle, replace, haystack)
Находит и заменяет все вхождения строки `needle` внутри строки `haystack` на строку `replace`
// Находит и заменяет все вхождения строки `needle`
// внутри строки `haystack` на строку `replace`
</code></pre>
<p>Здесь нарушены сразу несколько правил:</p>
<ul>
@ -437,7 +556,8 @@ str_search_for_characters(lookup_character_set, str)
</ul>
<p>Отдельное следствие из этого правила — денежные величины <em>всегда</em> должны сопровождаться указанием кода валюты.</p>
<p>Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что как ни сделай — кто-то останется недовольным. Классический пример такого рода — порядок геокоординат ("широта-долгота" против "долгота-широта"). Здесь, увы, вам остаётся только смириться и проявлять выдержку при нападках на ваше API.</p>
<h4 id="7">7. Сохранение точности дробных чисел</h4>
<p>// TODO: блокнот душевного спокойствия</p>
<h4 id="7">7. Сохраняйте точность дробных чисел</h4>
<p>Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.</p>
<p>Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому, либо использовать строковый тип.</p><div class="page-break"></div></article>
</body></html>

Binary file not shown.

View File

@ -12,6 +12,6 @@
Однако статическое удобство и понятность API — это простая часть. В конце концов, никто не стремится специально сделать API нелогичным и нечитаемым — всегда при разработке мы начинаем с каких-то понятных базовых концепций. При минимальном опыте проектирования сложно сделать ядро API, не удовлетворяющее критериям очевидности, читаемости и консистентности.
Проблемы начинаются, когда мы начинаем API развивать. Добавление новой фунциональности рано или поздно приводит к тому, что некогда простое и понятное API становится наслоением разных концепций, а попытки сохранить обратную совместимость приводят к нелогичным, неочевидным и попросту плохим решениям. Отчасти это связано так же и с тем, что невозможно обладать полным знанием о будущем: ваше понимание о «правильном» API тоже будет меняться со временем, как в объективной части (какие задачи решает API и как лучше это сделать), так и в субъективной — что такое очевидность, читабельность и консистентность для вашего API.
Проблемы начинаются, когда мы начинаем API развивать. Добавление новой функциональности рано или поздно приводит к тому, что некогда простое и понятное API становится наслоением разных концепций, а попытки сохранить обратную совместимость приводят к нелогичным, неочевидным и попросту плохим решениям. Отчасти это связано так же и с тем, что невозможно обладать полным знанием о будущем: ваше понимание о «правильном» API тоже будет меняться со временем, как в объективной части (какие задачи решает API и как лучше это сделать), так и в субъективной — что такое очевидность, читабельность и консистентность для вашего API.
Принципы, которые я буду излагать ниже, во многом ориентированы именно на то, чтобы API правильно развивалось во времени и не превращалось в нагромождение разнородных неконсистентных интерфейсов. Важно понимать, что такой подход тоже небесплатен: необходимость держать в голове варианты развития событий и закладывать возможность изменений в API означает избыточность интерфейсов и возможно излишнее абстрагирование. И то, и другое, помимо прочего, усложняет и работу программиста, пользующегося вашим API. **Закладывание перспектив «на будущее» имеет смысл, только если это будущее у API есть, иначе это попросту оверинжиниринг**.

View File

@ -6,8 +6,8 @@
Конечно, крупные компании с прочным положением на рынке могут позволить себе такой налог взымать. Более того, они могут вводить какие-то санкции за отказ от перехода на новые версии API, вплоть до отключения приложений.
С нашей точки зрения, подобное поведение ничем не может быть оправдано. Избегайте скрытых налогов на своих пользователей. Если вы можете не ломать обратную совсемстимость — не ломайте её.
С нашей точки зрения, подобное поведение ничем не может быть оправдано. Избегайте скрытых налогов на своих пользователей. Если вы можете не ломать обратную совместимость — не ломайте её.
Да, безусловно, поддержка старых версий API — это тоже своего рода налог. Технологии меняются, и, как бы хорошо ни было спроектировано ваше API, всего предусмотреть невозможно. В какой-то момент ценой поддержки старых версий становится невозможность предоставлять новую функциональность и поддерживать новые платформы, и выпустить новую версию всё равно придётся. Однако вы по крайней мере сможете убедить своих потребителей в необходимости перехода.
Более подробно о политиках версионирования будет рассказано в разделе II.
Более подробно о жизненном цикле API и политиках выпуска новых версий будет рассказано в разделе II.

View File

@ -7,4 +7,6 @@
3. Вторая цифра (минорная версия) увеличивается при добавлении новой функциональности с сохранением обратной совместимости
4. Третья цифра (патч) увеличивается при выпуске новых версий, содержащих только исправление ошибок
Выражения «мажорная версия API» и «версия API, содержащая обратно несовместимые изменения функциональности» тем самым следует считать эквивалентными.
Выражения «мажорная версия API» и «версия API, содержащая обратно несовместимые изменения функциональности» тем самым следует считать эквивалентными.
Более подробно о политиках версионирования будет рассказано в разделе II. В разделе I мы ограничимся лишь указанием версии API в формате `v1`, `v2`, etc.

View File

@ -4,6 +4,6 @@
Для составных частей сущности, к сожалению, достаточно нейтрального термина нам придумать не удалось, поэтому мы используем слова «поля» и «методы».
Большинство примеров API в общих разделах будут даны в виде JSON-over-HTTP-эндпойтов. Это некоторая условность, которая помогает описать концепции, как нам кажется, максимально понятно. Вместо `GET /orders` вполне может быть вызов метода `orders.get()`, локальный или удалённый; вместо JSON может быть любой другой формат данных. Смысл утверждений от этого не меняется.
Большинство примеров API в общих разделах будут даны в виде JSON-over-HTTP-эндпойтов. Это некоторая условность, которая помогает описать концепции, как нам кажется, максимально понятно. Вместо `GET /v1/orders` вполне может быть вызов метода `orders.get()`, локальный или удалённый; вместо JSON может быть любой другой формат данных. Смысл утверждений от этого не меняется.
Также в примерах часто применяется следующая конвенция. Запись `{ "begin_date" }` (т.е. отсутствие значения у поля в JSON-объекте) означает, что в поле находится именно то, что ожидается — т.е. в данном примере какая-то дата начала.

View File

@ -8,6 +8,6 @@
Этот алгоритм строит API сверху вниз, от общих требований и сценариев использования до конкретной номенклатуры сущностей; фактически, двигаясь этим путем, вы получите на выходе готовое API — чем этот подход и ценен.
Может показаться, что наиболее полезные советы и best practice приведены в последнем разделе, однако это не так; цена ошибки, допущенной на разных уровнях весьма различна. Если исправить плохое именование довольно просто, то исправить неверное понимание того, зачем вообще нужно API, практически невозможно.
Может показаться, что наиболее полезные советы приведены в последнем разделе, однако это не так; цена ошибки, допущенной на разных уровнях весьма различна. Если исправить плохое именование довольно просто, то исправить неверное понимание того, зачем вообще нужно API, практически невозможно.
_NB_. Здесь и далее мы будем рассматривать концепции разработки API на примере некоторого гипотетического API заказа кофе в городских кофейнях. На всякий случай сразу уточним, что пример является синтетическим; в реальной ситуации, если бы такое API пришлось проектировать, оно вероятно было бы совсем не похоже на наш выдуманный пример.

View File

@ -8,26 +8,26 @@
3. Какую проблему _мы_ решаем? Действительно ли решение этой проблемы находится в нашей компетенции? Действительно ли мы находимся в той позиции, чтобы решить эту проблему?
4. Какую проблему мы _решаем_? Правда ли, что решение, которое мы предлагаем, действильно решает проблему? Не создаём ли мы на её месте другую проблему, более сложную?
4. Какую проблему мы _решаем_? Правда ли, что решение, которое мы предлагаем, действительно решает проблему? Не создаём ли мы на её месте другую проблему, более сложную?
Итак, предположим, что мы хотим предоставить API автоматического заказа кофе в городских кофейнях. Попробуем применить к ней этот принцип.
1. Зачем кому-то может потребоваться API для приготовления кофе? В чем неудобство заказа кофе через интерфейс, человек-человек или человек-машина? Зачем нужна возможность заказа машина-машина?
* Возможно, мы хотим решить проблему выбора и знания? Чтобы человек наиболее полно знал о доступных ему здесь и сейчас опциях.
* Возможно, мы оптимизируем время ожидания? Чтобы человеку не пришлось ждать, пока его заказ готовится.
* Возможно, мы хотим минимизировать ошибки? Чтобы человек получил именно то, что хотел заказть, не потеряв информацию при разговорном общении либо при настройке незнакомого интерфейса кофе-машины.
* Возможно, мы хотим минимизировать ошибки? Чтобы человек получил именно то, что хотел заказать, не потеряв информацию при разговорном общении либо при настройке незнакомого интерфейса кофе-машины.
Вопрос «зачем» — самый важный из тех вопросов, которые вы должны задавать себе. Не только глобально в отношении целей всего проекта, но и локально в отношении каждого кусочка функциональности. **Если вы не можете коротко и понятно ответить на вопрос «зачем эта сущность нужна» — значит, она не нужна**.
Здесь и далее предположим (в целях придания нашему примеру глубины и некоторой упоротости), что мы оптимизируем все три фактора в порядке убывания важности.
2. Правда ли решаемая проблема существует? Дейсвительно ли мы наблюдаем неравномерную загрузку кофейных автоматов по утрам? Правда ли люди страдают от того, что не могут найти поблизости нужный им латте с ореховым сиропом? Действительно ли людям важны те минуты, которые они теряют, стоя в очередях?
2. Правда ли решаемая проблема существует? Действительно ли мы наблюдаем неравномерную загрузку кофейных автоматов по утрам? Правда ли люди страдают от того, что не могут найти поблизости нужный им латте с ореховым сиропом? Действительно ли людям важны те минуты, которые они теряют, стоя в очередях?
3. Действительно ли мы обладаем достаточным ресурсом, чтобы решить эту проблему? Есть ли у нас доступ к достаточному количеству кофемашин и клиентов, чтобы обеспечить работоспособность системы?
3. Действительно ли мы обладаем достаточным ресурсом, чтобы решить эту проблему? Есть ли у нас доступ к достаточному количеству кофе-машин и клиентов, чтобы обеспечить работоспособность системы?
4. Наконец, правда ли мы решим проблему? Как мы поймём, что оптимизировали перечисленные факторы?
На все эти вопросы, в общем случае, простого ответа нет. В идеале ответы на эти вопросы должны даваться с цифрами в руках. Сколько конкретно времени тратится неоптимально, и какого значения мы рассчитываем добиться, располагая какой плотностью кофемашин? Заметим также, что в реальной жизни просчитать такого рода цифры можно в основном для проектов, которые пытаются влезть на уже устоявшийся рынок; если вы пытаетесь сделать что-то новое, то, вероятно, вам придётся ориентироваться в основном на свою интуицию.
На все эти вопросы, в общем случае, простого ответа нет. В идеале ответы на эти вопросы должны даваться с цифрами в руках. Сколько конкретно времени тратится неоптимально, и какого значения мы рассчитываем добиться, располагая какой плотностью кофе-машин? Заметим также, что в реальной жизни просчитать такого рода цифры можно в основном для проектов, которые пытаются влезть на уже устоявшийся рынок; если вы пытаетесь сделать что-то новое, то, вероятно, вам придётся ориентироваться в основном на свою интуицию.
#### Почему API?
@ -46,7 +46,7 @@
1. Что конкретно мы делаем
2. Как мы это делаем
В случае нашего кофепримера мы:
В случае нашего кофе-примера мы:
1. Предоставляем сервисам с большой пользовательской аудиторией API для того, чтобы их потребители могли максимально удобно для себя заказать кофе.
2. Для этого мы абстрагируем за нашим HTTP API доступ к «железу» и предоставим методы для выбора вида напитка и места его приготовления и для непосредственно исполнения заказа.

View File

@ -1,13 +1,13 @@
### Разделение уровней абстракции
«Разделите свой код на уровни абстракции» - пожалуй, самый общий совет для разработчиков программного обеспечения. Однако будет вовсе не преувеличением сказать, что изоляция уровней абстрации — самая сложная задача, стоящая перед разработчиком API.
«Разделите свой код на уровни абстракции» - пожалуй, самый общий совет для разработчиков программного обеспечения. Однако будет вовсе не преувеличением сказать, что изоляция уровней абстракции — самая сложная задача, стоящая перед разработчиком API.
Прежде чем переходить к теории, следует чётко сформулировать, _зачем_ нужны уровни абстракции и каких целей мы хотим достичь их выделением.
Вспомним, что программный продукт - это средство связи контекстов, средство преобразования терминов и операций одной предметной области в другую. Чем дальше друг от друга эти области отстоят - тем большее число промежуточных передаточных звеньев нам придётся ввести. Вернёмся к нашему примеру с кофейнями. Какие уровни сущностей мы видим?
1. Непосредственно состояние кофе-машины и шаги приготовления кофе. Температура, давление, объём воды.
2. У кофе есть мета-характерстики: сорт, вкус, вид напитка.
2. У кофе есть мета-характеристики: сорт, вкус, вид напитка.
3. Мы готовим с помощью нашего API *заказ* — один или несколько стаканов кофе с определенной стоимостью.
4. Наши кофе-машины как-то распределены в пространстве (и времени).
5. Кофе-машина принадлежит какой-то сети кофеен, каждая из которых обладает какой-то айдентикой и специальными возможностями.
@ -22,12 +22,12 @@
Допустим, мы имеем следующий интерфейс:
* `GET /recipes/lungo`
* `GET /v1/recipes/lungo`
— возвращает рецепт лунго;
* `POST /coffee-machines/orders?machine_id={id}`
* `POST /v1/coffee-machines/orders?machine_id={id}`
`{recipe:"lungo"}`
— размещает на указанной кофе-машине заказ на приготовление лунго и возвращает идентификатор заказа;
* `GET /orders?order_id={id}`
* `GET /v1/orders?order_id={id}`
— возвращает состояние заказа;
И зададимся вопросом, каким образом разработчик определит, что заказ клиента готов. Допустим, мы сделаем так: добавим в рецепт лунго эталонный объём, а в состояние заказа — количество уже налитого кофе. Тогда разработчику нужно будет проверить совпадение этих двух цифр, чтобы убедиться, что кофе готов.
@ -39,11 +39,11 @@
2. Мы автоматически получаем проблемы, если захотим варьировать размер кофе. Допустим, в какой-то момент мы захотим представить пользователю выбор, сколько конкретно миллилитров лунго он желает. Тогда нам придётся проделать один из следующих трюков:
* или мы фиксируем список допустимых объёмов и заводим фиктивные рецепты типа `/recipes/small-lungo`, `recipes/large-lungo`. Почему фиктивные? Потому что рецепт один и тот же, меняется только объём. Нам придётся либо тиражировать одинаковые рецепты, отличающиеся только объёмом, либо вводить какое-то «наследование» рецептов, чтобы можно было указать базовый рецепт и только переопределить объём;
* или мы модифицируем интерфейс, объявляя объём кофе, указанный в рецепте, значением по умолчанию; при размещении заказа мы разрешаем указать объём, отличный от эталонного:
`POST /coffee-machines/orders?machine_id={id}`
`POST /v1/coffee-machines/orders?machine_id={id}`
`{recipe:"lungo","volume":"800ml"}`
Для таких кофе произвольного объёма нужно будет получать требуемый объём не из `GET /recipes`, а из `GET /orders`. Сделав так, мы сразу получаем клубок из связанных проблем:
* разработчик, которому придётся поддержать эту функциональность, имеет высокие шансы сделать ошибку: добавив поддержку произвольного объёма кофе в код, работающий с `POST /coffee-machines/orders` нужно не забыть переписать код проверки готовности заказа;
* мы получим классическую ситуацию, когда одно и то же поле (объём кофе) значит разные вещи в разных интерфейсах. В `GET /recipes` поле «объём» теперь значит «объём, который будет запрошен, если не передать его явно в `POST /coffee-machines/orders`»; переименовать его в «объём по умолчанию» уже не получиться, с этой проблемой теперь придётся жить.
Для таких кофе произвольного объёма нужно будет получать требуемый объём не из `GET /v1/recipes`, а из `GET /v1/orders`. Сделав так, мы сразу получаем клубок из связанных проблем:
* разработчик, которому придётся поддержать эту функциональность, имеет высокие шансы сделать ошибку: добавив поддержку произвольного объёма кофе в код, работающий с `POST /v1/coffee-machines/orders` нужно не забыть переписать код проверки готовности заказа;
* мы получим классическую ситуацию, когда одно и то же поле (объём кофе) значит разные вещи в разных интерфейсах. В `GET /v1/recipes` поле «объём» теперь значит «объём, который будет запрошен, если не передать его явно в `POST /v1/coffee-machines/orders`»; переименовать его в «объём по умолчанию» уже не получится, с этой проблемой теперь придётся жить.
3. Вся эта схема полностью неработоспособна, если разные модели кофе-машин производят лунго разного объёма. Для решения задачи «объём лунго зависит от вида машины» нам придётся сделать совсем неприятную вещь: сделать рецепт зависимым от id машины. Тем самым мы начнём активно смешивать уровни абстракции: одной частью нашего API (рецептов) станет невозможно пользоваться без другой части (информации о кофе-машинах). Что немаловажно, от разработчиков потребуется изменить логику своего приложения: если раньше они могли предлагать сначала выбрать объём, а потом кофе-машину, то теперь им придётся полностью изменить этот шаг.
@ -51,8 +51,6 @@
1. От сценариев использования к их внутренней реализации: высокоуровневые сущности и номенклатура их методов должны напрямую отражать сценарии использования API; низкоуровневый - отражать декомпозицию сценариев на составные части.
Здесь мы должны явно обратиться к выписанному нами ранее «что» и «как». В идеальном мире высший уровень абстракции вашего API должен быть просто переводом записанной человекочитаемой фразы на машинный язык. Если нужно узнать, готов ли заказ — значит, должен быть метод `is-order-ready` (если мы считаем эту операцию действительно важной и частотной) или хотя бы `GET /orders/{id}/status` для того, чтобы явно узнать статус заказа. Эту логику требуется прорастить вниз до самых мелких и частных сценариев типа определения температуры напитка или наличия у исполнителя картонного держателя нужного размера.
2. От терминов предметной области пользователя к терминам предметной области исходных данных — в нашем случае от высокоуровневых понятий «рецепт», «заказ», «бренд», «кофейня» к низкоуровневым «температура напитка» и «координаты кофе-машины»
3. Наконец, от структур данных, в которых удобно оперировать пользователю к структурам данных, максимально приближенных к «сырым» - в нашем случае от «лунго» и «сети кофеен "Ромашка"» - к сырым байтовый данным, описывающим состояние кофе-машины марки «Доброе утро» в процессе приготовления напитка.
@ -64,36 +62,147 @@
* с одной стороны, «заказ» не должен содержать информацию о датчиках и сенсорах кофе-машины;
* с другой стороны, кофе-машина не должна хранить информацию о свойствах заказа (да и вероятно её API такой возможности и не предоставляет).
Введём промежуточный уровень: нам нужно звено, которое одновременно знает о заказе, рецепте и кофе-машине. Назовём его «уровнем исполнения»: его ответственностью является интерпретация заказа, превращение его в набор команд кофе-машине. Самый простой вариант — ввести абстрактную сущность «задание» `task`:
Наивный подход в такой ситуации — искусственно ввести некий промежуточный уровень абстракции, «передаточное звено», который переформулирует задачи одного уровня абстракции в другой. Например, введём сущность `task` вида:
```
{
"volume_requested": "800ml",
"volume_prepared": "200ml",
"readiness_policy": "check_volume",
"ready": false,
"operation_state": {
"status": "executing",
"operations": [
// описание операций, запущенных на
// физической кофе-машине
]
}
}
```
* заказ порождает одно или несколько заданий, указывая для задания конкретный рецепт и кофе-машину;
* задание в свою очередь оперирует командами кофе-машины и отвечает за интерпретацию состояния датчиков.
Я называю этот подход «наивным» не потому, что он неправильный; напротив, это вполне логичное решение «по умолчанию», если вы на данном этапе ещё не знаете или не понимаете, как будет выглядеть ваше API. Проблема его в том, что он умозрительный: он не добавляет понимания того, как устроена предметная область.
Таким образом, наше API будет выглядеть примерно так:
* `POST /orders` — создаёт заказ;
* `GET /tasks?order_id={order_id}` — позволяет получить список заданий по заказу.
Хороший разработчик в нашем примере должен спросить: хорошо, а какие вообще говоря существуют варианты? Как можно определять готовность напитка? Если вдруг окажется, что сравнение объёмов — единственный способ определения готовности во всех без исключения кофе-машинах, то почти все рассуждения выше — неверны: можно совершенно спокойно включать в интерфейсы определение готовности кофе по объёму, т.к. никакого другого и не существует. Прежде, чем что-то абстрагировать — надо представлять, *что* мы, собственно, абстрагируем.
Внимательный читатель может здесь поинтересоваться, а в чём, собственно разница по сравнению с наивным подходом? Напомню, мы рассмотрели выше примерно такой вариант:
Для нашего примера допустим, что мы сели изучать спецификации API кофе-машин и выяснили, что существует принципиально два класса устройств:
* кофе-машины с предустановленными программами, которые умеют готовить заранее прошитые N видов напитков, и мы можем управлять только какими-то параметрами напитка (скажем, объёмом напитка, вкусом сиропа и видом молока); у таких машин отсутствует доступ к внутренним функциям и датчикам, но зато машина умеет через API сама отдавать статус приготовления напитка;
* кофе-машины с предустановленными функциями типа «смолоть такой-то объём кофе», «пролить N миллилитров воды», «взбить молочную пену» и т.д.: у таких машин отсутствует понятие «программа приготовления», но есть доступ к микрокомандам и датчикам.
* `POST /coffee-machines/orders?machine_id={id}`
`{recipe:"lungo","volume":"800ml"}`
— создаёт заказ указанного объёма
* `GET /orders/{id}`
`{…"volume_requested":"800ml","volume_prepared":"120ml"…}`
— состояние исполнения заказа (налито 120 мл из запрошенных 800).
Предположим, для большей конкретности, что эти два класса устройств поставляются вот с таким физическим API:
По сути пара `volume_requested` / `volume_prepared` и является аналогом дополнительной сущности `task`, зачем мы тогда усложняли?
* Машины с предустановленными программами:
```
GET /programs
// Возвращает список предустановленных программ
{
// идентификатор программы
"program": "01",
// вид кофе
"type": "lungo"
}
```
```
POST /execute
{
"program": 1,
"volume": "200ml"
}
// Запускает указанную программу на исполнение
// и возвращает статус исполнения
{
// Уникальный идентификатор задания
"execution_id": "01-01",
// Идентификатор исполняемой программы
"program": 1,
// Запрошенный объём напитка
"volume": "200ml",
// Ожидаемое время приготовления
"preparation_time": "20s",
// Готовность
"ready": false
}
```
```
POST /cancel
// Отменяет текущую программу
```
```
GET /execution/{execution_id}/status
// Возвращает статус исполнения
// Формат аналогичен формату ответа `POST /execute`
```
Во-первых, в схеме с дополнительным уровнем абстракции мы скрываем конструирование самого объекта `task`. Если от `GET /orders/{id}` ожидается, что он вернёт хотя бы логически те же параметры заказа, что были переданы в `POST /coffee-machines/orders`, то при конструировании `task` сформировать нужный набор параметров — уже наша ответственность, спрятанная внутри обработчика создания заказа. Мы можем переформулировать параметры заказа в более удобные для исполнения на кофе-машине термины — например, возвращаясь к вопросу проверки готовности, явно сформулировать политику определения готовности кофе:
_NB_. На всякий случай отметим, что данное API нарушает множество описанных нами принципов проектирования, начиная с отсутствия версионирования; оно приведено в таком виде по двум причинам: (1) чтобы мы могли показать, как спроектировать API более удачно; (2) скорее всего, в реальной жизни вы получите именно такое API от производителей кофе-машин, и это ещё довольно вменяемый вариант.
* `POST /tasks/?order_id={order_id}`
`{…"volume_requested":"800ml","readiness_policy":"check_volume"…}`
— внутри обработчика создания заказа мы обратились к спецификации кофе-машины и поставили задачу в соответствии с ней. (Здесь мы предполагаем, что `POST /tasks` — внутренний метод создания задач; он может и не существовать в виде API.)
* `GET /tasks/{id}/status`
`{…"volume_prepared":"200ml","ready":false}`
— в публичном интерфейсе
* Машины с предустановленными функциями:
```
GET /functions
// Возвращает список доступных функций
{
"functions": [
{
// Тип операции
// * set_cup — поставить стакан
// * grind_coffee — смолоть кофе
// * shed_water — пролить воду
// * discard_cup — утилизировать стакан
"type": "set_cup",
// Допустимые аргументы для каждой операции
// Для простоты ограничимся одним аргументом:
// * volume — объём стакана, кофе или воды
"arguments": ["volume"]
},
]
}
```
```
POST /functions
{
"type": "set_cup",
"arguments": [{ "name": "volume", "value": "300ml" }]
}
// Запускает на исполнение функцию
// с передачей указанных значений аргументов
```
```
GET /sensors
// Возвращает статусы датчиков
{
"sensors": [
{
// Допустимые значения
// * cup_volume — объём установленного стакана
// * ground_coffee_volume — объём смолотого кофе
// * cup_filled_volume — объём напитка в стакане
"type": "cup_volume",
"value": "200ml"
},
]
}
```
_NB_. Пример нарочно сделан умозрительным для моделирования ситуации, описанной в начале главы: для определения готовности напитка нужно сличить объём налитого с эталоном.
На это (совершенно верное!) замечаниемы ответим, что выделение уровней абстракции — прежде всего _логическая_ процедура: как мы объясняем себе и разработчику, из чего состоит наш API. Мы могли бы просто ограничиться выделением секции `task` в ответе `GET /orders/{id}` — или вовсе сказать, что `task` — это просто четверка полей (`ready`, `volume_requested`, `volume_prepared`, `readiness_policy`) и есть. **Абстрагируемая дистанция между сущностями существует объективно**, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни _явно_. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.
Теперь картина становится более явной: нам нужно абстрагировать работу с кофе-машиной так, чтобы наш «уровень исполнения» в API предоставлял общие функции (такие, как определение готовности напитка) в унифицированном виде. Важно отметить, что с точки зрения разделения абстракций два этих вида кофе-машин сами находятся на разных уровнях: первые предоставляют API более высокого уровня, нежели вторые; следовательно, и «ветка» нашего API, работающая со вторым видом машин, будет более «развесистой».
Следующий шаг, необходимый для отделения уровней абстракции — необходимо понять, какую функциональность нам, собственно, необходимо абстрагировать. Для этого нам необходимо обратиться к задачам, которые решает разработчик на уровне работы с заказами, и понять, какие проблемы у него возникнут в случае отсутствия нашего слоя абстракции.
1. Очевидно, что разработчику хочется создавать заказ унифицированным образом — перечислить высокоуровневые параметры заказа (вид напитка, объём и специальные требования, такие как вид сиропа или молока) — и не думать о том, как на конкретной машине исполнить этот заказ.
2. Разработчику надо понимать состояние исполнения — готов ли заказ или нет; если не готов — когда ожидать готовность (и надо ли её ожидать вообще в случае ошибки исполнения).
3. Разработчику нужно уметь соотносить заказ с его положением в пространстве и времени — чтобы показать потребителю, когда и как нужно заказ забрать.
4. Наконец, разработчику нужно выполнять атомарные операции — прежде всего, отменять заказ.
Таким образом, наш промежуточный уровень абстракции должен:
* скрывать параметры создания задания на приготовление напитка — их вычисление должно производиться в нашем коде;
* обобщать состояние исполнения в единый набор статусов, не зависящий от физического уровня приготовления напитка;
* связывать параметры заказа (его уникальный идентификатор) с конкретным процессом приготовления напитка;
* предоставлять атомарный интерфейс логических операций, скрывая за фасадом различия в физической имплементации этих операций.
Первая из проблем, которую мы видим на данном этапе — это отсутствие на физическом уровне машин второго типа самого понятия «заказ» или «напиток»: машина выполняет какой-то набор операций
Выделение уровней абстракции — прежде всего _логическая_ процедура: как мы объясняем себе и разработчику, из чего состоит наш API. Мы могли бы просто ограничиться выделением секции `task` в ответе `GET /orders/{id}` — или вовсе сказать, что `task` — это просто четверка полей (`ready`, `volume_requested`, `volume_prepared`, `readiness_policy`) и есть. **Абстрагируемая дистанция между сущностями существует объективно**, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни _явно_. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.
NB: важно заметить, что с дальнейшей проработкой уровень исполнения, скорее всего, сам должен будет разделиться на два и более уровня, т.к. «задача» по сути — просто сущность-зонтик, связывающая в рамках заказа несколько высокоуровневых сущностей. Идея определения параметров кофе-машины на этапе создания заказов не очень удобна, да и до манипуляции командами кофе-машины и состоянием сенсоров всё ещё далеко с точки зрения абстрагирования. Но мы пока оставим в таком виде, для удобства дальнейшего изложения.
@ -105,7 +214,7 @@ NB: важно заметить, что с дальнейшей проработ
Важным следствием этого принципа является то, что информацию о готовности заказа нам придётся «прорастить» через все уровни абстракции:
1. На физическом уровне мы будем оперировать состоянием кофе-машины, её сенсоров;
1. На физическом уровне мы будем оперировать состоянием кофе-машины, её сенсоров; с точки зрения физических сенсоров нет никакой «готовности заказа», есть только состояние выполнения команд;
2. На уровне исполнения статус готовности означает, что состояние сенсоров приведено к эталонному (в случае политики "check_volume" — что налит именно тот объём кофе, который был запрошен);
3. На пользовательском уровне статус готовности заказа означает, что все ассоциированные задачи выполнены.
@ -115,8 +224,8 @@ NB: важно заметить, что с дальнейшей проработ
* `POST /orders/{id}/cancel` работает с высокоуровневыми данными о заказе:
* проверяет авторизацию, т.е. имеет ли право этот пользователь отменять этот заказ;
* решает денежные вопросы — нужно ли делать рефанд
* находит все незавершённые задачи и отменяет их
* решает денежные вопросы — нужно ли делать рефанд;
* находит все незавершённые задачи и отменяет их;
* `POST /tasks/{id}/cancel` работает с исполнением заказа:
* определяет, возможно ли физически отменить исполнение, есть ли такая функция у кофе-машины;
* генерирует последовательность действий отмены (возможно, не только непосредственно для самой машины — вполне вероятно, необходимо будет поставить задание сотруднику кофейни утилизировать невостребованный напиток);
@ -128,10 +237,6 @@ NB: важно заметить, что с дальнейшей проработ
`{"status":"canceled","operation_state":{"status":"canceling","operations":[…]}`
— с т.з. высокоуровневого кода задача завершена (`canceled`), но с точки зрения низкоуровневого кода список исполняемых операций непуст, т.е. задача продолжает работать.
NB: так как `task` связывает два разных уровня абстракции, то и статусов у неё два: внешний `canceled` и внутренний `canceling`. Мы могли бы опустить второй статус и предложить ориентироваться на содержание `operations`, но это вновь (а) неявно, (б) предполагает необходимость разбираться в более низкоуровневом интерфейсе `operation_state`, что, быть может, разработчику вовсе и не нужно.
**NB**: так как `task` связывает два разных уровня абстракции, то и статусов у неё два: внешний `canceled` и внутренний `canceling`. Мы могли бы опустить второй статус и предложить ориентироваться на содержание `operations`, но это (а) неявно, (б) предполагает необходимость разбираться в более низкоуровневом интерфейсе `operation_state`, что, быть может, разработчику вовсе и не нужно.
Может показаться, что соблюдение правила изоляции уровней абстракции является избыточным и заставляет усложнять интерфейс. И это в действительности так: важно понимать, что никакая гибкость, логичность, читабельность и расширяемость не бывает бесплатной. Можно построить API так, чтобы оно выполняло свою функцию с минимальными накладными расходами, по сути — дать интерфейс к микроконтроллерам кофе-машины. Однако пользоваться им будет крайне неудобно, и расширяемость такого API будет нулевой.
Дублирование функций на каждом уровне абстракций позволяет добиться важной вещи: возможности сменить нижележащие уровни без необходимости переписывать верхнеуровневый код. Мы можем добавить другие виды кофе-машин с принципиально другими физическими способами определения готовности напитка, и наш метод `GET /orders?order_id={id}` продолжит работать, как работал.
Да, код, который работал с физическим уровнем, придётся переписать. Но, во-первых, это неизбежно: изменение принципов работы физического уровня автоматически означает необходимость переписать код. Во-вторых, такое разделение ставит перед нами четкий вопрос: до какого момента API должно предоставлять публичный доступ? Стоило ли предоставлять пользователю методы физического уровня?

View File

@ -26,13 +26,9 @@
#### Декомпозиция интерфейсов
// TODO
На этапе разделения уровней абстракции мы упомянули, что одна из целей такого разделения — добиться возможности безболезненно сменить нижележащие уровни абстракции при добавлении новых технологий или изменении нижележащих протоколов. Декомпозиция интерфейсов — основной инструмент, позволяющий добиться такой гибкости.
NB. Во многих языках программирования нет поддержки интерфейсов, абстрактных классов и/или множественного наследования. Однако выделять интерфейсы нам это не мешает, поскольку мы всегда можем "договориться", что объект source имеет право пользоваться только вот этим набором свойств и методов. Конечно, контролировать соблюдение этой договоренности в достаточно развесистом API довольно сложно, но, поверьте автору, вполне возможно, тем более, что тестами и/или статическим анализом кода соблюдение договоренностей об интерфейсах можно проверить почти всегда.
Разделение контекстов - не единственная причина, по которой выделение интерфейсов критически важно при проектировании API. Предъявление к входящим параметрам требования только удовлетворять интерфейсу существенно упрощает создание альтернативных реализаций ваших объектов, в том числе в целях тестирования. Теперь чтобы протестировать объект source достаточно написать mock на IGeoContext, а не весь класс map целиком. Аналогично, если мы захотим использовать наши оверлеи для показа их, скажем, в качестве какой-то инфографики или на абстрактном плане местности, нам не придётся переделывать для этого класс Map - достаточно будет альтернативной реализации IGraphicalContext.
При выделении интерфейсов важно также понимать, что интерфейс, в отличие от его реализации, должен быть минимально достаточным и не должен включать в себя вспомогательные методы. Например, если класс map имеет как метод для получения всей области картографирования в виде четырехугольника getBBox, так и методы получения каждого из углов по отдельности - getLeftBottom, getRightTop, например, — то интерфейс IGeoContext должен содержать что-то одно. Нет никакого смысла загромождать интерфейс альтернативными реализациями одной и той же функциональности — это затрудняет чтение и усложняет написание собственных реализаций. Если только нет каких-то показаний с точки зрения производительности, следует отдать предпочтение максимально общему методу - в нашем случае getBBox.
На этом этапе нам предстоит отделить частное от общего: понять, какие свойства сущностей фиксированы в нашей модели, а какие будут зависеть от реализации. В нашем кофе-примере таким сильно зависящим от имплементации объектом, очевидно, будут сами кофе-машины. Попробуем ещё немного развить наше API: каким образом нам нужно описать модели кофе-машин для того, чтобы предоставить интерфейсы для работы с задачами заказа?
#### Интерфейсы как универсальный паттерн
@ -67,4 +63,5 @@ NB. Во многих языках программирования нет по
Дерево информационных контекстов (какой объект обладает какой информацией, и кто является транслятором из одного контекста в другой), по сути, представляет собой «срез» нашего дерева иерархии интерфейсов; выделение такого среза позволяет проще и удобнее удерживать в голове всю архитектуру проекта.
// TODO: простые вещи делаются просто без бойлерплейта
// TODO
// Хелперы, бойлерплейт

View File

@ -20,19 +20,19 @@
* плохо:
```
GET /orders/cancellation
отменяет заказ
// отменяет заказ
```
Неочевидно, что достаточно просто обращения к сущности `cancellation` (что это?), тем более немодифицирующим методом `GET`, чтобы отменить заказ;
Хорошо:
```
POST /orders/cancel
отменяет заказ
POST /orders/cancel
отменяет заказ
```
* плохо:
* плохо:
```
GET /orders/statistics
Возвращает агрегированную статистику заказов за всё время
// Возвращает агрегированную статистику заказов за всё время
```
Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.
@ -40,11 +40,17 @@
```
POST /orders/statistics/aggregate
{ "start_date", "end_date" }
Возвращает агрегированную статистику заказов за указанный период
// Возвращает агрегированную статистику заказов за указанный период
```
**Стремитесь к тому, чтобы из сигнатуры функции было абсолютно ясно, что она делает, что принимает на вход и что возвращает**. Вообще, при прочтении кода, работающего с вашим API, должно быть сразу понятно, что, собственно, он делает — без подглядывания в документацию.
Два важных следствия:
**1.1.** Если операция модифицирующая, это должно быть очевидно из сигнатуры. В частности, не может быть модифицирующих операций за `GET`.
**1.2.** Если операция асинхронная, это должно быть очевидно из сигнатуры, **либо** должна существовать конвенция именования, позволяющая отличаться синхронные операции от асинхронных.
#### 2. Сущности должны именоваться конкретно
Избегайте слов-«амёб» без определённой семантики, таких как get, apply, make. Сущности должны именоваться конкретно:
@ -59,13 +65,11 @@
* плохо:
```
strpbrk (str1, str2)
возвращает положение первого вхождения в строку str2 любого символа из строки str2
// возвращает положение первого вхождения в строку str2
// любого символа из строки str2
```
Возможно, автору этого API казалось, что аббревиатура `pbrk` что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк `str1`, `str2` является набором символов для поиска.
Хорошо:
```
str_search_for_characters(lookup_character_set, str)
```
Хорошо: `str_search_for_characters(lookup_character_set, str)`
Однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение `string` до `str` выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.
#### 4. Тип поля должен быть ясен из его названия
@ -80,7 +84,16 @@
* плохо: `"task.status": true` — неочевидно, что статус бинарен, плюс такое API будет нерасширяемым;
хорошо: `"task.is_finished": true`
Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учетом специфики first-class citizen-типов. Например, объекты типа `Date`, если таковые имеются, разумно индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at`, etc).
Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учетом специфики first-class citizen-типов. Например, объекты типа `Date`, если таковые имеются, разумно индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at`, etc) или `_date`.
Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс, чтобы избежать непонимания.
* Плохо:
```
GET /coffee-machines/functions
// Возвращает список встроенных функций кофе-машины
```
Слово "functions" многозначное; может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).
Хорошо: `GET /coffee-machines/builtin-functions-list`
#### 5. Подобные сущности должны называться подобно и вести себя подобным образом
@ -90,11 +103,13 @@
* плохо:
```
strpos(haystack, needle)
Находит первую позицию позицию строки `needle` внутри строки `haystack`
// Находит первую позицию позицию строки `needle`
// внутри строки `haystack`
```
```
str_replace(needle, replace, haystack)
Находит и заменяет все вхождения строки `needle` внутри строки `haystack` на строку `replace`
// Находит и заменяет все вхождения строки `needle`
// внутри строки `haystack` на строку `replace`
```
Здесь нарушены сразу несколько правил:
* написание неконсистентно в части знака подчеркивания;
@ -123,9 +138,10 @@
Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что как ни сделай — кто-то останется недовольным. Классический пример такого рода — порядок геокоординат ("широта-долгота" против "долгота-широта"). Здесь, увы, вам остаётся только смириться и проявлять выдержку при нападках на ваше API.
#### 7. Сохранение точности дробных чисел
// TODO: блокнот душевного спокойствия
#### 7. Сохраняйте точность дробных чисел
Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.
Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому, либо использовать строковый тип.

View File

@ -22,11 +22,21 @@ body {
padding-left: 92px;
}
code {
code, pre {
font-family: Inconsolata, sans-serif;
font-size: 12pt;
}
pre {
margin: 12pt 0;
padding: 12pt;
border-radius: .25em;
border-top: 1px solid rgba(0,0,0,.45);
border-left: 1px solid rgba(0,0,0,.45);
box-shadow: .1em .1em .1em rgba(0,0,0,.45);
page-break-inside: avoid;
}
.page-break {
page-break-after: always;
}