mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-05-31 22:09:37 +02:00
CRUD
This commit is contained in:
parent
8c7e94c410
commit
baf2d5cd88
@ -1,9 +0,0 @@
|
||||
### Работа с серверными ошибками в HTTP API
|
||||
|
||||
**Ошибки `5xx`** индицируют, что клиент, со своей стороны, выполнил запрос правильно, и проблема заключается в сервере. Для клиента, по большому счёту, важно только то, имеет ли смысл повторять запрос и, если да, то через какое время. Таким образом, кодов `500 Internal Server Error` и `503 Service Unavailable` было бы достаточно (второй код указывает, что отказ в обслуживании имеет разовый характер и есть смысл автоматически повторить запрос) — или можно вовсе ограничиться одним из них с опциональным заголовком `Retry-After`. Для целей мониторинга состояния системы имеет определённый смысл использовать другие коды (`502` и `504` для индикации проблем с гейтвеями) но, из общих соображений, унифицированный интерфейс (когда внутренняя информация о серверных ошибках передаётся в виде метаданных и используется для диагностики так же, как и для клиентских ошибок) был бы более консистентным и удобным в настройке — с точностью до того, что внешний гейтвей должен её удалить, чтобы не выдавать внутренние ошибки внешнему наблюдателю.
|
||||
|
||||
Разумеется, серверные ошибки также должны содержать информацию для разработчика и для конечного пользователя системы с описанием действий, которые необходимо выполнить при получении ошибки, и вот здесь мы вступаем на очень скользкую территорию.
|
||||
|
||||
Современная практика реализации HTTP-клиентов такова, что безусловно повторяются только немодифицирующие (`GET`, `HEAD`, `OPTIONS`) запросы. В случае модифицирующих запросов *разработчик должен написать код*, который повторит запрос — и для этого разработчику нужно очень внимательно прочитать документацию к API.
|
||||
|
||||
*Теоретически* идемпотентные методы `PUT` и `DELETE` можно вызывать повторно. Практически, однако, ввиду того, что многие разработчики упускают требование идемпотентности этих методов, фреймворки работы с HTTP API по умолчанию перезапросов модифицирующих методов, как правило, не делают; перезапросов потенциально неидемпотентных `POST` и `PATCH` (а также частичного `PUT`) фреймворки не допускают почти никогда.
|
@ -30,11 +30,11 @@
|
||||
* разработка REST API должна фокусироваться на описании медиатипов, представляющих ресурсы; при этом клиент вообще ничего про эти медиатипы знать не должен;
|
||||
* в REST API не должно быть фиксированных имён ресурсов и операций над ними, клиент должен извлекать эту информацию из ответов сервера.
|
||||
|
||||
REST по Филдингу-2008 подразумевает, что клиент, получив каким-то образом ссылку на точку входа в REST API, далее должен быть в состоянии полностью выстроить взаимодействие с API, не обладая вообще никаким априорным знанием о нём, и уж тем более не должен содержать никакого специально написанного кода для работы с этим API.
|
||||
REST по Филдингу-2008 подразумевает, что клиент, получив каким-то образом ссылку на точку входа в REST API, далее должен быть в состоянии полностью выстроить взаимодействие с API, не обладая вообще никаким априорным знанием о нём, и уж тем более не должен содержать никакого специально написанного кода для работы с этим API. Это требование — гораздо более сильное, нежели принципы, описанные в диссертации 2000 года. В частности, из идеи REST-2008 вытекает отсутствие фиксированных шаблонов URL для выполнения операций над ресурсами — предполагается, что такие URL присутствуют в виде гиперссылок в представлениях ресурсов. Диссертация же 2000 года никаких строгих определений «гипермедиа», которые препятствовали бы идее конструирования ссылок на основе априорных знаний об API (например, по спецификации), не содержит.
|
||||
|
||||
**NB**: оставляя за скобками тот факт, что Филдинг весьма вольно истолковал свою же диссертацию, просто отметим, что ни одна существующая система в мире не удовлетворяет описанию REST по Филдингу-2008.
|
||||
**NB**: оставляя за скобками тот факт, что Филдинг весьма вольно истолковал свою собственную диссертацию, просто отметим, что ни одна существующая система в мире не удовлетворяет описанию REST по Филдингу-2008.
|
||||
|
||||
Нам неизвестно, почему из всех обзоров абстрактной сетевой архитектуры именно диссертация Филдинга обрела столь широкую популярность; очевидно другое: теория Филдинга, преломившись в умах миллионов программистов (включая самого Филдинга), превратилась в целую инженерную субкультуру. Путём редукции абстракций REST применительно конкретно к протоколу HTTP и стандарту URL родилась химера «RESTful API», [конкретного смысла которой никто не знает](https://restfulapi.net/).
|
||||
Нам неизвестно, почему из всех обзоров абстрактной сетевой архитектуры именно концепция Филдинга обрела столь широкую популярность; очевидно другое: теория Филдинга, преломившись в умах миллионов программистов (включая самого Филдинга), превратилась в целую инженерную субкультуру. Путём редукции абстракций REST применительно конкретно к протоколу HTTP и стандарту URL родилась химера «RESTful API», [конкретного смысла которой никто не знает](https://restfulapi.net/).
|
||||
|
||||
Хотим ли мы тем самым сказать, что REST является бессмысленной концепцией? Отнюдь нет. Мы только хотели показать, что она допускает чересчур широкую интерпретацию, в чём одновременно кроется и её сила, и её слабость.
|
||||
|
@ -125,17 +125,11 @@ HTTP-глагол определяет два важных свойства HTTP
|
||||
|
||||
**NB**: разделение на группы по первой цифре кода имеет очень важное практическое значение. В случае, если возвращаемый сервером код ошибки `xyz` неизвестен клиенту, согласно спецификации клиент обязан выполнить то действие, которое выполнил бы при получении ошибки `x00`.
|
||||
|
||||
В основе технологии статус-кодов лежит понятное желание сделать ошибки машиночитаемыми, так, чтобы все промежуточные агенты могли понять, что конкретно произошло с запросом. Номенклатура статус-кодов HTTP действительно весьма подробно описывает почти любые проблемы, которые могут случиться с HTTP-запросом: недопустимые значения `Accept-*`-заголовков, отсутствующий `Content-Length`, неподдерживаемый HTTP-метод, слишком длинный URI и так далее.
|
||||
|
||||
Однако следует отметить, что большинство HTTP-ошибок специфичны именно для протокола HTTP, т.е. описывают исключительные ситуации, возникающие при попытке использовать различные возможности протокола. Поэтому большинство RPC-фреймворков либо полностью игнорирует номенклатуру статус-кодов (используя только `200 OK`), либо полагается очень ограниченный их набор. В самом деле, если ваш API не использует возможности протокола, вам достаточно трёх ошибок:
|
||||
|
||||
* `400` для персистентных ошибок (если просто повторить запрос — ошибка никуда не денется);
|
||||
* `404` для статуса неопределённости (повтор запроса может дать другой результат);
|
||||
* `500` для проблем на стороне сервера плюс заголовок `Retry-After`, чтобы дать понять клиенту, когда обратиться с повторным запросом.
|
||||
В основе технологии статус-кодов лежит понятное желание сделать ошибки машиночитаемыми, так, чтобы все промежуточные агенты могли понять, что конкретно произошло с запросом. Номенклатура статус-кодов HTTP действительно подробно описывает почти любые проблемы, которые могут случиться с HTTP-запросом: недопустимые значения `Accept-*`-заголовков, отсутствующий `Content-Length`, неподдерживаемый HTTP-метод, слишком длинный URI и так далее.
|
||||
|
||||
**NB**: обратите внимание на проблему дизайна спецификации. По умолчанию все `4xx` коды не кэшируются, за исключением: `404`, `405`, `410`, `414`. Мы не сомневаемся, что это было сделано из благих намерений, но подозреваем, что множество людей, знающих об этих тонкостях, примерно совпадает с множеством редакторов спецификации HTTP.
|
||||
|
||||
К сожалению, для описаний ошибок, возникающих в бизнес-логике, номенклатура статус-кодов HTTP совершенно недостаточна и вынуждает либо использовать статус-коды в нарушение стандарта, либо обогащать ответ дополнительной информацией об ошибке. Авторы спецификации, будучи в курсе этой проблемы, добавили следующую фразу: ‘The response message will usually contain a representation that explains the status’. Мы с ними, разумеется, полностью согласны, но не можем не отметить, что эта фраза противоречит парадигме REST: другие агенты в многоуровневой системе не могут понять, что же там «объясняет» представление ошибки, и сама ошибка становится для них непрозрачной. Проблемы имплементации системы ошибок в HTTP API мы обсудим подробнее в главе 6.
|
||||
К сожалению, для описаний ошибок, возникающих в бизнес-логике, номенклатура статус-кодов HTTP совершенно недостаточна и вынуждает использовать статус-коды в нарушение стандарта и/или обогащать ответ дополнительной информацией об ошибке. Проблемы имплементации системы ошибок в HTTP API мы обсудим подробнее в главах «Организация клиентских ошибок в HTTP API» и «Работа с серверными ошибками в HTTP API».
|
||||
|
||||
#### Важное замечание о кэшировании
|
||||
|
@ -68,13 +68,18 @@ Content-Type: application/json
|
||||
|
||||
**NB**: альтернативно мы могли бы закодировать имя пользователя в самом токене согласно, например, [стандарту JWT](https://www.rfc-editor.org/rfc/rfc7519) — для данного кейса это неважно, поскольку `user_id` всё равно остаётся частью HTTP-заголовка.
|
||||
|
||||
Теперь сервисы B и C получают запрос в таком виде, что им не требуется выполнение дополнительных действий (идентификации пользователя через сервис А) для получения результата. Тем самым мы переформулировали запрос так, что он не требует от (микро)сервиса обращаться за данными за пределами его области ответственности, добившись соответствия stateless-принципу.
|
||||
Теперь сервисы B и C получают запрос в таком виде, что им не требуется выполнение дополнительных действий (идентификации пользователя через сервис А) для получения результата. Тем самым мы переформулировали запрос так, что он *не требует от (микро)сервиса обращаться за данными за пределами его области ответственности*, добившись соответствия stateless-принципу.
|
||||
|
||||
Вопрос о том, в чём разница между **stateless** и **stateful** подходами, вообще говоря, не имеет простого ответа. Микросервис B сам по себе хранит состояние клиента (профиль пользователя) и, таким образом, является stateful с точки зрения буквы диссертации Филдинга. Тем не менее, мы соглашаемся с тем, что хранить данные по профилю пользователя и только проверять валидность токена — это более правильный подход, чем хранить те же данные плюс кэш токенов, из которого можно извлечь идентификатор пользователя. Фактически, мы говорим здесь о *логическом* принципе изоляции уровней абстракции, который мы подробно обсуждали в соответствующей главе:
|
||||
* микросервисы разрабатываются так, чтобы не хранить данные, не относящиеся к другим уровням абстракции;
|
||||
* такие «внешние» данные являются лишь идентификаторами контекстов, и сам микросервис никак их не трактует;
|
||||
* если всё же какие-то дополнительные операции с внешними данными требуется производить (например, проверять, авторизована ли запрашивающая сторона на выполнение операции), то следует *организовать передачу данных так, чтобы свести операцию к проверке целостности переданных данных* (в нашем примере — использовать подписывание запросов вместо хранения копии базы данных токенов).
|
||||
|
||||
Пойдём теперь чуть дальше и подметим, что профиль пользователя меняется достаточно редко, и нет никакой нужды каждый раз получать его заново — мы могли бы закэшировать его на стороне гейтвея D. Для этого нам нужно сформировать ключ кэша, которым фактически является идентификатор клиента. Мы можем пойти длинным путём:
|
||||
* перед обращением в сервис B составить ключ и обратиться к кэшу;
|
||||
* если данные имеются в кэше, ответить клиенту из кэша; иначе обратиться к сервису B и сохранить полученные данные в кэш.
|
||||
|
||||
А можем срезать пару углов: если мы добавим идентификатор пользователя непосредственно в запрос, то можем положиться на HTTP-кэширование, которое наверняка или реализовано в нашем фреймворке, или добавляется в качестве плагина за пять минут. Тогда гейтвей D обратится к ресурсу `/v1/profiles/<user-id>` в сервисе B и получит данные либо из кэша, либо непосредственно из сервиса.
|
||||
А можем срезать пару углов: если мы добавим идентификатор пользователя непосредственно в запрос, то можем положиться на HTTP-кэширование, которое наверняка или реализовано в нашем фреймворке, или добавляется в качестве плагина за пять минут. Тогда гейтвей D обратится к ресурсу `/v1/profiles/{user_id}` в сервисе B и получит данные либо из кэша, либо непосредственно из сервиса.
|
||||
|
||||
Теперь рассмотрим сервис C. Результат его работы мы тоже могли бы кэшировать, однако состояние текущего заказа меняется гораздо чаще, чем список завершённых заказов. Вспомним, однако, описанное нами в разделе «Паттерны API» оптимистичное управление параллелизмом: для корректной работы сервиса нам нужна ревизия состояния ресурса, и ничто не мешает нам воспользоваться этой ревизией как ключом кэша. Пусть сервис С возвращает нам токен, соответствующий текущему состоянию заказов пользователя:
|
||||
|
||||
@ -82,7 +87,7 @@ Content-Type: application/json
|
||||
GET /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
→
|
||||
HTTP/1.1 200 Ok
|
||||
ETag: w/<ревизия>
|
||||
ETag: <ревизия>
|
||||
…
|
||||
```
|
||||
|
||||
@ -93,30 +98,37 @@ ETag: w/<ревизия>
|
||||
* найти закэшированное состояние, если оно есть
|
||||
* отправить запрос к сервису B вида
|
||||
```
|
||||
GET /v1/orders?user_id=<user_id>
|
||||
If-None-Match: w/<ревизия>
|
||||
GET /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
If-None-Match: <ревизия>
|
||||
```
|
||||
* если сервис B отвечает статусом 304 Not Modified, вернуть данные из кэша
|
||||
* если сервис B отвечает новой версией данных, сохранить её в кэш и вернуть
|
||||
|
||||
**NB**: мы использовали нотацию `/v1/orders?user_id`, а не, допустим, `/v1/users/<user_id>/orders` по двум причинам:
|
||||
**NB**: мы использовали нотацию `/v1/orders?user_id`, а не, допустим, `/v1/users/{user_id}/orders` по двум причинам:
|
||||
* сервис текущих заказов хранит заказы, а не пользователи — логично если URL будет это отражать;
|
||||
* если нам потребуется в будущем позволить нескольким пользователям делать общий заказ, нотация `/v1/orders?user_id` будет лучше отражать отношения между сущностями (напомним, путь традиционно используется для индикации строгой иерархии).
|
||||
|
||||
Впрочем, мы не настаиваем на этом решении как на единственно верном: в первую нам важно, чтобы URL был ключом кэширования и идемпотентности, а для этого подходят обе нотации.
|
||||
Подчеркнём ещё раз: стандарт не определяет, каким образом формируются URL — как разбивать путь на части (и разбивать ли вообще), в каких случаях передавать параметр в query, а в каких в path и т.д. — поэтому path и query формируются сугубо из удобства чтения и использования. Если представить, что гейтвей D реализован в виде stateless прокси с декларативной конфигурацией, то было бы гораздо удобнее получать от клиента запрос в виде:
|
||||
* `GET /v1/state?user_id=<user_id>`
|
||||
и преобразовывать в пару вложенных запросов
|
||||
* `GET /v1/profiles?user_id=<user_id>`
|
||||
* `GET /v1/ongoing-orders?user_id=<user_id>`
|
||||
поскольку эту операцию [замена одного path целиком на другой] достаточно описать в конфигурации, и в большинстве ПО для веб-серверов она поддерживается из коробки. Напротив, извлечение данных из разных частей запроса и полная пересборка URL — достаточно сложная функциональность, которая, скорее всего, потребует от гейтвея поддержки скриптового языка программирования и/или написания специального модуля для таких манипуляций. Аналогично, автоматическое построение мониторинговых панелей в популярных сервисах типа связки Prometheus+Grafana гораздо проще организовать по path, чем вычленять из данных запроса какой-то синтетический ключ группировки запросов.
|
||||
|
||||
Использовав такое решение, мы автоматически получаем ещё один приятный бонус: эти же данные пригодятся нам, если пользователь попытается создать новый заказ. Допустим, пользователь выполняет запрос вида:
|
||||
Таким образом, мы не настаиваем на этом решении [организации доступа к заказам пользователя через манипуляцию path в URL с передачей идентификатора пользователя в виде query-параметра] как на единственно верном, хотя он в большинстве случаев упрощает и организацию гейтвеев и мониторингов: в первую очередь нам важно, чтобы URL был ключом кэширования и идемпотентности, а для этого подходит любой формат, лишь бы он задавал однозначное соответствие URL списку заказов конкретного пользователя.
|
||||
|
||||
Использовав такое решение [с формированием URL как ключа кэширования и идемпотентности], мы автоматически получаем ещё один приятный бонус: эти же данные пригодятся нам, если пользователь попытается создать новый заказ. Допустим, пользователь выполняет запрос вида:
|
||||
|
||||
```
|
||||
POST /v1/orders HTTP/1.1
|
||||
If-Match: w/<ревизия>
|
||||
If-Match: <ревизия>
|
||||
```
|
||||
|
||||
Гейтвей D подставляет в запрос идентификатор пользователя и формирует запрос к сервису B:
|
||||
|
||||
```
|
||||
POST /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
If-Match: w/<ревизия>
|
||||
If-Match: <ревизия>
|
||||
```
|
||||
|
||||
Если ревизия правильная, гейтвей D может сразу же получить в ответе сервиса B:
|
||||
@ -124,7 +136,7 @@ If-Match: w/<ревизия>
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Location: /v1/orders?user_id<user_id>
|
||||
ETag: w/<новая ревизия>
|
||||
ETag: <новая ревизия>
|
||||
|
||||
{ /* обновлённый список текущих заказов */ }
|
||||
```
|
||||
@ -133,7 +145,7 @@ ETag: w/<новая ревизия>
|
||||
|
||||
**Важно**: обратите внимание на то, что, после всех преобразований, мы получили систему, в которой мы можем *убрать гейтвей D* и возложить его функции непосредственно на клиентский код. В самом деле, ничто не мешает клиенту:
|
||||
* хранить на своей стороне `user_id` (либо извлекать его из токена, если формат позволяет) и последний полученный ETag состояния списка заказов;
|
||||
* вместо одного запроса `GET /v1/state` сделать два запроса (`GET /v1/profiles/<user_id>` и `GET /v1/orders?user_id=<user_id>`), благо протокол HTTP/2 поддерживает мультиплексирование запросов по одному соединению;
|
||||
* вместо одного запроса `GET /v1/state` сделать два запроса (`GET /v1/profiles/{user_id}` и `GET /v1/orders?user_id=<user_id>`), благо протокол HTTP/2 поддерживает мультиплексирование запросов по одному соединению;
|
||||
* поддерживать на своей стороне кэширование результатов обоих запросов.
|
||||
|
||||
С точки зрения реализации сервисов B и C наличие или отсутствие гейтвея перед ними ни на что не влияет (особенно если мы используем stateless-токены). Мы также можем добавить и второй гейтвей в цепочку, если, скажем, мы захотим разделить хранение заказов на «горячее» и «холодное» хранилища.
|
@ -1,11 +1,11 @@
|
||||
### Организация клиентских ошибок в HTTP API
|
||||
### Работа с ошибками в HTTP API
|
||||
|
||||
Рассмотренный в предыдущей главе пример организации API согласно стандарту HTTP и принципам REST покрывает т.н. «happy path», т.е. стандартный процесс работы с API в отсутствие ошибок. Конечно, более интересен обратный кейс — каким образом в таком HTTP API следует работать с ошибками, и чем стандарт и архитектурные принципы могут нам в этом помочь. Пусть какой-то агент в системе (неважно, клиент или гейтвей) пытается создать новый заказ:
|
||||
|
||||
```
|
||||
POST /v1/orders?user_id=<user_id> HTTP/1.1
|
||||
Authorization: Bearer <token>
|
||||
If-Match: w/<ревизия>
|
||||
If-Match: <ревизия>
|
||||
|
||||
{ /* параметры заказа */ }
|
||||
```
|
||||
@ -39,6 +39,8 @@ If-Match: w/<ревизия>
|
||||
|
||||
Для определения, на чьей стороне произошла ошибка, используется первая цифра статус-кода: `4xx` — клиентские ошибки (за исключением состояния неопределённости, см. ниже), `5xx` — серверные.
|
||||
|
||||
#### Клиентские ошибки
|
||||
|
||||
**Ошибки `4xx`** повторять бессмысленно — если не предпринять дополнительных действий по изменению состояния сервиса, этот запрос не будет выполнен успешно никогда. Однако из этого правила есть исключения, самые важные из которых — `429 Too Many Requests` и `404 Not Found`. Последняя по стандарту имеет смысл «состояния неопределённости»: сервер имеет право использовать её, если не желает раскрывать причины ошибки. После получения ошибки `404`, можно сделать повторный запрос, и он вполне может отработать успешно. для индикации *персистентной* ошибки «ресурс не найден» используется отдельный статус `410 Gone`.
|
||||
|
||||
Более интересный вопрос — а что клиент может (или должен) сделать, получив такую ошибку. Как мы указывали в главе «Разграничение областей ответственности», если ошибка может быть исправлена программно, необходимо в машиночитаемом виде индицировать это клиенту; если ошибка не может быть исправлена, необходимо включить человекочитаемые сообщения для пользователя (фактически, вы можете предложить только два вида таких сообщений — «попробуйте повторить операцию позднее» и «попробуйте начать сначала / перезагрузить приложение») и для разработчика, который будет разбираться с проблемой.
|
||||
@ -107,3 +109,13 @@ X-OurApi-Error-Kind: wrong_parameter_value
|
||||
```
|
||||
|
||||
Также напомним, что любые неизвестные `4xx`-статус-коды клиент должен трактовать как ошибку `400 Bad Request`, следовательно, формат (мета)данных ошибки `400` должен быть максимально общим.
|
||||
|
||||
#### Серверные ошибки
|
||||
|
||||
**Ошибки `5xx`** индицируют, что клиент, со своей стороны, выполнил запрос правильно, и проблема заключается в сервере. Для клиента, по большому счёту, важно только то, имеет ли смысл повторять запрос и, если да, то через какое время. Если учесть, что в любых публично доступных API причиных серверных ошибок, как правило, не раскрывают — в абсолютном большинстве кодов `500 Internal Server Error` и `503 Service Unavailable` достаточно для индикации серверных ошибок (второй код указывает, что отказ в обслуживании имеет разовый характер и есть смысл автоматически повторить запрос), или можно вовсе ограничиться одним из них с опциональным заголовком `Retry-After`.
|
||||
|
||||
Разумеется, серверные ошибки также должны содержать информацию для разработчика и для конечного пользователя системы с описанием действий, которые необходимо выполнить при получении ошибки, и вот здесь мы вступаем на очень скользкую территорию.
|
||||
|
||||
Современная практика реализации HTTP-клиентов такова, что безусловно повторяются только немодифицирующие (`GET`, `HEAD`, `OPTIONS`) запросы. В случае модифицирующих запросов *разработчик должен написать код*, который повторит запрос — и для этого разработчику нужно очень внимательно прочитать документацию к API.
|
||||
|
||||
*Теоретически* идемпотентные методы `PUT` и `DELETE` можно вызывать повторно. Практически, однако, ввиду того, что многие разработчики упускают требование идемпотентности этих методов, фреймворки работы с HTTP API по умолчанию перезапросов модифицирующих методов, как правило, не делают; перезапросов потенциально неидемпотентных `POST` и `PATCH` (а также частичного `PUT`) фреймворки не допускают почти никогда, и эту логику приходится реализовывать разработчикам (клиента или гейтвея). Однако сама эта возможность — автоматически повторять идемпотентные запросы в случае серверной / сетевой ошибки — очень большое преимущество, которое даёт нам следование парадигме HTTP API, поскольку в альтернативных парадигмах (RPC, в частности) промежуточный агент (например, гейтвей) обычно никак не может узнать, идемпотентна ли операция.
|
90
src/ru/drafts/05-Раздел IV. HTTP API и REST/07.md
Normal file
90
src/ru/drafts/05-Раздел IV. HTTP API и REST/07.md
Normal file
@ -0,0 +1,90 @@
|
||||
### CRUD
|
||||
|
||||
Одно из самых популярных приложений HTTP API — это реализация CRUD-интерфейсов. Акроним CRUD (**C**reate, **R**ead, **U**pdate, **D**elete) был популяризирован ещё в 1983 году Джеймсом Мартином, но с развитием HTTP API обрёл второе дыхание. Ключевая идея соответствия CRUD и HTTP заключается в том, что каждой из CRUD-операций соответствует один из глаголов HTTP:
|
||||
* операции создания — создание ресурса через метод `POST`;
|
||||
* операции чтения — возврат представления ресурса через метод `GET`;
|
||||
* операции редактирования — перезапись ресурса через метод `PUT` или редактирование через `PATCH`;
|
||||
* операции удаления — удаление ресурса через метод `DELETE`.
|
||||
|
||||
Фактически, подобное соответствие — это просто мнемоническое правило, позволяющее определить, какой глагол следует использовать к какой операции. Мы, однако, должны предостеречь читателя: глагол следует выбирать по его семантике согласно стандарту, а не по мнемоническим правилам. Может показаться, что, например, операцию удаления 3-го элемента списка нужно реализовать через `DELETE`:
|
||||
* `DELETE /v1/list/{list_id}/?position=3 HTTP 1.1`
|
||||
но, как мы помним, делать так категорически нельзя: во-первых, такой вызов неидемпотентен; во-вторых, нарушает требование консистентности `GET` и `DELETE`.
|
||||
|
||||
С точки зрения удобства разработки концепция выглядит очень удобной — каждому виду ресурсов соответствует свой URL, каждой операции — свой глагол. При пристальном рассмотрении, однако, оказывается, что отношение CRUD-операция / HTTP-глагол — очень упрощённое представление о манипуляции ресурсами, и, что самое неприятное, плохо расширяемое.
|
||||
|
||||
##### Создание
|
||||
|
||||
Начнём с операции создания ресурса. Как мы помним из предыдущих глав, операция создания в любой сколько-нибудь ответственной предметной области обязана быть идемпотентной и, очень желательно, ещё и позволять управлять параллелизмом. В рамках парадигмы HTTP API идемпотентное создание можно организовать одним из трёх способов:
|
||||
1. Через метод `POST` с передачей токена идемпотентности (им может выступать, в частности, ревизия ресурса):
|
||||
```
|
||||
POST /v1/orders/?user_id=<user_id> HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
|
||||
{ … }
|
||||
```
|
||||
|
||||
2. Через метод `PUT`, предполагая, что идентификатор заказа сгенерирован клиентом (ревизия при этом всё ещё может использоваться для управления параллелизмом, но токеном идемпотентности является сам URL):
|
||||
```
|
||||
PUT /v1/orders/{order_id} HTTP/1.1
|
||||
If-Match: <ревизия>
|
||||
|
||||
{ … }
|
||||
```
|
||||
|
||||
3. Через схему создания черновика методом `POST` и его подтверждения методом `PUT`:
|
||||
```
|
||||
POST /v1/drafts HTTP/1.1
|
||||
|
||||
{ … }
|
||||
→
|
||||
HTTP/1.1 201 Created
|
||||
Location: /v1/drafts/{id}
|
||||
```
|
||||
```
|
||||
PUT /v1/drafts/{id}/status
|
||||
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 и пять операций:
|
||||
|
||||
1. URL создания черновика(`POST /v1/drafts`), который дополнительно потребует существования URL самого черновика и/или списка черновиков пользователя (`GET /v1/drafts/?user_id=<user_id>` или что-то аналогичное).
|
||||
2. URL подтверждения черновика (`PUT /v1/drafts/{id}/status`) и симметричную операцию чтения статуса черновика (через которую клиент должен будет получать актуальную ревизию для подтверждения черновика).
|
||||
3. URL заказа (`GET /v1/orders/{id}`).
|
||||
|
||||
##### Чтение
|
||||
|
||||
Идём дальше. Операция чтения на первый взгляд не вызывает сомнений:
|
||||
* `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 более сложных структур, чем пары ключ-значение, не существует.
|
||||
|
||||
Довольно скоро мы придём к тому, что, наряду с доступом по идентификатору заказа потребуется ещё и, во-первых, способ строго перебрать все заказы и способ искать по нестрогому совпадению.
|
||||
|
||||
Кроме того, если к заказу можно прикладывать какие-то медиа-данные (скажем, фотографии), то для доступа к ним придётся разработать отдельные URL:
|
||||
* `GET /v1/orders/{order_id}/attachements/{id}`
|
||||
|
||||
##### Редактирование
|
||||
|
||||
Вопросы частичного редактирования мы подробно разбирали в соответствующей главе раздела «Паттерны API». Идея полной перезаписи ресурса методом `PUT` быстро разбивается о необходимость работать с вычисляемыми и неизменяемыми полями, необходимость совместного редактирования и/или большой объём передаваемых данных. Работа через метод `PATCH` возможна, но, так как этот метод по умолчанию считается неидемпотентным, для него справедливо всё то же соображение об опасности автоматических перезапросов. Достаточно быстро мы придём к одному из двух вариантов:
|
||||
* либо `PUT` декомпозирован на множество составных `PUT /v1/orders/{id}/address`, `PUT /v1/orders/{id}/volume` и т.д. — по ресурсу для каждой частной операции;
|
||||
* либо существует отдельный ресурс, принимающий список изменений, причём, вероятнее всего, через схему черновик-подтверждение.
|
||||
|
||||
Если к сущности прилагаются медиаданные, для их редактирования также придётся разработать отдельные эндпойнты.
|
||||
|
||||
##### Удаление
|
||||
|
||||
С удалением ситуация проще всего: никакие данные в современных сервисах не удаляются моментально, а лишь архивируются или помечаются удалёнными. Таким образом, вместо `DELETE /v1/orders/{id}` необходимо разработать эндпойнт типа `PUT /v1/orders/{id}/archive` или `PUT /v1/archive?order=<order_id>`.
|
||||
|
||||
Таким образом, идея CRUD как способ минимальным набором операций описать типовые действия над ресурсом в при столкновении с реальностью быстро эволюционирует в сторону семейства эндпойнтов, каждый из которых описывает отдельный аспект взаимодействия с сущностью в течение её жизненного цикла. Изложенные выше соображения следует считать не критикой концепции CRUD как таковой, а скорее призывом не лениться и разрабатывать номенклатуру ресурсов и операций над ними исходя из конкретной предметной области, а не абстрактных мнемонических правил, к которым является эта концепция.
|
1
src/ru/drafts/05-Раздел IV. HTTP API и REST/08.md
Normal file
1
src/ru/drafts/05-Раздел IV. HTTP API и REST/08.md
Normal file
@ -0,0 +1 @@
|
||||
### Общие рекомендации
|
Loading…
x
Reference in New Issue
Block a user