company_banner

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:
      <?php
      return [
          'xdebugServer' => [
              // 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

    Badoo
    336,25
    Big Dating
    Поддержать автора
    Поделиться публикацией

    Комментарии 16

      0
      Судя по всему вот репозиторий. Насколько он официален — не знаю, но различий в коде между тем, что по вашей ссылке и в этом репозитории — немного.
        +2
        Да, видел этот репозиторий.
        Совсем не похоже на официальный репозиторий, нет никакой связи с komodo и ActiveState. Так же судя по звёздочкам и инструкциям в интернете не похоже что pydbgpproxy скачивается из этого репозитория. Соответственно делать pull request в этот репозиторий смысла нет.
        +2
        Отладка в режиме php-fpm несколькими разработчиками выходит за рамки данной статьи, но описана, например, здесь

        ссылку забыли подставить
          +1
          Добавил, спасибо!
          +2
          О! Рад что проект не заброшен. А вообще удивительно что при такой популярности php реализаций прокси так мало.
          Хорошее дело, надо развивать.
            +1
            Согласен. Сам был удивлён, когда не нашел ни одной прокси на php.
              0

              При популяризации Docker многопользовательская разработка на единственном тестовом (dev) окружении стала вымирать. Да и, вообще, компаний, которые могли бы похвастаться невоспроизводимым окружением всегда были единицы.
              Для чего вы используете (планируете или хотели бы использовать) xdebug proxy?

                +1
                Окружение для разработки допустим у каждого разраба своё (не полностью идентичное продакшену, в частности по БД), но есть например стэйдж-сервер, на который собирается проект от разных разрабов, при этом на стейдже окружение максимально приближенное к проду включая данные (свежие дампы БД к примеру) — и все разрабы, пуллы которых тестируются — могут спокойно проверять каждый свой участок.
              +2
              Другой способ сделать soft mocks отлаживаемыми я подглядел у похожего на soft mocks проекта, но детали немного отличаются. Даже прислал pull request довольно давно: github.com/badoo/soft-mocks/pull/35/files.

              Суть заключается в том, что вместо того, чтобы SoftMocks::rewrite() переписывал путь до файла и возвращал уже переписанное содержимое, можно возвращать путь-враппер вида «soft://».

              Магия заключается в том, чтобы зарегистрировать обработчик стрима «soft://» (класс SoftMocksStream в этом pull request) и возвращать оригинальное имя файла:

              <?php
              class SoftMocks {
              ...
              -    private static function doRewrite($file)
              +    public static function doRewrite($file, &$opened_path = '')
              ...
              }
              class SoftMocksStream {
              ...
              public function stream_open($path, $mode, $options, &$opened_path) {
              ...
                          $rewritten = SoftMocks::doRewrite($path, $opened_path);
              ...
                              $this->fp = fopen($rewritten, $mode);
              ...
              }
              }
              


              Также становятся ненужными встроенные моки для debug_backtrace и в некоторых других местах, так как backtrace становится «нормальным» (без враппера soft://, что немаловажно) и для xdebug тоже этого достаточно, чтобы он «поверил», что имя файла оригинальное.
                0

                Спасибо за PR, это интересное решение. Мы, кстати, ждём твой ответ в комментариях к нему.

                0

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

                  0
                  Туннель поможет, если на один php-fpm будет один разработчик. В случае же когда на один php-fpm несколько разработчиков, Xdebug не будет знать, в какой туннель ему идти. Xdebug proxy позволяет определить нужный туннель по `idekey`.
                    0

                    В xdebug есть встроенный multiuser-механизм (remote_connect_back), чем он не подошел?

                      +1

                      Да есть, но в случае закрытой сети он работать не будет. Например, если разработчик сидит за NAT-ом.

                  0
                  Есть версия этой статьи на английском?
                    +1

                    В планах есть перевести эту статью на английский. Я скину сюда ссылку, когда переведём.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое