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

HTTP architecture

This commit is contained in:
Sergey Konstantinov
2023-02-17 01:01:49 +02:00
parent a468c0db28
commit 1af865a330
2 changed files with 87 additions and 16 deletions

View File

@ -7,7 +7,7 @@
HTTP-запрос представляет собой применение определённого глагола к URL с указанием версии протокола и передачей дополнительной мета-информации в заголовках и, возможно, каких-то данных в теле запроса:
```
POST /v1/orders HTTP/2.0
POST /v1/orders HTTP/2
Host: our-api-host.tld
Content-Type: application/json
@ -102,7 +102,7 @@ HTTP-глагол определяет два важных свойства HTTP
* промежуточные агенты могут ответить на такой запрос из кэша, если какая-то из директив кэширования отсутствует, либо, напротив, повторить запрос при получении сетевого таймаута;
* некоторые агенты считают себя вправе переходить по таким ссылкам без явного волеизъявления пользователя или разработчика; например, социальные сети и мессенджеры выполняют такие вызовы для генерации оформления ссылки, если пользователь пытается ей поделиться;
* размещение неидемпотентных операций за идемпотентными методами `PUT` / `DELETE`
* хотя промежуточные агенты редко автоматически повторяют модифицирующие запросы, тем не менее это легко может сделать используемый разработчиком клиента или сервера фреймворк
* хотя промежуточные агенты редко автоматически повторяют модифицирующие запросы, тем не менее это легко может сделать используемый разработчиком клиента или сервера фреймворк;
* обычно эта ошибка сочетается с наличием у запроса тела (чтобы всё-таки отличать, что конкретно нужно перезаписать или удалить), что является само по себе проблемой, так как любой сетевой агент вправе это тело проигнорировать;
* несоблюдение требования симметричности операций `GET` / `PUT` / `DELETE` (т.е., например, после выполнения `DELETE /url` операция `GET /url` продолжает возвращать какие-то данные).

View File

@ -1,18 +1,18 @@
### Принципы организации HTTP API
Перейдём теперь к конкретике: как разрабатывать HTTP API так, чтобы извлекать выгоду из следования протоколу. Представим себе обычную операцию получения последних заказов пользователя. Например, мы можем поступить следующим образом:
Перейдём теперь к конкретике: как разрабатывать HTTP API так, чтобы извлекать выгоду из следования протоколу. Представим себе, например, процедуру старта приложения. Как правило, на старте требуется, используя сохранённый токен аутентификации, получить профиль текущего пользователя и важную информацию о нём (в нашем случае — текущие заказы). Мы можем достаточно очевидным образом предложить для этого эндпойнт:
```
GET /orders
GET /v1/state
Authorization: Bearer <token>
```
Получив такой запрос, сервер проверит валидность токена, получит идентификатор пользователя `user_id`, обратится к базе данных и вернёт отсортированный по времени создания список заказов.
Получив такой запрос, сервер проверит валидность токена, получит идентификатор пользователя `user_id`, обратится к базе данных и вернёт профиль пользователя и список его заказов.
Пока вопросы производительности нас не волнуют, подобная схема прекрасно работает. Однако, с ростом количества пользователей, мы рано или поздно столкнёмся с тем, что подобная монолитная архитектура нам слишком дорого обходится, и принимаем решение декомпозировать единый бэкенд на четыре микросервиса:
* сервис A, проверяющий авторизационные токены;
* сервис B, хранящий текущие заказы пользователя;
* сервис C, хранящий завершённые заказы пользователя;
* сервис B, хранящий профили пользователей;
* сервис C, хранящий заказы пользователей;
* сервис-гейтвей D, который маршрутизирует запросы между другими микросервисами.
Таким образом, запрос будет проходить по следующему пути:
@ -20,26 +20,97 @@ Authorization: Bearer <token>
* сервисы B и C обратятся к сервису A, проверят токен, и вернут данные по запросу;
* сервис D скомбинирует ответы сервисов B и C и вернёт их пользователю.
Нетрудно заметить, что мы тем самым создаём нагрузку на сервис A: теперь к нему обращается каждый из вложенных микросервисов, поскольку самостоятельно выяснить идентификатор пользователя они не могут. Каким образом мы можем решить проблему? Очевидно, сделав так, чтобы `user_id` уже был включён в запрос:
Нетрудно заметить, что мы тем самым создаём нагрузку на сервис A: теперь к нему обращается каждый из вложенных микросервисов; даже если мы откажемся от аутентификации пользователей в конечных сервисах, оставив её только в сервисе D, проблему это не решит, поскольку сервисы B и C самостоятельно выяснить идентификатор пользователя они не могут. Очевидный способ избавиться от лишних запросов — сделать так, чтобы однажды полученный `user_id` передавался остальным сервисам по цепочке:
* гейтвей D получает запрос и через сервис A меняет токен на `user_id`
* гейтвей D обращается к сервису B
```
GET /ongoing-orders
GET /v1/profiles
X-OurApi-User-Id: <user id>
```
и к сервису C
```
GET /finished-orders
GET /v1/orders
X-OurApi-User-Id: <user id>
```
**NB**: альтернативно мы могли бы закодировать имя пользователя в самом токене согласно, например, [стандарту JWT](https://www.rfc-editor.org/rfc/rfc7519).
**NB**: альтернативно мы могли бы закодировать имя пользователя в самом токене согласно, например, [стандарту JWT](https://www.rfc-editor.org/rfc/rfc7519) — для данного кейса это неважно, поскольку `user_id` всё равно остаётся частью HTTP-заголовка.
Теперь сервисы B и C получают запрос в таком виде, что им не требуется выполнение дополнительных действий (идентификация пользователя через сервис А) для получения результата. Тем самым мы переформулировали запрос так, что он сформулирован в терминах предметной области сервиса (в данном случае — сервис истории заказов естественным образом хранит идентификатор пользователя как неизменяемый параметр заказа), а не клиента, осуществляющего вызов. Этот принцип скрывается под буквой S в аббревиатура REST: «stateless».
Теперь сервисы B и C получают запрос в таком виде, что им не требуется выполнение дополнительных действий (идентификации пользователя через сервис А) для получения результата. Тем самым мы переформулировали запрос так, что он не требует от (микро)сервиса обращаться за данными за пределами его области ответственности. Этот принцип скрывается под буквой S в аббревиатуре REST: «stateless».
Пойдём теперь чуть дальше и подметим, что список завершённых заказов пользователя меняется очень редко. Нет никакой нужды каждый раз получать его заново — мы могли бы закэшировать его на стороне гейтвея D. Для этого нам нужно сформировать ключ кэша, которым фактически является идентификатор клиента. Мы можем пойти длинным путём:
* перед обращением в сервис C составить ключ и обратиться к кэшу;
* если данные имеются в кэше, ответить клиенту из кэша; иначе обратиться к сервису C и сохранить полученные данные в кэш.
Пойдём теперь чуть дальше и подметим, что профиль пользователя меняется достаточно редко, и нет никакой нужды каждый раз получать его заново — мы могли бы закэшировать его на стороне гейтвея D. Для этого нам нужно сформировать ключ кэша, которым фактически является идентификатор клиента. Мы можем пойти длинным путём:
* перед обращением в сервис B составить ключ и обратиться к кэшу;
* если данные имеются в кэше, ответить клиенту из кэша; иначе обратиться к сервису B и сохранить полученные данные в кэш.
А можем срезать пару углов: если мы добавим идентификатор пользователя непосредственно в запрос, то можем положиться на HTTP-кэширование, которое наверняка или реализовано в нашем фреймворке, или добавляется в качестве плагина за пять минут. Тогда гейтвей D, получив запрос `GET /orders`, обратится к ресурсу `service-c.tld/<user-id>/finished-orders` и получит данные либо из кэша, либо непосредственно из сервиса.
А можем срезать пару углов: если мы добавим идентификатор пользователя непосредственно в запрос, то можем положиться на HTTP-кэширование, которое наверняка или реализовано в нашем фреймворке, или добавляется в качестве плагина за пять минут. Тогда гейтвей D обратится к ресурсу `service-c.tld/profiles/<user-id>` и получит данные либо из кэша, либо непосредственно из сервиса.
Теперь рассмотрим сервис C. Результат его работы мы тоже могли бы кэшировать, однако состояние текущего заказа меняется гораздо чаще, чем список завершённых заказов. Вспомним, однако, описанное нами в разделе «Паттерны API» оптимистичное управление параллелизмом: для корректной работы сервиса нам нужна ревизия состояния ресурса, и ничто не мешает нам воспользоваться этой ревизией как ключом кэша. Пусть сервис С возвращает нам токен, соответствующий текущему состоянию заказов пользователя:
```
GET /v1/orders?user_id=<user_id>
200 Ok
ETag: w/<ревизия>
```
И тогда гейтвей D при выполнении запроса может:
1. Закэшировать результат выполнения `GET /v1/orders?user_id=<user_id>`, использовав URL как ключ кэша
2. При получении повторного запроса:
* найти закэшированное состояние, если оно есть
* отправить запрос к сервису B вида
```
GET /v1/orders?user_id=<user_id>
If-None-Match: w/<ревизия>
```
* если сервис B отвечает статусом 304 Not Modified, вернуть данные из кэша
* если сервис B отвечает новой версией данных, сохранить её в кэш и вернуть
**NB**: мы использовали нотацию `/v1/orders?user_id`, а не, допустим, `/v1/users/<user_id>/orders` по двум причинам:
* сервис текущих заказов хранит заказы, а не пользователи — логично если URL будет это отражать;
* если нам потребуется в будущем позволить нескольким пользователям делать общий заказ, нотация `/v1/orders?user_id` будет лучше отражать отношения между сущностями (напомним, путь традиционно используется для индикации строгой иерархии).
Впрочем, мы не настаиваем на этом решении как на единственно верном: в первую нам важно, чтобы URL был ключом кэширования и идемпотентности, а для этого подходят обе нотации.
Использовав такое решение, мы автоматически получаем ещё один приятный бонус: эти же данные пригодятся нам, если пользователь попытается создать новый заказ. Допустим, пользователь выполняет запрос вида:
```
POST /v1/orders
If-Match: w/<ревизия>
```
Гейтвей D подставляет в запрос идентификатор пользователя и формирует запрос к сервису B:
```
POST /v1/orders?user_id=<user_id>
If-Match: w/<ревизия>
```
Если ревизия правильная, гейтвей D может сразу же получить в ответе сервиса B:
```
200 OK
Content-Location: /v1/orders?user_id<user_id>
ETag: w/<новая ревизия>
{ /* обновлённый список текущих заказов */ }
```
и обновить кэш в соответствии с новыми данными.
**Важно**: обратите внимание на то, что, после всех преобразований, мы получили систему, в которой мы можем *убрать гейтвей D* и возложить его функции непосредственно на клиентский код. В самом деле, ничто не мешает клиенту:
* хранить на своей стороне `user_id` (либо извлекать его из токена, если формат позволяет) и последний полученный ETag состояния списка заказов;
* вместо одного запроса `GET /v1/state` сделать два запроса (`GET /v1/profiles/<user_id>` и `GET /v1/orders?user_id=<user_id>`), благо протокол HTTP/2 поддерживает мультиплексирование запросов по одному соединению;
* поддерживать на своей стороне кэширование результатов обоих запросов.
С точки зрения реализации сервисов B и C наличие или отсутствие гейтвея перед ними ни на что не влияет (особенно если мы используем stateless-токены). Мы также можем добавить и второй гейтвей в цепочку, если, скажем, мы захотим разделить хранение заказов на «горячее» и «холодное» хранилища.
Если мы теперь обратимся к описанию REST как он дан в диссертации Филдинга, мы обнаружим, что мы построили систему, полностью соответствующую требованиям REST:
* запросы к сервисам уже несут в себе все данные, которые необходимы для выполнения запроса;
* интерфейс взаимодействия настолько унифицирован, что мы можем передавать функции гейтвея клиенту и обратно;
* политика кэширования каждого вида данных размечена.
Важнейшее качество, которое следование семантике HTTP придаёт нашему сервису — это унификация кода различных агентов системы. Клиент и гейтвей почти полностью идентичны и взаимозаменяемы, что позволяет понятным и предсказуемым образом наращивать номенклатуру сервисов и горизонтально, и вертикально.
**NB**: повторимся, что мы можем добиться того же самого, использовав RPC-протоколы или разработав свой формат описания статуса операции, параметров кэширования, версионирования ресурсов, приписывания и чтения метаданных и параметров операции, а также реализовав гейтвей D поверх RPC-протокола с чтением полного тела запросов и ответов и интерпретацией всех придуманных нами (или вендором RPC-протокола) форматов данных. Но автор этой книги позволит себе, во-первых, высказать некоторые сомнения в качестве получившегося решения, и, во-вторых, отметить огромное количество кода, которое придётся написать для реализации всего вышеперечисленного.