Стажёр Вася и его опыт разработки нового 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 микросервиса конфигов. Таким образом можно максимально быстро изменить конфигурацию бэкенда и выключить сломавшуюся функциональность.


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


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


  • 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 может столкнуться начинающий разработчик.


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


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


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

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


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

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


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


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


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

Источник: https://habr.com/ru/company/yandex/blog/583332/

Интересные статьи

Интересные статьи

Сколько раз вы были в ситуации, когда вы хотели добавить новую функцию в свое приложение, но не могли, потому что код был не масштабируемый? Сколько раз вам приходилось переписывать код, чтобы сделать...
Я старый. При этом я в ладу с собой. Я не лежу ночью, беспокоясь о своей старости. Но прекрасно понимаю, что я определённо стар — по крайней мере в смысле программирования. Большинство не...
Вопрос разработки и приготовления плейбуков по реагированию на инциденты сейчас очень активно обсуждается и порождает разное количество подходов, поиск баланса в которых крайне важен....
Привет Хабр! Язык программирования Swift обладает большой популярностью ввиду его использования в написании приложений под iOS, поэтому его развитие представляет интерес для всех заня...
Рассказываем о его компонентах и перспективах. / фото Gozha Net Unsplash Почему появился новый стек Существует множество стеков для разработки программного обеспечения. Один из наиболее из...