Легко ли разработать новый API? На что обратить внимание, чтобы не ошибиться при реализации, и к каким компромиссам стоит быть готовым?
Привет! Меня зовут Иван Ивашковский. Я руковожу группой разработки международных проектов в Яндекс Go. Этот пост — продолжение цикла историй о вымышленном стажёре Васе. Предыдущий материал, про идемпотентность, можно почитать здесь. В посте я расскажу, как Вася разрабатывал API для новой фичи и с какими проблемами он столкнулся в процессе. В конце приведу чеклист с советами, как проверить себя на каждом этапе разработки, если вы решаете похожую задачу.
Новая задача Васи
Васе поставили задачу улучшить сбор фидбека о поездках на такси.
Продакт-менеджер предложил задавать вопрос «Почему эта поездка была лучше предыдущей?» каждому пользователю, который оценил текущий заказ выше, чем прошлый. Ответы нужно сохранять в базу данных и отсылать в систему саппорта.
Это окно фидбека. Пользователь видит его при завершении поездки
Тимлид был в отпуске, но Вася быстро придумал решение самостоятельно.
За показ окна фидбека и сохранение ответов на бэкенде отвечают два endpoint: GET /feedback-screen
и POST /save-feedback
.
В Яндекс Go для описания API сервисов используется OpenAPI 3.0. У Васи и его коллег есть внутренний гайд, в котором прописаны рекомендации по разработке API — в основном гайд агрегирует общеизвестные best practices и затрагивает внутреннюю специфику Go. Чтобы читать статью было легче, будем рассматривать упрощённый код API, над которым работает Вася.
В GET-запросе Вася решил возвращать оценку предыдущего заказа и варианты ответа для нового вопроса.
GET /feedback-screen
Было:
{
"quality_choices": ["Приятная беседа", "Комфортное вождение", "Чистота", ...]
}
Стало:
{
"quality_choices": ["Приятная беседа", "Комфортное вождение", "Чистота", ...],
"better_quality_choices": ["Машина приехала быстрее", "Более плавная езда", ...],
"prev_order_stars": 5
}
В POST-запросе Вася начал сохранять несколько ответов, попросив передавать их в endpoint как словарь. Он намеренно сломал обратную совместимость API и решил обработать это в коде, чтобы в будущем было проще добавлять новые вопросы.
POST /save-feedback
Было:
{
"order_id": "yandex2021",
"comment": "Very good",
"reasons": ["Хорошая музыка", "Приятная беседа"]
}
Стало:
{
"order_id": "yandex2021",
"comment": "Very good",
"reasons": {
"quality_choices": ["Хорошая музыка", "Приятная беседа"],
"better_quality_choices": ["Машина приехала быстрее", "Более плавная езда"]
}
}
Одновременно с Васей мобильный разработчик Федя написал в приложении следующую логику:
if (request.prev_order_stars && request.prev_order_stars < current_order.stars) {
ShowMoreQuestions();
CallNewSaveFeedbackAPI();
}
Федя предупредил Васю, что приложение раскатывается в App Store и Google Play постепенно: в отличие от обновлений бэкенда, в этом случае откатить приложение до более низкой версии не получится. Доступ к новой версии сначала открывают для маленького процента пользователей, чтобы быстро остановить распространение, если что-то сломается.
Это значит, что пока все пользователи не обновятся, запросы будут приходить как от старой версии приложения, так и от новой. Поэтому, чтобы не сломать сервис из-за несовместимости API POST /save-feedback
, Вася научился обрабатывать в коде разные форматы входного запроса: и старый, и новый. Получилось примерно так:
if (reasons.IsArray()) {
DoOldStuff();
} else if (reasons.IsDict()) {
DoNewStuff();
}
Команда написала тесты. В тестовой среде всё заработало, и продакт-менеджер дал добро на раскатку. Новая версия приложения поехала в сторы, а бэкенд поехал в прод.
Небыстрый откат
Вася был очень доволен, что сделал фичу. Настолько, что даже просмотрел начало проблем при выкатке: сервис начал падать на запросах POST /save-feedback
.
Вот что произошло:
- Сервис выкатился на несколько машин.
- Запросы
GET /feedback-screen
начали отдавать данные для дополнительного вопроса «Почему эта поездка была лучше предыдущей?» - Новое поле
prev_order_stars
в ответеGET /feedback-screen
включало в приложении фичу, если рейтинг текущего заказа был выше, чем предыдущего. Приложение начало сохранять фидбэк через новый APIPOST /save-feedback
, отсылая туда словарь с ответами на несколько вопросов. - Запрос прилетал на машины бэкенда, куда ещё не успел раскатиться релиз.
- Старый код ожидал массив на входе, а приходил словарь — сервис падал на десериализации данных.
Возможность быстро выключить и включить фичу Вася поленился добавить как на бэкенде, так и в конфигурации мобильного приложения. Ему показалось, что он всё протестировал и предусмотрел, и ничего страшного не произойдёт. На деле Васе пришлось срочно откатывать релиз — это быстрее, чем ждать, пока он выедет до конца.
Что Вася мог сделать в этой ситуации, чтобы проблем не возникло:
- Изначально не делать несовместимых изменений в API. Интерфейс остался бы таким, чтобы с ним успешно работала как старая версия кода, так и новая. Для задачи, которую решал Вася, можно было бы класть словарь причин в новое поле
multiple_reasons
, оставивreasons
неизменным. - Разбить работу на два этапа. Сперва подготовить сервис к изменениям в API, научить его работать как со старой, так и с новой версией API и выкатить это изменение в прод. Затем включить новую функциональность конфигом или вторым релизом.
- Версионировать API, например
GET /v2/feedback-screen
,POST /v2/save-feedback
. Это предполагает создание нового endpoint с собственной логикой и правильную последовательность релизов: сначала выкатывается бэкенд с новой версией, затем на обновление переключаются мобильные приложения.
В реальности во время релиза в продакшн-окружении пойти не так может что угодно: появятся сложноуловимые баги, обнаружатся крайне редкие кейсы, обрабатывать которые не планировалось, возникнут проблемы с ростом потребления CPU и RAM. Поэтому Васе всё же стоило добавить возможность быстро отключить новую функциональность. Даже если ему казалось, что он всё предусмотрел. Полагаться на включение-выключение посредством релиза ненадёжно, потому что это долгий и не всегда предсказуемый процесс.
Для решения этой задачи коллеги Васи в Яндекс Go сделали микросервис конфигов, инкапсулирующий в себе логику их хранения, получения и изменения. Каждый сервис периодически опрашивает этот микросервис, чтобы получить и закешировать актуальную версию своих конфигов. В веб-интерфейсе админки можно посмотреть и поправить любой конфиг, сохранив результат через API микросервиса конфигов. Таким образом можно максимально быстро изменить конфигурацию бэкенда и выключить сломавшуюся функциональность.
Для того чтобы включать/выключать новый код на стороне мобильного приложения, где тоже возможны баги, у коллег Васи есть аналогичная схема. Приложения на старте получают и периодически обновляют конфигурацию от бэкенда. Кеширование конфигурации распространяется на одну или несколько сессий пользователя, что даёт приемлемое время реакции на изменения конфигов.
Несколько полезных статей с Хабра о быстром контуре конфигурации:
- Run, config, run: как мы ускорили деплой конфигов в Badoo
- Как раскатывать опасный рефакторинг на прод с миллионом пользователей?
Также более полно проблема раскрыта в выступлении моего коллеги Максима Педченко о надёжности сервисов Такси на HighLoad Spring 2021.
Вывод: Всегда предусматривайте возможность быстро выключить новую функциональность, даже если вы полностью в ней уверены.
Толстый или тонкий клиент
Прошла неделя, и Вася всё-таки докатил фичу. Все радовались, особенно продакт-менеджер. Однако спустя несколько дней пользователи начали жаловаться, что им слишком часто задают дополнительные вопросы. Из-за этого кто-то вообще перестал оставлять фидбек. Чтобы исправить это, продакт-менеджер предложил проверять, растёт ли оценка, на трёх последних заказах вместо двух.
Вася понял задачу и начал добавлять в API новое поле prev_prev_order_stars
. Также он попросил Федю доделать логику приложения. Но, как это часто бывает, стоило начать разработку, и всё сразу поменялось. Продакт-менеджер предложил показывать новый вопрос только core-аудитории —
лояльным пользователям, регулярно пользующимся Go, а количество заказов сделать настраиваемым параметром. «А что, если требования опять поменяются? Как лучше всего решать такую задачу?» — подумал Вася. Есть несколько вариантов.
Тонкий клиент
Вася мог бы прописать всю логику на бэкенде: тогда для принятия решений приложение будет смотреть в ответы бэкенда. В Яндекс Go это выглядит так: пользователь ставит оценку текущему заказу. Приложение отсылает результат на бэкенд и получает в ответ флажок, нужно ли показывать дополнительный вопрос и данные для него. На сервере при этом может быть реализован алгоритм любой сложности — эта логика полностью скрыта от мобильного приложения.
Преимущества:
- Можно реализовать ресурсоёмкую логику, для которой нужны большие мощности.
- Цикл релиза бэкенда обычно более быстрый = фичи быстрее доставляются в прод.
- Разработка в приложении не нужна, достаточно бэкенда.
- Логика сосредоточена в одном месте, что ускоряет погружение в неё новых сотрудников.
Недостатки:
- Дополнительные запросы к серверу ухудшают отзывчивость UX, особенно при медленном соединении.
- В многошаговых сценариях нужно продумывать фоллбэки для каждого шага на случай отказа бэкенда.
Толстый клиент
Если бы Вася выбрал этот вариант, он зашил бы всю бизнес-логику в мобильное приложение. Это значит, что бэкенд становится поставщиком всех необходимых данных: например, возвращает оценки предыдущих заказов, пороговые значения, когорту текущего пользователя. Основная логика действий при этом прописана в коде приложения. Там же происходит проверка разных условий и разбор всех возможных случаев, учтены любые другие пожелания продакт-менеджера и прописан алгоритм действий на случай проблем.
Преимущества:
- Из-за уменьшения количества сетевых запросов улучшается отзывчивость.
- В случае серверных проблем сценарий может работать автономно.
- В коде можно использовать нативные фичи мобильных ОС, например, ARKit
Недостатки:
- Двойной объём разработки: и на бэкенде, и в приложении.
- Долгий цикл релиза: всегда найдутся те, кто никогда не обновится.
- Увеличится потребление ресурсов на устройстве (например, заряда батареи).
- Нельзя реализовать ресурсоёмкие вычисления.
- Не все данные можно открыто передавать на клиент. Подробнее об этом расскажу ниже.
Гибридный способ
Есть у Васи и третий вариант: бэкенд может присылать на клиент и данные, и алгоритм, действий.
Этот способ позволяет совместить достоинства обоих подходов за счёт добавления ещё одного слоя абстракции. Можно передавать с бэкенда и необходимые данные, и сам алгоритм вычисления нужных величин в некотором виде. Чтобы решить задачу Васи, нужно на стороне приложения вычислять булев флаг, показывать ли дополнительный вопрос.
Мобильное приложение имеет доступ:
- к своим переменным (например, к текущей оценке заказа);
- к переменным, полученным с бэкенда.
Остаётся научить его интерпретировать и подставлять переменные в полученный алгоритм. При необходимости можно считать и более сложные вещи: какие из данных бэкенда отрисовать, в какой API ходить для сохранения.
Алгоритм может быть передан, например, в виде заранее условленного набора инструкций прямо в JSON. Или в виде JavaScript-кода с шаблонизацией. Или даже в виде байткода со своим интерпретатором.
Недостаток гибридного способа — дороговизна его имплементации. Тем не менее, в Яндекс Go есть несколько мест, где такой подход успешно используется.
Вася пообщался с коллегами и остановился на варианте с тонким клиентом. Команды бэкенда и мобильной разработки дружно сказала, что толстый клиент — это плохо по указанным выше причинам. Особенно — потому что любое расширение функционала требует двойного объёма работ.
Когда в тестинге появилась работающая реализация, её решили показать продакт-менеджеру. В это время продакт-менеджер находился в другой стране, но согласился отвлечься от отдыха и посмотреть на результат. Прогнав тестовый заказ, он не увидел дополнительных вопросов в окне фидбека. Начали дебажить.
По логам оказалось: в стране, где находился продакт-менеджер, отправка текущей оценки и получение в ответ дополнительных вопросов занимала больше секунды T_not_russia > 1s
. Типичный пользователь просто не видит вопросы, поскольку за это время успевает поставить и сохранить оценку.
Команда погрузилась в холивары: оставить всё как есть или же сделать толстый клиент, чтобы избежать долгих запросов. Продакт-менеджер убедил всех в необходимости более отзывчивого UX. Яндекс Go — международная компания, и фидбек от зарубежных пользователей важен. Они должны видеть этот дополнительный вопрос. Также во многих регионах России всё ещё распространен 3G, на котором наблюдается такая же проблема с latency.
В итоге Вася и его коллеги пришли к соглашению двигаться итеративно: быстро решить проблему в рамках текущей задачи, но также подготовить задел на будущее. Они договорились делать толстый клиент, получающий все необходимые данные с бэкенда. И параллельно начали прорабатывать обобщенный интерпретатор формул для гибридного клиента.
Интересные статьи, где тоже выбрали толстый клиент:
- Киберпанк, который мы заслужили, или как Prisma превращает ваши селфи в произведение искусства
- V8 в бэкенде С++: от одного JS-скрипта до фреймворка онлайн-вычислений
Вывод: Не всегда толстый клиент — это плохо. UX пользователей — прежде всего.
Идемпотентность — это важно
Идемпотентным называют такой метод API, повторный вызов которого не меняет состояние ресурса. Почему идемпотентность так важна, разбирались в предыдущей статье о Васе. Предлагаю вспомнить на примере.
Через несколько дней к Васе постучался его знакомый из саппорта — Миша. Он рассказал, что его команде часто прилетают дублирующиеся задачи по новой фиче. И саппортам приходится тратить много времени на их дедупликацию. Вася пообещал разобраться. Его новый код в endpoint POST /save-feedback
...
{
"order_id": "yandex2021",
"comment": "Very good",
"reasons": {
"quality_choices": ["Хорошая музыка", "Приятная беседа"],
"better_quality_choices": ["Машина приехала быстрее", "Более плавная езда"]
}
}
… был написан так:
// Сохраняем первый вопрос и рейтинг
write_reasons_to_db(reasons, order_id);
add_rating(stars, order_id);
// Васин код — сохраняем ответ на новый вопрос и создаём таск на поддержку
const std::string support_task_id = uuid.uuid4();
send_to_support(better_quality_reasons, support_task_id);
write_better_quality_reasons_to_db(better_quality_reasons, order_id);
Вася стал разбираться и вспомнил, что уже встречался с похожими проблемами. Баг возникает в такой ситуации:
1) Запрос send_to_support
выполняется успешно, но затем база данных не может обработать второй write
.
2) Из-за ошибки весь endpoint POST /save-feedback
отвечает кодом 500.
3) Мобильное приложение делает ретрай и пытается сохранить фидбек ещё раз.
4) При ретрае весь код прогоняется заново, и send_to_support
заводит ещё один таск в очереди саппорта.
После некоторого раздумья и чтения документации Вася узнал, что таск-трекер не позволяет завести 2 задачи с одинаковым support_task_id
. Так как на каждый заказ возможно только 1 успешное сохранение фидбека, то можно использовать id заказа order_id
в качестве ключа идемпотентности при заведении задачи.
Чтобы решить проблему, Вася написал следующий код:
// Сохраняем первый вопрос и рейтинг
write_reasons_to_db(reasons, order_id);
add_rating(stars, order_id);
// Новый код
try {
const std::string support_task_id = order_id;
send_to_support(better_quality_reasons, support_task_id);
} catch (const DuplicateTask& error) {
// Ошибка значит, что задача уже была создана в предыдущей попытке
}
write_better_quality_reasons_to_db(better_quality_reasons, order_id);
Вывод: Всегда думайте об идемпотентности API.
Международные платежи
<#Продакт-менеджер предложил Васе добавить новую фичу — ввод размера чаевых на экране фидбека. Если пользователю понравилась поездка, он может оставить N рублей чаевых.
Вася расширил API POST /save-feedback
, добавив туда поле tips
и его десериализацию в integer-переменную. Фича оказалась настолько классной, что её решили раскатить на международные направления. Но она почему-то не заработала в Финляндии, Латвии, Эстонии и других европейских странах. Количество чаевых на графиках для этих стран практически не отличалось от нуля. Вася начал искать баг.
Оказалось, что все дело в валюте. Евро — довольно ценная денежная единица. И для точных вычислений в логику подсчёта цен нужно включить центы.
Что происходит, когда на бэкенд в качестве чаевых приходит 0,2 евро? Из-за типа integer в коде это значение округляется до 0. Вася изменил тип переменной на decimal64
— это позволяет передавать цену как строку в API, а в коде работать с ней как с числом с плавающей точкой без потери точности.
Вывод: Заранее узнавайте все бизнес-потребности и уточняйте продуктовые вопросы, от этого зависит реализация API.
Ваши данные увидят все
Чтобы помочь пользователю выбрать размер чаевых, продакт-менеджер предложил показывать в интерфейсе подсказку со значением по умолчанию:
В качестве значения по умолчанию он предложил использовать средний размер чаевых по городу — такая статистика соберётся достаточно быстро.
Вася воспринял указание слишком буквально и добавил в API новое поле —
average_tips_by_city
. К этому времени руководитель Васи уже вернулся из отпуска и попросил его изменить название этого поля на tips_suggestion
. Он аргументировал это тем, что average_tips_by_city
раскрывает часть бизнес-информации о заработке партнеров и о его распределении по географии. Этим могут воспользоваться конкуренты, неблагополучные пассажиры и много кто ещё.
Вторым доводом было, что в подсказку в будущем захочется класть что-то более хитрое, чем средний размер чаевых, и название average_tips_by_city
не подойдёт. Раскрытие чувствительных данных — очень частый сценарий, что доказывает огромное количество статей на эту тему (1, 2, 3, 4, 5).
Вот список нескольких типичных проблем:
- Автоинкрементальное поле в качестве
id
. Позволяет получить информацию о количестве объектов. - В API видны технические данные. От них по цепочке можно добраться до чего-то поинтереснее.
- Доступ к API без аутентификации. Упрощает получение данных и делает его неконтролируемым.
- Перекладывание сырых данных из базы в API as is. При этом отсутствует контроль за видимостью разных полей.
Чтобы избежать этих ошибок, в Яндекс Go, как и в других крупных компаниях, все внешние API проходят отдельный аудит безопасности.
Вывод: Чтобы поймать шпиона, надо думать как шпион: проверяйте насколько безопасен ваш API и насколько чувствительные данные доступны через него.
Заключение
На примере создания простой фичи я рассказал, с какими проблемами при разработке API может столкнуться начинающий разработчик.
О чём стоит помнить:
-
До разработки:
- максимально уточните продуктовый контекст задачи — это поможет выбрать правильную реализацию и избежать проблем с корнеркейсами.
-
Во время разработки:
- подумайте, как планируется развивать фичу, чтобы сразу подготовить задел на будущее;
- не забывайте о безопасности ваших данных: кто-то обязательно будет их исследовать;
- проверьте и перепроверьте себя: типичные проблемы с API связаны с идемпотентностью, несовместимостью, состоянием «гонок» и неучётом редких случаев.
-
После разработки:
- убедитесь, что вы сможете быстро выключить новую функциональность в продакшене: по закону Мерфи если что-нибудь может пойти не так, оно пойдёт не так.
Проектирование API микросервисов — одна из повседневных задач в Яндекс Go. Все большие проекты сервиса в конечном итоге строятся из множества маленьких интерфейсов, скрывающих за собой детали реализации.
При наличии хороших интерфейсов можно уделять больше внимания техническим решениям и архитектуре. Хороший API позволяет нам быстрее внедрять новые фичи, тратить меньше времени на поддержку, уменьшать количество проблем на проде и внедрять эффективные фоллбэки. Про эти процессы мы расскажем в других статьях.
Автор: Ивашковский Иван