Разработка финальной версии интерфейса
ПиццаФабрика ITНефункциональные требования к системе
Команда подошла к реализации финальной версии уже дав пользователям отзывчивый интерфейс, которым начали пользоваться логисты. Нам предстояло перенести всю логику работы на бэкенд так, чтобы пользователи не заметили, что состояние сборки контролируется сервером, который ещё успевает рассылать уведомления в интерфейс других логистов в одном филиале.
Работа с мобильных устройств на нестабильном канале связи
Для быстрой сборки заказа интерфейс должен быть всегда под рукой у логиста и не занимать постоянно руки. Поэтому мы решили использовать служебные телефоны. Но с телефонами другая проблема: не везде на предприятиях он попадает в зону хорошей работы служебного Wi-Fi. Потеря пакетов из-за помех — это то, что делает сетевые запросы как минимум медленными, а как максимум — приводит к постоянным ошибкам и невозможности выполнить запрос или прочитать ответ с данными от сервера.
Классический REST API хорошо подходит для одиночных запросов, но запросы к нему обязательно нужно делать с таймаутами, чтобы при проблемах пользователь не ждал реакции интерфейса слишком долго.
Чтобы доставлять до клиентов realtime обновления, которые выполнены с других девайсов, мы взяли протокол Web Socket. Учитывая нестабильность канала связи, мы решили, что корректность работы интерфейса не должна зависеть от стабильности прихода socket сообщений. Когда со связью всё хорошо — клиент видит уведомления максимально оперативно, но когда они к нему не доходят, то у пользователя всё-равно должны оставаться способы получать актуальные данные без перезагрузки страницы.
Как сделать интерфейс быстрым
Это уже комплексная задача. Пришлось оптимизировать целиком путь от момента нажатия пользователем кнопки до получения ответа от сервера.
Уровень интерфейса
Тут правило простое: минимальная блокировка UI.
Мы не должны блокировать каждое действие пользователя. Для того чтобы это реализовать мы посмотрели на подход Optimistic UI. Он позволяет сделать интерфейс более отзывчивым ценой усложнения логики из-за необходимости отката состояния назад, когда ошибка всё-таки произошла. Его лучше не использовать, когда вероятность ошибок значительная или когда пользователю нужно быть точно уверенным, что действие выполнилось.
Как выглядела реализация: когда пользователь кликает на чекбокс, мы ему показываем, что отметка уже поставлена и параллельно отправляем запрос на сервер с таймаутом, но не меняем цвет шапки панели, хотя могли бы. Если нам вернулась ошибка или сработал таймаут ожидания ответа — мы возвращаем назад состояние чекбокса и сообщаем пользователю об ошибке. После этого он видит такой чекбокс и может нажать по нему ещё раз. И если на этот запрос придёт успешный ответ — мы используем данные из него чтобы, например, покрасить шапку согласно состоянию сборки на сервере. Также мы предупреждаем пользователя, когда он пытается уйти с панели, когда не на все запросы ещё получены ответы или ошибки. Ну и конечно мы должны были разрешить пользователю делать клики сразу на нескольких чекбоксах не дожидаясь ответа.
Уровень API
Тут обошлось без экспериментов и сработали классические подходы: минимум запросов, возможность максимальной параллелизации этих запросов и batching (отправка нескольких команд за один запрос на сервер).
Уровень сервиса
Опыт профилирования бэкенда подсказывал, что основное время система проводит ожидая данные из базы. Поэтому предстояло организовать такой способ работы с ней, который с одной стороны гарантировал бы выполнение бизнес-правил и консистентность данных в базе и в интерфейсе, с другой — не приводил к лишним операциям.
Переходим к доске
Все требования вроде понятны, мы начали в деталях описывать что делает фронт и бэкенд по каждому действию пользователя, как фронт реагирует на получение socket сообщений. Получилась детальная схема, которую перепроверили несколько раз.
Но когда приступили к описанию REST API, стало понятно, что она не учитывает разные эффекты, которые легко наблюдать в распределённых системах: устаревшие данные на клиенте, задержки в ответах и переупорядочивание сообщений, которые приходят параллельно в Ajax ответах и socket сообщениях про одни и те же сборки.
Data Race Problem, или когда блокировки в базе данных всё-равно не помогают
Известно, что с помощью блокировок в базе данных можно решить проблему конкурентных изменений, когда мы пытаемся одновременно сделать два разных действия, которые ведут к разным новым состояниям. Но даже если на уровне базы данных строго упорядочить применение изменений к данным, при параллельных запросах клиенты могут получать ответы от сервера не в том порядке, в каком изменения выполнялись на самом деле.
Это иллюстрирует следующая схема:
Представим, что клиент делает два одновременных запроса на сервер. Даже если Сервер 1 обработал запрос раньше Сервера 2, нет гарантий, что клиент не прочитает ответ на первый запрос быстрее второго. Причины могут быть разные: первый сервер после работы с базой немного подвис или из-за проблем со связью ответ шёл медленнее обычного. Всё это приводит к тому, что клиент может привести своё состояние совсем не к тому, которое находится в базе. Более того, он может находиться в этом некорректном состоянии бесконечно долго. Добавление отметок времени тоже не спасёт, так как на серверах часы могут быть не синхронизированы. Для решения этой проблемы либо используют версионирование состояния, либо делают такие операции, которые обладают свойством коммутативности (то есть их можно выполнять в произвольном порядке при этом получая всегда один и тот же результат).
Мы выбрали вариант с версионированием: для каждой сборки заводится счётчик, который увеличивается при каждом изменении состояния сборки и он же отсылается в сообщении клиенту. Когда клиент получает новое сообщение, он смотрит на версию: если она больше, чем та, которую он последний раз сохранял у себя, то он перезаписывает свой сохранённый счётчик и забирает актуальные данные из сообщения. Если версия не больше — он просто игнорирует это сообщение. При этом важно учесть, что каждое сообщение должно быть самодостаточно: содержать полное представление объекта, чтобы схема работала даже когда сообщение отбрасывается или теряется.
Этот же приём позволяет нам разобраться в ситуации, когда к нам приходит информация об одном и том же объекте с разных каналов данных — Ajax ответов и socket-сообщений и избежать ситуации, когда интерфейс начинает откатываться к предыдущим состояниям, как показано на схеме ниже:
Stale Client State Problem
В распределённой системе кто-то всегда может иметь устаревшие данные и на их основе делать некорректные действия. Рассмотрим пример:
Для решения этой проблемы используется подход, когда клиент отправляет на сервер не только команду, но и предоставляет нечто, что позволяет определить — обладает ли клиент актуальными данным. В HTTP для этого используются ETag, но в чистом виде их мы использовать не могли, т.к. установка любой отметки — это изменение ETag ресурса, а мы хотим разрешить параллельные запросы. Актуальность данных нам важна не на уровне всей сборки, а на уровне отдельных отметок. Но т.к. мы хотим предоставить возможность одним запросом изменять несколько отметок, то единого ресурса, связанного только с конкретной отметкой, у нас не может быть. Поэтому пришлось выкручиваться чуть иначе.
Непосредственно, API
Натягиваем REST на операции с сущностями
У нас есть сущность сборка, которая привязана к заказу. Нам необходимы операции:
- Передать информацию по всем сборкам для первоначальной отрисовки интерфейса.
- Сделать текущего пользователя сборщиком заказа и в случае успеха вернуть состояние сборки. Эта операция будет выполняться, когда пользователь пытается перейти в панель сборки заказа.
- Перестать быть сборщиком заказа, если им является. Выполняется при уходе с панели сборки.
- Устанавливать и снимать отметки о том, что наличие элемента из чек-листа проверено сборщиком. Выполняется, когда пользователь кликает по чекбоксу.
Чтобы придерживаться конвенций REST API, пришлось пораскинуть мозгами как эти операции выразить в виде работы с ресурсами. Основной трюк был в том, что ресурсы не обязаны быть сущностями. Вы можете придумать «виртуальные ресурсы», операции с которыми будут делать то, что надо и при этом не слишком противоречить REST API,
Вот как выглядел наш API в формате Open API Specification.
Мы не будем расписывать полностью каждый endpoint (можете посмотреть этот момент в видео <тут>), а акцентируем внимание только на важных моментах.
Конфликты между фронтом и бэкендом
В API возможны ситуации, когда фронт не знает о каких-то изменениях, которые произошли с сущностью, и из-за этого мы должны отказывать в выполнении корректного, с точки зрения спецификации, запроса. В нашем случае: когда два пользователя пытаются одновременно стать сборщиками одного заказа. Для возврата таких ошибок спецификацией HTTP предусмотрен отдельный код 409 Conflict. Но мало вернуть этот код, необходимо добавить в ответ информацию, по которой клиент точно сможет понять: в каком состоянии находится ресурс, что операция к нему сейчас не применима.
Этот же код мы использовали для предотвращения отправки отметки для уже отменённой позиции или изменённой сдачи. Для контроля за такими операциями, в представление каждого элемента чек-листа было строковое поле tag, которое генерировалось сервером и менялось им, если позиция отменялась, или если это была сдача и менялся её размер. Клиент должен был помимо идентификатора элемента чек-листа отправлять этот tag. Если на сервере tag из запроса не сходился с tag-ом из базы — клиент получал 409 Conflict и текущее представление сборки. После этого он показывал пользователю сообщение, что чек-лист был изменён и подсвечивал: какие конкретно элементы нужно перепроверить логисту.
![](/file/bd0a558828749cd56b361.gif)
Гетерогенные коллекции в JSON
Как вы наверно помните, состав чек-листа не однороден: тут и позиции заказа и сдача и разные типы подарков.
В принципе, Open API Specification позволяет выразить коллекцию из разных объектов. Но в языках со строгой типизацией (у нас был Golang), такие трюки делать сложно, если кодогенератор вообще сможет вам создать объекты, откуда вы сможете вычитать полученные данные. Поэтому было принято решение:
- Выделить общие поля у разных типов элементов чек-листа.
- Все отличия вынести в разные опциональные отдельные под-объекты.
- В родительском объекте завести поле, которое бы указывало — какой тип элемента чек-листа в этом объекте.
Такой подход позволяет разобрать любой пришедший объект, не лишая себя преимуществ статической типизации.
Рекомендации по структуре JSON объектов
- Не делайте разницу между отсутствующим полем и null значением. Не все инструменты могут отличать эти случаи.
- Возвращайте пустые массивы как [], а не как null. В JavaScript любят использовать функцию forEach(), которая падает, если массив будет null.
- Никогда не возвращайте денежные единицы в виде float. В интернете есть куча статей как это делать правильно.
Описание WebSocket API
Для него мы использовали формат Async API. Это надстройка над Open API Specification, которая позволяет выражать источники сообщений и формат данных сообщений.
У нас было два сообщения: при отмене позиции мы должны уведомить об этом логиста как можно раньше и, чтобы видеть что делают другие логисты, мы должны получать сообщения об изменениях в сборках всех заказов для предприятия.
Бэкенд сам не занимается доставкой этих сообщений. Для этого у нас есть отдельный платформенный сервис, которому мы просто отправляем запрос: подписчики какого сообщения должны получить указанные данные.
Как видно из примера, в каждом сообщении у нас есть то самое поле version, которое помогает фронтенду понимать — актуальны ли для него эти данные.
ВЫВОДЫ
Умей работать в двух режимах: «делай быстро» и «делай хорошо»
Это два независимых друг от друга режима. Полезно работать итерациями, когда «непонятно», когда при каждом новом шаге мы не уверены в правильности направления. Мы использовали MVP для сбора low-hanging fruit (хоть как-то работающий инструмент нужен был логистам ещё вчера) и фидбэка.
Когда стало «понятно» — честно переписали большую часть кода, чтобы перед пользователями (имеются ввиду все: бизнес, юзеры и разработчики, которые будут это дальше поддерживать) не было стыдно.
Дизайн-документы предполагают, а практика — располагает
Всегда что-то пойдёт не так, как не пытайся всё предусмотреть заранее.
Ускоряйте интеграцию между коллегами в команде
Мы использовали подход API First, когда сперва договорились о API, а уже потом пошли его реализовывать.
Когда код бэкенда ещё дошлифовывался, а фронтенд уже был готов, фронтендер сам собрал бэкенд сервиса локально и попробовал поработать с ним. Это позволило найти первые ошибки ещё до того, как код бэкенда был залит в общую ветку в репозитория.
В «ПиццаФабрике» все доработки проходят этап code review, который выполняют самые опытные сотрудники. Но когда ваши доработки достаточно сложные и требуют глубокого погружения ревьювера в вашу задачу, намного эффективнее начать проводить ревью уже внутри команды.
Инварианты — наше всё
Критически важно описывать свойства объектов, которые создаёте и обеспечивать, что эти свойства всегда выполняются. Только так можно строить большие системы из компонентов. Эти свойства могут быть продиктованы бизнесом («текстовое описание прогресса заполнения чек-листа должно быть синхронизовано с отметками в нём») или техническими протоколами («любое изменение сборки увеличивает version»).
Хорошая архитектура и API не рождаются с первого раза
Как сказал Kent Beck: «Make it work, make it right, make it fast». «Сначала сделайте, чтобы это работало, а потом сделайте это как следует, а потом сделайте это быстрым».
Так было и у нас. Первая версия бэкенда была очень простая, но уже работающая. Когда мы посмотрели на получившийся код, он буквально кричал о том, какая архитектура должна быть: что с чем связано, а что стоит отделить. Выделенные компоненты стали понятны и хорошо тестируемы. Это позволило заняться оптимизацией работы с базой данных не трогая ничего другого.
Всегда есть чему поучиться у других
Во время работы над этим модулем пришлось перечитать много статей, которые читали раньше. Это нормально: без практики многое забывается.
В первую очередь рекомендуем почитать книгу Сергея Константинова «The API». https://twirl.github.io/The-API-Book/index.ru.html
Это практически исчерпывающий материал по теме дизайна REST API.
Что дальше
В этих двух статьях мы вкратце рассказали о разработке функционала по сборке заказов. Но остались ещё более узкие истории: как наш фронтендер строил Optimistic UI на React и Redux, как бэкендер пытался адаптировать идеи Domain Driven Design к языку Golang, как тестировщик придумывал тесткейсы и тестировал сложные сценарии сетевых отказов. Если эти темы интересны, мы по возможности опубликуем и эти доклады.