[Мессенджеры, Open source, Системное администрирование, PHP, Программирование] Рефакторинг пет проекта: докеризация, метрики, тесты
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Всем привет, я 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 с этим ботом)
Настройка 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. Тут уже рефакторинг проекта дал свои плоды, запуск бота был быстрым и простым.ИтогиЯ жалею, что не сделал этого раньше. Я останавливал бота на период переустановки с новыми конфигами, и пользователи сразу стали просить пару новых фичей, которые добавить стали легче и быстрее. Надеюсь эта публикация вдохновит отрефакторить свой пет-проект и привести его в порядок. Не переписать, а именно отрефакторить. Ссылки на проекты
- https://github.com/omentes/asterios-bot
- [url=https://github.com/omentes/bots-environment ]https://github.com/omentes/bots-environment [/url]
===========
Источник:
habr.com
===========
Похожие новости:
- [Программирование, C++, Тестирование веб-сервисов, Конференции] Как достичь полной автоматизации в Automotive тестировании и особенности Move конструктора: узнаем 25 февраля
- [Assembler, Программирование микроконтроллеров] Assembler Editor Plus: Установка
- [DevOps, Kubernetes] Разворачиваем кластер Kubernetes с помощью Kubernetes (перевод)
- [Программирование, C++, Работа с 3D-графикой, Разработка игр, CGI (графика)] Vulkan. Руководство разработчика. Swap chain (перевод)
- [Системное администрирование, Системное программирование, DevOps] Первый взгляд на Tekton Pipelines (перевод)
- [Программирование, Математика, Matlab] Реализация моделей динамических систем средствами контроллера
- [Информационная безопасность, WordPress, Open source, Администрирование доменных имен] Новый плагин CrowdSec для защиты сайтов на WordPress
- [Децентрализованные сети, Open source, Администрирование баз данных] OrbitDB — децентрализованная база данных на IPFS
- [Информационная безопасность, Open source, Браузеры] Эффективный фингерпринтинг через кэш фавиконов в браузере
- [Программирование, C++, Qt, Visual Studio, DevOps] О поиске утечек памяти в С++/Qt приложениях
Теги для поиска: #_messendzhery (Мессенджеры), #_open_source, #_sistemnoe_administrirovanie (Системное администрирование), #_php, #_programmirovanie (Программирование), #_php, #_telegram, #_docker, #_dockercompose, #_prometheus, #_grafana, #_petproject, #_github_actions, #_testing, #_messendzhery (
Мессенджеры
), #_open_source, #_sistemnoe_administrirovanie (
Системное администрирование
), #_php, #_programmirovanie (
Программирование
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 13:56
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Всем привет, я php разработчик. Я хочу поделиться историей, как я рефакторил один из своих телеграм ботов, который из поделки на коленке стал сервисом с более чем 1000 пользователей в очень узкой и специфической аудитории. ПредысторияПару лет назад я решил тряхнуть стариной и поиграть в LineAge IIна одном из популярных пиратских серверов. В этой игре есть один игровой процесс, в котором требуется "поговорить" с ящиками после смерти 4 боссов. Ящик стоит после смерти 2 минуты. Сами боссы после смерти появляются спустя 24 +/- 6ч, то есть шанс появится есть как через 18ч, так и через 30ч. У меня на тот момент была фуллтайм работа, да и в целом не было времени ждать эти ящики. Но нескольким моим персонажам требовалось пройти этот квест, поэтому я решил "автоматизировать" этот процесс. На сайте сервера есть RSS фид в формет XML, где публикуются события с серверов, включая события смерти босса.Задумка была следующей:
<?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]; } } <?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; } } worker:
build: context: . dockerfile: docker/worker/Dockerfile container_name: 'asterios-bot-worker' restart: always volumes: - .:/app/ networks: - tier 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"] <?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); } } [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 [supervisord]
nodaemon=true [include] files = /etc/supervisor/conf.d/*.conf supervisorctl status # статус воркеров
supervisorctl stop all # остановить все воркера supervisorctl start all # запустить все воркера supervisorctl start worker # запустить один воркера с конфига, блок [program:worker] # 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: ~ 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 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 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 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 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 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 docker-compose up -d
docker-compose exec grafana grafana-cli plugins install grafana-image-renderer docker-compose stop grafana docker-compose up -d grafana <?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); } } if ($counter) {
$this->metrics->increaseHealthCheck($serverName); } # 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 $metrics = Metrics::getInstance();
$renderer = new RenderTextFormat(); $result = $renderer->render($metrics->getRegistry()->getMetricFamilySamples()); header('Content-type: ' . RenderTextFormat::MIME_TYPE); echo $result; Настройка Grafana
Alert в Телеграм
=========== Источник: habr.com =========== Похожие новости:
Мессенджеры ), #_open_source, #_sistemnoe_administrirovanie ( Системное администрирование ), #_php, #_programmirovanie ( Программирование ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 13:56
Часовой пояс: UTC + 5