SoftMocks: наша замена runkit для PHP 7

в 13:34, , рубрики: badoo, php, php7, runkit, softmocks, Блог компании Badoo, Веб-разработка, тестирование

SoftMocks: наша замена runkit для PHP 7 - 1 Компания 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 Implementationn";
}
testme();
runkit_function_redefine('testme','','echo "New Testme Implementationn";');
testme();

Вывод тестовой программы:

Original Testme Implementation
New Testme Implementation

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

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

Мы бы хотели получить такую же функциональность с возможностью переопределять любые функции и методы на лету, без предварительных объявлений. Вот как выглядит программа с использованием SoftMocks вместо runkit (пример тот же):

<?php // файл test.php
function testme() {
  echo "Original Testme Implementationn";
}
testme();
QASoftMocks::redefineFunction('testme', '', 'echo "New Testme Implementationn";');
testme();

Команда для запуска примера выглядит следующим образом:

$ php -r 'require("init.inc"); require(SoftMocks::rewrite("test.php"));'

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

Файл init.inc содержит в себе код для инициализации класса SoftMocks и выглядит следующим образом (конкретный вид файла будет зависеть от вашего приложения):

<?php
// код загрузки всех классов PhpParser
// (необходимо сделать в самом начале, до загрузки SoftMocks)
require($php_parser_dir . "Autoloader.php");
PhpParserAutoloader::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");
QASoftMocks::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) {
  QASoftMocks::call([parent::class, 'test'], [$a, $b]);
  $c = QASoftMocks::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(QASoftMocks::rewrite("front-orig.php"));
} else {
 include("front-orig.php");
}

После прохода нашего парсера файл front-orig.php будет выглядеть следующим образом:

// переписанный код фронт-контроллера с помощью SoftMocks
<?php
require(QASoftMocks::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-разработчик

Автор: Badoo

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js