
Основная часть работы современных разработчиков ПО1 связана с API: публичными интерфейсами для общения с программой, например, API Twilio. Я потратил кучу времени на работу с API как их разработчик и пользователь. Я писал публичные API для сторонних разработчиков, приватные API для внутреннего использования (или для потребления одной страницей фронтенда), API REST и GraphQL и даже несетевые интерфейсы, например, для инструментов командной строки.
Думаю, большинство рекомендаций по проектированию API слишком уж уходит в тонкости. Разработчики отвлекаются на обсуждения того, что же такое «реальный» REST, правильно ли использовать HATEOAS и так далее. В этом посте я попытаюсь рассказать всё, что знаю о проектировании хороших API.
При проектировании API важен баланс между понятностью и гибкостью
Это справедливо в отношении систем и ещё более справедливо в отношении API: хорошие API скучны. Интересный API — это плохой API (или, по крайней мере, он был бы лучше, если бы был менее интересным). API для их разработчиков — это сложные продукты, на проектирование и совершенствование которых требуется много времени. Но для пользующихся ими это инструменты, необходимые для выполнения какой-то другой задачи. Всё время, которое они тратят на размышления об API вместо размышлений о задаче — это время, потраченное впустую. С их точки зрения, идеальный API должен быть им настолько знаком, что они более-менее сумеют им пользоваться, ещё не начав читать документацию2.
Однако API сильно отличаются от большинства программных систем тем, что API сложно менять. После публикации API им начинают пользоваться люди, и любые изменения в интерфейсе поломают ПО пользователей. Разумеется, вносить изменения возможно. Но (как я скажу ниже) каждое изменение накладывает серьёзные расходы: каждый раз, когда вы заставляете пользователей обновлять их ПО, они серьёзно задумываются о переходе на другой, более стабильный API. Это сильно стимулирует разработчиков API проектировать их тщательно и делать всё правильно с первого раза.
Такое давление создаёт у инженеров-разработчиков API интересную динамику. С одной стороны, они хотят создать максимально простой API. С другой стороны, они хотят применять умные решения, чтобы сохранять гибкость на далёкую перспективу. Если вкратце, то проектирование API — это поиск компромисса между этими двумя несовместимыми целями.
Мы не ломаем пользовательское пространство
Что происходит, когда нам нужно внести изменения в API? Аддитивные изменения, например, добавление в ответ нового поля, обычно вполне приемлемы. Некоторые потребители поломаются, если получат больше полей, чем ожидали, но, на мой взгляд, такое поведение безответственно. Следует ожидать от потребителей API, что они будут игнорировать неожиданные поля (разумные типизированные языки с парсингом JSON по умолчанию поступают именно так).
Однако нельзя удалять или менять типы полей. Нельзя менять структуру имеющихся полей (например, перемещать user.address
в user.details.address
в ответе JSON). Если вы так поступите, то каждый блок кода, зависящий от этих полей, немедленно поломается. Потребители этого кода сообщат об этом, как о баге, а мейнтейнеры кода с полным правом разгневаются на вас за то, что вы намеренно поломали их ПО.
Здесь следует применять принцип в стиле знаменитого слогана Линуса Торвальдса: МЫ НЕ ЛОМАЕМ ПОЛЬЗОВАТЕЛЬСКОЕ ПРОСТРАНСТВО. Если вы мейнтейнер API, то у вас есть своего рода священная обязанность: вы должны избегать нанесения ущерба потребителям вниз по потоку. Этот закон очень строг, потому что многие программы зависят от такого количества API (которые, в свою очередь, зависят от API вверх по потоку и так далее). Один беспечный мейнтейнер API достаточно высоко по потоку может поломать сотни или тысячи программ вниз по потоку ПО.
Никогда не стоит вносить изменения в API просто потому, что он будет красивее или потому, что он немного некрасивый. Известный пример: заголовок «referer» в спецификации HTTP — это слово «referrer» с опечаткой, но его не поменяли, потому что мы не ломаем пользовательское пространство.
Внесение изменений в API без поломки пользовательского пространства
Откровенно говоря, сложно придумать примеры того, когда API по-настоящему требуются ломающие изменения. Но иногда техническая ценность изменения так высока, что вы решаете рискнуть и всё равно внедрить его. Как в подобных случаях изменять API ответственным образом? Для этого необходима версионность.
Под версионностью API подразумевается, что мы будем обрабатывать и старую, и новую версии одновременно. Уже имеющиеся потребители смогут продолжать пользоваться старой версией, а новые потребители — решить выбрать новую. Проще всего это сделать, добавив в URL API что-то типа /v1/
. API чата OpenAI находится по адресу v1/chat/completions, поэтому если компания решит полностью переработать его структуру, она сможет сделать это в v2/chat/completions
, не поломав ничего у старых потребителей.
Как только старая и новая версии заработают одновременно, вы можете предлагать пользователям апгрейдиться до новой версии. Для этого понадобится много времени: месяцы или даже годы. Даже если у вас будут баннеры на веб-сайте, документация, вы разошлёте письма и добавите заголовки в ответ API, то когда, наконец, старая версия будет удалена, множество рассерженных пользователей всё равно будет жаловаться, что вы поломали их ПО. Но, по крайней мере, вы пытались с этим что-то сделать.
Версионность API можно реализовывать ещё кучей других способов. Stripe API реализует версионность в заголовке и позволяет аккаунтам задавать в UI их версию по умолчанию. Но принцип остаётся тем же — все потребители Stripe API могут быть уверенными в том, что Stripe не решил поломать их приложения и что они могут апгрейдить версии в удобном им темпе.
Мне не нравится версионность API. Я считаю, что это в лучшем случае необходимое зло, но тем не менее всё равно зло. Она запутывает пользователей, которым сложно искать документацию по API, не проверив, что селектор версии соответствует используемой ими версии. И это оказывается кошмаром для мейнтейнеров. Если у вас есть тридцать конечных точек API, то каждая новая версия добавляет тридцать новых конечных точек, которые нужно поддерживать. Вскоре у вас появляются сотни API, которые нужно тестировать и отлаживать, а также обеспечивать поддержку их пользователей.
Разумеется, добавление новой версии не увеличивает размер кодовой базы вдвое. У любого разумного бэкенда версионности API есть некий слой трансляции, превращающий ответ в одну из версий публичного API. Нечто подобное есть у Stripe: сама бизнес-логика одинакова для всех версий, поэтому версионность учитывается только при сериализации и десериализации параметров. Однако подобные абстракции всегда протекают. См. комментарий на Hacker News за 2017 год от сотрудника Stripe, в котором говорится, что некоторые изменения в версионности требуют добавления условной логики в «код ядра».
Подведём итог: следует использовать версионность API только в качестве крайней меры.
Успех вашего API полностью зависит от продукта
Сам по себе API ничего не делает. Это слой между пользователем и тем, что ему нужно на самом деле. В случае OpenAI API это способность создания инференса при помощи языковой модели. В случае Twilio API это отправка SMS-сообщений. Никто не пользуется API только потому, что сам API очень изящно спроектирован. Им пользуются, чтобы взаимодействовать с вашим продуктом. Если ваш продукт достаточно ценен, то пользователи перейдут даже на ужасный API.
Именно поэтому некоторые из самых популярных API столь ужасны. Facebook и Jira известны своими отвратительными API, но это не имеет значения — если вы хотите реализовать интеграцию с Facebook или Jira (а вы хотите), то вам придётся потратить время, чтобы разобраться в них. Да, было бы здорово, если бы у этих компаний имелся более качественный API. Но зачем вкладывать время и деньги, если пользователям всё равно нужна интеграция? Писать хорошие API очень сложно.
В оставшейся части поста я дам множество конкретных советов о том, как писать хорошие API. Однако стоит помнить, что чаще всего это не важно. Если ваш продукт желанен и популярен, то сойдёт и едва работающий API; если он нелюбим, то не поможет и хороший API. Качество API — это несущественная фича: она важна только тогда, когда пользователь выбирает между двумя, по сути, эквивалентными продуктами.
Однако вопрос наличия API — это совершенно иная история. Если у одного продукта совершенно нет API, то это серьёзная проблема. Технические пользователи будут требовать от вас реализации какого-нибудь способа интеграции через код с ПО, которое они покупают.
У плохо спроектированных продуктов обычно плохие API
Технически качественный API не спасёт продукт, которым никто не хочет пользоваться. Однако технически некачественный продукт делает практически невозможным создание красивого API. Причина этого в том, что обычно дизайн API основан на «базовых ресурсах» продукта (например, ресурсы Jira — это issue, проекты, пользователи и так далее). Когда эти ресурсы реализованы плохо, то некрасивым становится и API.
Например, рассмотрим платформу для блогинга, хранящую комментарии в памяти в виде связанного списка (у каждого комментария есть поле next
, указывающее на следующий комментарий в теме). Это ужасный способ хранения комментариев. Наивным решением для прикручивания к этой системе REST API будет примерно такой интерфейс:
GET /comments/1 -> { id: 1, body: "...", next_comment_id: 2 }
Или, хуже того, такой:
GET /comments -> {body: "...", next_comment: { body: "...", next_comment: {...}}}
Этот пример может показаться дурацким, потому что на практике мы бы просто итеративно обходили связанный список и возвращали в ответе API массив комментариев. Но даже если мы готовы будем проделать эту дополнительную работу, то насколько далеко мы будем выполнять итерации? В теме с тысячами комментариев не будет ли попросту невозможно получить любой комментарий после первых нескольких сотен? Будет ли ваш API получения комментариев вынужден использовать фоновую задачу, превращая интерфейс в нечто подобное?
POST /comments/fetch_job/1 -> { job_id: 589 } GET /comments_job/589 -> { status: 'complete', comments: [...] }
Вот так и создаются одни из худших API. Технические ограничения могут быть хитро спрятаны в UI и раскрыты в API, заставляя потребителей API гораздо глубже разбираться в архитектуре системы, чем это необходимо.
Аутентификация
Следует позволить людям пользоваться вашими API с долгоживущим ключом API. Да, ключи API не так безопасны, как различные виды учётных данных с коротким сроком жизни, например, OAuth (их, вероятно, вам тоже стоит поддерживать). Но это не важно. Любая интеграция с вашим API начинает свою жизнь как простой скрипт, а использование ключа API — простейший способ обеспечить работу простого скрипта. Следует как можно сильнее упрощать разработчикам начальное освоение API.
Хотя потребители API будут писать код, многие из ваших пользователей не будут профессиональными разработчиками. Это могут быть люди из отделов продаж, продакт-менеджеры, студенты, любители и так далее. Когда вы инженер в технологической компании, разрабатывающей API, легко представить, что он создаётся для людей, подобных вам: компетентных, профессиональных разработчиков ПО, работающих на полной ставке. На самом деле, это не так. Вы создаёте его для широкого среза людей, многие из которых испытывают трудности с чтением или написанием кода. Если из-за вашего API пользователям приходится делать что-то сложное, например, выполнять рукопожатие OAuth, то у многих из них возникнут сложности.
Идемпотентность и повторные попытки
При успешном выполнении запроса API вы знаете, что он пытался сделать. Но что, если он оказался сбойным? Некоторые типы сбоев сообщают о том, что произошло: 422 обычно означает, что сбой произошёл на этапе валидации запроса, ещё до того, как были предприняты какие-то действия3. Но как насчёт 500? Как насчёт таймаута?
Это актуально для тех операций API, которые выполняют действия. Если вы обращаетесь к какому-то Jira API для создания комментария по issue, а запрос возвращает 500 или завершается по таймауту, то следует ли пробовать отправлять его повторно? Вы не знаете точно, был ли создан комментарий, потому что ошибка может происходить после этой операции. Если вы повторите, то, возможно, запостите два комментария. И ещё важнее это тогда, когда на кону нечто большее, нежели комментарий в Jira. Что, если вы переводите деньги? Или выписываете лекарство?
Решением в этой ситуации будет идемпотентность, то есть возможность безопасного повторения запроса без создания дубликатов. Стандартом является поддержка «ключа идемпотентности» в запросе (допустим, некая определяемая пользователем строка в параметре или заголовке). Когда сервер получает запрос «создать комментарий» с ключом идемпотентности, он сначала проверяет, встречал ли такой ключ идемпотентности раньше. Если да, то он ничего не делает; в противном случае он создаёт комментарий, а затем сохраняет ключ идемпотентности. Благодаря этому пользователь может отправлять сколько угодно повторов: если все они имеют одинаковый ключ идемпотентности, операция будет выполнена только один раз.
Как следует хранить ключ? Я видел случаи, когда он хранился каким-нибудь надёжным, привязанным к ресурсу образом (например, в виде столбца в таблице comments
), но не думаю, что это строго необходимо. Проще всего сохранять его в Redis или какое-нибудь другое похожее хранилище ключей и значений (где ключом будет ключ идемпотентности). UUID достаточно уникальны для того, чтобы не ограничивать область их действия каждым пользователем, но можно поступать и так. Если вы не работаете с платежами, можно даже завершать срок их действия через несколько часов, потому что большинство повторных попыток происходит сразу же.
Нужны ли ключи идемпотентности для каждого запроса? Они не нужны для запросов чтения, потому что удвоенное чтение не нанесёт вреда. Также обычно4 они не нужны для запросов удаления, потому что если вы удаляете ID ресурса, этот ID служит в качестве ключа идемпотентности. Если отправить три запроса DELETE comments/32
подряд, то мы не удалим три комментария. Первый успешный запрос удалит комментарий с ID 32, а оставшиеся запросы вернут 404, потому что не смогут найти уже удалённый комментарий.
В большинстве случаев идемпотентность должна быть опциональной. Как говорилось выше, нужно обеспечивать понятность API для нетехнических пользователей (которым идемпотентность часто кажется сложной концепцией). В целом, привлечение большего количества людей к вашему API важнее, чем возникающие время от времени дубли комментариев от пользователей, не прочитавших документацию.
Безопасность и ограничение частоты запросов
Взаимодействующие с вашим UI пользователи ограничены скоростью своего ввода. Если какой-то поток затратен для вашего бэкенда, то злоумышленный или беспечный пользователь сможет запускать этот поток не быстрее скорости кликов. С API ситуация иная. Все раскрытые через API операции можно вызывать со скоростью кода.
Будьте аккуратны с API, выполняющими большой объём работы в одном запросе. Когда я работал в Zendesk, у нас был API, рассылавший уведомления всем пользователям определённого приложения. Один хитроумный сторонний разработчик5 воспользовался этим для создания системы чата внутри приложения: каждое сообщение отправляло уведомление всем остальным пользователям аккаунта. Когда на аккаунтах было достаточно много активных пользователей, этот хак стабильно убивал сервер бэкенда приложений.
Мы не предвидели, что кто-то создаст приложение для чатов поверх этого API. Но как только он стал публично доступен, люди могли делать с ним всё, что пожелают. Я разбирал множество инцидентов, первопричиной которых была какая-нибудь клиентская интеграция, делавшая глупые вещи, например:
Создание и удаление одних и тех же записей сотни раз в минуту без какой-либо пользы
Бесконечные опросы большой конечной точки
/index
без паузИмпорт или экспорт множества данных без прекращения в случае ошибок
Следует накладывать ограничения частоты запросов к API, и чем затратнее операции, тем строже должны быть ограничения. Разумно будет также обеспечить возможность временного отключения API для конкретных клиентов, чтобы снять нагрузку с бэкенд-системы в случае повышенного стресса.
Добавляйте метаданные ограничения частоты в ответы API. Заголовки X-Limit-Remaining
и Retry-After
дают клиентам информацию, необходимую для уважительного пользования API, и позволяют при необходимости усиливать ограничения частоты.
Пагинация
Почти каждый API должен обрабатывать большой список записей. Иногда это крайне длинный список (например, API /tickets
Zendesk может содержать миллионы тикетов). Как можно передавать эти записи?
Наивное решение с SELECT * FROM tickets WHERE...
забьёт всю доступную память (если данные находятся не в базе данных, то это произойдёт в слое приложения, где вы будете пытаться сериализовать список с миллионом элементов). Мы попросту не можем передавать все тикеты в одном запросе. Необходима пагинация.
Простейший способ реализации пагинации — применение страниц (или, если в более общем смысле, «смещений»). При обращении к /tickets
мы передаём аккаунту первые десять тикетов. Чтобы получить больше, нужно обратиться к /tickets?page=2
или к /tickets?offset=20
. Такую систему легко реализовать, потому что сервер просто может добавлять в конец запроса к базе данных OFFSET 20 LIMIT 10
. Но при очень большом количестве записей такая система плохо масштабируется. Реляционным базам данных придётся каждый раз подсчитывать смещение, поэтому каждая следующая передаваемая страница будет чуть медленнее предыдущей. К моменту, когда смещение доходит до сотен тысяч, это становится реальной проблемой.
Правильным решением будет «пагинация на основе курсоров». Вместо того, чтобы передавать offset=20
для получения второй страницы, мы берём последний тикет на первой странице (скажем, с ID 32) и передаём cursor=32
. Затем API возвращает следующие десять тикетов, начиная с тикета номер 32. В запросе не используется OFFSET
, он имеет вид WHERE id > cursor ORDER BY id LIMIT 10
. Такой запрос одинаково быстр, когда вы находитесь в начале списка или спустя сотни тысяч тикетов, потому что база данных может мгновенно находить позицию (индексированную) курсорного тикета вместо того, чтобы подсчитывать всё смещение.
Для баз данных, которые могут стать большими, всегда следует использовать пагинацию с курсорами. Эту концепцию потребителям понять сложнее, однако когда начинают возникать проблемы масштабирования, вы, вероятно, будете вынуждены перейти к пагинации с курсорами, а затраты на внесение таких изменений часто очень высоки. Однако в остальных случаях вполне приемлемо использовать пагинацию на основе страниц или смещений.
Обычно бывает правильно добавлять в ответы API со списками поле next_page
. Это позволяет потребителям не выяснять самостоятельно номер следующей страницы или курсора.
Опциональные поля и GraphQL
Если обработка некоторых частей ответа API затратна, сделайте их опциональными. Например, если для получения статуса подписки пользователя бэкенд должен выполнять вызов API, то можно сделать так, чтобы конечная точка /users/:id
не возвращала статус подписки, если запрос не передаёт параметр include_subscription
. В общем случае можно реализовать параметр-массив includes
со всеми опциональными полями. Подобное часто применяется для связанных записей (например, можно передавать includes: [posts]
на запрос пользователя, чтобы получать в ответе посты пользователя).
Это один из принципов, лежащих в основе GraphQL — стиль API, при котором вместо обращения к разным конечным точкам для каждой операции мы создаём единый запрос со всеми необходимыми данными, а бэкенд уже сам его обрабатывает6.
Мне не особо нравится GraphQL по трём причинам. Во-первых, он совершенно непонятен неинженерам (и многим инженерам). Если освоить его, он станет обычным инструментом, но барьер входа гораздо выше, чем GET /users/1
. Во-вторых, я не люблю давать пользователям свободу в создании произвольных запросов. Это усложняет кэширование и повышает количество пограничных случаев, которые вам придётся учитывать. В-третьих, по моему опыту, для реализации бэкенда требуется гораздо больше настроек, чем в случае стандартного REST API.
Я не испытываю сильного негатива по отношению к GraphQL. Я работал с ним в разных контекстах около полугода, так что ни в коем случае не специалист в нём. Уверен, что в некоторых случаях он обеспечивает достаточную гибкость, стоящую затрат. Но пока я бы использовал его только тогда, когда это абсолютно необходимо.
Внутренние API
Всё рассказанное выше относилось к публичным API. А что насчёт внутренних API, которые используются только коллегами в компании? Некоторые из приведённых мной допущений к внутренним API не относятся. Например, их потребителями обычно бывают профессиональные разработчики ПО. Кроме того, в них можно вносить ломающие изменения, потому что (а) часто пользователей на порядки меньше и (б) вы можете выпустить новый код для всех этих пользователей. Вы можете даже добавить при желании форму аутентификации.
Однако внутренние API всё равно могут становиться источниками инцидентов, и они обязаны быть идемпотентными для ключевых операций.
Подведём итог
Разрабатывать API сложно, потому что они не гибки, но должны быть просты в освоении.
Основная обязанность мейнтейнеров API — НЕ ЛОМАТЬ ПОЛЬЗОВАТЕЛЬСКОЕ ПРОСТРАНСТВО. Никогда не вносите ломающих изменений в публичные API.
Версионность API позволяет вносить изменения, но воздвигает серьёзные препятствия перед реализацией и освоением.
Если ваш продукт достаточно ценен, то качество API не особо важно, им всё равно будут пользоваться.
Однако если ваш продукт плохо спроектирован, то не важно, насколько тщательно вы будете проектировать API; скорее всего он всё равно будет плохим.
Ваш API должен поддерживать простые ключи API для аутентификации, потому что многие пользователи не будут профессиональными разработчиками.
Запросы, выполняющие действия (и особенно критичные действия наподобие платежей), должны включать в себя какой-нибудь ключ идемпотентности, чтобы обезопасить повторные попытки.
Ваш API всегда будет источником инцидентов. Реализуйте ограничения частоты запросов и функцию экстренного отключения.
Используйте пагинацию с курсорами для датасетов, которые потенциально могут становиться очень большими.
Делайте затратные поля опциональными и по умолчанию отключенными, но GraphQL — это уже перебор (на мой взгляд).
С внутренними API ситуация немного иная (потому что потребители сильно отличаются).
Чего я не затронул? Я почти ничего не писал о сравнении REST и SOAP или JSON и XML, потому что не думаю, что это так уж важно. Мне нравятся REST и JSON, но не могу сказать, что ничто другое не стоит использовать. Кроме того, я ничего не говорил о схеме OpenAPI — это полезный инструмент, но я считаю, что при желании вполне можно писать документацию к API в Markdown.
Дополнение: этот пост обсуждался на Hacker News и Reddit, набрав множество комментариев. Комментаторы указали, что я должен был упомянуть PUT в разделе об идемпотентности, потому что он по сути своей идемпотентен. Возможно, мне следовало это сделать, я не силён в нём и мне кажется, что этот метод HTTP не более идемпотентен, чем POST. Также было много споров об использовании Redis в качестве хранилища идемпотентности, потому что невозможно реализовать безопасные атомарные операции, координирующие Redis и базу данных. Вполне разумное опасение для таких высокорисковых сфер, как платежи, но настройка Redis поверх уже существующего неидемпотентного API — всё равно лучше, чем ничего.
По крайней мере, в моей области (SaaS большой технологической компании).
Именно поэтому паттерн REST так часто используют для API. Нельзя сказать, что он хорош во всех остальных смыслах, но сегодня он уже достаточно всем знаком, чтобы потребители смогли разобраться, даже не читая вашу документацию к API.↩
Некоторые типы API (например, SOAP) вместо этого отвечают 200 с XML-элементом
<Fault>
, но принцип остаётся тем же.Если только вы не используете какую-нибудь не привязанную к ID операцию наподобие «удалить самую последнюю запись».
Позже его наняли в команду разработки приложений, в которой я работал с ним много лет.
Ещё один принцип GraphQL заключается в том, что разные сервисы бэкенда обслуживают разные части единого API непрозрачным для потребителя API образом.