Проблема
На протяжении года мы с моей командой пишем телеграмм ботов для коммерческого использования малыми бизнесами (аутсорсинг) и столкнулись с большим количеством проблем, самая неприятная из которых это реализация statefull через stateless. Эта проблема знакома всем разработчикам, которые пытались реализовать заполнение формы в TelegramApi.
Разработчики придумывают всякие костыли для этого, потому что в эталонном REST API поддержка состояния от запроса к запросу это нарушение REST-паттерна, однако при заполнении формы приходится использовать состояние.
Актуальность
Первый раз я столкнулся с этой проблемой на первом курсе университета в 2024 году. После долгого серчинга мне пришлось смириться с тем что сегодня за меня код будет писать LLM (вернее помогать). Информации и правда мало, что очень странно, так как проблема нерешенная.
Например неделю назад, в довольно распространенном боте @MergeBot, который специализируется на записи данных о мигренях мне попался странный баг: если достаточно быстро нажать 2 раза на одну и ту же inline-кнопку ответа на первый вопрос, то бот начинает спрашивать третий вопрос, вместо того чтобы перейти ко второму, причем в ответе на второй вопрос даже нету варианта ответа как в первом.
А это значит что реализация состояния на сервере работает с помощью последовательного заполнения формы от запроса к запросу.
3 Проблемы реализации statefull через REST
-
Неидемпотентная обработка callback’ов
Один и тот же пользовательский action может быть доставлен или обработан больше одного раза. Если обработчик каждый раз двигает состояние вперёд, пользователь перескакивает через шаги формы.
-
Нарушение инвариантов сценария
Пользователь может отправить ответ не на тот вопрос, повторить старую кнопку, нажать callback из предыдущего сообщения или вызвать команду параллельно с текущим flow.
-
Невоспроизводимость состояния
Если сервер хранит только
currentStep, невозможно понять, как пользователь туда попал, какой ответ был на предыдущих шагах и где именно flow сломался.
Наивная реализация
Разработчики, знающие как работает Spring Context под коробкой догадались до реализации сразу.
Напомню: Spring Context это просто Map<Class, ConcreteObject>, то есть структура, которая позволяет получать объект в любом месте программы без явного DI (Dependency Injecton).
Поэтому мой выбор первой реализации по аналогии пал на Map<UserId, StateObject>. Это максимально логичная реализация в рамках однопоточного telegram flow.
Серверу приходит объект Update в котором есть вся информация об отправителе и контенте:
Таскать такой объект с огромным количеством полей глупо (Да и не очень хорошо зависеть от реализации Telegram, вдруг завтра мне понадобится перенести бота в ВК), поэтому был написан API для работы с Update: RequestPOJO/AnswerPOJO.
2) Парсим поля которые нам нужны из Update и дальше уже работает с RequestPOJO.
И здесь мы сталкиваемся с паттерном ООП, без которого, кажется, невозможно реализовать взаимодействие с ботом в принципе (Strategy).
Интересна тут реализация самого CommandManager и да, я понимаю что тут куча нарушений принципов SOLID, как, например, сильная перегруженность класса, но для объяснения это даже лучшe.
Уберем из рассмотрения ненужные зависимости, которые не играют роли для контекста данной статьи:
3) У CommandManager есть только 1 метод, и он обрабатывает RequestPOJO и возвращает AnswerPOJO.
Здесь используется отдельный Singleton объект типа CommandUserSessionSaver.class, который хранит все RequestPOJO/AnswerPOJO для каждого пользователя в мапе.
И как ранее обсуждалось ранее, мапа имеет тип <UserId, StateObject>, то есть для каждого юзера мы храним абстрактный объект состояния и тут кроется главное архитектурное решение, которое сподвигло меня написать статью.
Реализация через модель Map<UserId, Flow>
Абстракция Map<UserId, Flow>, где Flow = accepted events
Конкретная реализация Map<UserId, Map<RequestPOJO, AnswerPOJO>>
Модель решает проблемы?
1. Неидемпотентная обработка callback’ов
Скрытый текст
*В примерах ниже используется in-memory Map, потому что она хорошо показывает идею. В production эту же модель лучше переносить в Redis, PostgreSQL или другое хранилище с атомарной операцией вставки
Дубликаты можно сделать безопасными, если обработка запроса идемпотентна: один и тот же Request должен иметь стабильный ключ, корректно реализованные equals/hashCode и не должен повторно менять пользовательский flow
Map<RequestPOJO, AnswerPOJO> не является магическим решением. Оно становится рабочей только если построены три свойства: идемпотентность, атомарное добавление события и проверка допустимого перехода
2. Нарушение инвариантов сценария
Раньше: map.put(UserId_1, Question_2)
Теперь: map.put(UserId_1, user1Map.put(Question_1) )
Невозможно перепрыгнуть без истории, так как мы храним не последнее состояние, а весь flow пользователя и конечно же этот flow используется в коде для проверки состояния (конкретную реализацию проверки можно посмотреть на GitHub)
3. Невоспроизводимость состояния
Восстановление flow / replay / rollback, то есть это буквально реализация высокоуровневого паттерна проектирования (не путать с паттернами ООП), а именно Event Sourcing, философия этого паттерна.
«Не храни только текущее состояние, храни последовательность событий.»
На уровне бизнес-логики это можно рассматривать как lightweight event log, состояние формы становится производным от последовательности принятых запросов.
Благодаря чему появляется возможность replay, rollback и аудит пользовательского сценария (собственно аудит потом и помог повысить конверсию заказов в боте, так что мы убили одним выстрелом двух зайцев).
Важный момент про состояние
Map неупорядоченная структура (если не брать конкретные реализации), как восстанавливается последовательность событий пользователя?
Скрытый текст
🙁
Решение 1 Передавать в объект запроса время и фиксировать состояние по времени события. Но ошибка этого рассуждения в том, что мы как раз боремся с тем, чтобы доверять порядку запросов телеграмм.
🙂
Решение 2 Доверять только объектам, которые есть в мапе. То есть мы точно знаем, что в ней должно быть Q1, Q2, Q3 и все что нам достаточно сделать это поддерживать инвариант добавления Q(n)-го вопроса, если есть Q(n-1).
Подробнее про решение 1
Опять же, объект Q2 может прийти раньше Q1 и тогда в мапе время второго вопроса будет раньше первого.
Подробнее про решение 2
Доверять только объектам, которые есть в мапе. То есть мы точно знаем, что в ней должно быть Q1, Q2, Q3 и все что нам достаточно сделать это поддерживать инвариант добавления Q(n)-го вопроса, если есть Q(n-1).
Ивариант accept(Qn) ⇔ exists(Qn-1) && !exists(Qn)
Пример 1: Если Q2 пришел раньше, мапа еще пустая и добавлять Q2 нет смысла, игнорируем запрос, либо записываем в аудит ошибок для анализа их возникновения.
Пример 2: Если Q2 пришел позже, то в мапе уже есть Q1, тогда добавляем Q2.
Заключение
Несмотря на популярность Telegram-ботов, проблема корректной реализации stateful-сценариев часто недооценивается. Во многих проектах форма сводится к хранению текущего шага пользователя: условного currentStep. Для простых сценариев этого действительно может быть достаточно, но такая модель начинает ломаться при повторных callback’ах, быстрых нажатиях на inline-кнопки, сетевых задержках и неконсистентном порядке обработки update’ов.
Главная идея этой статьи не в том, что нужно заменить одну Map на другую. Идея в изменении модели состояния: вместо хранения только последнего состояния пользователя стоит хранить историю принятых действий, а текущее состояние вычислять из неё.
Такой подход делает обработку пользовательского flow идемпотентной, позволяет валидировать переходы между шагами и даёт возможность восстановить сценарий после ошибки. Это особенно важно для коммерческих ботов, где форма — не просто интерфейс, а часть бизнес-процесса: запись клиента, оформление заказа, сбор медицинских данных, анкета или заявка.
Конечно, in-memory Map — это не финальное production-решение. При масштабировании её придётся заменить на Redis, PostgreSQL или другое хранилище с атомарной записью событий. Но модель остаётся той же: не доверять порядку прихода запросов.
ссылка на оригинал статьи https://habr.com/ru/articles/1042246/