Легко ли разработать новый 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();
}

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


Это значит, что пока все пользователи не обновятся, запросы будут приходить как от старой версии приложения, так и от новой. Поэтому, чтобы не сломать сервис из-за несовместимости 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 позволяет нам быстрее внедрять новые фичи, тратить меньше времени на поддержку, уменьшать количество проблем на проде и внедрять эффективные фоллбэки. Про эти процессы мы расскажем в других статьях.

Комментарии (24)


  1. roboter
    14.10.2021 11:46
    +8

    Федя предупредил Васю, что приложение раскатывается в AppStore и GooglePlay постепенно: в отличие от обновлений бэкенда,

    А оказалось, что бэкенд тоже раскатывается постепенно.


    1. ARechitsky
      14.10.2021 13:34
      +2

      Выдергиваете из контекста.

      в отличие от обновлений бэкенда, в этом случае откатить приложение до более низкой версии не получится

      Бекенд можно оперативно закатить обратно, если что-то пошло не так. Но да, конечно, бекенд раскатывается не мгновенно (если у вас не один сервер), это нужно учитывать.


      1. roboter
        14.10.2021 13:59
        +1

        Что я выдёргиваю? Вторая часть как бы про откат, к ней претензий нет.

        Первая же часть прямо утверждает, что бэкэнд раскатывается мгновенно.

        Приложение = постепенно.
        бэкенд + в отличие от = мгновенно.

        Может у меня конечно с русским языком плохо, и значение "в отличие от" стало уже другим?


        1. ARechitsky
          14.10.2021 14:14

          Я не особо филолог, но двоеточие - "более сильный" разделитель, чем запятая. "В отличие от" относится к откату, а не к "постепенно". Возможно, лучше было написать "постепенно и неотвратимо" - в отличие от бекенда, который можно откатить. Получается, что кусочек "в отличие от" вы выдергиваете из контекста отката.


          1. roboter
            14.10.2021 15:27
            +1

            Хорошо, я не буду утверждать как понял эту задачу тот-же Вася.
            Но останусь при своём мнении, тут сделан акцент на развёртывании приложения, про откат вообще не думаешь на этапе обдумывания изменений.
            Понятно что откат приложения болезненней.
            Так что 1/2 вины на продакт-менеджере, сделал акцент на приложении, забыв про бэкенд.


  1. MentalBlood
    14.10.2021 12:06
    +6

    GET /feedback-screen и POST /save-feedback

    Это нормальная практика? Почему бы не GET /feedback и POST /feedback например? POST /save-feedback вообще как масло масленое


    1. iv-ivan Автор
      14.10.2021 12:29
      +4

      Спасибо за вопрос!

      В статье я намеренно привожу такие именования - так проще воспринимается текст. Не нужно лишний раз думать во время чтения, идёт ли речь о сохранении или получении данных.

      В целом API сильно упрощён, о чем я упомянул вначале. В Такси у нас в основном RESTfull API у всех микросервисов, но бывают и исключения - при разработке мы стараемся исходить из здравой логики.

      Скорее всего в продакшене endpoint был бы таким, как вы предположили - POST /feedback


      1. arthuriantech
        14.10.2021 12:48
        +1

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

        HTTP-пуристы могли бы предложить что-то вроде PUT /feedback/<order_id> (при этом имеем ввиду, что нейминг с REST никак не связан).


        1. ARechitsky
          14.10.2021 13:18

          GET /order/<id>/feedback-screen и PUT /order/<id>/feedback

          Подразумевая, что если я что-то сохранил через PUT, через GET я получу примерно то же самое. Очевидно, что feedback и feedback-screen - разные ресурсы.


  1. MilashchenkoEA
    14.10.2021 13:42

    А как ведётся статистическая обработка данных по работе сервиса? Есть отдельные микросервисы, которые выдают всевозможную статистическую информацию по тем или иным моментам по запросу?


    1. iv-ivan Автор
      14.10.2021 14:15
      +1

      Спасибо за вопрос!

      На все сервисы у нас есть риалтайм мониторинги и графики с кодами ответов, потребляемыми ресурсами и другими метриками. Время реакции у них не больше 1 минуты - по ним мы оперативно можем замечать текущие проблемы.

      Если нам хочется более подробной информации по работе сервиса - сервисы пишут логи, логи уезжают в Elasticsearch для оперативного поиска и хранятся там 2 дня. Для долгосрочного хранения и анализа больших объёмов они также архивируются на Mapreduce (в Яндексе есть свой, называется YT)

      По эластику можно искать логи и что-то считать через UI - через Kibana.

      Но для каких-то частотных сценариев у нас написан свой “микросервис логов” - он, например, умеет разархивировать логи с YT и заливать из обратно в эластик, позволяет быстро накликать фильтры (например логи по сервису, городу или заказу), склеивает и показывает логи в рамках одного запроса (простая реализация opentracing) и тд.


      1. MilashchenkoEA
        14.10.2021 17:34

        Понял. Могу я, например, с ходу получить статистику по определенному городу, в какое время суток наибольший или наименьший спрос по районам (зонам)? И тому подобные вещи.


  1. alex_gopher
    14.10.2021 15:30

    не понял насчет гибридного метода, передавать алгоритм не совсем секьюрно. может в текущей реализации для модалок и опросов сойдет, но в целом, ничто не мешает клиенту подменять алгоритм на выгодный для себя

    ну то же касаемо и толстого клиента. некоторая бизнес-логика должна быть защищена и может быть только на бэкенде


    1. iv-ivan Автор
      14.10.2021 15:41

      Да, вы правы

      Если сервис внутренний, то проблема безопасности может быть не так критична.

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

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


      1. alex_gopher
        14.10.2021 17:28

        По толстому клиенту есть проблемы для небольших компаний) вам конечно безразлично.

        Есть скажем ios-приложение, android с гугл-сервисами, android/huawei. не берем в расчет всякую экзотику аля windows phone. есть веб-приложение. возможно у кого то есть еще и десктопные.

        И вот меняется логика выползания какой то модалки и .... это ж какие ресурсы надо иметь

        чтоб везде править логику. тонкий клиент дешевле обойдется


  1. devalio
    14.10.2021 19:54

    Можно я немного позанудствую по поводу идемпотентности?

    Я понимаю посыл, который в этой части вложен, понимаю, что там описана такая логика ретрая (ну может еще с отбоем по кол-ву ретраев):
    `repeat until PostSaveFeedback(stars, reasons, better_quality_reasons, order_id)`
    но представленное решение не до конца правильное. Истинной идемпотентности можно было добиться дополнительно изменив метод запроса /save-feedback с POST на PUT.

    В таком случае не сломается логика, если по прошествии трех плохих ретраев обработчик вернет управление UI, а там пользователь сможет поменять ответ (ну кто его знает, бизнес-требования все время изменяются). В текущем решении мы рискуем получить разные данные для better_quality_reasons между вот этими вызовами:
    send_to_support(better_quality_reasons, support_task_id);
    write_better_quality_reasons_to_db(better_quality_reasons, order_id);
    ведь send_to_support отвалится с DuplicateTask

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


    1. iv-ivan Автор
      14.10.2021 21:44
      +1

      Хороший пример, спасибо

      Он конечно не совсем про идемпотентность - по ее определению подразумевается, что функция была вызвана с теми же самыми аргументами. Но все равно интересный кейс

      Не очень понял, как конкретно смена http-метода помогает избежать этой ситуации.

      Тут скорее нужен просто правильный код под капотом - например, безусловно перезаписывать старые данные, если новые отличаются.

      Ну и конечно интересно сразу подумать про гонки - что в каком порядке вызывается, если параллельно пришло несколько разных параллельных запросов, и что делать с согласованностью данных.

      Чтобы не думать об этом, параллельные запросы можно запрещать на уровне UI (блокирующий UI, запрет кросс-девайс запросов - проще, но не защищает от curl из консольки), либо более умно - через глобальный лок или через разбор случаев, атомарные операции над ресурсами (типично в продуктовой разработке - ресурс это БД) и проверки, что никакой другой запрос не успел параллельно поменять те же самые данные (тоже типично - на уровне SQL). Второй способ более сложен в разработке и понимании людьми вне контекста задачи, и часто бизнес-требования не такие строгие, допускается просто не переспрашивать фидбек, если не вышло единожды.


      1. devalio
        15.10.2021 06:55
        +1

        Ага, это я перепутал на какой стороне Вася написал код.

        Сейчас попробую развернуть свою мысль про PUT. Если мы примем за факт то, что одна поездка может иметь только одну оценку, то ключом идемпотентности всего запроса можно считать order_id. Семантика метода PUT говорит нам о том, что ресурс должен быть изменен (или создан, если его еще не существует), что дает нам право рассчитывать на то, что повторный вызов этого метода не будет пытаться создавать новые ресурсы (задачи для саппорта). Согласно RFC7231 метод PUT является идемпотентным, а POST - нет.

        Ок, я понимаю, что в Вашем примере Вася сделал идемпотентным вызов send_to_support, но по поводу остального мы никаких гарантий дать не можем. Т.е. я, например, не уверен, что вызванный дважды write_reasons_to_db не создаст две записи в БД.

        Теперь представим, что мы изменили метод на PUT, и в соответствии с его семантикой давайте попробуем изменить код под капотом, для этого я предлагаю использовать в функциях работы с БД не INSERT, а UPSERT. Я уверен, что сейчас оно так и есть, но вот название функции add_rating наводит меня на мысль, что под капотом у нее что-то вроде INSERT. Если это не так - изменим ее название.

        После того, как мы убедились, что все вставки в БД работают как UPSERT и при создании новой заявки в суппорт (что тоже лучше сделать как PUT) не создается дубликатов, мы можем считать, что код работает как обработчик PUT. Может быть в моем тексте много букв и кое-что уводит от того посыла, который я пытаюсь сюда вложить, поэтому я сейчас выражу его в пунктах:

        • давайте стараться не делать ретраи для POST, хотите ретраить - безопаснее это делать на бэкэнде (ну запуште сообщение в брокер и обрабатывайте, когда сможете, в Вашем примере пользователю все равно, когда это произойдет)

        • если мы сможем сделать идемпотентным все тело обработчика, то можно использовать метод, который в RFC7231 обозначен, как идемпотентный (тогда даже прокси сможет сделать вам ретрай)

        • если выполнение запроса отвалилось с ошибкой 5xx, то хорошо бы, чтобы мы не имели неконсистентные данные (хочется атомарности, откатите половину изменений, которые внесли)

        Кроме того, если send_to_support поставить после вызова write_better_quality_reasons_to_db, то отказоустойчивость этого эндпоинта улучшится прямо на пустом месте за бесплатно.

        Ну и все это пока не касается гонок, curl и прочих особенных случаев. Только безопасного повторного выполнения


  1. navferty
    15.10.2021 01:22
    +1

    Вася изменил тип переменной на decimal64 — это позволяет передавать цену как строку в API, а в коде работать с ней как с числом с плавающей точкой без потери точности.

    А потом вдруг выяснилось, что у клиента была списана сумма чаевых в 10 раз больше... В чём же дело? Оказывается, в локали приложения число 1.5 было сериализовано с запятой, а парсер бэкенда в локали en-US "проглотил" запятую - для него десятичный разделитель - это точка. Вот и списали 15 баксов...

    А избежать этого можно было бы, либо передавая число число (json стандарт это позволяет), тут правда надо учесть отдельным полем валюту. Либо стандартизировать сериализацию.


    1. jirfag
      15.10.2021 09:53

      спасибо, в целом валидно, что тут много тонкостей

      парсер бэкенда в локали en-US "проглотил" запятую

      обычно на такое юнит-тесты пишут, особенно если работа с деньгами

      передавая число число (json стандарт это позволяет

      с этим связаны свои риски, что дальше на таком double (вместо decimal) по ошибке пойдет арифметика и получим проблемы с точностью.

      Либо стандартизировать сериализацию

      мы в Яндекс Go передаем суммы как int в API - умножаем decimal на 10000 (знаем что больше 4-х знаков не поддерживаем) - там где нужна обработка значения. Там где не нужна, например, просто отрендерить на клиенте текст, там строкой.


    1. iv-ivan Автор
      15.10.2021 11:05

      Спасибо за коммент!
      Вижу, что вам уже ответил Денис Исаев.

      Я уточнил в своей статье, что decimal конечно нужен, только когда начинается арифметика, и мы можем потерять в точности чисел при операциях с ними.

      Вы правы, в самом API можно передавать число как число. В таких случаях надо помнить, что в коде бэкенда не стоит делать промежуточную десериализацию этого числа в float из-за неточности представления, нужно создавать decimal сразу из полученной строки. Можно посмотреть, почему так, на примере метода from_float тут: https://docs.python.org/3/library/decimal.html


  1. dom1n1k
    15.10.2021 16:17
    +3

    Оффтоп.
    Сделайте возможность забанить водителя, не выставляя ему оценку. Иногда бывает, что водитель не сделал ничего объективно ужасного, но по каким-то субъективным причинам он мне не нравится. И мне неудобно портить ему рейтинг плохой оценкой, но и увидеть его во второй раз тоже не хочется.


  1. rudinandrey
    18.10.2021 15:44

    >>> Запрос прилетал на машины бэкенда, куда ещё не успел раскатиться релиз.

    а можно вопрос вот про эту часть задать? есть скажем 1_000_000 девайсов, 1_000 серверов. запрос с каждого девайса распределяется на какой то "случайным" образом выделенный сервер, как у вас это происходит? есть какая то точка входа скажем api.yandex.ru за ним лоад балансер который распределяет запрос на один из 1_000 серверов? или при начале работе выделяет за клиентом какой то сервер и он с ним общается? или каждый запрос каждый раз случайным образом попадает на разный сервер?

    есть ли единая точка входа? что если она упадет? откуда приложение знает на какой сервер идти отправлять запрос?


    1. iv-ivan Автор
      18.10.2021 17:24

      Выделенного соединения между клиентами и сервером в обычных сценариях общения нет. Между ними обычно стоят лоад-балансеры (как L3, так и L4). Они балансируют запрос в определенный датацентр, затем как-то выбирают между всеми инстансами бэкенда (типично - по round robin). Для проверки доступности инстансов мы используем как пассивные, так и активные проверки.

      При общении между микросервисами каждый из них тоже закрыт балансировщиком, и запросы идут через него, но тут есть нюансы. Иногда мы не хотим делать кросс-ДЦ запросы на этом этапе, а хотим ходить только внутри одного датацентра для уменьшения latency. А иногда вообще балансировщик выглядит лишним, если есть хороший service-discovery, то можно прикрутить клиентскую балансировку и ходить напрямую.

      При этом для узкого класса задач у нас всетаки устанавливается прямое TCP-соединение между бэкендом и каким-то объектом. Например, в этот класс попадают все задачи по телеметрии самокатов, которые мы запустили этим летом.

      В дополнение к своему комменту приведу ссылку на хорошую вводную статью про балансировку: https://habr.com/ru/company/mailru/blog/347026/