- §
@@ -4671,6 +4671,7 @@ Content-Type: application/json
+NB: распространено мнение, что метод POST
предназначен только для создания новых ресурсов. Это совершенно не так, создание ресурса только один из вариантов «обработки запроса согласно внутреннему устройству» эндпойнта.
Важное свойство модифицирующих идемпотентных глаголов — это то, что URL запроса является его ключом идемпотентности. PUT /url
полностью перезаписывает ресурс, заданный своим URL (/url
), и, таким образом, повтор запроса не изменяет ресурс. Аналогично, повторный вызов DELETE /url
должен оставить систему в том же состоянии (ресурс /url
удалён). Учитывая, что метод GET /url
семантически должен вернуть представление целевого ресурса /url
, то, если этот метод реализован, он должен возвращать консистентное предыдущим PUT
/ DELETE
представление. Если ресурс был перезаписан через PUT /url
, GET /url
должен вернуть представление, соответствующее переданном в PUT /url
телу (в случае JSON-over-HTTP API это, как правило, просто означает, что GET /url
возвращает в точности тот же контент, чтобы передан в PUT /url
, с точностью до значений полей по умолчанию). DELETE /url
обязан удалить указанный ресурс — так, что GET /url
должен вернуть 404
или 410
.
Идемпотентность и симметричность методов GET
/ PUT
/ DELETE
влечёт за собой нежелательность для GET
и DELETE
запросов иметь тело (поскольку этому телу невозможно приписать никакой осмысленной роли). Однако (по-видимому в связи с тем, что многие разработчики попросту не знают семантику этих методов) распространённое ПО веб-серверов обычно разрешает этим методам иметь тело запроса и транслирует его дальше к коду обработки эндпойнта (использование этой практики мы решительно не рекомендуем).
Достаточно очевидным образом ответы на модифицирующие запросы не кэшируются (хотя при определённых условиях закэшированный ответ метода POST
может быть использован при последующем GET
-запросе) и, таким образом, повторный POST
/ PUT
/ DELETE
/ PATCH
запрос обязательно будет доставлен до конечного сервера (ни один промежуточный агент не имеет права ответить из кэша). В случае GET
-запроса это, вообще говоря, неверно — гарантией может служить только наличие в ответе директив кэширования no-store
или no-cache
.
@@ -4796,6 +4797,7 @@ X-ApiName-Partner-Id: <partner_id>
Конечно, большинство этих инструментов применимы и для работы с API, реализующими альтернативные парадигмы. Однако именно способность промежуточных агентов считывать метаданные HTTP запросов позволяет легко строить сложные конвейеры типа экспортировать access-логи nginx в Prometheus и из коробки получить удобные мониторинги статус-кодов ответов в Grafana.
Отдельно заметим что HTTP API является на сегодняшний день выбором по умолчанию при разработке публичных API. В силу озвученных выше причин, как бы ни был устроен технологический стек партнёра, интегрироваться с HTTP API он сможет без особых усилий. При этом распространённость технологии понижает и порог входа, и требования к квалификации инженеров партёра.
Главным недостатком HTTP API является то, что промежуточные агенты, от клиентских фреймворков до API-гейтвеев, умеют читать метаданные запроса и выполнять какие-то действия с их использованием — настраивать политику перезапросов и таймауты, логировать, кэшировать, шардировать, проксировать и так далее — даже если вы их об этом не просили. Более того, так как стандарты HTTP являются сложными, концепция REST — непонятной, а разработчики программного обеспечения — неидеальными, то промежуточные агенты (и разработчики партнёра!) могут трактовать метаданные запроса неправильно. Особенно это касается каких-то экзотических и сложных в имплементации стандартов. Как правило, одной из причин разработки новых RPC-фреймворков декларируется стремление обеспечить простоту и консистентность работы с протоколом, чтобы таким образом уменьшить поле для потенциальных ошибок в реализации интеграции с API.
+Указанное выше соображение распространяется не только на программное обеспечение, но и на его создателей. Представление разработчиков о HTTP API, увы, также фрагментировано. Практически любой программист как-то умеет работать с HTTP API, но редко при этом знает стандарт или хотя бы консультируется с ним при написании кода. Это ведёт к тому, что добиться качественной и консистентной реализации логики работы с HTTP API может быть сложнее, нежели при использовании альтернативных технологий — причём это соображение справедливо как для партнёров-интеграторов, так и для самого провайдера API.
Вопросы производительности
В пользу многих современных альтернатив HTTP API — таких как GraphQL, gRPC, Apache Thrift — часто приводят аргумент о низкой производительности JSON-over-HTTP API по сравнению с рассматриваемой технологией; конкретнее, называются следующие проблемы:
@@ -5041,7 +5043,164 @@ Authorization: Bearer <token>
Этот подход можно в дальнейшем усложнять: добавлять гранулярные разрешения выполнять конкретные операции, вводить уровни доступа, проверку прав в реальном времени через дополнительный вызов ACL-сервиса и так далее.
-Важно, что кажущаяся избыточность перестала быть таковой: user_id
в запросе теперь не дублируется в данных токена; эти идентификаторы имеют разный смысл: над каким ресурсом исполняется операция и кто исполняет операцию. Совпадение этих двух сущностей — пусть частотный, но всё же частный случай. Что, к сожалению, не отменяет его неочевидности и возможности легко забыть выполнить проверку в коде. Таков путь.
+Важно, что кажущаяся избыточность перестала быть таковой: user_id
в запросе теперь не дублируется в данных токена; эти идентификаторы имеют разный смысл: над каким ресурсом исполняется операция и кто исполняет операцию. Совпадение этих двух сущностей — пусть частотный, но всё же частный случай. Что, к сожалению, не отменяет его неочевидности и возможности легко забыть выполнить проверку в коде. Таков путь.
+Как мы уже отмечали в предыдущих главах, стандарты HTTP и URL, а также принципы REST, не предписывают определённой семантики значимым компонентам URL (в частности, частям path и парам ключ-значение в query). Правила организации URL в HTTP API существуют только для читабельности кода и удобства разработчика. Что, впрочем, совершенно не означает, что они неважны: напротив, URL в HTTP API являются средством выразить уровни абстракции и области ответственности объектов. Правильный дизайн иерархии сущностей в API должен быть отражён в правильном дизайне номенклатуры URL.
+NB: отсутствие строгих правил естественным образом привело к тому, что многие разработчики их просто придумали сами для себя. Некоторые наиболее распространённые стихийные практики, например, требование использовать в URL только существительные, в советах по разработке HTTP API в Интернете часто выдаются за стандарты или требования REST, которыми они не являются. Тем не менее, демонстративное игнорирование таких самопровозглашённых правил тоже не лучший подход для провайдера API, поскольку он увеличивает шансы быть неверно понятым.
+Традиционно частям URL приписывается следующая семантика:
+
+- части path (фрагменты пути между символами
/
) используются для организации вложенных сущностей вида /partner/{id}/coffee-machines/{id}
; при этом путь часто может наращиваться, т.е. к конкретному пути продолжают приписываться новые суффиксы, указывающие на подчинённые ресурсы;
+- query используется для организации нестрогой иерархии (отношений «многие ко многим», например
/recipes/?partner=<partner_id>
) либо как способ передать параметры операции (/search/?recipe=lungo
).
+
+Подобная конвенция достаточно хорошо подходит для того, чтобы отразить номенклатуру сущностей почти любого API, поэтому следовать ей вполне разумно (и, наоборот, демонстративное нарушение этого устоявшегося соглашения чревато тем, что разработчики вас просто неправильно поймут). Однако подобная некодифицированная и размытая концепция неизбежно вызывает множество разночтений в конкретных моментах:
+
+-
+
Каким образом организовывать эндпойнты, связывающие две сущности, между которыми нет явных отношений подчинения? Скажем, каким должен быть URL запуска приготовления лунго на конкретной кофе-машине?
+
+/coffee-machines/{id}/recipes/lungo/prepare
+/recipes/lungo/coffee-machines/{id}/prepare
+/coffee-machines/{id}/prepare?recipe=lungo
+/recipes/lungo/prepare?coffee_machine_id=<id>
+/prepare?coffee_machine_id=<id>&recipe=lungo
+/?action=prepare&coffee_machine_id=<id>&recipe=lungo
+
+Все эти варианты семантически вполне допустимы и в общем-то равноправны.
+
+-
+
Насколько строго должна выдерживаться буквальная интерпретация конструкции ГЛАГОЛ /ресурс
? Если мы принимаем правило «части URL обязаны быть существительными» (и ведь странно применять глагол к глаголу!), то в примерах выше должно быть не prepare
, а preparator
или preparer
(а вариант /action=prepare&coffee_machine_id=<id>&recipe=lungo
вовсе недопустим, так как нет объекта действия), что, честно говоря, лишь добавляет визуального шума в виде суффиксов «ator», но никак не способствует большей лаконичности и однозначности понимания.
+
+-
+
Если сигнатура вызова по умолчанию модифицирующая или неидемпотентная, означает ли это, что операция обязана быть модифицирующей / идемпотентной? Двойственность смысловой нагрузки глаголов (семантика vs побочные действия) порождает неопределённость в вопросах организации API. Рассмотрим, например, ресурс /v1/search
, осуществляющий поиск предложений кофе в нашем учебном API. С каким глаголом мы должны к нему обращаться?
+
+- С одной стороны,
GET /v1/search?query=<поисковый запрос>
позволяет явно продекларировать, что никаких посторонних эффектов у этого запроса нет (никакие данные не перезаписываются) и результаты его можно кэшировать (при условии, что все значимые параметры передаются в URL).
+- С другой стороны, согласно семантике операции,
GET /v1/search
должен возвращать представление ресурса search
. Но разве результаты поиска являются представлением ресурса-поисковика? Смысл операции «поиск» гораздо точнее описывается фразой «обработка запроса в соответствии с внутренней семантикой ресурса», т.е. соответствует методу POST
. Кроме того, можем ли мы вообще говорить о кэшировании поисковых запросов? Страница результатов поиска формируется динамически из множества источников, и повторный запрос с той же поисковой фразой почти наверняка выдаст другой список результатов.
+
+Иными словами, для любых операций, результат которых представляет собой результат работы какого-то алгоритма (например, список релевантных предложений по запросу) мы всегда будем сталкиваться с выбором, что важнее: семантика глагола или отсутствие побочных эффектов? Кэширование ответа или индикация того, что операция вычисляет результаты на лету?
+NB: эта дихотомия волнует не только нас, но и авторов стандарта, которые в конечном итоге предложили новый глагол QUERY
, который по сути является немодифицирующим POST
. Мы, однако, сомневаемся, что он получит широкое распространение — поскольку уже существующий SEARCH
оказался в этом качестве никому не нужен.
+
+
+Простых ответов на вопросы выше у нас, к сожалению, нет. В рамках настоящей книги мы придерживаемся следующего подхода:
+
+- сигнатура вызова в первую очередь должна быть лаконична и читабельна; усложнение сигнатур в угоду абстрактным концепциям нежелательно;
+- иерархия ресурсов выдерживается там, где она однозначна (т.е., если сущность низшего уровня абстракции однозначно подчинена сущности высшего уровня абстракции, то отношения между ними будут выражены в виде вложенных путей);
+
+- если есть сомнения в том, что иерархия в ходе дальнейшего развития API останется неизменной, лучше завести новый верхнеуровневый префикс, а не вкладывать новые сущности в уже существующие;
+
+
+- для выполнения «кросс-доменных» операций (т.е. при необходимости сослаться на объекты разных уровней абстракции в одном вызове) предпочтительнее завести специальный ресурс, выполняющий операцию (т.е. в примере с кофе-машинами и рецептами автор этой книги выбрал бы вариант
/prepare?coffee_machine_id=<id>&recipe=lungo
);
+- семантика HTTP-глагола приоритетнее ложного предупреждения о небезопасности/неидемпотентности (в частности, если операция является безопасной, но ресурсозатратной, с нашей точки зрения вполне разумно использовать метод
POST
для индикации этого факта).
+
+NB: отметим, что передача параметров в виде пути или query-параметра в URL влияет не только на читабельность. Вернёмся к примеру из предыдущей главы и представим, что гейтвей D реализован в виде stateless прокси с декларативной конфигурацией. Тогда получать от клиента запрос в виде:
+
+-
+
GET /v1/state?user_id=<user_id>
+и преобразовывать в пару вложенных запросов
+
+-
+
GET /v1/profiles?user_id=<user_id>
+
+-
+
GET /v1/orders?user_id=<user_id>
+гораздо удобнее, чем извлекать идентификатор из path и преобразовывать его в query-параметр. Первую операцию [замена одного path целиком на другой] достаточно просто описать декларативно, и в большинстве ПО для веб-серверов она поддерживается из коробки. Напротив, извлечение данных из разных компонентов и полная пересборка запроса — достаточно сложная функциональность, которая, скорее всего, потребует от гейтвея поддержки скриптового языка программирования и/или написания специального модуля для таких манипуляций. Аналогично, автоматическое построение мониторинговых панелей в популярных сервисах типа связки Prometheus+Grafana (да и в целом любой инструмент разбора логов) гораздо проще организовать по path, чем вычленять из данных запроса какой-то синтетический ключ группировки запросов.
+Всё это приводит нас к соображению, что поддержание одинаковой структуры URL, в которой меняется только путь или домен, а параметры всегда находятся в query и именуются одинаково, приводит к ещё более унифицированному интерфейсу, хотя бы и в ущерб читабельности и семантичности URL. Во многих внутренних системах выбор в пользу удобства выглядит самоочевидным, хотя во внешних API мы бы такой подход не рекомендовали.
+
+
+CRUD-операции
+Одно из самых популярных приложений HTTP API — это реализация CRUD-интерфейсов. Акроним CRUD (Create, Read, Update, Delete) был популяризирован ещё в 1983 году Джеймсом Мартином, но с развитием HTTP API обрёл второе дыхание. Ключевая идея соответствия CRUD и HTTP заключается в том, что каждой из CRUD-операций соответствует один из глаголов HTTP:
+
+- операции создания — создание ресурса через метод
POST
;
+- операции чтения — возврат представления ресурса через метод
GET
;
+- операции редактирования — перезапись ресурса через метод
PUT
или редактирование через PATCH
;
+- операции удаления — удаление ресурса через метод
DELETE
.
+
+NB: фактически, подобное соответствие — это просто мнемоническое правило, позволяющее определить, какой глагол следует использовать к какой операции. Мы, однако, должны предостеречь читателя: глагол следует выбирать по его семантике согласно стандарту, а не по мнемоническим правилам. Может показаться, что, например, операцию удаления 3-го элемента списка нужно реализовать через DELETE
:
+
+-
+
DELETE /v1/list/{list_id}/?position=3
+но, как мы помним, делать так категорически нельзя: во-первых, такой вызов неидемпотентен; во-вторых, нарушает требование консистентности GET
и DELETE
.
+
+
+С точки зрения удобства разработки концепция соответствия CRUD и HTTP выглядит очень удобной — каждому виду ресурсов соответствует свой URL, каждой операции — свой глагол. При пристальном рассмотрении, однако, оказывается, что это отношение — очень упрощённое представление о манипуляции ресурсами, и, что самое неприятное, плохо расширяемое.
+
+Начнём с операции создания ресурса. Как мы помним из главы «Стратегии синхронизации”, операция создания в любой сколько-нибудь ответственной предметной области обязана быть идемпотентной и, очень желательно, ещё и позволять управлять параллелизмом. В рамках парадигмы HTTP API идемпотентное создание можно организовать одним из трёх способов:
+
+-
+
Через метод POST
с передачей токена идемпотентности (им может выступать, в частности, ETag
ресурса):
+POST /v1/orders/?user_id=<user_id> HTTP/1.1
+If-Match: <ревизия>
+
+{ … }
+
+
+-
+
Через метод PUT
, предполагая, что идентификатор заказа сгенерирован клиентом (ревизия при этом всё ещё может использоваться для управления параллелизмом, но токеном идемпотентности является сам URL):
+PUT /v1/orders/{order_id} HTTP/1.1
+If-Match: <ревизия>
+
+{ … }
+
+
+-
+
Через схему создания черновика методом POST
и его подтверждения методом PUT
:
+POST /v1/drafts HTTP/1.1
+
+{ … }
+→
+HTTP/1.1 201 Created
+Location: /v1/drafts/{id}
+
+PUT /v1/drafts/{id}/commit
+If-Match: <ревизия>
+
+{"status": "confirmed"}
+→
+HTTP/1.1 200 OK
+Location: /v1/orders/{id}
+
+
+
+Метод (2) в современных системах используется редко, так как вынуждает доверять правильности генерации идентификатора заказа клиентом. Если же рассматривать варианты (1) и (3), то необходимо отметить, что семантике протокола вариант (3) соответствует лучше, так как POST
-запросы по умолчанию считаются неидемпотентными, и их автоматический повтор в случае получения сетевого таймаута или ошибки сервера будет выглядеть для постороннего наблюдателя опасной операцией (которой запрос и правда может стать, если сервер изменит политику проверки заголовка If-Match
на более мягкую). Повтор PUT
-запроса (а мы предполагаем, что таймауты и серверные ошибки на «тяжёлой» операции создания заказа намного более вероятны, чем на «лёгкой» операции создания черновика) вполне может быть автоматизирован, и не будет создавать дубликаты заказа, даже если проверка ревизии будет отключена вообще. Однако теперь вместо двух URL и двух операций (POST /v1/orders
— GET /v1/orders/{id}
) мы имеем четыре URL и пять операций:
+
+- URL создания черновика (
POST /v1/drafts
), который дополнительно потребует существования URL последнего черновика и/или списка черновиков пользователя (GET /v1/drafts/?user_id=<user_id>
или что-то аналогичное).
+- URL подтверждения черновика (
PUT /v1/drafts/{id}/status
) и, может быть, симметричную операцию чтения статуса черновика для получения актуальной ревизии (хотя эндпойнт GET /v1/drafts
, описанный выше, для этого подходит лучше).
+- URL заказа (
GET /v1/orders/{id}
).
+
+
+Идём дальше. Операция чтения на первый взгляд не вызывает сомнений:
+
+Стоит, однако, присмотреться внимательнее, и всё оказывается не так просто. Клиент как минимум должен обладать способом выяснить, какие заказы сейчас выполняются от его имени, что требует создания отдельного ресурса-поисковика:
+
+GET /v1/orders/?user_id=<user_id>
.
+
+Передача списков без ограничений по их длине — потенциально плохая идея, а значит необходимо ввести поддержку пагинации:
+
+GET /v1/orders/?user_id=<user_id>&cursor=<cursor>
.
+
+Если заказов много, наверняка пользователю понадобятся фильтры, скажем, по названию напитка:
+
+GET /v1/orders/?user_id=<user_id>&recipe=lungo
.
+
+Однако, если пользователь захочет видеть в одном списке и латте и лунго, этот интерфейс уже окажется ограниченно применимым, поскольку общепринятого стандарта передачи в URL более сложных структур, чем пары ключ-значение, не существует. Довольно скоро мы придём к тому, что, наряду с доступом по идентификатору заказа потребуется ещё и поисковый эндпойнт со сложной семантикой (которую гораздо удобнее было бы разместить за POST
):
+
+POST /v1/orders/search { /* parameters */ }
+
+Кроме того, если к заказу можно прикладывать какие-то медиа-данные (скажем, фотографии), то для доступа к ним придётся разработать отдельные URL:
+
+GET /v1/orders/{order_id}/attachements/{id}
+
+
+Проблемы частичного обновления ресурсов мы подробно разбирали в соответствующей главе раздела «Паттерны дизайна API». Напомним, что полная перезапись ресурса методом PUT
возможна, но быстро разбивается о необходимость работать с вычисляемыми и неизменяемыми полями, необходимость совместного редактирования и/или большой объём передаваемых данных. Работа через метод PATCH
возможна, но, так как этот метод по умолчанию считается неидемпотентным (и часто нетразитивным), для него справедливо всё то же соображение об опасности автоматических перезапросов. Достаточно быстро мы придём к одному из двух вариантов:
+
+- либо
PUT
декомпозирован на множество составных PUT /v1/orders/{id}/address
, PUT /v1/orders/{id}/volume
и т.д. — по ресурсу для каждой частной операции;
+- либо существует отдельный ресурс, принимающий список изменений, причём, вероятнее всего, через схему черновик-подтверждение в виде пары методов
POST
+ PUT
.
+
+Если к сущности прилагаются медиаданные, для их редактирования также придётся разработать отдельные эндпойнты.
+
+С удалением ситуация проще всего: никакие данные в современных сервисах не удаляются моментально, а лишь архивируются или помечаются удалёнными. Таким образом, вместо DELETE /v1/orders/{id}
необходимо разработать эндпойнт типа PUT /v1/orders/{id}/archive
или PUT /v1/archive?order=<order_id>
.
+В качестве заключения
+Идея CRUD как способ минимальным набором операций описать типовые действия над ресурсом в при столкновении с реальностью быстро эволюционирует в сторону семейства эндпойнтов, каждый из которых описывает отдельный аспект взаимодействия с сущностью в течение её жизненного цикла.
+Изложенные выше соображения следует считать не критикой концепции CRUD как таковой, а скорее призывом не лениться и разрабатывать номенклатуру ресурсов и операций над ними исходя из конкретной предметной области, а не абстрактных мнемонических правил, к которым является эта концепция. Если вы всё же хотите разработать типовой API для манипуляции типовыми сущностями, стоит изначально разработать его гораздо более гибким, чем предлагает CRUD-HTTP методология.
Когда мы говорим об API как о продукте, необходимо чётко зафиксировать два важных тезиса.
-
diff --git a/docs/API.ru.pdf b/docs/API.ru.pdf
index 9dbc632..f2d8c81 100644
Binary files a/docs/API.ru.pdf and b/docs/API.ru.pdf differ
diff --git a/docs/index.html b/docs/index.html
index 0133e36..07663ce 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -114,8 +114,8 @@
- Chapter 35. Components of an HTTP Request and Their Semantics
- Chapter 36. Advantages and Disadvantages of HTTP APIs
- Chapter 37. Organizing HTTP APIs Based on the REST Principles
-- Chapter 38. Working with HTTP API Errors
-- Chapter 39. Organizing the HTTP API Resources and Operations
+- Chapter 38. Designing a Nomenclature of URLs and Applicable Operations
+- Chapter 39. Working with HTTP API Errors
- Chapter 40. Final Provisions and General Recommendations
diff --git a/docs/index.ru.html b/docs/index.ru.html
index d0503ed..3ffdbdf 100644
--- a/docs/index.ru.html
+++ b/docs/index.ru.html
@@ -114,8 +114,8 @@