ペットプロジェクトのリファクタリング:ドッキング、メトリック、テスト

みなさん、こんにちは。私は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





, " - ". , , , . , .





  1. , . - , god object , , . PSR-12.









  2. supervisor





  3. , Codeception





  4. MySQL Redis





  5. Github Actions code style





  6. Prometheus, Grafana





  7. , /metrics Prometheus





  8. , 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の構成
Grafana
  1. increase(asterios_bot_healthcheck_x3[1m])



    asterios_bot_healthcheck_x3 1





  2. ( )





  3. 4.





  4. 3.





  1. , . 30





  2. , alert. " 10 "





  3. - alert





  4. alert





alert ( alert?)





テレグラムのアラート
Alert
  1. , alert , . Grafana alert, . 30





  2. 30 alert





  3. , alert





  4. dashboard









9:

. , supervisor. , .





もっと早くやったらいいのに。新しい構成で再インストールする期間ボットを停止すると、ユーザーはすぐに、追加がより簡単かつ迅速になるいくつかの新機能を求め始めました。この投稿があなたのペットプロジェクトをリファクタリングして整理するきっかけになることを願っています。書き直すのではなく、リファクタリングします。





プロジェクトへのリンク








All Articles