PHP Xdebug proxy: когда стандартных возможностей Xdebug не хватает

PHP Xdebug proxy: когда стандартных возможностей Xdebug не хватает

Для отладки PHP-программ часто используют Xdebug. Однако стандартных возможностей IDE и Xdebug не всегда достаточно. Часть проблем можно решить с помощью Xdebug proxy — pydbgpproxy, но всё же не все. Поэтому я реализовал PHP Xdebug proxy на базе асинхронного фреймворка amphp.

Под катом я расскажу, что не так с pydbgpproxy, чего в нём не хватает и почему я не стал его дорабатывать. Также объясню, как работает PHP Xdebug proxy, и покажу на примере, как его расширять.


Pydbgpproxy vs PHP Xdebug proxy

Xdebug proxy является промежуточным сервисом между IDE и Xdebug (проксирует запросы от Xdebug к IDE и обратно). Чаще всего он используется для multiuser debugging. Это когда у вас один веб-сервер, а разработчиков — несколько.

В качестве proxy обычно используют pydbgpproxy. Но у него есть несколько проблем:


  • нет официальной страницы;
  • тяжело найти, где его скачивать; оказывается, это можно сделать здесь — внезапно, Python Remote Debugging Client;
  • официального репозитория я не нашёл;
  • как следствие предыдущего пункта, непонятно, куда приносить pull request;
  • proxy, как видно из названия, написан на Python, который знают далеко не все PHP-шники, а значит, расширять его — это проблема;
  • продолжение предыдущего пункта: если есть какой-то код на PHP, и его нужно будет использовать в proxy, то его придется портировать на Python, а дублирование кода — это всегда не очень хорошо.

Поиск Xdebug proxy, написанного на PHP, на GitHub и в интернете результатов не дал. Поэтому я написал PHP Xdebug proxy. Под капотом я использовал асинхронный фреймворк amphp.

Основные преимущества PHP Xdebug proxy перед pydbgpproxy:


  • PHP Xdebug proxy написан на хорошо знакомом PHP-шникам языке, а значит:
    • в нём легче решать проблемы;
    • его легче расширять;
  • у PHP Xdebug proxy есть публичный репозиторий, а значит:
    • можно форкнуть и допилить его под свои нужды;
    • можно прислать pull request с недостающей фичей или решением какой-либо проблемы.


Как работать с PHP Xdebug proxy


Установка

PHP Xdebug proxy можно установить как dev-зависимость через composer:

composer.phar require mougrim/php-xdebug-proxy --dev

Но если вы не хотите тащить в свой проект лишние зависимости, то PHP Xdebug proxy можно установить как проект через тот же composer:

composer.phar create-project mougrim/php-xdebug-proxy
cd php-xdebug-proxy

PHP Xdebug proxy расширяемый, но по умолчанию для работы требуется ext-dom (расширение включено по умолчанию в PHP) для разбора XML и amphp/log для асинхронной записи в логи:

composer.phar require amphp/log '^1.0.0'


Запуск

PHP Xdebug proxy

Запускается proxy следующим образом:

bin/xdebug-proxy

Proxy запустится с настройками по умолчанию:

Using config path /path/to/php-xdebug-proxy/config
[2019-02-14 10:46:24] xdebug-proxy.NOTICE: Use default ide: 127.0.0.1:9000 array ( ) array ( )
[2019-02-14 10:46:24] xdebug-proxy.NOTICE: Use predefined ides array (   'predefinedIdeList' =>    array (     'idekey' => '127.0.0.1:9000',   ), ) array ( )
[2019-02-14 10:46:24] xdebug-proxy.NOTICE: [Proxy][IdeRegistration] Listening for new connections on '127.0.0.1:9001'... array ( ) array ( )
[2019-02-14 10:46:24] xdebug-proxy.NOTICE: [Proxy][Xdebug] Listening for new connections on '127.0.0.1:9002'... array ( ) array ( )

Из лога видно, что по умолчанию proxy:


  • прослушивает 127.0.0.1:9001 для подключений регистраций IDE;
  • прослушивает 127.0.0.1:9002 для подключений Xdebug;
  • использует 127.0.0.1:9000 как IDE по умолчанию и предустановленную IDE с ключом idekey.


Конфигурация

Если есть желание настроить прослушиваемые порты и т. д., то можно указать путь до папки с настройками. Достаточно скопировать папку config:

cp -r /path/to/php-xdebug-proxy/config /your/custom/path

В папке с настройками три файла:


  • config.php:
     [
            // host:port для прослушивания подключений Xdebug
            'listen' => '127.0.0.1:9002',
        ],
        'ideServer' => [
            // Если proxy не может найти IDE, то он будет использовать IDE по умолчанию,
            // если нужно отключить IDE по умолчанию, то нужно передать пустую строку.
            // IDE по умолчанию полезна, когда proxy пользуется только один человек.
            'defaultIde' => '127.0.0.1:9000',
            // Предопределённые IDE указываются в формате 'idekey' => 'host:port',
            // если предопределённые IDE не нужны, то можно указать пустой массив.
            // Предопределённые IDE полезны, когда пользователи proxy меняются нечасто,
            // так им не нужно будет заново регистрироваться при каждом перезапуске proxy.
            'predefinedIdeList' => [
                'idekey' => '127.0.0.1:9000',
            ],
        ],
        'ideRegistrationServer' => [
            // host:port для прослушивания подключений регистраций IDE,
            // если требуется отключить регистрации IDE, то нужно передать пустую строку.
            'listen' => '127.0.0.1:9001',
        ],
    ];
  • logger.php: можно настроить логгер; файл должен возвращать объект, который является экземпляром \Psr\Log\LoggerInterface, по умолчанию используется \Monolog\Logger с \Amp\Log\StreamHandler (для неблокирующей записи), выводит логи в stdout;
  • factory.php: можно настроить классы, которые используются в proxy; файл должен возвращать объект, который является экземпляром Factory\Factory, по умолчанию используется Factory\DefaultFactory.

После копирования файлы можно отредактировать и запустить proxy:

bin/xdebug-proxy --configs=/your/custom/path/config


Отладка

О том, как отлаживать код с помощью Xdebug, написано много статей. Отмечу основные моменты.

В php.ini должны быть следующие настройки в секции [xdebug] (исправьте их, если они отличаются от стандартных):


  • idekey=idekey
  • remote_host=127.0.0.1
  • remote_port=9002
  • remote_enable=On
  • remote_autostart=On
  • remote_connect_back=Off

Дальше можно запускать отлаживаемый PHP-код:

php /path/to/your/script.php

Если вы всё правильно сделали, то отладка начнётся с первого breakpoint в IDE. Отладка в режиме php-fpm несколькими разработчиками выходит за рамки данной статьи, но описана, например, здесь.


Расширение функций proxy

Всё, что мы рассмотрели выше, в той или иной степени умеет и pydbgpproxy.

Теперь поговорим о самом интересном в PHP Xdebug proxy. Прокси можно расширять, используя свою фабрику (создаётся в конфиге factory.php, см. выше). Фабрика должна реализовывать интерфейс Factory\Factory.

Наиболее мощными являются так называемые подготовители запросов (request preparers). Они могут изменять запросы от Xdebug к IDE и обратные. Чтобы добавить подготовитель запроса, нужно переопределить метод Factory\DefaultFactory::createRequestPreparers(). Метод возвращает массив объектов, которые реализовывают интерфейс RequestPreparer\RequestPreparer. При проксировании запроса от Xdebug к IDE они выполняются в прямом порядке, при проксировании запроса от IDE к Xdebug — в обратном.

Подготовители запросов могут использоваться, например, для изменения путей до файлов (в breakpoints и выполняемых файлах).


Дебаг переписанных файлов

Для того, чтобы привести пример подготовителя, сделаю небольшое отступление. В unit-тестах мы используем soft-mocks (GitHub). Soft-mocks позволяет подменять функции, статические методы, константы и т. д. в тестах, является альтернативой для runkit и uopz. Работает это за счет переписывания PHP-файлов на лету. Подобным образом ещё работает AspectMock.

Но стандартные возможности Xdebug и IDE позволяют отлаживать переписанные (имеющие другой путь), а не оригинальные файлы.

Рассмотрим подробнее проблему отладки с использованием soft-mocks в тестах. Для начала возьмём случай, когда PHP-код выполняется локально.

Первые сложности появляются на этапе установки точек останова (breakpoints). В IDE они устанавливаются в оригинальные файлы, а не в переписанные. Чтобы поставить breakpoint через IDE, нужно найти актуальный переписанный файл. Проблема усугубляется тем, что при каждом изменении оригинального файла создаётся новый переписанный файл, то есть для каждого уникального содержимого файла будет уникальный переписанный файл.

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

Теперь рассмотрим ситуацию посложнее: приложение выполняется на удалённой машине.

В этом случае можно примонтировать папку с переписанными файлами, например, через SSHFS. Если локальный и удалённый пути до папки различаются, то ещё нужно прописать mappings в IDE.

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

В AspectMock обошли проблему включением режима дебага без возможности его отключить:

public function init(array $options = [])
{
    if (!isset($options['excludePaths'])) {
        $options['excludePaths'] = [];
    }
    $options['debug'] = true;
    $options['excludePaths'][] = __DIR__;

    parent::init($options);
}

В простом примере теста режим дебага навскидку медленнее процентов на 20. Но у меня нет достаточного количества тестов на AspectMock, чтобы дать более точную оценку того, насколько он медленнее. Если у вас есть много тестов на AspectMock, я буду рад, если вы поделитесь сравнением в комментариях.


Использование Xdebug с soft-mocks

Xdebug + soft-mocks

Теперь, когда понятна проблема, рассмотрим, как можно её решить с использованием PHP Xdebug proxy. Основная часть находится в классе RequestPreparer\SoftMocksRequestPreparer.

В конструкторе класса определяем путь до скрипта инициализации soft-mocks и запускаем его (предполагается, что soft-mocks подключён как зависимость, но в конструктор можно передать любой путь):

public function __construct(LoggerInterface $logger, string $initScript = '')
{
    $this->logger = $logger;
    if (!$initScript) {
        $possibleInitScriptPaths = [
            // proxy установлен как проект, soft-mocks — как зависимость проекта
            __DIR__.'/../../vendor/badoo/soft-mocks/src/init_with_composer.php',
            // proxy и soft-mocks установлены как зависимости
            __DIR__.'/../../../../badoo/soft-mocks/src/init_with_composer.php',
        ];
        foreach ($possibleInitScriptPaths as $possiblInitScriptPath) {
            if (file_exists($possiblInitScriptPath)) {
                $initScript = $possiblInitScriptPath;

                break;
            }
        }
    }

    if (!$initScript) {
        throw new Error("Can't find soft-mocks init script");
    }
    // инициализируем soft-mocks (путь до папки с переписанными файлами и т.д.)
    require $initScript;
}

Xdebug + soft-mocks: from Xdebug to IDE

Для подготовки запроса от Xdebug к IDE нужно заменить путь до переписанного файла путём оригинального файла:

public function prepareRequestToIde(XmlDocument $xmlRequest, string $rawRequest): void
{
    $context = [
        'request' => $rawRequest,
    ];
    $root = $xmlRequest->getRoot();
    if (!$root) {
        return;
    }
    foreach ($root->getChildren() as $child) {
        // путь до переписанного файла лежит в одном из тегов:
        // - 'stack': https://xdebug.org/docs-dbgp.php#stack-get
        // - 'xdebug:message': https://xdebug.org/docs-dbgp.php#error-notification
        if (!in_array($child->getName(), ['stack', 'xdebug:message'], true)) {
            continue;
        }
        $attributes = $child->getAttributes();
        if (isset($attributes['filename'])) {
            // если в атрибутах тега есть путь до переписанного файла, то заменяем его оригинальным путём
            $filename = $this->getOriginalFilePath($attributes['filename'], $context);
            if ($attributes['filename'] !== $filename) {
                $this->logger->info("Change '{$attributes['filename']}' to '{$filename}'", $context);
                $child->addAttribute('filename', $filename);
            }
        }
    }
}

Xdebug + soft-mocks: from IDE to Xdebug

Для подготовки запроса от IDE к Xdebug нужно заменить путь до оригинального файла путём до переписанного:

public function prepareRequestToXdebug(string $request, CommandToXdebugParser $commandToXdebugParser): string
{
    // разбираем запрос на команду и аргументы
    [$command, $arguments] = $commandToXdebugParser->parseCommand($request);
    $context = [
        'request' => $request,
        'arguments' => $arguments,
    ];
    if ($command === 'breakpoint_set') {
        // если есть аргумент -f, то заменяем путь до оригинального файла путём до переписанного
        // см. https://xdebug.org/docs-dbgp.php#id3
        if (isset($arguments['-f'])) {
            $file = $this->getRewrittenFilePath($arguments['-f'], $context);
            if ($file) {
                $this->logger->info("Change '{$arguments['-f']}' to '{$file}'", $context);
                $arguments['-f'] = $file;
                // собираем обратно запрос
                $request = $commandToXdebugParser->buildCommand($command, $arguments);
            }
        } else {
            $this->logger->error("Command {$command} is without argument '-f'", $context);
        }
    }

    return $request;
}

Чтобы подготовитель запроса заработал, нужно создать свой класс фабрики и либо наследовать его от Factory\DefaultFactory, либо имплементировать интерфейс Factory\Factory. Для soft-mocks фабрика Factory\SoftMocksFactory выглядит так:

class SoftMocksFactory extends DefaultFactory
{
    public function createConfig(array $config): Config
    {
        // здесь создаём объект своего класса конфига
        return new SoftMocksConfig($config);
    }

    public function createRequestPreparers(LoggerInterface $logger, Config $config): array
    {
        $requestPreparers = parent::createRequestPreparers($logger, $config);
        return array_merge($requestPreparers, [$this->createSoftMocksRequestPreparer($logger, $config)]);
    }

    public function createSoftMocksRequestPreparer(LoggerInterface $logger, SoftMocksConfig $config): SoftMocksRequestPreparer
    {
        // здесь передаём путь до init-скрипта из конфига
        return new SoftMocksRequestPreparer($logger, $config->getSoftMocks()->getInitScript());
    }
}

Свой класс конфига здесь нужен, чтобы можно было указать путь init-скрипта soft-mocks. Что он из себя представляет, посмотреть можно в Config\SoftMocksConfig.

Осталась самая малость: создать новую фабрику и указать путь до init-скрипта soft-mocks. Как это делается, можно посмотреть в softMocksConfig.


Неблокирующий API

Как я уже писал выше, PHP Xdebug proxy под капотом использует amphp, а значит, для работы с I/O должен использоваться неблокирующий API. В apmphp уже есть немало компонентов, которые реализуют этот неблокирующий API. Если вы собираетесь расширять PHP Xdebug proxy и использовать его в многопользовательском режиме, то обязательно используйте неблокирующие API.


Выводы

PHP Xdebug proxy — ещё довольно молодой проект, но в Badoo он уже активно используется для отладки тестов с использованием soft-mocks.

PHP Xdebug proxy:


  • заменяет pydbgpproxy при многопользовательской отладке;
  • может работать с soft-mocks;
  • можно расширить:
    • можно заменять пути до файлов, приходящих от IDE и от Xdebug;
    • можно собирать статистику: в режиме отладки как минимум доступен выполняемый контекст при отладке (значения переменных и выполняемая строчка кода).

Если вы используете Xdebug proxy для чего-то, кроме multiuser debugging, то поделитесь своим кейсом и Xdebug proxy, которым пользуетесь, в комментариях.

Если вы используете pydbgpproxy или какой-то другой Xdebug proxy, то попробуйте PHP Xdebug proxy, расскажите о своих проблемах, поделитесь pull requests. Давайте развивать проект вместе! :)

P.S. Спасибо моему коллеге Евгению Махрову aka eZH за идею proxy smdbgpproxy!


Ещё раз ссылки


  • PHP Xdebug proxy — Xdebug proxy, о котором идёт речь в статье;
  • pydbgpproxy можно скачать здесь — внезапно, Python Remote Debugging Client;
  • amphp — асинхронный неблокирующий фреймворк на PHP;
  • инструменты для mock-ов:

Спасибо за внимание!

Буду рад комментариям и предложениям.

Ринат Ахмадеев, Sr. PHP developer

© Habrahabr.ru