Русский
Русский
English
Статистика
Реклама

Рефакторинг пет проекта докеризация, метрики, тесты

Всем привет, я 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 подписчиков на канале в телеграм

Я откладывал месяцами рефакторинг этого кода, как говориться "работает - не трогай". Но хотелось этот проект привести в порядок, чтобы проще было вносить изменения, легче запускать и переносить на другой сервер, мониторить работоспособность и тд. При этом сделать это за выходные, в свое личное время.

Ожидаемый результат после рефакторинга

  1. Отрефакторить код так, чтобы легче было вносить изменения. Важный момент - отрефакторить без изменения бизнес логики, по сути раскидать god object по файлам, сам код не править, иначе это затянет сроки. Следовать PSR-12.

  2. Докеризировать воркера для удобства переноса на другой сервер и прозрачность запуска и остановки

  3. Запускать воркера через supervisor

  4. Внедрить процесс тестирования кода, настроить Codeception

  5. Докеризировать MySQL и Redis

  6. Настроить Github Actions для запуска тестов и проверки на code style

  7. Поднять Prometheus, Grafana для метрик и мониторинга работоспособности

  8. Сделать докер контейнер, который будет отдавать метрики на страницу /metrics для Prometheus

  9. Сделать докер образ для бота телеграм, который будет отдавать срез по всем статусам 4 боссов в данный момент командами боту в личку

Важное замечание. Все эти шаги выполнялись не совсем в том порядке, как я их описываю в этом туториале. Сделал все требуемое за выходные плюс пара вечеров после работы. Так же в целях не было сделать проект "идеальным", не совершать "революции", а дать возможность проекту плавно эволюционировать. Большая часть пунктов из плана давала возможность развивать проект.

Шаг 1. Рефакторинг приложения

Одним из требований было не потратить на это недели, поэтому основные классы я решил сделать наследниками Singleton

<?phpdeclare(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()

Вот так, например, выглядел класс подключения к базе данных

<?phpdeclare(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 codeCOPY . /appRUN 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 ExtenstionsRUN docker-php-ext-install pdo pdo_mysqlRUN docker-php-ext-enable pdo_mysql# RedisRUN apk add --no-cache pcre-dev $PHPIZE_DEPS \        && pecl install redis \        && docker-php-ext-enable redis.so# Install PHP ComposerRUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer# Remove CacheRUN rm -rf /var/cache/apk/*# setup supervisorADD docker/supervisor/asterios.conf /etc/supervisor/conf.d/asterios.confADD docker/supervisor/supervisord.conf /etc/supervisord.confVOLUME ["/app"]WORKDIR /appRUN composer installCMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

Обратите внимание на последнюю строку в Dockerfile, там я запускаю supervisord, который будет мониторить работу воркеров.

Шаг 3: Настройка supervisor

Важный дисклеймер по supervisor. Он предназначен для работы с процессами, которые работают долго, и в случае его "падения" - перезапустить. Мои же php скрипты работали быстро и сразу завершались. supervisor пробовал их перезапустить, и в конце концов переставал пытаться поднять снова. Поэтому я решил сам код воркера запускать на 1 минуту, чтобы это работало с supervisor.

Код файла worker.php

<?phprequire __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.phpstderr_logfile=/app/logs/supervisor/worker.lognumprocs = 1user = rootstartsecs = 3startretries = 10exitcodes = 0,2stopsignal = SIGINTreloadsignal = SIGHUPstopwaitsecs = 10autostart = trueautorestart = truestdout_logfile = /dev/stdoutstdout_logfile_maxbytes = 0redirect_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: UnitTestermodules:    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:      - tiernetworks:  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: Actionson:  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=testTG_API=XXXXXTG_ADMIN_ID=123TG_NAME=AsteriosRBbotDB_HOST=mysqlDB_NAME=rootDB_PORT=3306DB_CHARSET=utf8DB_USERNAME=rootDB_PASSWORD=passwordLOG_PATH=./logs/DB_NAME_TEST=test_dbREDIS_HOST=redisREDIS_PORT=6379REDIS_DB=0SILENT_MODE=trueFILLER_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-networknetworks:  asterios-tests-network:    driver: bridge

Я закомментировал контейнер с Redis, но оставил возможность использовать его в будущем. Сборка с кастомным docker-compose файлом, а затем тесты - запускается так

docker-compose -f docker-compose.github.actions.yml -p asterios-tests up -ddocker-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 -ddocker-compose exec grafana grafana-cli plugins install grafana-image-rendererdocker-compose stop  grafana docker-compose up -d grafana

Шаг 8: Публикация метрик приложения

Для сбора и публикации метрик я использовал endclothing/prometheus_client_php

Так выглядит мой класс для метрик

<?phpdeclare(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 configglobal:  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 в ТелеграмAlert в Телеграм
  1. Обратите внимание, alert заметил падение, но все восстановилось. Grafana приготовилась слать alert, но передумала. Это то самое правило 30 секунд

  2. Тут уже все упало больше чем на 30 секунд и alert был отправлен

  3. Сообщение, которое мы указали в настройках alert

  4. Ссылка на dashboard

  5. Источник метрики

Шаг 9: Телеграм бот

Настройка телеграм бота ничем не отличается от настройки воркера. Телеграм бот по сути у меня это еще один воркер, я запустил его при помощи добавления настроек в supervisor. Тут уже рефакторинг проекта дал свои плоды, запуск бота был быстрым и простым.

Итоги

Я жалею, что не сделал этого раньше. Я останавливал бота на период переустановки с новыми конфигами, и пользователи сразу стали просить пару новых фичей, которые добавить стали легче и быстрее. Надеюсь эта публикация вдохновит отрефакторить свой пет-проект и привести его в порядок. Не переписать, а именно отрефакторить.

Ссылки на проекты

Источник: habr.com
К списку статей
Опубликовано: 17.02.2021 20:19:33
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Мессенджеры

Open source

Системное администрирование

Php

Программирование

Telegram

Docker

Docker-compose

Prometheus

Grafana

Pet-project

Github actions

Testing

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru