- BrainTools - https://www.braintools.ru -

Чтобы поменять запятую в боте, нужен был деплой

255 — столько у нас было пар «экран × язык». Полсотни экранов, пять локалей, и каждая строчка текста лежала внутри PHP-класса. Хочешь убрать лишнюю запятую в немецком: ветка, PR, ревью, прогон тестов, деплой.

Человек, который пишет эти тексты, так не умеет. Он пишет в чат «поправьте, пожалуйста» и ждёт два дня.

Мы решили это вылечить очевидным способом: вынести экраны из кода в YAML. По оценке — пара спринтов. По факту — несколько месяцев, потому что бот живой. Вебхук молотит круглосуточно, состояние пользователей лежит в базе, а окна обслуживания у телеграм-бота не бывает в принципе. «Мы на двадцать минут прилягем» означает, что человек в середине разговора жмёт кнопку и не получает ничего.

Проект под NDA, поэтому дальше без имён: названия экранов, тексты и пути выдуманы, продуктовая специфика убрана. Проблемы настоящие, я каждую прочувствовал.

Как это выглядело раньше

Схема для ботов обычная: экран — это класс. Тексты внутри, клавиатура собирается методом.

class BalanceMenu extends Menu
{
    protected function texts(): array
    {
        return [
            'ru' => "*Баланс* 💰nnСписаний за сегодня: :spentnЛимит обновится через: :refillIn",
            'en' => "*Balance* 💰nnSpent today: :spentnLimits reset in: :refillIn",
            'es' => "...",
            'fr' => "...",
            'de' => "...",
        ];
    }

    public function replyMarkup(): InlineKeyboardMarkup
    {
        return new InlineKeyboardMarkup([
            [new InlineKeyboardButton(text: $this->buttons['topup'][$locale], callbackData: 'action:topup')],
            $this->controlButtonsRow(),
        ]);
    }
}

Пока экранов десять, это прекрасно: IDE подсказывает, PHPStan ругается, всё типизировано. На пятидесяти начинается другое.

Продуктовая гипотеза «давай переформулируем на трёх экранах» превращается в задачу разработчику. Переводчик в PHP-файл не полезет, а если полезет — сломает экранирование. И главное: карты бота не существует. Кто куда ведёт, знает только тот, кто это писал, и то примерно.

Что хотели получить: экран (текст, медиа, клавиатура) и переходы между экранами становятся данными в репозитории. Код остаётся там, где нужна логика [1], и больше нигде.

Почему не получилось одним коммитом

Первый план был такой: пишем новый движок, перекладываем в него все экраны, выкатываем в выходные. План прожил до первого обсуждения.

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

Вторая засада приятнее по формулировке и неприятнее по последствиям: старые сообщения никуда не деваются. Кнопка, отрисованная три месяца назад, всё ещё живёт в чате. Пользователь листает историю, жмёт — и попадает в обработчик, которого в репозитории уже нет.

Плюс откат несимметричный. Код откатывается за полминуты, данные, которые новый код успел записать — нет.

Поэтому разрезали на фазы, между которыми бот продолжает работать.

Фазы миграции: что жило в коде, а что стало данными

Фазы миграции: что жило в коде, а что стало данными

Дальше — по порядку то, обо что мы ударились. Половина выглядит очевидной, если читать, а не делать.

1. n в PHP и n в YAML — это разные n

Первый же автоперенос текстов поехал.

В PHP двойные кавычки разворачивают n, и весь код был в двойных кавычках. В YAML двойные кавычки тоже разворачивают, а одинарные — нет. Скрипт переноса часть строк обернул в одинарные, потому что внутри были двойные, и в проде появились сообщения с буквальным nn посреди абзаца.

Лечится тем, что кавычки для многострочных текстов больше не используются вообще:

text:
  ru: |-
    *Баланс* 💰

    Списаний за сегодня: :spent
    Лимит обновится через: :refillIn
  en: |-
    *Balance* 💰

    Spent today: :spent
    Limits reset in: :refillIn

Про | и |- стоит знать заранее. Первый оставляет перевод строки в конце, второй срезает. В телеге этот n глазом не виден, зато его прекрасно видят снапшот-тесты, которыми мы сверяли старый рендер с новым. Полтораста расхождений на ровном месте, пока не разобрались.

2. YAML не падает. Он молча меняет смысл

Рутину переноса делали скриптом с LLM внутри — пятьдесят классов по пять локалей руками это неделя тоски и опечаток. Текст модель переносит отлично. В отступы попадает как повезёт.

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

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

if (!array_key_exists($locale, $texts)) {
    Log::debug('No translation for text.', [
        'locale'  => $locale,
        'chat_id' => $this->chat->id,
    ]);

    $locale = 'en';
}

Благодаря ему дырка в данных выглядит не как ошибка [2], а как английский текст у немецкого пользователя. Неприятно, но это не пятисотка в середине оплаты.

3. :refillIn по-испански

В текстах есть подстановки: :spent, :refillIn, дальше обычный str_replace на рендере. Пока они жили в коде, их никто не трогал.

Потом файлы открыл человек с задачей «поправь формулировки», и в испанской локали появилось :gastado. Подстановка не сработала, пользователь увидел в сообщении сырое двоеточие с английским словом. Ничего не упало. Узнали из тикета в поддержку.

Тест, который вытаскивает из каждой локали экрана токены по /:[a-zA-Z]+/ и сравнивает множества между языками, пишется за двадцать минут. Написали, конечно, после.

4. Экранирование поверх экранирования

MarkdownV2 требует экранировать почти всё: . - ! ( ) [ ] { } + = | ~ # >. Пока строка жила в коде, автор сам ставил слэши и сразу видел результат в тестовом боте.

После переезда появился новый тип текста: строка, где часть спецсимволов уже экранирована осознанно (нужна звёздочка как символ), а часть — это разметка (звёздочка как жирный). Наивный проход по таблице превращает * в \*, телеграм отвечает 400, пользователь видит пустоту вместо экрана.

Пришлось делать в два прохода: спрятать уже экранированные последовательности в плейсхолдеры, экранировать остальное, вернуть спрятанное на место.

private const ESCAPE_CHARACTERS = [
    '*' => '*', '_' => '_', '[' => '[', ']' => ']',
    '(' => '(', ')' => ')', '~' => '~', '`' => '`',
    '#' => '#', '+' => '+', '-' => '-', '=' => '=',
    '|' => '|', '{' => '{', '}' => '}', '.' => '.',
    '!' => '!', '\' => '\\',
];

Если коротко: как только текст стал данными, он перестал быть доверенным. Писать его будет не разработчик.

5. Клавиатура — не всегда данные

Большинство клавиатур ложатся в YAML без вопросов:

keyboard:
  allow_back: true
  allow_exit: true
  buttons:
    - - text:
          ru: "Пополнить"
          en: "Top up"
    - - text:
          ru: "История списаний"
          en: "History"

А потом попадается экран, где кнопка зависит от состояния: у человека с активной подпиской «Продлить», у остальных «Оформить». В коде это был if на две строки.

Сделали два инструмента. Правила прямо в файле:

decisions:
  check_subscription:
    rules:
      - { if: "user.is_subscribed == true", then: "balance_subscribed" }
      - { if: "default",                    then: "balance_regular" }

И люк наружу, когда нужен запрос в базу или поход в платёжку:

keyboard:
  builder: "App\Flow\KeyboardBuilders\SubscriptionKeyboard"

Договорились так: если ветвление решает, что показать — это правило в YAML. Если ветвление требует данных, которых в контексте нет — это класс.

Держаться этой границы сложнее, чем кажется. «Ну давай ещё один маленький оператор в условие» — и через полгода у тебя самодельный язык программирования без отладчика, тестов и статического анализа, зато в конфиге. Поэтому в if умеет только сравнение поля контекста с константой, и расширять этот список мы отказываемся принципиально.

6. 64 байта

У инлайн-кнопки callback_data ограничено 64 байтами. Не символами. Пара UUID туда уже не влезает, а нам надо было передавать три идентификатора.

Схема стандартная: в кнопку кладём ссылку, содержимое едет в базу.

$callbackData = TelegramCallbackData::create([
    'data'    => $parameters,          // JSON-колонка
    'chat_id' => $this->id,
]);

$button = new InlineKeyboardButton(
    text: $button->text,
    callbackData: 'action:callback;id:' . $callbackData->id,
);

На нажатии — обратная операция:

case 'callback':
    $data = TelegramCallbackData::firstWhere([
        'id'      => $parameters['id'],
        'chat_id' => $this->chat->id,
    ]);

    if (!$data) {
        throw new CallbackDataNotFoundException($parameters);
    }

    $parameters = ['action' => $data->action, ...$data->data];
    break;

Дальше начинается интересное. Каждая отрисовка клавиатуры теперь пишет в базу: экран с пятью кнопками — пять INSERT. Эта таблица растёт быстрее, чем таблица сообщений, и требует индекса по (chat_id, id) и чистки по TTL, иначе однажды закончится диск.

И второе, уже про миграцию. Раз старые кнопки живут в чатах месяцами и ссылаются на имена действий в старом формате, эти имена — публичный контракт. Переименовать «заодно, раз уж всё равно переписываем» нельзя. Мы держим таблицу соответствия старых кодов новым ровно столько, сколько живёт TTL, а потом выпиливаем одним коммитом.

7. «Назад» живёт в JSON-колонке

В телеграме нет истории навигации. Её ведёшь ты сам, руками. У нас это стек в строке чата:

public function back(): ?MenuInterface
{
    $parameters = ['action' => 'home'];

    if (count($this->chat->menu_history ?? []) > 0) {
        $history = $this->chat->menu_history;

        array_pop($history);                    // текущий экран

        if (count($history) > 0) {
            $parameters = array_pop($history);  // предыдущий
        }

        $this->chat->menu_history = $history;
    }

    return $this->action($parameters);
}

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

Конвертировать миграцией — значит на несколько минут залочить самую горячую таблицу. Почистить — значит у всех, кто прямо сейчас в диалоге, «Назад» выкинет в главное меню. Оба варианта пользователь заметит, поэтому новый движок просто умеет читать оба формата.

Расплата — слой совместимости, который очень легко забыть и оставить в коде навсегда. На него сразу заведена задача с датой удаления, привязанной к TTL из предыдущего пункта.

8. Кэш, который пережил деплой

Файлы экранов кэшируются, парсить YAML на каждый апдейт незачем:

return Cache::remember("menu:yaml:{$file}", null, fn () => Yaml::parseFile($path));

null здесь — «навсегда». Выкатили новые тексты, зашли в бота — старые. Ничего не упало, в логах чисто, в репозитории всё правильно. Полчаса мы искали ошибку в импорте, потому что версия «кэш» в голову приходит последней.

Теперь есть команда и хук, который зовёт её до прогрева конфигов:

before('artisan:optimize', artisan('menu:clear-cache'));

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

invoke('artisan:migrate');       // схема
invoke('artisan:sync:import');   // контент

9. Заодно вынесли контент из базы в git

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

Мотив был не только «чтобы правил не разработчик». Прод и стейдж разъезжались: кто, когда и зачем поменял сущность, знала только память [3] коллеги. Ни истории, ни ревью, ни отката.

Импортёр устроен просто и целиком в транзакции:

public function importFromPath(string $path): array
{
    $stats = ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'archived' => 0, 'skipped' => 0];

    DB::transaction(function () use ($path, &$stats) {
        $files = $this->getFilesToProcess($path);
        $parsedIds = $this->processFiles($files, $stats);
        $stats['archived'] += $this->archiveMissingEntities($parsedIds);
    });

    return $stats;
}

Решения, которые себя оправдали.

Первичный ключ лежит в самом файле — UUID, а не автоинкремент из базы. Иначе один и тот же файл на стейдже и на проде это две разные сущности, и всё рассыпается на первом же переносе.

Рядом с сущностью хранится md5_file(). Совпал — файл не трогаем вообще. Импорт гоняется на каждом деплое по всем файлам, и без этого фильтра каждый деплой был бы полной перезаписью контента.

Если хэш изменился, содержимое ещё сравнивается по существу. Поправил комментарий, переставил ключи местами — обновляем только хэш, новую версию не плодим:

if ($currentVersion->isIdenticalTo($newVersion)) {
    $currentVersion->file_hash = $fileHash;
    $stats['unchanged']++;

    return;
}

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

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

10. Чем проверяли, что ничего не развалилось

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

Сняли снапшоты: на старом коде прогнали рендер каждого экрана в каждой локали, сохранили итоговый текст после подстановок и экранирования плюс структуру клавиатуры — сетку рядов, подписи кнопок, коды действий. Потом то же самое на новом и diff.

Расхождений было под две сотни, настоящих из них — десятка полтора. Остальное те самые хвостовые переводы строки и порядок ключей.

Чего снапшоты не видят: экран отрисован идеально, а кнопка ведёт не туда. Переходы — это граф, и его надо обходить отдельно. Простую часть закрыли тестом (из каждого экрана по каждой кнопке — существует ли экран назначения), остальное прошли руками в стейджинговом боте по списку. Скучно, зато один раз.

Где мы сейчас

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

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

Чеклист, если собираетесь делать то же самое

  1. Снапшоты старого поведения [4] снимаются до того, как написана первая строчка нового.

  2. Режьте на фазы. Сначала данные, потом поведение [5]. Между фазами система работает, и это не компромисс, а требование.

  3. Многострочные тексты — только блочные скаляры. Кавычки в YAML не то, чем кажутся.

  4. Тест на полноту локалей и на совпадение плейсхолдеров между ними. Двадцать минут работы.

  5. Фоллбэк на основной язык в рантайме: дырка в данных не должна быть пятисоткой.

  6. Текст из данных недоверенный, экранирование обязано пережить уже экранированный ввод.

  7. Заранее договоритесь, что описывается декларативно, а что кодом. Иначе конфиг станет языком программирования, и очень быстро.

  8. Имена действий и экранов — публичный контракт. В чатах лежат кнопки, отправленные месяцы назад.

  9. Состояние пользователей мигрируется совместимостью, а не конвертацией. Это единственный способ обойтись без даунтайма.

  10. Слой совместимости заводится сразу с задачей на удаление и датой, иначе он ваш навсегда.

  11. Кэш данных чистится в деплое, до прогрева конфигов.

  12. Импорт контента — транзакция, идемпотентность, архивирование вместо удаления. И красный деплой, если хоть один файл не импортировался.

Если у вас была похожая миграция и набор ишью не совпал — расскажите в комментариях, на что наступали вы. Особенно интересно, как вы храните навигацию в ботах: я подозреваю, что стек в JSON-колонке не самое изящное решение, просто оно работает.

Автор: i_alakey

Источник [6]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/35899

URLs in this post:

[1] логика: http://www.braintools.ru/article/7640

[2] ошибка: http://www.braintools.ru/article/4192

[3] память: http://www.braintools.ru/article/4140

[4] поведения: http://www.braintools.ru/article/9372

[5] поведение: http://www.braintools.ru/article/5593

[6] Источник: https://habr.com/ru/articles/1084298/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1084298

www.BrainTools.ru

Rambler's Top100