みなさん、こんにちは。私はPHP開発者です。手工芸品から膝まで、非常に狭い特定のオーディエンスで1000人以上のユーザーが利用できるサービスとなったテレグラムボットの1つをリファクタリングした方法についての話を共有したいと思います。
バックグラウンド
数年前、私は昔を振り払い、人気のある海賊サーバーの1つでLineAgeIIをプレイすることにしました。このゲームには、4人のボスが死亡した後にボックスに「話す」必要があるゲームプレイが1つあります。箱は死後2分間立っています。死後のボス自身は24 +/- 6時間後に現れます。つまり、18時間後と30時間後の両方で食べるチャンスがあります。当時、私はフルタイムの仕事をしていて、一般的にこれらの箱を待つ時間はありませんでした。しかし、私のキャラクターのいくつかはこのクエストを完了する必要があったので、私はこのプロセスを「自動化」することにしました。サーバーサイトにはXML形式のRSSフィードがあり、上司の死亡イベントを含むサーバーからのイベントが公開されます。
アイデアは次のとおりです。
RSSからデータを取得する
データベース内のローカルコピーとデータを比較する
データに違いがある場合-テレグラムチャネルに報告してください
最初の9時間に上司が殺されなかった場合は、「残り3時間」と「残り1.5時間」というメッセージを表示して、個別に報告してください。夕方、残り3時間というメッセージが届いたとしましょう。つまり、私が寝る前に上司が亡くなるということです。
phpコードはすぐに書かれ、最終的に3つのphpファイルができました。1つは神オブジェクトクラスを使用し、他の2つは2つのモードでプログラムを起動しました。新しいモードをパーサーするか、最大の「リスポーン」でボスがいるかどうかを確認します。コマンドで起動しました。これはうまくいき、私の問題を解決しました。
, , 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. , .
もっと早くやったらいいのに。新しい構成で再インストールする期間ボットを停止すると、ユーザーはすぐに、追加がより簡単かつ迅速になるいくつかの新機能を求め始めました。この投稿があなたのペットプロジェクトをリファクタリングして整理するきっかけになることを願っています。書き直すのではなく、リファクタリングします。
プロジェクトへのリンク