Стажёр Вася и его опыт разработки нового API

Легко ли разработать новый API? На что обратить внимание, чтобы не ошибиться при реализации, и к каким компромиссам стоит быть готовым?

Привет, Хабр! Меня зовут Иван Ивашковский. Я руковожу группой разработки международных проектов в Яндекс Go. Этот пост — продолжение цикла историй о вымышленном стажёре Васе. Предыдущий материал, про идемпотентность, можно почитать здесь. В посте я расскажу, как Вася разрабатывал API для новой фичи и с какими проблемами он столкнулся в процессе. В конце приведу чеклист с советами, как проверить себя на каждом этапе разработки, если вы решаете похожую задачу.

Новая задача Васи

Васе поставили задачу улучшить сбор фидбека о поездках на такси.

Продакт-менеджер предложил задавать вопрос «Почему эта поездка была лучше предыдущей?» каждому пользователю, который оценил текущий заказ выше, чем прошлый. Ответы нужно сохранять в базу данных и отсылать в систему саппорта.

Это окно фидбека. Пользователь видит его при завершении поездки

Тимлид был в отпуске, но Вася быстро придумал решение самостоятельно.
За показ окна фидбека и сохранение ответов на бэкенде отвечают два endpoint: GET /feedback-screen и POST /save-feedback.

Упрощённый API приведён ниже.

В Яндекс 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();
}

Федя предупредил Васю, что приложение раскатывается в AppStore и GooglePlay постепенно: в отличие от обновлений бэкенда, в этом случае откатить приложение до более низкой версии не получится. Доступ к новой версии сначала открывают для маленького процента пользователей, чтобы быстро остановить распространение, если что-то сломается.

Это значит, что пока все пользователи не обновятся, запросы будут приходить как от старой версии приложения, так и от новой. Поэтому, чтобы не сломать сервис из-за несовместимости API POST /save-feedback, Вася научился обрабатывать в коде разные форматы входного запроса: и старый, и новый. Получилось примерно так:

if (reasons.IsArray()) { DoOldStuff();
} else if (reasons.IsDict()) { DoNewStuff();
}

Команда написала тесты. В тестовой среде всё заработало, и продакт-менеджер дал добро на раскатку. Новая версия приложения поехала в сторы, а бэкенд поехал в прод.

Небыстрый откат

Вася был очень доволен, что сделал фичу. Настолько, что даже просмотрел начало проблем при выкатке: сервис начал падать на запросах POST /save-feedback.

Вот что произошло:

  1. Сервис выкатился на несколько машин.
  2. Запросы GET /feedback-screen начали отдавать данные для дополнительного вопроса «Почему эта поездка была лучше предыдущей?»
  3. Новое поле prev_order_stars в ответе GET /feedback-screen включало в приложении фичу, если рейтинг текущего заказа был выше, чем предыдущего. Приложение начало сохранять фидбэк через новый API POST /save-feedback, отсылая туда словарь с ответами на несколько вопросов.
  4. Запрос прилетал на машины бэкенда, куда ещё не успел раскатиться релиз.
  5. Старый код ожидал массив на входе, а приходил словарь — сервис падал на десериализации данных.

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

Что Вася мог сделать в этой ситуации, чтобы проблем не возникло:

  1. Изначально не делать несовместимых изменений в API. Интерфейс остался бы таким, чтобы с ним успешно работала как старая версия кода, так и новая. Для задачи, которую решал Вася, можно было бы класть словарь причин в новое поле multiple_reasons, оставив reasons неизменным.
  2. Разбить работу на два этапа. Сперва подготовить сервис к изменениям в API, научить его работать как со старой, так и с новой версией API и выкатить это изменение в прод. Затем включить новую функциональность конфигом или вторым релизом.
  3. Версионировать API, например GET /v2/feedback-screen, POST /v2/save-feedback. Это предполагает создание нового endpoint с собственной логикой и правильную последовательность релизов: сначала выкатывается бэкенд с новой версией, затем на обновление переключаются мобильные приложения.

В реальности во время релиза в продакшн-окружении пойти не так может что угодно: появятся сложноуловимые баги, обнаружатся крайне редкие кейсы, обрабатывать которые не планировалось, возникнут проблемы с ростом потребления CPU и RAM. Поэтому Васе всё же стоило добавить возможность быстро отключить новую функциональность. Даже если ему казалось, что он всё предусмотрел. Полагаться на включение-выключение посредством релиза ненадёжно, потому что это долгий и не всегда предсказуемый процесс.

Для решения этой задачи коллеги Васи в Яндекс Go сделали микросервис конфигов, инкапсулирующий в себе логику их хранения, получения и изменения. Каждый сервис периодически опрашивает этот микросервис, чтобы получить и закешировать актуальную версию своих конфигов. В веб-интерфейсе админки можно посмотреть и поправить любой конфиг, сохранив результат через API микросервиса конфигов. Таким образом можно максимально быстро изменить конфигурацию бэкенда и выключить сломавшуюся функциональность.

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

Несколько полезных статей с Хабра о быстром контуре конфигурации:

Также более полно проблема раскрыта в выступлении моего коллеги Максима Педченко о надёжности сервисов Такси на 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.

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

Интересные статьи, где тоже выбрали толстый клиент:

Вывод: Не всегда толстый клиент — это плохо. 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 может столкнуться начинающий разработчик.

О чём стоит помнить:

  1. До разработки:

    • максимально уточните продуктовый контекст задачи — это поможет выбрать правильную реализацию и избежать проблем с корнеркейсами.

  2. Во время разработки:

    • подумайте, как планируется развивать фичу, чтобы сразу подготовить задел на будущее;
    • не забывайте о безопасности ваших данных: кто-то обязательно будет их исследовать;
    • проверьте и перепроверьте себя: типичные проблемы с API связаны с идемпотентностью, несовместимостью, состоянием «гонок» и неучётом редких случаев.

  3. После разработки:

    • убедитесь, что вы сможете быстро выключить новую функциональность в продакшене: по закону Мерфи если что-нибудь может пойти не так, оно пойдёт не так.

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

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

Читайте так же: