Всем привет, я php разработчик. Я хочу поделиться историей, как я рефакторил один из своих телеграм ботов, который из поделки на коленке стал сервисом с более чем 1000 пользователей в очень узкой и специфической аудитории.
Предыстория
Пару лет назад я решил тряхнуть стариной и поиграть в LineAge II на одном из популярных пиратских серверов. В этой игре есть один игровой процесс, в котором требуется "поговорить" с ящиками после смерти 4 боссов. Ящик стоит после смерти 2 минуты. Сами боссы после смерти появляются спустя 24 +/- 6ч, то есть шанс появится есть как через 18ч, так и через 30ч. У меня на тот момент была фуллтайм работа, да и в целом не было времени ждать эти ящики. Но нескольким моим персонажам требовалось пройти этот квест, поэтому я решил "автоматизировать" этот процесс. На сайте сервера есть RSS фид в формет XML, где публикуются события с серверов, включая события смерти босса.
Задумка была следующей:
-
получить данные с RSS
-
сравнить данные с локальной копией в базе данных
-
если есть разница данных - сообщить об этом в телеграм канал
-
отдельно сообщать если босса не убили за первые 9ч сообщением "осталось 3ч", и "осталось 1,5ч". Допустим вечером пришло сообщение, что осталось 3ч, значит смерть босса будет до того, как я пойду спать.
Код на php был написан быстро и в итоге у меня было 3 php файла. Один был с god object классом, а другие два запускали программу в двух режимах - парсер новых, или проверка есть ли боссы на максимальном "респе". Запускал я их крон командами. Это работало и решало мою проблему.
Другие игроки замечали, что я появляюсь в игре сразу после смерти боссов, и через 10 дней у меня на канале было около 50 подписчиков. Так же попросили сделать такое же для второго сервера этого пиратского сервиса. Задачу я тоже решил копипастой. В итоге у меня уже 4 файла с почти одинаковым кодом, и файл с god object. Потом меня попросили сделать то же самое для третьего сервера этого пиратского сервиса. И это отлично работало полтора года.
В итоге у меня спустя полтора года:
-
у меня 6 файлов, дублируют себя почти полностью (по 2 файла на сервер)
-
один god object на несколько сотен строк
-
MySQL и Redis на сервере, где разместил код
-
cron задачи, которые запускают файлы
-
~1400 подписчиков на канале в телеграм
Я откладывал месяцами рефакторинг этого кода, как говориться "работает - не трогай". Но хотелось этот проект привести в порядок, чтобы проще было вносить изменения, легче запускать и переносить на другой сервер, мониторить работоспособность и тд. При этом сделать это за выходные, в свое личное время.
Ожидаемый результат после рефакторинга
-
Отрефакторить код так, чтобы легче было вносить изменения. Важный момент - отрефакторить без изменения бизнес логики, по сути раскидать god object по файлам, сам код не править, иначе это затянет сроки. Следовать PSR-12.
-
Докеризировать воркера для удобства переноса на другой сервер и прозрачность запуска и остановки
-
Запускать воркера через supervisor
-
Внедрить процесс тестирования кода, настроить Codeception
-
Докеризировать MySQL и Redis
-
Настроить Github Actions для запуска тестов и проверки на code style
-
Поднять Prometheus, Grafana для метрик и мониторинга работоспособности
-
Сделать докер контейнер, который будет отдавать метрики на страницу /metrics для Prometheus
-
Сделать докер образ для бота телеграм, который будет отдавать срез по всем статусам 4 боссов в данный момент командами боту в личку
Важное замечание. Все эти шаги выполнялись не совсем в том порядке, как я их описываю в этом туториале. Сделал все требуемое за выходные плюс пара вечеров после работы. Так же в целях не было сделать проект "идеальным", не совершать "революции", а дать возможность проекту плавно эволюционировать. Большая часть пунктов из плана давала возможность развивать проект.
Шаг 1. Рефакторинг приложения
Одним из требований было не потратить на это недели, поэтому основные классы я решил сделать наследниками Singleton
<?phpdeclare(strict_types=1);namespace AsteriosBot\Core\Support;use AsteriosBot\Core\Exception\DeserializeException;use AsteriosBot\Core\Exception\SerializeException;class Singleton{ protected static $instances = []; /** * Singleton constructor. */ protected function __construct() { // do nothing } /** * Disable clone object. */ protected function __clone() { // do nothing } /** * Disable serialize object. * * @throws SerializeException */ public function __sleep() { throw new SerializeException("Cannot serialize singleton"); } /** * Disable deserialize object. * * @throws DeserializeException */ public function __wakeup() { throw new DeserializeException("Cannot deserialize singleton"); } /** * @return static */ public static function getInstance(): Singleton { $subclass = static::class; if (!isset(self::$instances[$subclass])) { self::$instances[$subclass] = new static(); } return self::$instances[$subclass]; }}
Таким образом вызов любого класса, который от него наследуются,
можно делать методом getInstance()
Вот так, например, выглядел класс подключения к базе данных
<?phpdeclare(strict_types=1);namespace AsteriosBot\Core\Connection;use AsteriosBot\Core\App;use AsteriosBot\Core\Support\Config;use AsteriosBot\Core\Support\Singleton;use FaaPz\PDO\Database as DB;class Database extends Singleton{ /** * @var DB */ protected DB $connection; /** * @var Config */ protected Config $config; /** * Database constructor. */ protected function __construct() { $this->config = App::getInstance()->getConfig(); $dto = $this->config->getDatabaseDTO(); $this->connection = new DB($dto->getDsn(), $dto->getUser(), $dto->getPassword()); } /** * @return DB */ public function getConnection(): DB { return $this->connection; }}
В процессе рефакторинга я не менял саму бизнес логику, оставил все "как было". Цель было именно разнести по файлам для облегчения изменения правок, а так же для возможности потом покрыть тестами.
Шаг 2: Докеризация воркеров
Запуск всех контейнеров я сделал через
docker-compose.yml
Конфиг сервиса для воркеров выглядит так:
worker: build: context: . dockerfile: docker/worker/Dockerfile container_name: 'asterios-bot-worker' restart: always volumes: - .:/app/ networks: - tier
А сам docker/worker/Dockerfile
выглядит так:
FROM php:7.4.3-alpine3.11# Copy the application codeCOPY . /appRUN apk update && apk add --no-cache \ build-base shadow vim curl supervisor \ php7 \ php7-fpm \ php7-common \ php7-pdo \ php7-pdo_mysql \ php7-mysqli \ php7-mcrypt \ php7-mbstring \ php7-xml \ php7-simplexml \ php7-openssl \ php7-json \ php7-phar \ php7-zip \ php7-gd \ php7-dom \ php7-session \ php7-zlib \ php7-redis \ php7-session# Add and Enable PHP-PDO ExtenstionsRUN docker-php-ext-install pdo pdo_mysqlRUN docker-php-ext-enable pdo_mysql# RedisRUN apk add --no-cache pcre-dev $PHPIZE_DEPS \ && pecl install redis \ && docker-php-ext-enable redis.so# Install PHP ComposerRUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer# Remove CacheRUN rm -rf /var/cache/apk/*# setup supervisorADD docker/supervisor/asterios.conf /etc/supervisor/conf.d/asterios.confADD docker/supervisor/supervisord.conf /etc/supervisord.confVOLUME ["/app"]WORKDIR /appRUN composer installCMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
Обратите внимание на последнюю строку в Dockerfile, там я запускаю supervisord, который будет мониторить работу воркеров.
Шаг 3: Настройка supervisor
Важный дисклеймер по supervisor. Он предназначен для работы с процессами, которые работают долго, и в случае его "падения" - перезапустить. Мои же php скрипты работали быстро и сразу завершались. supervisor пробовал их перезапустить, и в конце концов переставал пытаться поднять снова. Поэтому я решил сам код воркера запускать на 1 минуту, чтобы это работало с supervisor.
Код файла worker.php
<?phprequire __DIR__ . '/vendor/autoload.php';use AsteriosBot\Channel\Checker;use AsteriosBot\Channel\Parser;use AsteriosBot\Core\App;use AsteriosBot\Core\Connection\Log;$app = App::getInstance();$checker = new Checker();$parser = new Parser();$servers = $app->getConfig()->getEnableServers();$logger = Log::getInstance()->getLogger();$expectedTime = time() + 60; // +1 min in seconds$oneSecond = time();while (true) { $now = time(); if ($now >= $oneSecond) { $oneSecond = $now + 1; try { foreach ($servers as $server) { $parser->execute($server); $checker->execute($server); } } catch (\Throwable $e) { $logger->error($e->getMessage(), $e->getTrace()); } } if ($expectedTime < $now) { die(0); }}
У RSS есть защита от спама, поэтому пришлось сделать проверку на секунды и посылать не более 1го запроса в секунду. Таким образом мой воркер каждую секунду выполняет 2 действия, сначала проверяет rss, а затем калькулирует время боссов для сообщений о старте или окончании времени респауна боссов. После 1 минуты работы воркер умирает, и его перезапускает supervisor
Сам конфиг supervisor выглядит так:
[program:worker]command = php /app/worker.phpstderr_logfile=/app/logs/supervisor/worker.lognumprocs = 1user = rootstartsecs = 3startretries = 10exitcodes = 0,2stopsignal = SIGINTreloadsignal = SIGHUPstopwaitsecs = 10autostart = trueautorestart = truestdout_logfile = /dev/stdoutstdout_logfile_maxbytes = 0redirect_stderr = true
После старта контейнеров супервизор стартует воркера
автоматически. Важный момент - в файле основного конфига
/etc/supervisord.conf
обязательно нужно указать
демонизация процесса, а так же подключение своих конфигов
[supervisord]nodaemon=true[include]files = /etc/supervisor/conf.d/*.conf
Набор полезных команд supervisorctl:
supervisorctl status # статус воркеровsupervisorctl stop all # остановить все воркераsupervisorctl start all # запустить все воркераsupervisorctl start worker # запустить один воркера с конфига, блок [program:worker]
Шаг 4: Настройка Codeception
Я планирую в свободное время по чуть-чуть покрывать
unit
тестами уже существующий код, а со временем
сделать еще и интеграционные. Пока что настроил только юнит
тестирование и написал пару тестов на особо важную бизнес логику.
Настройка была тривиальной, все завелось с коробки, только добавил
в конфиг поддержку базы данны
# Codeception Test Suite Configuration## Suite for unit or integration tests.actor: UnitTestermodules: enabled: - Asserts - \Helper\Unit - Db: dsn: 'mysql:host=mysql;port=3306;dbname=test_db;' user: 'root' password: 'password' dump: 'tests/_data/dump.sql' populate: true cleanup: true reconnect: true waitlock: 10 initial_queries: - 'CREATE DATABASE IF NOT EXISTS test_db;' - 'USE test_db;' - 'SET NAMES utf8;' step_decorators: ~
Шаг 5: Докеризация MySQL и Redis
На сервере, где работало это приложение, у меня было еще пара других ботов. Все они использовали один сервер MySQL и один Redis для кеша. Я решил вынести все, что связано с окружением в отельный docker-compose.yml, а самих ботов залинковать через внешний docker network
Выглядит это так:
version: '3'services: mysql: image: mysql:5.7.22 container_name: 'telegram-bots-mysql' restart: always ports: - "3306:3306" environment: MYSQL_ROOT_PASSWORD: "${DB_PASSWORD}" MYSQL_ROOT_HOST: '%' volumes: - ./docker/sql/dump.sql:/docker-entrypoint-initdb.d/dump.sql networks: - tier redis: container_name: 'telegram-bots-redis' image: redis:3.2 restart: always ports: - "127.0.0.1:6379:6379/tcp" networks: - tier pma: image: phpmyadmin/phpmyadmin container_name: 'telegram-bots-pma' environment: PMA_HOST: mysql PMA_PORT: 3306 MYSQL_ROOT_PASSWORD: "${DB_PASSWORD}" ports: - '8006:80' networks: - tiernetworks: tier: external: name: telegram-bots-network
DB_PASSWORD я храню в .env файле, а ./docker/sql/dump.sql у меня лежит бекап для инициализации базы данных. Так же я добавил external network так же, как в этом конфиге - в каждом docker-compose.yml каждого бота на сервере. Таким образом они все находятся в одной сети и могут использовать общие базу данных и редис.
Шаг 6: Настройка Github Actions
В шаге 4 этого туториала я добавил тестовый фреймфорк Codeception, который для тестирования требует базу данных. В самом проекте нет базы, в шаге 5 я ее вынес отдельно и залинковал через external docker network. Для запуска тестов в Github Actions я решил полностью собрать все необходимое на лету так же через docker-compose.
name: Actionson: pull_request: branches: [master] push: branches: [master]jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Get Composer Cache Directory id: composer-cache run: | echo "::set-output name=dir::$(composer config cache-files-dir)" - uses: actions/cache@v1 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | ${{ runner.os }}-composer- - name: Composer validate run: composer validate - name: Composer Install run: composer install --dev --no-interaction --no-ansi --prefer-dist --no-suggest --ignore-platform-reqs - name: PHPCS check run: php vendor/bin/phpcs --standard=psr12 app/ -n - name: Create env file run: | cp .env.github.actions .env - name: Build the docker-compose stack run: docker-compose -f docker-compose.github.actions.yml -p asterios-tests up -d - name: Sleep uses: jakejarvis/wait-action@master with: time: '30s' - name: Run test suite run: docker-compose -f docker-compose.github.actions.yml -p asterios-tests exec -T php vendor/bin/codecept run unit
Инструкция on
управляет когда билд триггернётся. В
моем случае - при создании пулл реквеста или при коммите в
мастер.
Инструкция uses: actions/checkout@v2
запускает
проверку доступа процесса к репозиторию.
Далее идет проверка кеша композера, и установка пакетов, если в кеше не найдено
Затем в строке run: php vendor/bin/phpcs --standard=psr12
app/ -n
я запускаю проверку кода соответствию стандарту
PSR-12 в папке
./app
Так как тут у меня специфическое окружение, я подготовил файл
.env.github.actions
который копируется в
.env
Cодержимое .env.github.actions
SERVICE_ROLE=testTG_API=XXXXXTG_ADMIN_ID=123TG_NAME=AsteriosRBbotDB_HOST=mysqlDB_NAME=rootDB_PORT=3306DB_CHARSET=utf8DB_USERNAME=rootDB_PASSWORD=passwordLOG_PATH=./logs/DB_NAME_TEST=test_dbREDIS_HOST=redisREDIS_PORT=6379REDIS_DB=0SILENT_MODE=trueFILLER_MODE=true
Из важного тут только настройки базы данных, которые не должны отличаться от настроек базы в этом окружении.
Затем я собираю проект при помощи
docker-compose.github.actions.yml
в котором прописано
все необходимое для тестирвания, контейнер с проектом и база
данных. Содержимое
docker-compose.github.actions.yml
:
version: '3'services: php: build: context: . dockerfile: docker/php/Dockerfile container_name: 'asterios-tests-php' volumes: - .:/app/ networks: - asterios-tests-network mysql: image: mysql:5.7.22 container_name: 'asterios-tests-mysql' restart: always ports: - "3306:3306" environment: MYSQL_DATABASE: asterios MYSQL_ROOT_PASSWORD: password volumes: - ./tests/_data/dump.sql:/docker-entrypoint-initdb.d/dump.sql networks: - asterios-tests-network## redis:# container_name: 'asterios-tests-redis'# image: redis:3.2# ports:# - "127.0.0.1:6379:6379/tcp"# networks:# - asterios-tests-networknetworks: asterios-tests-network: driver: bridge
Я закомментировал контейнер с Redis, но оставил возможность использовать его в будущем. Сборка с кастомным docker-compose файлом, а затем тесты - запускается так
docker-compose -f docker-compose.github.actions.yml -p asterios-tests up -ddocker-compose -f docker-compose.github.actions.yml -p asterios-tests exec -T php vendor/bin/codecept run unit
Внимательный читатель обратит внимание на пункт между стартом контейнеров и запуском тестов. Это задержка в 30 секунд для того, чтобы база данных успела заполниться тестовыми данными.
Шаг 7: Настройка Prometheus и Grafana
В шаге 5 я вынес MySQL и Redis в отдельный docker-compose.yml. Так как Prometheus и Grafana тоже общие для всех моих телеграм ботов, я их добавил туда же. Сам конфиг этих контейнеров выглядит так:
prometheus: image: prom/prometheus:v2.0.0 command: - '--config.file=/etc/prometheus/prometheus.yml' restart: always ports: - 9090:9090 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml networks: - tier grafana: container_name: 'telegram-bots-grafana' image: grafana/grafana:7.1.1 ports: - 3000:3000 environment: - GF_RENDERING_SERVER_URL=http://renderer:8081/render - GF_RENDERING_CALLBACK_URL=http://grafana:3000/ - GF_LOG_FILTERS=rendering:debug volumes: - ./grafana.ini:/etc/grafana/grafana.ini - grafanadata:/var/lib/grafana networks: - tier restart: always renderer: image: grafana/grafana-image-renderer:latest container_name: 'telegram-bots-grafana-renderer' restart: always ports: - 8081 networks: - tier
Они так же залинкованы одной сетью, которая потом линкуется с external docker network.
Prometheus: я прокидываю свой конфиг prometheus.yml, где я могу указать источники для парсинга метрик
Grafana: я создаю volume, где будут храниться конфиги и установленные плагины. Так же я прокидываю ссылку на сервис рендеринга графиков, который мне понадобиться для отправки alert. С этим плагином alert приходит со скриншотом графика.
Поднимаю проект и устанавливаю плагин, затем перезапускаю Grafana контейнер
docker-compose up -ddocker-compose exec grafana grafana-cli plugins install grafana-image-rendererdocker-compose stop grafana docker-compose up -d grafana
Шаг 8: Публикация метрик приложения
Для сбора и публикации метрик я использовал endclothing/prometheus_client_php
Так выглядит мой класс для метрик
<?phpdeclare(strict_types=1);namespace AsteriosBot\Core\Connection;use AsteriosBot\Core\App;use AsteriosBot\Core\Support\Singleton;use Prometheus\CollectorRegistry;use Prometheus\Exception\MetricsRegistrationException;use Prometheus\Storage\Redis;class Metrics extends Singleton{ private const METRIC_HEALTH_CHECK_PREFIX = 'healthcheck_'; /** * @var CollectorRegistry */ private $registry; protected function __construct() { $dto = App::getInstance()->getConfig()->getRedisDTO(); Redis::setDefaultOptions( [ 'host' => $dto->getHost(), 'port' => $dto->getPort(), 'database' => $dto->getDatabase(), 'password' => null, 'timeout' => 0.1, // in seconds 'read_timeout' => '10', // in seconds 'persistent_connections' => false ] ); $this->registry = CollectorRegistry::getDefault(); } /** * @return CollectorRegistry */ public function getRegistry(): CollectorRegistry { return $this->registry; } /** * @param string $metricName * * @throws MetricsRegistrationException */ public function increaseMetric(string $metricName): void { $counter = $this->registry->getOrRegisterCounter('asterios_bot', $metricName, 'it increases'); $counter->incBy(1, []); } /** * @param string $serverName * * @throws MetricsRegistrationException */ public function increaseHealthCheck(string $serverName): void { $prefix = App::getInstance()->getConfig()->isTestServer() ? 'test_' : ''; $this->increaseMetric($prefix . self::METRIC_HEALTH_CHECK_PREFIX . $serverName); }}
Для проверки работоспособности парсера мне нужно сохранить метрику в Redis после получения данных с RSS. Если данные получены, значит все нормально, и можно сохранить метрику
if ($counter) { $this->metrics->increaseHealthCheck($serverName); }
Где переменная $counter это количество записей в RSS. Там будет 0, если получить данные не удалось, и значит метрика не будет сохранена. Это потом понадобится для alert по работе сервиса.
Затем нужно метрики опубликовать на странице /metric чтобы Prometheus их спарсил. Добавим хост в конфиг prometheus.yml из шага 7.
# my global configglobal: scrape_interval: 5s # Set the scrape interval to every 15 seconds. Default is every 1 minute. evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute. # scrape_timeout is set to the global default (10s).scrape_configs: - job_name: 'bots-env' static_configs: - targets: - prometheus:9090 - pushgateway:9091 - grafana:3000 - metrics:80 # тут будут мои метрики по uri /metrics
Код, который вытащит метрики из Redis и создаст страницу в текстовом формате. Эту страничку будет парсить Prometheus
$metrics = Metrics::getInstance();$renderer = new RenderTextFormat();$result = $renderer->render($metrics->getRegistry()->getMetricFamilySamples());header('Content-type: ' . RenderTextFormat::MIME_TYPE);echo $result;
Теперь настроим сам дашборд и alert. В настройках Grafana сначала укажите свой Prometheus как основной источник данных, а так же я добавил основной канал нотификации Телеграм (там добавляете токен своего бота и свой chat_id с этим ботом)
Настройка Grafana-
Метрика
increase(asterios_bot_healthcheck_x3[1m])
Показывает на сколько метрика asterios_bot_healthcheck_x3 увеличилась за 1 минуту -
Название метрики (будет под графиком)
-
Название для легенды в пункте 4.
-
Легенда справа из пункта 3.
-
Правило, по которому проверяется метрика. В моем случае проверяет что за последние 30 секунд проблем не было
-
Правило, по которому будет срабатывать alert. В моем случае "Когда сумма из метрики А между сейчас и 10 секунд назад"
-
Если нет данных вообще - слать alert
-
Сообщение в alert
Выглядит alert в телеграм так (помните мы настраивали рендеринг картинок для alert?)
Alert в Телеграм-
Обратите внимание, alert заметил падение, но все восстановилось. Grafana приготовилась слать alert, но передумала. Это то самое правило 30 секунд
-
Тут уже все упало больше чем на 30 секунд и alert был отправлен
-
Сообщение, которое мы указали в настройках alert
-
Ссылка на dashboard
-
Источник метрики
Шаг 9: Телеграм бот
Настройка телеграм бота ничем не отличается от настройки воркера. Телеграм бот по сути у меня это еще один воркер, я запустил его при помощи добавления настроек в supervisor. Тут уже рефакторинг проекта дал свои плоды, запуск бота был быстрым и простым.
Итоги
Я жалею, что не сделал этого раньше. Я останавливал бота на период переустановки с новыми конфигами, и пользователи сразу стали просить пару новых фичей, которые добавить стали легче и быстрее. Надеюсь эта публикация вдохновит отрефакторить свой пет-проект и привести его в порядок. Не переписать, а именно отрефакторить.
Ссылки на проекты