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

Глава 15. О паттернах проектирования в контексте API

Концепция «паттернов» в области разработки программного обеспечения была введёна Кентом Бэком и Уордом Каннингемом в 1987 году, и популяризирован «бандой четырёх» (Эрих Гамма, Ричард Хелм, Ральф Джонсон и Джон Влиссидес) в их книге «Приёмы объектно‑ориентированного проектирования. Паттерны проектирования», изданной в 1994 году. Согласно общепринятому определению, паттерны программирования — «повторяемая архитектурная конструкция, представляющая собой решение проблемы проектирования в рамках некоторого часто возникающего контекста».

Если мы говорим об API, особенно если конечным потребителем этих API является разработчик (интерфейсы фреймворков, операционных систем), классические паттерны проектирования вполне к ним применимы. И действительно, многие из описанных в предыдущем разделе примеров представляют собой применение того или иного паттерна.

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

В рамках этого раздела мы попытаемся описать те задачи проектирования API, которые представляются нам наиболее важными. Мы не претендуем здесь на то, чтобы охватить все проблемы и тем более — все решения, и скорее фокусируемся на описании подходов к решению типовых задач с их достоинствами и недостатками. Мы понимаем, что читатель, знакомый с классическими трудами «банды четырёх», Гради Буча и Мартина Фаулера ожидает от раздела с названием «Паттерны API» большей системности и ширины охвата, и заранее просим у него прощения.

NB: первый паттерн, о котором необходимо упомянуть — это API‑first подход к разработке ПО, который мы описали в соответствующей главе.

Принципы решения типовых проблем проектирования API

Прежде, чем излагать сами паттерны, нам нужно понять, чем же разработка API отличается от разработки обычных приложений. Ниже мы сформулируем три важных принципа, на которые будем ссылаться в последующих главах.

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

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

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

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

    • для операций с разделяемыми ресурсами необходимо предусматривать механизмы блокировки ресурса.

Глава 16. Аутентификация партнёров и авторизация вызовов API 

Прежде, чем мы перейдём к обсуждению технических проблем и их решений, мы не можем не остановиться на важном вопросе авторизации вызовов API и аутентификации осуществляющих вызов клиентов. Исходя из всё того же принципа мультипликатора («API умножает как возможности, так и проблемы») организация авторизации и аутентификации (AA) — одна из самых насущных проблем провайдера API, особенно публичного. Тем удивительнее тот факт, что в настоящий момент не существует стандартного подхода к ней — почти каждый крупный сервис разрабатывает какой‑то свой интерфейс для решения этих задач, причём зачастую достаточно архаичный.

Если отвлечься от технических деталей имплементации (в отношении которых мы ещё раз настоятельно рекомендуем не изобретать велосипед и использовать стандартные подходы и протоколы безопасности), то, по большому счёту, есть два основных способа авторизовать выполнение некоторой операции через API:

  • завести в системе специальный тип аккаунта «робот» и выполнять операции от имени робота;

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

Разница между двумя подходами заключается в гранулярности доступа:

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

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

Первый подход, таким образом, является более гранулярным (робот может быть «виртуальным сотрудником» организации, то есть иметь доступ только к ограниченному набору данных) и вообще является естественным выбором для тех API, которые являются дополнением к существующему сервису для конечных пользователей (и, таким образом, иогут использовать уже существующие системы AA). Недостатками же этого подхода являются:

  1. Необходимо организовать какой‑то процесс безопасного получения токенов авторизации для пользователя‑робота (например, через получение для него токенов реальным пользователем из веб‑интерфейса), поскольку стандартная логин‑парольная схема логина (тем более двухфакторная) слаба применима к клиенту API.

  2. Необходимо сделать для пользователей‑роботов исключения из почти всех систем безопасности: * роботы выполняют намного больше запросов, чем обычные люди, и могут делать это в параллель (в том числе с разных IP‑адресов, расположенных в разных дата‑центрах); * роботы не принимают куки и не могут решить капчу; * робота нельзя профилактически разлогинить и/или инвалидировать его токен (это чревато простоем бизнеса партнёра), поэтому для роботов часто приходится изобретать токены с большим временем жизни и/или процедуру «подновления» токена.

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

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

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

Глава 17. Стратегии синхронизации 

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

  1. Клиент отправляет запрос на создание нового заказа.

  2. Из‑за сетевых проблем запрос идёт до сервера очень долго, а клиент получает таймаут: * клиент, таким образом, не знает, был ли выполнен запрос или нет.

  3. Клиент запрашивает текущее состояние системы и получает пустой ответ, поскольку таймаут случился раньше, чем запрос на создание заказа дошёл до сервера:

    const pendingOrders = await 
      api.getOngoingOrders(); // → []
    
  4. Сервер, наконец, получает запрос на создание заказа и исполняет его.

  5. Клиент, не зная об этом, создаёт заказ повторно.

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

Существуют два основных подхода к решению этой проблемы — пессимистичный (программная реализация блокировок) и оптимистичный (версионирование ресурсов).

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

Программные блокировки

Первый подход — очевидным образом перенести стандартные примитивы синхронизации на уровень API. Например, вот так:

let lock;
try {
  // Захватываем право
  // на эксклюзивное исполнение
  // операции создания заказа
  lock = await api.
    acquireLock(ORDER_CREATION);
  // Получаем текущий список
  // заказов, известных системе
  const pendingOrders = await 
    api.getPendingOrders();
  // Если нашего заказа ещё нет,
  // создаём его
  if (pendingOrders.length == 0) {
    const order = await api
      .createOrder(…)
  }
} catch (e) {
  // Обработка ошибок
} finally {
  // Разблокировка
  await lock.release();
}

Достаточно очевидно, что подобного рода подход крайне редко реализуется в распределённых сетевых API, из‑за комплекса связанных проблем:

  1. Ожидание получения блокировки вносит во взаимодействие дополнительные плохо предсказуемые и, в худшем случае, весьма длительные задержки.

  2. Сама по себе блокировка — это ещё одна сущность, для работы с которой нужно иметь отдельную весьма производительную подсистему, поскольку для работы блокировок требуется ещё и обеспечить сильную консистентность в API: метод getPendingOrders должен вернуть актуальное состояние системы, иначе повторный заказ всё равно будет создан.

  3. Поскольку клиентская часть разрабатывается сторонними партнёрами, мы не можем гарантировать, что написанный ими код корректно работает с блокировками; неизбежно в системе появятся «висящие» блокировки, а, значит, придётся предоставлять партнёрам инструменты для отслеживания и отладки возникающих проблем.

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

Оптимистичное управление параллелизмом

Более щадящий с точки зрения сложности имплементации вариант — это реализовать оптимистичное управление параллелизмом и потребовать от клиента передавать признак того, что он располагает актуальным состоянием разделяемого ресурса.

// Получаем состояние
const orderState = 
  await api.getOrderState();
// Частью состояния является
// версия ресурса
const version = 
  orderState.latestVersion;
// Заказ можно создать,
// только если версия состояния
// не изменилась с момента чтения
try {
  const task = await api
    .createOrder(version, …);
} catch (e) {
  // Если версия неверна, т.е. состояние
  // было параллельно изменено
  // другим клиентом, произойдёт ошибка
  if (Type(e) == INCORRECT_VERSION) {
    // Которую нужно как-то обработать…
  }
}

NB: внимательный читатель может возразить нам, что необходимость имплементировать стратегии синхронизации и строгую консистентность никуда не пропала, т.к. где‑то в системе должен существовать компонент, осуществляющий блокирующее чтение версии с её последующим изменением. Это не совсем так: стратегии синхронизации и строгая консистентность пропали из публичного API. Расстояние между клиентом, устанавливающим блокировку, и сервером, её обрабатывающим, стало намного меньше, и всё взаимодействие теперь происходит в контролируемой среде (это вообще может быть одна подсистема, если мы используем ACID‑совместимую базу данных или вовсе держим состояние ресурса в оперативной памяти).

Вместо версий можно использовать дату последней модификации ресурса (что в целом гораздо менее надёжно ввиду неидеальной синхронизации часов в разных узлах системы; не забывайте, как минимум, сохранять дату с максимально доступной точностью!) либо идентификаторы сущности (ETag).

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

NB. Выбор ресурса, версию которого мы требуем передать для получения доступа, очень важен. Если в нашем примере мы заведём глобальную версию всей системы, которая изменяется при поступлении любого заказа, то, очевидно, у пользователя будут околонулевые шансы успешно разместить заказ.

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


  1. itmind
    26.04.2023 02:00

    Из главы 16 не понял как все таки аутентифицировать пользователей обращающихся к API.

    Например есть 1 млн пользователей и функция API которая возвращает каждому пользователю только его записи из одной общей таблицы БД.

    Нужно же все 1 млн пользователей зарегистрировать в системе. Потом каждому каким то образом выдавать токен, токен передавать в функцию API и там определять по токену записи в таблице и возвращать их.

    Так?


    1. forgotten Автор
      26.04.2023 02:00

      Если пользователи и так имеют доступ к системе под своим логином — то, грубо говоря, да. Разрешить пользователю на спец. странице получить токен для API [не обязательно хранить его в базе, можно и stateless], и с этим токеном они смогут делать автоматические запросы.