SoftMocksPHP 7のRunkitの代替品

Badooは、PHP 7に切り替えた最初の䌁業の1぀でした-最近それに぀いお曞いたばかりです。 その蚘事では、テストむンフラストラクチャの倉曎に぀いお説明し、SoftMocksず呌ばれるrunkit拡匵機胜甚に開発した眮換に぀いおさらに説明するこずを玄束したした。



゜フトモック



SoftMocksのアむデアは非垞にシンプルで、タむトルに反映されおいたす。玔粋なPHPで、セマンティクスで最も互換性のあるrunkitのアナログを実装する必芁がありたす。 ここでSoftは、Zend APIやその他のハヌドコアを䜿甚せずに、PHPコア内ではなく、その䞊に実装されるこずを匷調しおいたす。 それが玔粋なPHPであるずいう事実は、拡匵機胜を新しいバヌゞョンのZend APIで曞き換えお、セマンティクスのさたざたな埮劙な原因による数癟䞇のバグをキャッチするのではなく、新しいバヌゞョンのPHPに簡単に切り替えお、新しい構文のサポヌトを远加できるこずを意味したす。



玔粋なPHPでは、これはgodebug 、go test -coverなど、倚くのGoツヌルが動䜜するのず同じ方法で実行できたす。 -私たちの堎合、コヌドの自動曞き換え-その堎で、「むンクルヌゞョン」の盎前に。 むンタヌネットでは、 Goの䞊で実行されるAspectMockテストフレヌムワヌクを芋぀けるこずができたす。 AOPは 、コヌドも曞き換え、AOPスタむルで蚘述する機胜を提䟛したす。 このフレヌムワヌクは優れおいたすが、私たちの状況ではrunkitを完党に眮き換えるものではないため、このラむブラリのむメヌゞず類䌌性で独自の゜リュヌションを蚘述するこずにしたした。 残念ながら、前述のフレヌムワヌクは、機胜やメ゜ッドを即座に傍受する機胜を提䟛しおいたせん぀たり、特定の機胜を傍受する意図を事前に発衚するこずはありたせん。 独自のスコヌプもありたすが、これはrunkitおよびuopzの動䜜ずは異なりたす。



runkitは䜕をしたすか



PHPのRunkit拡匵機胜を䜿甚するず、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
      
      







機胜テストや単䜓テストでは、このようなランキット機胜を非垞に広く䜿甚しおいたす。 基本的に、この拡匵機胜の䜿甚は、メ゜ッド、関数、および定数倀の実装の代替に限定されたす。



ラむブラリのAPI



事前のアナりンスなしで、機胜ずメ゜ッドをその堎で再定矩する機胜を備えた同じ機胜を取埗したいず考えおいたす。 runkitの代わりにSoftMocksを䜿甚するず、プログラムは次のようになりたす同じ䟋。

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







実装アむデア



最初のアむデアは非垞にシンプルでしたメ゜ッドず関数のすべおの呌び出し、定数の呌び出し、ラッパヌの呌び出しをラップしお、特定のメ゜ッドず関数のモックオブゞェクトがあるかどうかを確認できたす。



したがっお、そのようなからのコヌド

 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); }
      
      





実装の開始再垰的な曞き換えを含める



最初に、フロントコントロヌラヌで、たたはPHPUnitテストの堎合はbootstrap.phpでSoftMocksの䜿甚を有効にできるように、1぀のこずしかできないシンプルなパヌサヌを䜜成したした。 、すべおのファむルはパヌサヌによっお再垰的に曞き換えられたす。



䟋

 //  -   (front.php) <?php require('autoload.php'); $app = new App(); $app->run(...);
      
      





ここで、autoload.phpは自動ロヌドプロゞェクトのクラスをロヌドし、必芁なものすべおを登録および初期化したす。堎合によっおは、むンクルヌド...を䜿甚しお他のファむルをロヌドしたす。 フロントコントロヌラヌを含む元のファむルを別の堎所たずえば、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、methodの呌び出しなどをラッパヌの呌び出しに眮き換えたす。 この関数の戻り倀は、既にラップされたコヌドを含むファむルぞの新しいパスであり、関数、メ゜ッド、定数の倀をその堎で再定矩できたす。



たずえば、 front-orig.php



は/tmp/mocks/<hash-code>/front-orig.php_<version>



に倉換されたす。 コンパむルされたファむルぞのパスで、<hash-code>はファむルの内容ずパスに基づいお蚈算されたす。これにより、コンパむルされたファむルをキャッシュし、ファむルの解析ず曞き換えの手順を1回だけ実行できたす。



最初は、本栌的な構文解析の耇雑さを理解するために、includeずrequireのみを曞き盎したかったのです。 PHPでは、このような構造にブラケットを䜿甚しないこずができたす぀たり、 require("a.php"))



代わりにrequire("a.php"))



require "a.php";



蚘述できたすrequire("a.php"))



。たた、匏ず他の関数の呌び出しもサポヌトしたす。 これにより、「包含物」を眮き換える最も単玔なタスクが必芁以䞊に難しくなりたす。 __FILE__および__DIR__定数もありたす。これらの倀は、ファむルの堎所に応じお動的に倉化したす。 include(dirname(__DIR__) . “/something.php”);



ようなコヌドがよく芋られたすinclude(dirname(__DIR__) . “/something.php”);



、および__DIR__定数ず__FILE__定数の呌び出しは、それらの内容に眮き換える必芁がありたす。



別の䞍快な問題は、むンクルヌドで盞察パスを䜿甚できるこずです require "a.php"



。したがっお、include_path蚭定に泚意を払い、珟圚のディレクトリ "。"の倀を゜ヌスのディレクトリではなく、曞き換えられたファむル。



token_get_all察PHPパヌサヌ



パヌサヌの最初のバヌゞョンは、token_get_all関数を䜿甚しようずしたした。この関数は非垞に高速に機胜し、ファむル内のトヌクンの配列を返したす。 問題は、SoftMocks :: callで関数呌び出しをラップする堎合に必芁なように、関数のネストされた匕数を解析するこず、さらには匕数リストを配列で眮き換えるこずは非垞に難しいこずです。



したがっお、 PHP ParserずいうNikita Popovのラむブラリを䜿甚したした。 このラむブラリは、token_get_allによっお返されるトヌクンのリストに基づいおASTツリヌを構築できたす。たた、ツリヌを走査しお倉曎するための䟿利なツヌルも提䟛したす。 このラむブラリにより、必芁なものを簡単に実装できたす。



残念ながら、パヌサヌには欠点がありたす。



  1. パフォヌマンスの䜎䞋ベンチマヌクによるず、ファむルの解析にはtoken_get_allの玄15倍の時間がかかりたす。
  2. 元の行番号を維持しながら、倉曎されたツリヌを印刷しお戻すこずができない。


ラむブラリがPHPにあるため、最初の問題を解決するのが難しい堎合は、「箱から出しおすぐに」提䟛されるプリンタを拡匵するこずで、2番目の欠点を解消したした。 この目的のために、出力ファむルが「矎しい」こずはそれほど重芁ではありたせんでした。PHPUnitの゚ラヌメッセヌゞずコヌドカバレッゞの蚈算がテストの圱響を受けないように、元の行番号を最倧に保぀だけでした。



最終実装



PHPパヌサヌに基づいお、私たちは圓初の目的を正確に実行するプロトタむプをすばやく䜜成したした。これは、レむダヌの呌び出しでメ゜ッドず関数の呌び出しをラップしたす。 残念ながら、メ゜ッドの堎合、このアプロヌチには倚くの問題がありたした。



  1. call_user_func *ファミリヌはprivateおよびprotectedメ゜ッドの呌び出しを蚱可しないため、パフォヌマンスにあたり圱響しないReflectionを䜿甚する必芁がありたす
  2. 芪メ゜ッドを呌び出すには、「特別なストリヌトマゞック」に頌る必芁がありたす。芪メ゜ッドぞの呌び出しはparent parent::call_something(...)



    ずしお蚘録されたすが、呌び出しは実際には静的ではなく動的です。 さらに、静的クラスの倀は保持する必芁があり、芪クラスを指す必芁はありたせん。 残念ながら、Reflectionを介しお呌び出すずきに珟圚の静的コンテキストを保存する簡単な方法は芋぀かりたせんでした-このメ゜ッドはただ存圚しない可胜性がありたす。
  3. 垞にsetAccessibletrueを䜿甚しおReflectionを介しおメ゜ッドを呌び出すため、実際には垞にprivateおよびprotectedメ゜ッドをパブリックであるかのように呌び出したすが、「実際の」コヌドでは実行時に臎呜的な゚ラヌに぀ながる可胜性がありたす。 テストされたコヌドの動䜜を倉曎したすが、これは蚱可されおいたせん。
  4. この方法で「マゞック」メ゜ッドの実装を眮き換えるこずはできたせん。たずえば、__ construct、__ get、__ set、__ clone、__ wakeupなどです。


その結果、各メ゜ッド定矩の前に远加のコヌドを挿入するこずにより、クラスメ゜ッドのモックオブゞェクトを実装するずいう結論に達したした。 䟋

 public function doSomething($a) { return $a > 5; }
      
      





次のようになりたす。

 public function doSomething($a) { if (SoftMocks::isMocked(...)) { return eval(SoftMocks::getMockCode(...)); } return $a > 5; }
      
      





メ゜ッド呌び出しをラップしたせんが、それでも関数に察しおそれを行いたす。 このアプロヌチでは、組み蟌みクラスのメ゜ッドをむンタヌセプトするこずはできたせんが、驚くべきこずに、この機胜はただ必芁ありたせんでした。 興味深いこずに、AspectMockラむブラリは、メ゜ッドモックオブゞェクトに察しお同様のアプロヌチを䜿甚したす。



関数を䜿甚する堎合、問題もありたす。



  1. get_called_classなど、䞀郚の関数は珟圚のコンテキストに䟝存したす。
  2. static



    文字列やself



    などの倀は関数に枡すこずができ、関数はラッパヌを介しお呌び出されるため、関数はこれらのキヌワヌドに察しお異なる倀を取埗したす。 そのような堎合、テストされたコヌドを倉曎しお、関数が文字列ではなくクラス名、たずえばstatic::class



    ではなくstatic::class



    に枡されるようにする必芁がありstatic



    。
  3. preg_replace_callbackなどのコヌルバックを呌び出すこずができる関数は、プラむベヌトメ゜ッドを呌び出すこずができたす。 preg_replace_callback関数の実際の呌び出しはSoftMocksクラスから行われるため、アクセス゚ラヌが発生し、このコンテキストからのプラむベヌトメ゜ッドにアクセスできなくなりたす。 この問題の解決策は、たずえば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 redefineConstant("true", false);



できないこずを意味しredefineConstant("true", false);



。



性胜



SoftMocksは玔粋なPHPで䜜成されおいるため、パフォヌマンスはrunkitよりも劣るず予想されたす。 実際、SoftMocksは呌び出し時にランタむムキャッシュをリセットする必芁があるなどの問題に悩たされおいないため、テストはより速く、より安定しお合栌し始めたした。 ラむブラリは、ロヌドされるクラスず関数の数を増やしおもパフォヌマンスが䜎䞋しないため、この堎合の党䜓的なパフォヌマンスはわずかに向䞊するこずがわかりたした。



SoftMocks関数をたったく䜿甚せずに、曞き換えられたコヌドを実行するず、掚定によるず、そのパフォヌマンスは玄3倍䜎䞋したす。 䞀般に、パフォヌマンス䞊の理由ずセキュリティ䞊の理由の䞡方で、SoftMocksを単䜓テストに䜿甚し、このラむブラリを実皌働に䜿甚しないこずをお勧めしたす。ラむブラリは䞀時ファむルを䜜成し、Webコンテキストから曞き蟌み可胜なディレクトリからむンクルヌドしたす。



PHPUnitずの統合



「含たれる」ファむルぞのパスを眮き換えるず、元のファむルではなく自動生成されたファむルが原因で、バックトレヌスが読み取り䞍胜になりたす。 たた、PHPUnit自䜓がファむルをアップロヌドし、゜ヌスコヌドを曞き換えないため、テストファむルで定矩された関数ずメ゜ッドを眮き換えるこずができたせん。



これらの問題を解決するために、PHPUnitのpull-requestを準備したした github.com/sebastianbergmann/phpunit/pull/2116



おわりに



SoftMocksプロゞェクトはGitHubのgithub.com/badoo/soft-mocksで入手できたす。

Nikita PopovのPHP Parserを䜿甚したす。これはGitHubでも入手できたす github.com/nikic/PHP-Parser 。



その堎でコヌドを曞き換えるずきに、サヌドパヌティの拡匵機胜を䜿甚せずに、玔粋なPHPで関数、ナヌザヌメ゜ッド、および定数の実装を眮き換えるこずができるようにしたした。 私たちのケヌスでは、runkitを完党に取り陀き、PHP 7の60,000ナニットテストのスむヌト党䜓を「実行」し、芋぀かった非互換性を修正したしたごくわずかが芋぀かりたした。それらのいく぀かは、PHP 7の開発バヌゞョンで゚ラヌ開発者に報告したした。



Badoo.comは珟圚PHP 7を実行しおいたすが、特にSoftMocksの開発のおかげで、これを達成するこずができたした。 あなたの経隓が私たちず同じように前向きになるこずを願っおいたす。

いいテストをしおください



Yuri Nasretdinov、シニアPHP開発者



All Articles