Hallo allerseits, ich bin ein PHP-Entwickler. Ich möchte eine Geschichte darüber erzählen, wie ich einen meiner Telegramm-Bots überarbeitet habe, der vom Handwerk bis zum Knie zu einem Dienst mit mehr als 1000 Benutzern in einem sehr engen und spezifischen Publikum geworden ist.
Hintergrund
Vor ein paar Jahren habe ich beschlossen, die alten Zeiten abzuschütteln und LineAge II auf einem der beliebten Piratenserver zu spielen. Dieses Spiel hat ein Gameplay, bei dem du mit den Boxen "sprechen" musst, nachdem 4 Bosse gestorben sind. Die Box steht nach dem Tod für 2 Minuten. Die Chefs selbst erscheinen nach dem Tod nach 24 +/- 6 Stunden, dh es besteht die Möglichkeit, sowohl nach 18 Stunden als auch nach 30 Stunden zu essen. Zu dieser Zeit hatte ich einen Vollzeitjob und im Allgemeinen gab es keine Zeit, auf diese Kisten zu warten. Da einige meiner Charaktere diese Quest abschließen mussten, beschloss ich, diesen Prozess zu "automatisieren". Die Server-Site verfügt über einen RSS-Feed im XML-Format, in dem Ereignisse von den Servern veröffentlicht werden, einschließlich der Boss-Todesereignisse.
Die Idee war wie folgt:
Daten von RSS erhalten
Daten mit lokaler Kopie in der Datenbank vergleichen
Wenn es einen Datenunterschied gibt, melden Sie ihn dem Telegrammkanal
Melden Sie separat, wenn der Chef in den ersten 9 Stunden nicht getötet wurde, mit der Meldung "Noch 3 Stunden" und "Noch 1,5 Stunden". Nehmen wir an, am Abend kam eine Nachricht, dass noch 3 Stunden übrig sind, was bedeutet, dass der Chef sterben wird, bevor ich ins Bett gehe.
Der PHP-Code wurde schnell geschrieben und am Ende hatte ich 3 PHP-Dateien. Einer war bei der God-Objektklasse , und die anderen beiden haben das Programm in zwei Modi gestartet - neue analysieren oder prüfen, ob es Bosse mit maximalem "Respawn" gibt. Ich habe sie mit Befehlen gestartet. Dies hat funktioniert und mein Problem gelöst.
, , 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. , .
Ich wünschte, ich hätte das früher getan. Ich habe den Bot für eine Zeit der Neuinstallation mit neuen Konfigurationen angehalten, und die Benutzer haben sofort nach einigen neuen Funktionen gefragt, die einfacher und schneller hinzuzufügen sind. Ich hoffe, dieser Beitrag wird Sie dazu inspirieren, Ihr Haustierprojekt umzugestalten und in Ordnung zu bringen. Nicht umschreiben, sondern umgestalten.
Links zu Projekten