Refactorización de un proyecto mascota: dockerización, métricas, pruebas

Hola a todos, soy desarrollador php. Quiero compartir una historia sobre cómo refactoricé uno de mis bots de telegram, que de una artesanía a una rodilla se ha convertido en un servicio con más de 1000 usuarios en una audiencia muy reducida y específica.





Fondo

Hace un par de años, decidí deshacerme de los viejos tiempos y jugar LineAge II en uno de los populares servidores piratas. Este juego tiene un modo de juego que requiere que "hables" con las cajas después de que 4 jefes hayan muerto. La caja permanece después de la muerte durante 2 minutos. Los propios jefes después de la muerte aparecen después de 24 +/- 6 horas, es decir, existe la posibilidad de comer tanto después de 18 horas como después de 30 horas. En ese momento yo tenía un trabajo de tiempo completo y, en general, no había tiempo para esperar por estas cajas. Pero varios de mis personajes necesitaban completar esta búsqueda, así que decidí "automatizar" este proceso. El sitio del servidor tiene una fuente RSS en formato XML, donde se publican los eventos de los servidores, incluidos los eventos de muerte del jefe.





La idea fue la siguiente:





  • obtener datos de RSS





  • comparar datos con copia local en la base de datos





  • si hay una diferencia en los datos, infórmelo al canal de telegramas





  • informe por separado si el jefe no murió en las primeras 9 horas con el mensaje "Quedan 3 horas" y "Quedan 1,5 horas". Digamos que por la noche llegó un mensaje de que quedan 3 horas, lo que significa que el jefe morirá antes de que yo me vaya a la cama.





El código php se escribió rápidamente y al final tenía 3 archivos php. Uno fue con la clase de objeto dios , y los otros dos lanzaron el programa en dos modos: analizar nuevos o comprobar si hay jefes al máximo de "reaparición". Los lancé con comandos. Esto funcionó y resolvió mi problema.





, , 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 )





Configurando 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?)





Alerta en Telegram
Alert
  1. , alert , . Grafana alert, . 30





  2. 30 alert





  3. , alert





  4. dashboard









9:

. , supervisor. , .





Ojalá hubiera hecho esto antes. Detuve el bot durante un período de reinstalación con nuevas configuraciones, y los usuarios inmediatamente comenzaron a solicitar un par de nuevas funciones que se volvieron más fáciles y rápidas de agregar. Espero que esta publicación te inspire a refactorizar tu proyecto favorito y ponerlo en orden. No para reescribir, sino para refactorizar.





Enlaces a proyectos








All Articles