Это глава 37 раздела «HTTP API & REST» моей книги «API». Второе издание книги будет содержать три новых раздела: «Паттерны API», «HTTP API и REST», «SDK и UI‑библиотеки». Если эта работа была для вас полезна, пожалуйста, оцените книгу на GitHub, Amazon или GoodReads. English version on Substack.

Перейдём теперь к конкретике: что конкретно означает «следовать семантике протокола» и «разрабатывать приложение в соответствии с архитектурным стилем REST». Напомним, речь идёт о следующих принципах:

  • операции должны быть stateless;

  • данные должны размечаться как кэшируемые или некэшируемые;

  • интерфейсы взаимодействия между компонентами должны быть стандартизированы;

  • сетевые системы многослойны.

Эти принципы мы должны применить к протоколу HTTP, соблюдая дух и букву стандарта:

  • URL операции должен идентифицировать ресурс, к которому применяется действие, и быть ключом кэширования для GET и ключом идемпотентности — для PUT и DELETE;

  • HTTP‑глаголы должны использоваться в соответствии с их семантикой;

  • свойства операции (безопасность, кэшируемость, идемпотентность, а также симметрия GET / PUT / DELETE‑методов), заголовки запросов и ответов, статус‑коды ответов должны соответствовать спецификации.

NB: мы намеренно опускаем многие тонкости стандарта:

  • ключ кэширования фактически является составным [включает в себя заголовки запроса], если в ответе содержится заголовок Vary;

  • ключ идемпотентности также может быть составным, если в запросе содержится заголовок Range;

  • политика кэширования в отсутствие явных заголовков кэширования определяется не только глаголом, но и статус‑кодом и другими заголовками запроса и ответа, а также политиками платформы;

    — в целях сохранения размеров глав в рамках разумного касаться этих вопросов мы не будем, но стандарт всё‑таки рекомендуем внимательно прочитать.

Рассмотрим построение HTTP API на конкретном примере. Представим себе, например, процедуру старта приложения. Как правило, на старте требуется, используя сохранённый токен аутентификации, получить профиль текущего пользователя и важную информацию о нём (в нашем случае — текущие заказы). Мы можем достаточно очевидным образом предложить для этого эндпойнт:

GET /v1/state HTTP/1.1
Authorization: Bearer <token>
→
HTTP/1.1 200 OK
{ "profile", "orders" }

Получив такой запрос, сервер проверит валидность токена, получит идентификатор пользователя user_id, обратится к базе данных и вернёт профиль пользователя и список его заказов.

Подобный простой монолитный API‑сервис нарушает сразу несколько архитектурных принципов REST:

  • нет очевидного способа кэшировать ответ на клиенте (данные о заказе часто меняются и их нет смысла сохранять);

  • операция является stateful, т.к. сервер должен хранить токены в памяти, чтобы извлечь из них идентификатор клиента (к которому привязаны запрошенные данные);

  • система однослойна (и таким образом вопрос об унифицированном интерфейсе бессмыслен).

Пока вопросы масштабирования бэкенда нас не волнуют, подобная схема прекрасно работает. Однако, с ростом количества пользователей и функциональности сервиса (а также количества программистов, над ним работающим), мы рано или поздно столкнёмся с тем, что подобная монолитная архитектура нам слишком дорого обходится. Допустим, мы приняли решение декомпозировать единый бэкенд на четыре микросервиса:

  • сервис A, проверяющий авторизационные токены;

  • сервис B, хранящий профили пользователей;

  • сервис C, хранящий заказы пользователей;

  • сервис‑гейтвей D, который маршрутизирует запросы между другими микросервисами.

Таким образом, запрос будет проходить по следующему пути:

  • гейтвей D получит запрос и отправит его в сервисы B и C;

  • сервисы B и C обратятся к сервису A, проверят токен (переданный через проксирование заголовка Authorization или как явный параметр запроса), и вернут данные по запросу — профиль пользователя и список его заказов;

  • сервис D скомбинирует ответы сервисов B и C и вернёт их клиенту.

Исходная схема организации микросервисов.  Image Credit: CTL
Исходная схема организации микросервисов. Image Credit: CTL

Исходная схема организации микросервисов. Нажмите для увеличения

Нетрудно заметить, что мы тем самым создаём излишнюю нагрузку на сервис A: теперь к нему обращается каждый из вложенных микросервисов; даже если мы откажемся от аутентификации пользователей в конечных сервисах, оставив её только в сервисе D, проблему это не решит, поскольку сервисы B и C самостоятельно выяснить идентификатор пользователя не могут. Очевидный способ избавиться от лишних запросов — сделать так, чтобы однажды полученный user_id передавался остальным сервисам по цепочке:

  • гейтвей D получает запрос и через сервис A меняет токен на user_id

  • гейтвей D обращается к сервису B

    GET /v1/profiles/{user_id}

    и к сервису C

    GET /v1/orders?user_id=<user id>
Шаг 1. Явные идентификаторы пользователей.  Image Credit: CTL
Шаг 1. Явные идентификаторы пользователей

NB: мы использовали нотацию /v1/orders?user_id, а не, допустим, /v1/users/{user_id}/orders по двум причинам:

  • сервис текущих заказов хранит заказы, а не пользователей — логично если URL будет это отражать;

  • если нам потребуется в будущем позволить нескольким пользователям делать общий заказ, нотация /v1/orders?user_id будет лучше отражать отношения между сущностями.

    Более подробно о принципах формирования URL в HTTP API мы поговорим в следующей главе.

Теперь сервисы B и C получают запрос в таком виде, что им не требуется выполнение дополнительных действий (идентификации пользователя через сервис А) для получения результата. Тем самым мы переформулировали запрос так, что он не требует от (микро)сервиса обращаться за данными за пределами его области ответственности, добившись соответствия stateless‑принципу.

Отметим, что вопрос о разнице между stateless и stateful подходами, вообще говоря, не имеет простого ответа. Микросервис B сам по себе хранит состояние клиента (профиль пользователя) и, таким образом, является stateful с точки зрения буквы диссертации Филдинга. Тем не менее, мы скорее интуитивно соглашаемся с тем, что хранить данные по профилю пользователя и только проверять валидность токена — это более правильный подход, чем хранить те же данные плюс кэш токенов, из которого можно извлечь идентификатор пользователя. Фактически, мы говорим здесь о логическом принципе разделения уровней абстракции, который мы подробно обсуждали в соответствующей главе:

  • микросервисы разрабатываются так, чтобы иметь чётко очерченную зону ответственности и не хранить данные, относящиеся к другим уровням абстракции;

  • такие «внешние» данные являются лишь идентификаторами контекстов, и сам микросервис никак их не трактует;

  • если всё же какие‑то дополнительные операции с внешними данными требуется производить (например, проверять, авторизована ли запрашивающая сторона на выполнение операции), то следует организовать операцию так, чтобы свести её к проверке целостности переданных данных.

    В нашем примере мы могли бы избавиться от лишних запросов к сервису A иначе — начав использовать stateless‑токены, например, по стандарту JWT. Тогда сервисы B и C смогут сами раскодировать токен и извлечь идентификатор пользователя.

Пойдём теперь чуть дальше и подметим, что профиль пользователя меняется достаточно редко, и нет никакой нужды каждый раз получать его заново — мы могли бы организовать кэш профилей на стороне гейтвея D. Для этого нам нужно сформировать ключ кэша, которым фактически является идентификатор клиента. Мы можем пойти длинным путём:

  • перед обращением в сервис B составить ключ и обратиться к кэшу;

  • если данные имеются в кэше, ответить клиенту из кэша; иначе обратиться к сервису B и сохранить полученные данные в кэш.

А можем просто положиться на HTTP‑кэширование, которое наверняка или реализовано в нашем фреймворке, или добавляется в качестве плагина за пять минут. Тогда гейтвей D обратится к ресурсу /v1/profiles/{user_id} в сервисе B, получит данные и заголовки с параметрами кэширования, и сохранит их локально.

Теперь рассмотрим сервис C. Результат его работы мы тоже могли бы кэшировать, однако состояние текущего заказа меняется гораздо чаще профиля пользователя, и возврат неверного состояния может приводить к крайне неприятным последствиям. Вспомним, однако, описанный нами в главе «Стратегии синхронизации» паттерн оптимистичного управления параллелизмом: для корректной работы сервиса нам нужна ревизия состояния ресурса, и ничто не мешает нам воспользоваться этой ревизией как ключом кэша. Пусть сервис С возвращает нам тэг, соответствующий текущему состоянию заказов пользователя:

GET /v1/orders?user_id=<user_id> HTTP/1.1
→
HTTP/1.1 200 OK
ETag: <ревизия>
…

И тогда гейтвей D при выполнении запроса может:

  1. Закэшировать результат выполнения GET /v1/orders?user_id=<user_id>, использовав URL как ключ кэша

  2. При получении повторного запроса:

    • найти закэшированное состояние, если оно есть;

    • отправить запрос к сервису C вида

      GET /v1/orders?user_id=<user_id> HTTP/1.1
      If-None-Match: <ревизия>
      
    • если сервис C отвечает статусом 304 Not Modified, вернуть данные из кэша;

    • если сервис C отвечает новой версией данных, сохранить её в кэш и вернуть обновленный результат клиенту.

Шаг 2. Добавление серверного кэширования.  Image Credit: CTL
Шаг 2. Добавление серверного кэширования

Использовав такое решение [функциональность управления кэшом через ETag ресурсов], мы автоматически получаем ещё один приятный бонус: эти же данные пригодятся нам, если пользователь попытается создать новый заказ. Если мы используем оптимистичное управление параллелизмом, то клиент должен передать в запросе актуальную ревизию ресурса orders:

POST /v1/orders HTTP/1.1
If-Match: <ревизия>

Гейтвей D подставляет в запрос идентификатор пользователя и формирует запрос к сервису C:

POST /v1/orders?user_id=<user_id> HTTP/1.1
If-Match: <ревизия>

Если ревизия правильная, гейтвей D может сразу же получить в ответе сервиса C обновлённый список заказов и его ревизию:

HTTP/1.1 201 Created
Content-Location: /v1/orders?user_id=<user_id>
ETag: <новая ревизия>
{ /* обновлённый список текущих заказов */ }

и обновить кэш в соответствии с новыми данными.

Создание нового заказа.  Image Credit: CTL
Создание нового заказа

Важно: обратите внимание на то, что, после всех преобразований, мы получили систему, в которой мы можем убрать гейтвей D и возложить его функции непосредственно на клиентский код. В самом деле, ничто не мешает клиенту:

  • хранить на своей стороне user_id (либо извлекать его из токена, если формат позволяет) и последний полученный ETag состояния списка заказов;

  • вместо одного запроса GET /v1/state сделать два запроса (GET /v1/profiles/{user_id} и GET /v1/orders?user_id=<user_id>), благо протокол HTTP/2 поддерживает мультиплексирование запросов по одному соединению;

  • поддерживать на своей стороне кэширование результатов обоих запросов с помощью стандартных библиотек и/или плагинов.

С точки зрения реализации сервисов B и C наличие или отсутствие гейтвея перед ними ни на что не влияет кроме механики авторизации запросов. Мы также можем добавить и второй гейтвей в цепочку, если, скажем, мы захотим разделить хранение заказов на «горячее» и «холодное» хранилища, или заставить какой‑то из сервисов B или C работать в качестве гейтвея.

Если мы теперь обратимся к началу главы, мы обнаружим, что мы построили систему, полностью соответствующую требованиям REST:

  • запросы к сервисам уже несут в себе все данные, которые необходимы для выполнения запроса;

  • интерфейс взаимодействия настолько унифицирован, что мы можем передавать функции гейтвея клиенту или другому промежуточному агенту;

  • политика кэширования каждого вида данных размечена.

Повторимся, что мы можем добиться того же самого, использовав RPC‑протоколы или разработав свой формат описания статуса операции, параметров кэширования, версионирования ресурсов, приписывания и чтения метаданных и параметров операции. Но автор этой книги позволит себе, во‑первых, высказать некоторые сомнения в качестве получившегося решения, и, во‑вторых, отметить значительное количество кода, которое придётся написать для реализации всего вышеперечисленного.

Авторизация stateless-запросов

Рассмотрим подробнее подход, в котором авторизационного сервиса A фактически нет (точнее, он имплементируется как библиотека или локальный демон в составе сервисов B, C и D), и все необходимые данные зашифрованы в самом токене авторизации. Тогда каждый сервис должен выполнять следующие действия:

  1. Получить запрос вида

    GET /v1/profiles/{user_id}
    Authorization: Bearer <token>
  2. Расшифровать токен и получить вложенные данные, например, в следующем виде:

    {
      // Идентификатор пользователя-
      // владельца токена
      "user_id",
      // Таймстемп создания токена
      "iat"
    }
  3. Проверить, что указанные в данных токена права доступа соответствуют параметрам операции — в данном случае сравнить user_id, переданный как query‑параметр, и user_id, содержащийся в токене — и вынести решение о (не)допустимости операции.

Требование передавать user_id дважды и потом сравнивать две копии друг с другом может показаться нелогичным и избыточным. Однако это мнение ошибочно, и проистекает из широко распространённого (анти)паттерна, с описания которого мы начали главу, а именно — stateful‑определение параметров операции:

GET /v1/profile
Authorization: Bearer <token>

Такой эндпойнт фактически выполняет все три операции контроля доступа:

  • аутентифицирует пользователя путём поиска токена в кэше токенов;

  • идентифицирует пользователя путём извлечения связанного с токеном идентификатора;

  • авторизует операцию, дополнив её параметры и неявно предполагая, что пользователь всегда имеет доступ к своим собственным данным.

Проблема с таким подходом заключается в том, что разделить эти операции не представляется возможным. Вспомним описанные нами в главе «Аутентификация партнёров и авторизация вызовов API» варианты авторизации вызовов API: в любой достаточно сложной системе нам придётся разрешать пользователю X выполнять действия от имени пользователя Y — например, если мы продаем функциональность заказа кофе как B2B API, и директор компании‑партнёра желает лично или программно контролировать заказы, сделанные сотрудниками компании.

В случае «тройственного» эндпойнта проверки доступа мы можем только разработать новый эндпойнт с новым интерфейсом. В случае stateless‑токенов мы можем поступить так:

  1. Зашифровать в токене список пользователей, доступ к которым возможен через предъявление настоящего токена:

    {
      // Идентификаторы пользователей,
      // доступ к профилям которых
      // разрешён с настоящим токеном
      "user_ids",
      // Таймстемп создания токена
      "iat"
    }
  2. Изменить проверку авторизации (=внести изменения в код локального SDK или демона) так, чтобы она разрешала выполнение операции, если user_id в query‑параметре содержится в списке user_ids токена.

    Этот подход можно в дальнейшем усложнять: добавлять гранулярные разрешения выполнять конкретные операции, вводить уровни доступа, проверку прав в реальном времени через дополнительный вызов ACL‑сервиса и так далее.

Важно, что кажущаяся избыточность перестала быть таковой: user_id в запросе теперь не дублируется в данных токена; эти идентификаторы имеют разный смысл: над каким ресурсом исполняется операция и кто исполняет операцию. Совпадение этих двух сущностей — пусть частотный, но всё же частный случай. Что, к сожалению, не отменяет его неочевидности и возможности легко забыть выполнить проверку в коде. Таков путь.

Комментарии (18)


  1. nin-jin
    16.06.2023 10:20

    Вместо псевдостатики лучше было бы использовать HARP. Клиент делает запрос вида:

    ?user=jin=(name;order(position;cost))

    А гейтвей распаковывает его в запросы к микросервисам:

    ?user=jin=(name;order)
    ?order=12=(position;cost)
    ?order=34=(position;cost)
    ?order=56=(position;cost)

    Ну или клиент сам делает пакетные запросы к разным сервисам:

    ?user=jin=(name;order)
    ?order=12=34=56=(position;cost)

    Ну или так, если не хочется хранить в пользователе ссылки на заказы:

    ?user=jin=(name)
    ?order(participant=jin=;position;cost)


    1. forgotten Автор
      16.06.2023 10:20

      А чем лучше?


      1. nin-jin
        16.06.2023 10:20

        • Стандартизацией, не надо изобретать/изучать over9000 эндпоинтов и 100500 схем запросов/ответов.

        • Гибкостью, схема запросов легко расширяется под разные нужды.

        • Выборкой связанных ресурсов за 1 запрос вместо 1+n^k.

        • Получением лишь нужных полей, а не всех подряд.

        • Ну и другими плюшками типа фильтраций, сортировок, агрегаций и тд.


        1. forgotten Автор
          16.06.2023 10:20

          А какие недостатки этого подхода?


          1. nin-jin
            16.06.2023 10:20

            Основной недостаток - необычность подхода со всеми вытекающими.


            1. forgotten Автор
              16.06.2023 10:20
              +1

              Надо же, какая идеальная технология!

              Но я пожалуй подожду её более широкого внедрения. Вдруг там всё-таки есть недостатки.


              1. nin-jin
                16.06.2023 10:20

                Ну вот с таким подходим вы и дождались повсеместного внедрения GQL с кучей косяков на фундаментальном уровне.


                1. forgotten Автор
                  16.06.2023 10:20

                  С кучей известных косяков.


                  1. nin-jin
                    16.06.2023 10:20

                    Известных неисправимых косяков, да.


                    1. xmdy
                      16.06.2023 10:20

                      А можете поделиться списком этих известных неисправимых косяков или статьями на них?


                      1. nin-jin
                        16.06.2023 10:20

                        Там выше в статье всё есть.


        1. breninsul
          16.06.2023 10:20
          -1

          гусеничный велосипед.


  1. Senyaak
    16.06.2023 10:20

    Не могу понять, каким способом можно позволить клиенту отвечать за user_id ?


    1. forgotten Автор
      16.06.2023 10:20

      1. Возвращать user_id из эндпойнта регистрации / проверки логина и пароля [которого на схеме нет, но сделать его, очевидно, придётся]

      2. Требовать передачу user_id во все остальные эндпойнты [на самом деле, во все релевантные эндпойнты]


      1. Senyaak
        16.06.2023 10:20

        Всёравно придётся совершать проверку переданного user_id в сервисе D, получаются какието костыли и ненужная нагрузга логики всей архетектуры. Поэтому мне не понятно как можно позволить клиенту отвечать за user_id


        1. forgotten Автор
          16.06.2023 10:20

          Так же, как можно позволить клиенту отвечать за токен.


  1. LaRN
    16.06.2023 10:20

    • если сервис C отвечает статусом 304 Not Modified, вернуть данные из кэша;

    А в чем преимущество такого подхода?

    Ведь чтобы понять что ничего не поменялось, нужно дёрнуть сервис.

    Ну и проще отдать ответ сервиса, чем с кешем что-то делать.


    1. forgotten Автор
      16.06.2023 10:20

      Если профиль композитный, т.е. сервису B нужно самому выполнить несколько обращений для формирования ответа, то профит есть. Если там 20 байт JSON-а, то разница малозаметна, конечно.