Всем привет, я 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
<?php
declare(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()
Вот так, например, выглядел класс подключения к базе данных
<?php
declare(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 code
COPY . /app
RUN 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 Extenstions
RUN docker-php-ext-install pdo pdo_mysql
RUN docker-php-ext-enable pdo_mysql
# Redis
RUN apk add --no-cache pcre-dev $PHPIZE_DEPS && pecl install redis && docker-php-ext-enable redis.so
# Install PHP Composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Remove Cache
RUN rm -rf /var/cache/apk/*
# setup supervisor
ADD docker/supervisor/asterios.conf /etc/supervisor/conf.d/asterios.conf
ADD docker/supervisor/supervisord.conf /etc/supervisord.conf
VOLUME ["/app"]
WORKDIR /app
RUN composer install
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
Обратите внимание на последнюю строку в Dockerfile, там я запускаю supervisord, который будет мониторить работу воркеров.
Шаг 3: Настройка supervisor
Важный дисклеймер по supervisor. Он предназначен для работы с процессами, которые работают долго, и в случае его "падения" - перезапустить. Мои же php скрипты работали быстро и сразу завершались. supervisor пробовал их перезапустить, и в конце концов переставал пытаться поднять снова. Поэтому я решил сам код воркера запускать на 1 минуту, чтобы это работало с supervisor.
Код файла worker.php
<?php
require __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.php
stderr_logfile=/app/logs/supervisor/worker.log
numprocs = 1
user = root
startsecs = 3
startretries = 10
exitcodes = 0,2
stopsignal = SIGINT
reloadsignal = SIGHUP
stopwaitsecs = 10
autostart = true
autorestart = true
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
redirect_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: UnitTester
modules:
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:
- tier
networks:
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: Actions
on:
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=test
TG_API=XXXXX
TG_ADMIN_ID=123
TG_NAME=AsteriosRBbot
DB_HOST=mysql
DB_NAME=root
DB_PORT=3306
DB_CHARSET=utf8
DB_USERNAME=root
DB_PASSWORD=password
LOG_PATH=./logs/
DB_NAME_TEST=test_db
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB=0
SILENT_MODE=true
FILLER_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-network
networks:
asterios-tests-network:
driver: bridge
Я закомментировал контейнер с Redis, но оставил возможность использовать его в будущем. Сборка с кастомным docker-compose файлом, а затем тесты - запускается так
docker-compose -f docker-compose.github.actions.yml -p asterios-tests up -d
docker-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 -d
docker-compose exec grafana grafana-cli plugins install grafana-image-renderer
docker-compose stop grafana
docker-compose up -d grafana
Шаг 8: Публикация метрик приложения
Для сбора и публикации метрик я использовал endclothing/prometheus_client_php
Так выглядит мой класс для метрик
<?php
declare(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 config
global:
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 с этим ботом)
Метрика
increase(asterios_bot_healthcheck_x3[1m])
Показывает на сколько метрика asterios_bot_healthcheck_x3 увеличилась за 1 минутуНазвание метрики (будет под графиком)
Название для легенды в пункте 4.
Легенда справа из пункта 3.
Правило, по которому проверяется метрика. В моем случае проверяет что за последние 30 секунд проблем не было
Правило, по которому будет срабатывать alert. В моем случае "Когда сумма из метрики А между сейчас и 10 секунд назад"
Если нет данных вообще - слать alert
Сообщение в alert
Выглядит alert в телеграм так (помните мы настраивали рендеринг картинок для alert?)
Обратите внимание, alert заметил падение, но все восстановилось. Grafana приготовилась слать alert, но передумала. Это то самое правило 30 секунд
Тут уже все упало больше чем на 30 секунд и alert был отправлен
Сообщение, которое мы указали в настройках alert
Ссылка на dashboard
Источник метрики
Шаг 9: Телеграм бот
Настройка телеграм бота ничем не отличается от настройки воркера. Телеграм бот по сути у меня это еще один воркер, я запустил его при помощи добавления настроек в supervisor. Тут уже рефакторинг проекта дал свои плоды, запуск бота был быстрым и простым.
Итоги
Я жалею, что не сделал этого раньше. Я останавливал бота на период переустановки с новыми конфигами, и пользователи сразу стали просить пару новых фичей, которые добавить стали легче и быстрее. Надеюсь эта публикация вдохновит отрефакторить свой пет-проект и привести его в порядок. Не переписать, а именно отрефакторить.
Ссылки на проекты
eandr_67
Эталонно кошмарный код. Ручная инициализация подключения к Redis внутри класса метрики — вместо инверсии зависимостей. Синглтоны вместо контейнера внедрения зависимостей… И все эти устаревшие много лет назад приёмы программирования оформлены самым современным образом.
setnemo Автор
все верно, цель была «разблокировать» возможность развивать проект, а не переписать все нормально. Когда все упаковано в докеры и есть тестовый фреймворк, настроено github actions — можно потихоньку начинать переписывать нормально в свободное от работы время.
Tsimur_S
А зачем? Планируете добавлять какие-то фичи или просто рефакторинг ради рефакторинга?
setnemo Автор
Да, чтобы можно было спокойно добавить функционал не сломав уже имеющийся. Коммьюнити довольно активное, когда я сообщил им что выключаю постинг в каналы для обновления кодовой базы, сразу написали пару запросов на новые фичи для бота. И внедрить их уже было просто. А с тем что было до — это было бы практически невозможно
Arris
Контейнер внедрения зависимостей тянет за собой свой зоопарк. Часто совершенно избыточный для проектов уровня "Hello World".
Не надо бояться синглтона. Это же просто инструмент. Используйте его по назначению, а не везде.
Khaperets
Можно сказать «MVP в действии» :)