Здравствуйте, меня зовут Вадим Клеба. Я возглавляю команду разработки сервиса Яндекс Телемост. На сегодняшний день это один из наиболее популярных сервисов для видео встреч в нашей стране. Важнейшей частью Телемоста является его API. Оно позволяет множеству клиентов на разных платформах ежедневно объединять во встречах сотни тысяч пользователей. Мы часто воспринимаем API как нечто сугубо-техническое: набор эндпоинтов, контрактов, форматов данных. Но давайте на секунду сфокусируемся на последней букве — I, — которая означает интерфейс. Это та часть, которая стандартизирует способ взаимодействия двух систем. И это изобретение вовсе не нашего времени. Человечество создавало и оттачивало интерфейсы на протяжении всей своей истории.
Представьте. Есть клиент. Это мы, голодный человек. Есть ресурс, еда на тарелке. Вилка — это API, которая соединяет вас с едой. У этого API есть четкий, неписанный, но всем понятный контракт. Есть ручка. Часть, за которую мы держимся — это точка входа для клиента. Есть зубцы, рабочая часть, которая взаимодействует с ресурсом. Протокол использования интуитивен. Мы не пытаемся есть, держась за зубцы. Это неудобно и было бы ошибкой. Это хорошо спроектированное API, которому уже несколько тысячелетий. А что такое плохо спроектированная API в реальном мире? Это спорк. То есть ложка-вилка. Он пытается быть и ложкой, и вилкой, но в итоге плохо справляется с обеими задачами. Зубцы слишком короткие, чтобы нормально накалывать, а в черпале есть дырки. Это тот самый аппетит с эндпоинтом do everything, который делает все, но одинаково неудобно. Так что, когда мы проектируем наше API, мы по сути решаем тот же вопрос — дать пользователю интуитивно понятную вилку для его данных или обречь его на мучения со спорком. Именно про этот поиск идеальной вилки в построении API я и хочу поговорить.
API — это одна из фундаментальных вещей системы, которую мы строим. Мы опустим холиварные вопросы с выбором конкретного стиля. REST, RPC, GraphQL — на самом деле не так уж и важно, и выбор зависит от конкретной задачи. Мы погрузимся чуть глубже и рассмотрим универсальные подходы, которые мы применяем для построения любых API наших сервисов. Про API можно говорить бесконечно, поэтому мы остановимся только на самых интересных и нетривиальных моментах.
Когда мы в Яндекс 360 строим API, то всегда соблюдаем правила базовой гигиены. Это помогает нам строить API, с которым легко интегрироваться и поддерживать, а также они успешно развиваются и эволюционируют. Для этого мы создали свод правил, которых мы придерживаемся.
Итак, начнем с концептуальных правил, которых мы стараемся придерживаться:
Во-первых, API должно быть отражением предметной области, а не структуры кода или особенности внутренней архитектуры системы Во-вторых, API должно быть консистентным и предсказуемым В-третьих, API должно быть поддерживаемым, легко изменяться и расширяться. Мажорная версия API меняется только если мы не можем нести очередное изменение, не сломав обратную совместимость. Мы придерживаемся правил SemVer. Чтобы API было легко поддерживать и развивать, мы должны умело работать с обратной совместимостью. Добавление новых полей в ответ – это безопасное действие. Старые клиенты проигнорируют новое поле, а новые смогут подхватить и будут использовать. Иногда бывают и казусы. Некоторые клиенты могут забыть указать настройку "игнорировать новые поля", либо нарочно добавить строгую валядацию ответа, хотя в этом нет никакой необходимости. Оставим это на их совести.
Поэтому при проектировании сервисов важно следить за архитектурой end-to-end — от клиента до базы данных, — и, естественно, все изменения API необходимо тщательно проверять на обратную совместимость. Добавление полей в запрос напротив нарушает обратную совместимость, поэтому добавление поля в запрос должно быть опциональным. Помимо опциональности можно использовать значение по умолчанию. Это хороший вариант, когда значение должно быть в запросе.
А вот неочевидная вещь, которая часто ломает обратную совместимость — это изменение логики, скрытой под тем или иным значением в API. Несмотря на то, что формально API при этом может не поменяться, последствия таких изменений может быть очень печальными. Еще одна неочевидная вещь, часто приводящая к появлению целой новой версии API, это мой любимчик — перечисляемые типы, либо енамы в ответе. Это тот тип, который может испортить нам настроение и нам придется поднимать версию API. Клиенты очень часто жестко завязываются на конкретный набор возможных значений и мы не сможем ни удалить, ни добавить новые значения. Поэтому лучше для этого использовать обычные строки. Использование строк позволяет легко расширять список возможных значений.
Каждый раз, когда нам требуется сломать обратную совместимость, мы создаем новую версию API, а пользователи API вынуждены обновлять свои интеграции. Это все ложится в технический долг и не приносит пользы ни продукту, ни бизнесу. Количество технического долга будет расти, а нашим клиентам нужны будут ресурсы на миграцию.
Чтобы не плодить версии в Телемосте, мы развиваем API по следующей стратегии. Каждый клиент посылает на сервер информацию с API, какой версии он умеет работать. На сервере мы стараемся подстроиться под эту версию сколь возможно долго, поэтому API всегда предсказуемо для клиента. Чтобы не плодить технический долг на старой версии API, мы возвращаем дату, до какого времени данная API будет поддерживаться. Когда настанет день X, то сервер вместо ответа возвращает ошибку, которая принудительно запускает обновление на клиенте. Это позволяет не держать слишком старые версии и не ломать клиентов.
А теперь перейдем к консистентности и предсказуемости. Предсказуемость важна, потому что один раз выученные правила работают везде. Она снижает когнитивную нагрузку, позволяет уверенно угадывать поведение без лишнего обращения к документации, ускоряет интеграции, уменьшает число ошибок и удешевляет поддержку. Одно из важнейших правил для обеспечения предсказуемости API — это следовать спецификациям и сложившимся в индустрии практикам.
Для примера, давайте посмотрим на HTTP и метод GET или DELETE. Спецификация этих методов явно не запрещает добавления тела в запросы. В HTTP за тело отвечают за головки Content-Length и Content-Type. Может показаться хорошей мыслью добавить в GET-запрос эти заголовки и пробросить в тело параметры, которые не умещаются в ограниченную длину URL. Однако современная спецификация HTTP явно говорит о неоднозначности этого решения, а сложившаяся в индустрии практика подсказывает, что далеко не все HTTP-серверы, прокси и клиентские библиотеки умеют работать с телом GET-запроса. И такой шаг приведет к тому, что интегрироваться с нашим API сможет далеко не каждый клиент. Возможно, нам даже удастся, но эта часть API будет доставлять нам неудобство весь оставшийся жизненный цикл версий.
Теперь давайте поговорим о том, что должно представлять собой хорошее API. Хорошее API представляет собой модель предметной области. Оно отражает мир нашего проекта. Ключевые сущности, процессы, правила и цели, которые важны для пользователей API и бизнеса. Наше API становится по-настоящему понятным, когда клиент видит в нем не набор ендпоинтов, а живые сущности, состояниями и действиями. Он мысленно работает с видеоконференциями, с участниками конференции, с комнатой ожидания, а не с полями и флагами. Такой подход обеспечивает в долгосрочной перспективе легкость и гибкость изменений. API легко адаптируются под новые бизнес-требования и правила.
Реальный мир редко подвергается рефакторингу и полной перестройке внутренней архитектуры. Когда наши API отражают реальный мир, оно органично, эволюционно развивается вместе с этим миром. При этом под капотом публичного API могут меняться микросервисы, базы данных, протоколы и вообще все, что угодно. Эти изменения не затронут хорошо спроектированное публичное API.
Концептуальные вещи мы разобрали. Давайте теперь поговорим о некоторых практических техниках, применяемых нами при разработке HTTP API. Для этого нам потребуется пример. Представим, что мы разрабатываем клиент Яндек Заметок. Мы добавляем в приложение запрос на создание новой заметки. Проверяем, все великолепно работает. Заметка создается. Мы проходим тестирование и выкатываем приложение в продакшен. Все хорошо. В какой-то момент нам начинает поступать жалобы, что в сервисе появляются пустые заметки. Воспроизвести проблему не удается. В логах, собранных с клиентов, мы видим, что бэкенд периодически не отвечает клиенту. Пользователю приходится несколько раз нажимать кнопку создания заметок, чтобы приложение получило сообщение об успешном создании и открыло пользователю вновь созданную заметку для редактирования.
В чем же может быть проблема? Дело в том, что астрологи объявили неделю нестабильной сети, и между клиентом и сервером то и дело рвется соединение. Однако это не означает, что сервер не получил наш запрос. Как раз наоборот: он получил, успешно обработал запрос, но из-за разорванного соединения не смог отправить ответ об успешном создании заметки. В итоге у пользователя создаются пустые заметки, которые он якобы не создавал. Чтобы решить эту проблему, мы добавляем в эндпоинт, отвечающий за создание объектов, ключ идемпотентности. Этот ключ генерируется на клиенте и сохраняется в создаваемый объект в сервисе. Каждый раз, когда клиент выполняет действия пользователя, он генерирует новый ключ идемпотентности и отправляет в API. А API в свою очередь проверяет, не существует ли в системе объекта с таким ключом идемпотентности. И если существует, то просто отвечает так, как будто успешно создало объект. При этом клиент автоматически ретраит запросы и не обновляет ключ идемпотентности до тех пор, пока не получит успешный ответ.
Мы затронули тему ретраев. Давайте задумаемся, есть ли у нас точный ответ, когда нужно ретраить, а когда не стоит. Нужно ли ретраить 503 ошибку? А известную всем 404? Я помню, как однажды мы столкнулись с инцидентом, когда опирались на 404 код ошибки. Мы проверяли, не создан ли объект, получали 404 и создавали новую. Довольно долго все было хорошо. Но однажды мы обнаружили, что клиенты массово создают новые сущности. Как вы думаете, почему это произошло? Дело было в сломанном роутинге HTTP-балансера, который не находил маршрут для эндпоинта проверки существования объекта и возвращал стандартную ошибку 404. Для клиента же 404, возвращенная нашим сервисом и балансером перед ним, была неотличима. Клиент в полной уверенности, что объекта не существует, создавал новый.
Модель работы с ошибками – это довольно большая часть любого API. Мы придерживаемся тут ряда правил, которые помогают делать API надёжными, облегчают мониторинг и развитие.
Мы никогда не используем статус коды из диапазона от 100 до 399 в случае ошибок на сервере. Любые ответы из диапазона 400 считаются бизнесовыми ошибками, которые нельзя ретраить. Если API вернула ответ из диапазона от 400 до 498, немедленный ретрай никак не поможет и не имеет смысла, клиент должен сообщить пользователю об ошибке. Ошибки со статусом больше 500 считаются некорректной работой системы, и клиент должен их немедленно поретраить с экспоненциальным возрастанием задержки. Кроме того, в нашем API мы добавляем во все ответы с ошибками специальное поле, содержащее уникальный идентификатор ошибки для того, чтобы клиенты могли более гранулярно реагировать на различные известные причины ошибок, не ограничивая себя классами ошибок, предполагаемыми стандартам HTTP. Соблюдение семантики HTTP-методов и кодов ошибок позволяет легко строить систему мониторинга в API и пользоваться огромной инфраструктурой WorldWideWeb, даже не задумываясь о ней. Кэширование, автоматическая обработка ошибок и ретраев, оптимизация трафика, локализация, управление представлением и много всего, о чем за нас подумали еще 35 лет назад пионеры интернета.
Мы рассмотрели небольшую часть концептуальных и практических аспектов проектирования API. Это лишь вершина айсберга, под которым скрывается множество деталей решений и компромиссов. Правила и принципы помогают делать API удобным, стабильным, понятным и масштабируемым. Однако они не являются догмой. Существует множество подходов к проектированию API со своими ограничениями и сильными сторонами. Важно помнить, что API — это живая система, которая растет и развивается вместе с продуктом, и от которой зависит множество клиентов. Его архитектура должна отвечать текущим и будущим вызовам, стоящим перед системой. И развитие API должно всегда учитывать как внутренние потребности системы, так и ограничения внешних клиентов. В этом балансе ключ к успешной жизни любого публичного API.