Компания Badoo одной из первых перешла на PHP 7 — мы совсем недавно писали об этом. В той статье мы говорили об изменениях в инфраструктуре тестирования и обещали подробнее рассказать о разработанной нами замене для расширения runkit под названием SoftMocks.

SoftMocks


Идея у SoftMocks очень простая и отражена в названии: нужно реализовать аналог для runkit, максимально совместимый с ним по семантике, на чистом PHP. Soft здесь подчеркивает то, что он реализован не внутри ядра PHP, а поверх него, без использования Zend API и прочего hardcore. Тот факт, что он на чистом PHP, означает, что мы можем спокойно переходить на новую версию PHP и просто добавлять поддержку нового синтаксиса, а не переписывать расширения с новой версией Zend API и ловить миллионы багов из-за различных тонкостей в семантике.

На чистом PHP это можно сделать аналогично тому, как работают многие инструменты для Go, такие как godebug, go test -cover и т.д. — автоматизированным переписыванием кода, в нашем случае — на лету, прямо перед «инклюдами». В интернете можно найти фреймворк для тестирования AspectMock, работающий поверх библиотеки Go! AOP, которая тоже занимается переписыванием кода и предоставляет возможность писать в AOP-стиле. Фреймворк хорош, но он не позволяет полностью заменить runkit в наших условиях, поэтому мы решили написать свое решение по образу и подобию этой библиотеки. К сожалению, вышеупомянутый фреймворк не представляет возможности перехватывать функции и методы на лету (то есть без предварительного объявления о намерении перехватывать конкретную функцию). Это отличается от поведения runkit и uopz, хотя тоже имеет свою сферу применения.

Что позволяет делать runkit


Расширение runkit в PHP позволяет проводить различные манипуляции над состоянием объектов, функций, методов и констант прямо во время исполнения PHP-кода.

Пример из документации (http://php.net/manual/en/function.runkit-function-redefine.php).

Тестовая программа:
<?php
function testme() {
  echo "Original Testme Implementation\n";
}
testme();
runkit_function_redefine('testme','','echo "New Testme Implementation\n";');
testme();


Вывод тестовой программы:
Original Testme Implementation
New Testme Implementation


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

API нашей библиотеки


Мы бы хотели получить такую же функциональность с возможностью переопределять любые функции и методы на лету, без предварительных объявлений. Вот как выглядит программа с использованием SoftMocks вместо runkit (пример тот же):
<?php // файл test.php
function testme() {
  echo "Original Testme Implementation\n";
}
testme();
\QA\SoftMocks::redefineFunction('testme', '', 'echo "New Testme Implementation\n";');
testme();


Команда для запуска примера выглядит следующим образом:
$ php -r 'require("init.inc"); require(SoftMocks::rewrite("test.php"));'


Вывод тестовой программы такой же, как в runkit.

Файл init.inc содержит в себе код для инициализации класса SoftMocks и выглядит следующим образом (конкретный вид файла будет зависеть от вашего приложения):
<?php
// код загрузки всех классов PhpParser
// (необходимо сделать в самом начале, до загрузки SoftMocks)
require($php_parser_dir . "Autoloader.php");
\PhpParser\Autoloader::register(true);
$out = [];
exec('find ' . escapeshellarg($php_parser_dir) . " -type f -name '*.php'", $out);
foreach ($out as $f) {
    require_once($f);
}

// загрузка и инициализация SoftMocks (всего один файл!)
require_once("SoftMocks.php");
\QA\SoftMocks::init();


Идея реализации


Изначальная идея была достаточно простая: мы можем оборачивать все вызовы методов и функций, а также обращения к константам в вызовы нашей обертки (англ. wrapper), который проверяет, есть ли mock-объект для конкретного метода и функции или нет.

Таким образом, код из такого
class A extends B {
 public function test($a, $b) {
  parent::test($a, $b);
  $c = file_get_contents("something.txt");
  return $c;
 }
}

превратится в такой:
class A extends B {
 public function test($a, $b) {
  \QA\SoftMocks::call([parent::class, 'test'], [$a, $b]);
  $c = \QA\SoftMocks::call('file_get_contents', ['something.txt']);
  return $c;
 }
}

Код для метода SoftMocks::call() мог бы тогда выглядеть следующим образом:
public static function call($func, $args) {
 if (!self::isMocked($func)) {
  return call_user_func_array($func, $args);
 }
 return self::callMocks($func, $args);
}

Начало реализации: рекурсивное переписывание include


Вначале мы написали простенький парсер, который умел делать только одну вещь — подменять вызовы include(...) и require(...), чтобы мы могли включить использование SoftMocks во фронт-контроллере или, для PHPUnit-тестов, в bootstrap.php, и все файлы были бы рекурсивно переписаны нашим парсером.

Пример:
// код фронт-контроллера до модификаций (front.php)
<?php
require('autoload.php');
$app = new App();
$app->run(...);

Здесь autoload.php подгружает классы для autoload-проекта, регистрирует и инициализирует все необходимое, возможно, загружая еще какие-то файлы с помощью include(...). Оригинальный файл с фронт-контроллером нужно переместить в другое место, например, front-orig.php, и заменить на такое:
// код фронт-контроллера после модификаций
<?php
if ($soft_mocks_enabled) {
 require('soft_mocks_init.inc');
 include(\QA\SoftMocks::rewrite("front-orig.php"));
} else {
 include("front-orig.php");
}

После прохода нашего парсера файл front-orig.php будет выглядеть следующим образом:
// переписанный код фронт-контроллера с помощью SoftMocks
<?php
require(\QA\SoftMocks::rewrite('autoload.php'));
$app = new App();
$app->run(...);

Метод SoftMocks::rewrite($filename) переписывает файл, заменяя require, include, вызовы методов и прочее на вызов оберток. Возвращаемое значение этой функции — новый путь до файла, который содержит уже обернутый код и позволяет на лету переопределять значения функций, методов и констант.

Например, front-orig.php будет превращен в /tmp/mocks/<hash-code>/front-orig.php_<version>. В пути до скомпилированного файла <hash-code> считается на основании содержимого и пути до файла, что позволяет нам кешировать скомпилированные файлы и проводить процедуру парсинга и переписывания файла только один раз.

Вначале мы хотели переписывать только include и require, чтобы оценить сложность полноценного парсинга. Оказалось, что PHP позволяет не использовать скобки для таких конструкций (т.е. можно писать require "a.php"; вместо require("a.php")), а также поддерживаются выражения и вызовы других функций. Это делает простейшую задачу по замене «инклюдов» сложнее, чем это необходимо. Также есть константы __FILE__ и __DIR__, значения которых меняются динамически, в зависимости от расположения файла. У нас часто встречается код наподобие include(dirname(__DIR__) . “/something.php”);, и обращения к константам __DIR__ и __FILE__ нужно заменять на их содержимое.

Еще одной неприятной проблемой было то, что в include возможно использование относительных путей (require "a.php"), и, соответственно, нужно обращать внимание на настройку include_path и подменять значение текущей директории (".") на директорию у исходного, а не переписанного файла.

token_get_all() vs PHP Parser


Первая версия нашего парсера старалась использовать функцию token_get_all(), которая работает весьма быстро и возвращает массив токенов в файле. Проблема в том, что на ее основе очень сложно парсить вложенные аргументы функций и уж тем более заменять списки аргументов на массив, как нужно нам в случае с оборачиванием вызова функции в SoftMocks::call().

Поэтому мы взяли библиотеку Никиты Попова под названием PHP Parser. Эта библиотека умеет строить AST-дерево на основе списка токенов, возвращаемых token_get_all(), и также предоставляет удобные инструменты для обхода дерева и его модификации. Библиотека позволяет легко реализовать именно то, что нам нужно.

К сожалению, у парсера есть недостатки:

  1. Низкая производительность: парсинг файла занимает, по нашим бенчмаркам, примерно в 15 раз больше времени, чем token_get_all().
  2. Невозможность печати модифицированного дерева обратно с сохранением исходных номеров строк.

Если с первой проблемой сложно что-то сделать, поскольку библиотека на PHP, то второй недостаток мы устранили сами, расширив предлагаемый «из коробки» принтер. Для наших целей было не так важно, чтобы файл на выходе был «красивым», нам требовалось только по максимуму сохранить оригинальные номера строк, чтобы сообщения об ошибках в PHPUnit и подсчет покрытия кода тестами не пострадали.

Конечная реализация


На основе PHP Parser мы быстро написали прототип, который делает именно то, что мы изначально хотели — оборачивает обращения к методам и функциям в вызовы своей прослойки. К сожалению, в случае с методами обнаружилось немало проблем с таким подходом:

  1. Семейство call_user_func* не позволяет вызывать методы private и protected, поэтому нужно использовать Reflection, что не очень хорошо сказывается на производительности
  2. Чтобы вызвать родительский метод, нужно прибегать к «особой уличной магии» — вызовы parent-методов записываются как parent::call_something(...), при этом вызов на самом деле не статический, а динамический. Помимо этого, значение класса static должно сохраняться, а не указывать на parent-класс. К сожалению, мы не нашли простого способа сохранить текущий static-контекст при вызовах через Reflection — вероятно, такого способа пока что не существует.
  3. Поскольку мы всегда вызываем методы через Reflection с использованием setAccessible(true), то мы, по сути, всегда вызываем методы private и protected как если бы они были public, тогда как в «настоящем» коде это могло бы привести к Fatal error во время исполнения. Получается, что мы меняем поведение тестируемого кода, что непозволительно.
  4. Невозможно таким образом подменить реализацию для «магических» методов, например, для __construct, а также __get, __set, __clone, __wakeup и т.д.

В итоге мы пришли к тому, что mock-объекты для методов класса мы будем осуществлять путем вставки дополнительного кода перед каждым определением метода. Пример
public function doSomething($a) {
    return $a > 5;
}

превратится в следующее:
public function doSomething($a) {
    if (SoftMocks::isMocked(...)) { return eval(SoftMocks::getMockCode(...)); }
    return $a > 5;
}

Мы не оборачиваем вызовы методов, но все равно делаем это для функций. Такой подход не позволяет перехватывать методы встроенных классов, однако, на удивление, эта возможность нам так и не понадобилась. Интересно, что в библиотеке AspectMock используется похожий подход для mock-объектов методов.

При работе с функциями проблемы тоже есть.

  1. Некоторые функции зависят от текущего контекста, например, get_called_class().
  2. В функции могут передаваться значения вроде static (строкой) и self, и поскольку вызов функции осуществляется через нашу обертку, функции получают другие значения для этих ключевых слов. В таких случаях требуется модифицировать тестируемый код, чтобы в функции передавались не строки, а имена классов, например, static::class вместо static.
  3. Функции, которые могут вызывать callback, например, preg_replace_callback, могут вызывать private-методы. Поскольку настоящий вызов функции preg_replace_callback происходит из класса SoftMocks, то происходит ошибка доступа и private-методы из этого контекста становятся недоступны. Решением проблемы тоже является переписывание кода, например, передача анонимных функций вместо array($this, 'callback').

Для решения большинства этих проблем мы сделали поддержку «черного списка» функций, которые не оборачиваются и вызываются всегда напрямую. Вот некоторые из них: get_called_class, get_parent_class, func_get_args, usort, array_walk_recursive, extract, compact, get_object_vars. Эти функции нельзя подменить с помощью SoftMocks.

Перехват глобальных констант и констант классов осуществляется очень просто: все обращения к константам мы заменяем на вызов функций. Исключение составляют только те случаи, когда константы указываются в качестве значения по умолчанию в аргументах функций или свойств классов. То есть следующие места мы не можем переписывать:
class A { private $b = SOME_CONST; } // не может быть переписано, будет parse error

function doSomething($a = OTHER_CONST) {
// обращение к константе в таком месте не переписывается в данный момент,
// но это можно обойти, если анализировать число
// передаваемых аргументов в функцию
}

Также мы приняли решение не оборачивать константы true, false и null из соображений производительности. Это значит, что, в отличие от runkit, в SoftMocks нельзя будет сделать redefineConstant("true", false);.

Производительность


Поскольку SoftMocks написан на чистом PHP, можно было бы ожидать, что его производительность будет хуже по сравнению с runkit. На самом деле наши тесты стали проходить быстрее и стабильнее, поскольку SoftMocks не страдает от таких проблем, как необходимость сбрасывать runtime cache при любом вызове. Наша библиотека не страдает от деградации производительности при увеличении числа загруженных классов и функций, поэтому общая производительность в нашем случае оказалась даже немного лучше.

Если не использовать функции SoftMocks вообще, но все равно исполнять переписанный код, то его производительность, по нашим оценкам, снижается примерно в 3 раза. В целом мы бы рекомендовали использовать SoftMocks для юнит-тестов и не использовать эту библиотеку на продакшене как из соображений производительности, так и из соображений безопасности: библиотека создает временные файлы и делает include из директории с возможностью записи из веб-контекста.

Интеграция с PHPUnit


Поскольку мы подменяем пути до файлов, которые «инклюдятся», backtrace становится нечитаемым из-за автогенерированных файлов вместо оригинала. Также, поскольку PHPUnit сам загружает файлы, а его исходный код мы не переписываем, это делает невозможным подмену функций и методов, определенных в файлах с тестами.

Чтобы решить эти проблемы, мы подготовили pull-request для PHPUnit: github.com/sebastianbergmann/phpunit/pull/2116

Заключение


Наш проект SoftMocks выложен на GitHub по адресу: github.com/badoo/soft-mocks.
Мы используем PHP Parser Никиты Попова, который также доступен на GitHub: github.com/nikic/PHP-Parser.

Мы добились того, чтобы при переписывании кода на лету можно было подменять реализацию функций, пользовательских методов и констант — и все это на чистом PHP, без использования сторонних расширений. В нашем случае мы смогли полностью избавиться от runkit, «прогнать» весь наш suite из 60 000 юнит-тестов под PHP 7 и исправить найденные несовместимости (таковых было обнаружено очень мало, и часть из них — ошибки в dev-версиях PHP 7, о которых мы сообщили разработчикам).

В данный момент badoo.com работает на PHP 7, и мы смогли этого достичь в том числе благодаря разработке SoftMocks. Надеюсь, ваш опыт будет таким же положительным, как и наш.
Приятного тестирования!

Юрий Насретдинов, старший PHP-разработчик

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


  1. mipxtx
    18.03.2016 17:11
    +3

    А мы тем временем uopz допилили до рабочего состояния.


    1. alexkrash
      18.03.2016 17:27

      Это, бесспорно, круто, но не придётся ли тем же заниматься с новой версией PHP?


      1. mipxtx
        18.03.2016 17:54
        +1

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


        1. youROCK
          18.03.2016 20:24

          Практика runkit показывает, что API меняется достаточно часто и добавляются новые оптимизации, делающие написание и поддержку таких расширений все более тяжелой задачей. Например, в PHP 5.4 добавили runtime cache, который очень сильно замедляет работу runkit, поскольку ему приходится сбрасывать этот кеш при каждом вызове.


      1. MaxxArts
        18.03.2016 17:55
        +1

        Ну, вообще, у нас не так много таких мест, где без хардкорных моков прям не обойтись, это крайние случаи (и случаи приступов лени). Мы стараемся избегать подобной фигни. Поэтому, думаю, вероятность не такая большая. Хотя это зависит от того, насколько новую версию PHP вы имеете в виду. Для PHP 8 наверняка придётся патчить. А так — норм.


    1. youROCK
      18.03.2016 17:27
      +1

      Жаль, что этот патч они сделали только сейчас, а не 2 месяца назад, когда мы переводили наши тесты на PHP7 :(


      1. mipxtx
        18.03.2016 17:31

        А что сами не сделали? Таск треккер говорит — затрачено 67 часов. Не очень-то и эпично


        1. youROCK
          18.03.2016 18:15
          +1

          Мы пробовали вместе с автором решить те проблемы, что были. Тогда ни у кого не получилось. Т.е. тупо ни у нас ни у автора не было идей, как можно это починить :). Время тут не причем.

          В целом, наш подход с Soft Mocks позволяет нам хоть на HHVM перейти, и кстати на HHVM базовые тесты для Soft Mocks проходят, хотя я для этого дополнительно ничего не делал.


          1. mipxtx
            18.03.2016 18:26

            А в чем были проблемы? Мы, кроме сегфолтов и кеширования вызовов ни на что не наткнулись.


            1. youROCK
              18.03.2016 18:28

              Сегфолт — это серьезная проблема. Вот пример issue, который до сих пор висит открытым: https://github.com/krakjoe/uopz/issues/18. UPD: автор говорит, что на мастере больше не воспроизводится. Но у нас была такая проблема, когда мы тестили.


      1. MaxxArts
        18.03.2016 17:57
        +1

        По этому комментарию складывается впечатление, что вас soft-mocks не устраивают. Или я не так понял?


        1. youROCK
          18.03.2016 18:17
          +1

          Устраивают. Soft Mocks дают нам гарантию, что ничего не сломается при переходе на PHP 7.1 и последующие версии. Однако ещё лучше было бы, если бы нам не пришлось разрабатывать Soft Mocks вообще и мы могли бы пользоваться одним из этих расширений и они при этом работали.


          1. mipxtx
            18.03.2016 19:10
            +1

            Soft Mocks дают нам гарантию, что ничего не сломается при переходе на PHP 7.1

            Это далеко не факт. В последних версиях php (5.3, 5.4, 5.5, 5.6) добавлялись новые языковые конструкции. Вот под них надо дописывать парсер, возможно логику работы самого механизма мока.


            1. youROCK
              18.03.2016 20:21
              +2

              Ну, как минимум, пока мы не используем новые языковые конструкции, все будет работать. Во-вторых, парсер поддерживает Никита Попов и пока что переставать это делать он вроде бы не собирался. Ну и в конце концов, разработчики PHP обещают (ссылку не готов предоставить) открыть API для работы с AST в версии PHP 7.1, что тоже должно помочь при портировании на новую версию. Как бы то ни было, по сравнению с uopz и runkit, усилия по портированию на новую версию минимальны.


  1. Rathil
    18.03.2016 17:17
    +2

    А мы тем временем сидим на PHP 5.5 и не известно сколько ещё сидеть будем :(


  1. coylOne
    22.03.2016 12:30

    Всё-таки пришлось автолоад исправлять =)