- PVSM.RU - https://www.pvsm.ru -
Легко ли разработать новый API? На что обратить внимание, чтобы не ошибиться при реализации, и к каким компромиссам стоит быть готовым?
Привет! Меня зовут Иван Ивашковский. Я руковожу группой разработки международных проектов в Яндекс Go. Этот пост — продолжение цикла историй о вымышленном стажёре Васе. Предыдущий материал, про идемпотентность, можно почитать здесь [1]. В посте я расскажу, как Вася разрабатывал API для новой фичи и с какими проблемами он столкнулся в процессе. В конце приведу чеклист с советами, как проверить себя на каждом этапе разработки, если вы решаете похожую задачу.
Васе поставили задачу улучшить сбор фидбека о поездках на такси.
Продакт-менеджер предложил задавать вопрос «Почему эта поездка была лучше предыдущей?» каждому пользователю, который оценил текущий заказ выше, чем прошлый. Ответы нужно сохранять в базу данных и отсылать в систему саппорта.
Это окно фидбека. Пользователь видит его при завершении поездки
Тимлид был в отпуске, но Вася быстро придумал решение самостоятельно.
За показ окна фидбека и сохранение ответов на бэкенде отвечают два endpoint: GET /feedback-screen
и POST /save-feedback
.
В Яндекс Go для описания API сервисов используется OpenAPI 3.0 [2]. У Васи и его коллег есть внутренний гайд, в котором прописаны рекомендации по разработке 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
включало в приложении фичу, если рейтинг текущего заказа был выше, чем предыдущего. Приложение начало сохранять фидбэк через новый API POST /save-feedback
, отсылая туда словарь с ответами на несколько вопросов.
Возможность быстро выключить и включить фичу Вася поленился добавить как на бэкенде, так и в конфигурации мобильного приложения. Ему показалось, что он всё протестировал и предусмотрел, и ничего страшного не произойдёт. На деле Васе пришлось срочно откатывать релиз — это быстрее, чем ждать, пока он выедет до конца.
Что Вася мог сделать в этой ситуации, чтобы проблем не возникло:
multiple_reasons
, оставив reasons
неизменным.GET /v2/feedback-screen
, POST /v2/save-feedback
. Это предполагает создание нового endpoint с собственной логикой и правильную последовательность релизов: сначала выкатывается бэкенд с новой версией, затем на обновление переключаются мобильные приложения.В реальности во время релиза в продакшн-окружении пойти не так может что угодно: появятся сложноуловимые баги, обнаружатся крайне редкие кейсы, обрабатывать которые не планировалось, возникнут проблемы с ростом потребления CPU и RAM. Поэтому Васе всё же стоило добавить возможность быстро отключить новую функциональность. Даже если ему казалось, что он всё предусмотрел. Полагаться на включение-выключение посредством релиза ненадёжно, потому что это долгий и не всегда предсказуемый процесс.
Для решения этой задачи коллеги Васи в Яндекс Go сделали микросервис конфигов, инкапсулирующий в себе логику их хранения, получения и изменения. Каждый сервис периодически опрашивает этот микросервис, чтобы получить и закешировать актуальную версию своих конфигов. В веб-интерфейсе админки можно посмотреть и поправить любой конфиг, сохранив результат через API микросервиса конфигов. Таким образом можно максимально быстро изменить конфигурацию бэкенда и выключить сломавшуюся функциональность.
Для того чтобы включать/выключать новый код на стороне мобильного приложения, где тоже возможны баги, у коллег Васи есть аналогичная схема. Приложения на старте получают и периодически обновляют конфигурацию от бэкенда. Кеширование конфигурации распространяется на одну или несколько сессий пользователя, что даёт приемлемое время реакции на изменения конфигов.
Несколько полезных статей с Хабра о быстром контуре конфигурации:
Также более полно проблема раскрыта в выступлении моего коллеги Максима Педченко [5] о надёжности сервисов Такси на HighLoad Spring 2021.
Вывод: Всегда предусматривайте возможность быстро выключить новую функциональность, даже если вы полностью в ней уверены.
Прошла неделя, и Вася всё-таки докатил фичу. Все радовались, особенно продакт-менеджер. Однако спустя несколько дней пользователи начали жаловаться, что им слишком часто задают дополнительные вопросы. Из-за этого кто-то вообще перестал оставлять фидбек. Чтобы исправить это, продакт-менеджер предложил проверять, растёт ли оценка, на трёх последних заказах вместо двух.
Вася понял задачу и начал добавлять в API новое поле prev_prev_order_stars
. Также он попросил Федю доделать логику приложения. Но, как это часто бывает, стоило начать разработку, и всё сразу поменялось. Продакт-менеджер предложил показывать новый вопрос только core-аудитории —
лояльным пользователям, регулярно пользующимся Go, а количество заказов сделать настраиваемым параметром. «А что, если требования опять поменяются? Как лучше всего решать такую задачу?» — подумал Вася. Есть несколько вариантов.
Вася мог бы прописать всю логику на бэкенде: тогда для принятия решений приложение будет смотреть в ответы бэкенда. В Яндекс Go это выглядит так: пользователь ставит оценку текущему заказу. Приложение отсылает результат на бэкенд и получает в ответ флажок, нужно ли показывать дополнительный вопрос и данные для него. На сервере при этом может быть реализован алгоритм любой сложности — эта логика полностью скрыта от мобильного приложения.
Преимущества:
Недостатки:
Если бы Вася выбрал этот вариант, он зашил бы всю бизнес-логику в мобильное приложение. Это значит, что бэкенд становится поставщиком всех необходимых данных: например, возвращает оценки предыдущих заказов, пороговые значения, когорту текущего пользователя. Основная логика действий при этом прописана в коде приложения. Там же происходит проверка разных условий и разбор всех возможных случаев, учтены любые другие пожелания продакт-менеджера и прописан алгоритм действий на случай проблем.
Преимущества:
Недостатки:
Есть у Васи и третий вариант: бэкенд может присылать на клиент и данные, и алгоритм, действий.
Этот способ позволяет совместить достоинства обоих подходов за счёт добавления ещё одного слоя абстракции. Можно передавать с бэкенда и необходимые данные, и сам алгоритм вычисления нужных величин в некотором виде. Чтобы решить задачу Васи, нужно на стороне приложения вычислять булев флаг, показывать ли дополнительный вопрос.
Мобильное приложение имеет доступ:
Остаётся научить его интерпретировать и подставлять переменные в полученный алгоритм. При необходимости можно считать и более сложные вещи: какие из данных бэкенда отрисовать, в какой API ходить для сохранения.
Алгоритм может быть передан, например, в виде заранее условленного набора инструкций прямо в JSON. Или в виде JavaScript-кода с шаблонизацией. Или даже в виде байткода со своим интерпретатором.
Недостаток гибридного способа — дороговизна его имплементации. Тем не менее, в Яндекс Go есть несколько мест, где такой подход успешно используется.
Вася пообщался с коллегами и остановился на варианте с тонким клиентом. Команды бэкенда и мобильной разработки дружно сказала, что толстый клиент — это плохо по указанным выше причинам. Особенно — потому что любое расширение функционала требует двойного объёма работ.
Когда в тестинге появилась работающая реализация, её решили показать продакт-менеджеру. В это время продакт-менеджер находился в другой стране, но согласился отвлечься от отдыха и посмотреть на результат. Прогнав тестовый заказ, он не увидел дополнительных вопросов в окне фидбека. Начали дебажить.
По логам оказалось: в стране, где находился продакт-менеджер, отправка текущей оценки и получение в ответ дополнительных вопросов занимала больше секунды T_not_russia > 1s
. Типичный пользователь просто не видит вопросы, поскольку за это время успевает поставить и сохранить оценку.
Команда погрузилась в холивары: оставить всё как есть или же сделать толстый клиент, чтобы избежать долгих запросов. Продакт-менеджер убедил всех в необходимости более отзывчивого UX. Яндекс Go — международная компания, и фидбек от зарубежных пользователей важен. Они должны видеть этот дополнительный вопрос. Также во многих регионах России всё ещё распространен 3G, на котором наблюдается такая же проблема с latency.
В итоге Вася и его коллеги пришли к соглашению двигаться итеративно: быстро решить проблему в рамках текущей задачи, но также подготовить задел на будущее. Они договорились делать толстый клиент, получающий все необходимые данные с бэкенда. И параллельно начали прорабатывать обобщенный интерпретатор формул для гибридного клиента.
Интересные статьи, где тоже выбрали толстый клиент:
Вывод: Не всегда толстый клиент — это плохо. UX пользователей — прежде всего.
Идемпотентным называют такой метод API, повторный вызов которого не меняет состояние ресурса. Почему идемпотентность так важна, разбирались в предыдущей статье о Васе [1]. Предлагаю вспомнить на примере.
Через несколько дней к Васе постучался его знакомый из саппорта — Миша. Он рассказал, что его команде часто прилетают дублирующиеся задачи по новой фиче. И саппортам приходится тратить много времени на их дедупликацию. Вася пообещал разобраться. Его новый код в 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 [1].
<#Продакт-менеджер предложил Васе добавить новую фичу — ввод размера чаевых на экране фидбека. Если пользователю понравилась поездка, он может оставить 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 [8], 2 [9], 3 [10], 4 [11], 5 [12]).
Вот список нескольких типичных проблем:
id
. Позволяет получить информацию о количестве объектов.Чтобы избежать этих ошибок, в Яндекс Go, как и в других крупных компаниях, все внешние API проходят отдельный аудит безопасности.
Вывод: Чтобы поймать шпиона, надо думать как шпион: проверяйте насколько безопасен ваш API и насколько чувствительные данные доступны через него.
На примере создания простой фичи я рассказал, с какими проблемами при разработке API может столкнуться начинающий разработчик.
О чём стоит помнить:
До разработки:
Во время разработки:
После разработки:
Проектирование API микросервисов — одна из повседневных задач в Яндекс Go. Все большие проекты сервиса в конечном итоге строятся из множества маленьких интерфейсов, скрывающих за собой детали реализации.
При наличии хороших интерфейсов можно уделять больше внимания техническим решениям и архитектуре. Хороший API позволяет нам быстрее внедрять новые фичи, тратить меньше времени на поддержку, уменьшать количество проблем на проде и внедрять эффективные фоллбэки. Про эти процессы мы расскажем в других статьях.
Автор: Ивашковский Иван
Источник [13]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/interfejsy/368778
Ссылки в тексте:
[1] здесь: https://habr.com/ru/company/yandex/blog/442762/
[2] OpenAPI 3.0: https://swagger.io/specification/
[3] Run, config, run: как мы ускорили деплой конфигов в Badoo: https://habr.com/ru/company/badoo/blog/544102/
[4] Как раскатывать опасный рефакторинг на прод с миллионом пользователей?: https://habr.com/ru/company/manychat/blog/499034/
[5] в выступлении моего коллеги Максима Педченко: https://www.highload.ru/spring/2021/abstracts/6581
[6] Киберпанк, который мы заслужили, или как Prisma превращает ваши селфи в произведение искусства: https://habr.com/ru/company/prisma/blog/559028/
[7] V8 в бэкенде С++: от одного JS-скрипта до фреймворка онлайн-вычислений: https://habr.com/ru/company/yandex/blog/572880/
[8] 1: https://habr.com/ru/post/480956/
[9] 2: https://tjournal.ru/analysis/412915-v-zakrytom-reestre-minzdrava-v-pyat-raz-bolshe-sluchaev-covid-19-chem-oficialno-glavnoe-iz-rassledovaniy-treh-izdaniy
[10] 3: https://habr.com/ru/news/t/453186/
[11] 4: https://habr.com/ru/post/536750/
[12] 5: https://habr.com/ru/post/547272/
[13] Источник: https://habr.com/ru/post/583332/?utm_source=habrahabr&utm_medium=rss&utm_campaign=583332
Нажмите здесь для печати.