PHP-VCR が curl 関数を乗っ取る方法

PHP-VCR (https://github.com/php-vcr/php-vcr)というライブラリをご存知でしょうか? ruby に vcr (https://github.com/vcr/vcr) というライブラリがあってそれのPHP版となります。これらのライブラリは以下のようなことを行うためのものです。

  • HTTPアクセスを記録
  • 記録した内容を使ってHTTPアクセスをモックする

とても便利なので使ってみようとしたところ、実行例としていろんなところで書かれている file_get_contents を使ったアクセスは記録できたのに、curl を使ったアクセスがうまく記録できなくて、なぜ curl だけ記録できないのか、そもそも記録できたとして curl 関数を runkituopz という関数を置き換えるものを使わずに実行を乗っ取るのか?というのが疑問だったので調査してみました。

最終的に curl 関数の挙動は置き換えできました。最後まで読んで置き換えはできませんでしたといったことはないのでご安心を。

ストリーム

まず、file_get_contents(“https://www.google.com”); にようなHTTPアクセスをどのようにフックするのかですが、ストリーム (https://secure.php.net/manual/ja/book.stream.php) という仕組みを使っています。

PHP-VCR のソースでは https://github.com/php-vcr/php-vcr/blob/master/src/VCR/LibraryHooks/StreamWrapperHook.php で行われています。

<?php

class StreamWrapperHook implements LibraryHook
{
    // (中略)

    public function enable(\Closure $requestCallback)
    {
        Assertion::isCallable($requestCallback, 'No valid callback for handling requests defined.');
        self::$requestCallback = $requestCallback;
        stream_wrapper_unregister('http');
        stream_wrapper_register('http', __CLASS__, STREAM_IS_URL);

        stream_wrapper_unregister('https');
        stream_wrapper_register('https', __CLASS__, STREAM_IS_URL);

        $this->status = self::ENABLED;
    }

上記のコードの stream_wrapper_register(‘http’, __CLASS_, STERAM_IS_URL); とすると、 http スキームをつかったアクセスをこのクラスでフックするということができるようになります。https もやっているので、 http と https の両方のアクセスをフックできるようになります。

つまり、http://… https:// をつかってアクセスする file_get_contents のような関数の挙動がフックできるようになったということですね。

フックさえできてしまえば、http/httpsアクセスをしてその結果を記録し、記録したファイルがある場合は、http/httpsアクセスはせずにファイルから結果を返すといった挙動にしてしまうことができるということです。

じゃあ、curl はどうなんだ

curl はこのストリームという仕組みでは動いていません。なので、http://https://steam_wrapper で書き換えても挙動は変わりません。

しかし、PHP-VCR は runkituopz を使わずにそれを実現しています。どうやっているかというと、http/https スキームではなく、file:// スキームをラップしています。

実際にそれを行っているのは、https://github.com/php-vcr/php-vcr/blob/master/src/VCR/Util/StreamProcessor.php になります。

<?php

class StreamProcessor
{
    // (中略)

     * Stream protocol which is used when registering this wrapper.
     */
    const PROTOCOL = 'file';

    // (中略)

    /**
     * Registers current class as the PHP file stream wrapper.
     *
     * @return void
     */
    public function intercept()
    {
        if (!$this->isIntercepting) {
            stream_wrapper_unregister(self::PROTOCOL);
            $this->isIntercepting = stream_wrapper_register(self::PROTOCOL, __CLASS__);
        }
    }

file:// スキームをラップしたからといって、なんで curl の挙動を変更できるのか?ですが、以下のようなコードがあります。 (https://github.com/php-vcr/php-vcr/blob/master/src/VCR/CodeTransform/CurlCodeTransform.php)

<?php
namespace VCR\CodeTransform;

class CurlCodeTransform extends AbstractCodeTransform
{
    const NAME = 'vcr_curl';
    private static $patterns = array(
        '/(?<!::|->|\w_)\\\?curl_init\s*\(/i'                => '\VCR\LibraryHooks\CurlHook::curl_init(',
        '/(?<!::|->|\w_)\\\?curl_exec\s*\(/i'                => '\VCR\LibraryHooks\CurlHook::curl_exec(',
        '/(?<!::|->|\w_)\\\?curl_getinfo\s*\(/i'             => '\VCR\LibraryHooks\CurlHook::curl_getinfo(',
        '/(?<!::|->|\w_)\\\?curl_setopt\s*\(/i'              => '\VCR\LibraryHooks\CurlHook::curl_setopt(',
        '/(?<!::|->|\w_)\\\?curl_setopt_array\s*\(/i'        => '\VCR\LibraryHooks\CurlHook::curl_setopt_array(',
        '/(?<!::|->|\w_)\\\?curl_multi_add_handle\s*\(/i'    => '\VCR\LibraryHooks\CurlHook::curl_multi_add_handle(',
        '/(?<!::|->|\w_)\\\?curl_multi_remove_handle\s*\(/i' => '\VCR\LibraryHooks\CurlHook::curl_multi_remove_handle(',
        '/(?<!::|->|\w_)\\\?curl_multi_exec\s*\(/i'          => '\VCR\LibraryHooks\CurlHook::curl_multi_exec(',
        '/(?<!::|->|\w_)\\\?curl_multi_info_read\s*\(/i'     => '\VCR\LibraryHooks\CurlHook::curl_multi_info_read(',
        '/(?<!::|->|\w_)\\\?curl_reset\s*\(/i'               => '\VCR\LibraryHooks\CurlHook::curl_reset('
    );

    /**
     * @inheritdoc
     */
    protected function transformCode($code)
    {
        return preg_replace(array_keys(self::$patterns), array_values(self::$patterns), $code);
    }
}

これを最初見たときに、実ファイルを書き換えるのか? すごいことをするんだなと思ったんですが、そうではなく以下のような挙動をします。

  1. steam_wrapper_register(‘file’, __CLASS__);file:// スキームを StreamProcessor クラスがラップするようになる
  2. composer の autoload の延長線上で include を使って、ファイルが読み込まれる
  3. file:// スキームをラップしているので、include でファイルを読むという行為もラップされる
  4. ファイルが読み込まれる際に、curl_xxx という文字列が、CurlCodeTransform によって書き換えられながら読み込まれる
  5. 実際にコードが動くときには curl_xxx ではなく、書き換えられたラッパー関数に書き換えられているのでラッパー関数が実行される

runkituopz も使わずに、file:// スキームをフックすることによって、PHPスクリプトのファイルを読み込むときに内容を書き換えてしまうという豪快なことをしているんです。

これって、有名なやり方なんですかね? 正直自分は初めて見たやり方なので、かなり衝撃をうけました。

で、なぜ最初うまく行かなかったのか

私が PHP-VCR の動作確認のため、 https://github.com/kunit/php-vcr-test を作りました。実際のコードは以下のようなものです。

これの、file_get_contents は最初から意図通り動作したんですが、curl 側が fixtures ディレクトリに空のファイルが出力されるということに。

<?php

use PHPUnit\Framework\TestCase;
use VCR\VCR;

class VCRTest extends TestCase
{
    const URL = 'https://www.google.com';

    /**
     * @test
     */
    public function curlRequest() : void
    {
        VCR::turnOn();

        $cassetteName = 'curl.yaml';
        VCR::insertCassette($cassetteName);

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, static::URL);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        $expected = curl_exec($ch);
        curl_close($ch);

        VCR::eject();
        VCR::turnOff();

        $this->assertSameBody($expected, $cassetteName);
    }

    /**
     * @test
     */
    public function fileGetContentsRequest() : void
    {
        VCR::turnOn();

        $cassetteName = 'file_get_contents.yaml';
        VCR::insertCassette($cassetteName);

        $expected = file_get_contents(static::URL);

        VCR::eject();
        VCR::turnOff();

        $this->assertSameBody($expected, $cassetteName);
    }

    /**
     * @param string $expected
     * @param string $cassetteName
     */
    protected function assertSameBody(string $expected, string $cassetteName) : void
    {
        $yamlObjs = new \VCR\Storage\Yaml(dirname(__DIR__) . '/fixtures', $cassetteName);
        $actual = [];
        foreach ($yamlObjs as $obj) {
            $actual[] = $obj;
        }

        $body = $actual[0]['response']['body'];
        $this->assertSame($expected, $body);
    }
}

bootstrap.php に以下のように \VCR\VCR::turnOn(); を書くと動くようになりました。(\VCR\VCR::turnOff(); を書いてますが、これはあってもなくても動作に影響はないです)

<?php

use VCR\VCR;

require_once __DIR__ . '/../vendor/autoload.php';

VCR::turnOn();
VCR::turnOff();

file_get_contents のほうは、bootstrap.php\VCR\VCR::tunOn(); がなくても動きます。すでに curl 関数の置き換え方法を書いたので、なぜこれで動くようになるかは想像がつく方が多いと思いますが、turnOnbootstrap.php にある場合は以下のようになります。

  1. bootstrap.php 内で \VCR\VCR::trunOn() が実行され、その延長線上で、file:// スキームがラップされる(curl_xxx という文字列があれば書き換えるという挙動をするようになる)
  2. VCRTest.php を autoload で読み込むと curl_xxx という文字列があるので書き換えが発生する
  3. テストが実行されると curl_xxx のラップされたものが実行される

turnOnbootstrap.php にない場合は以下のようになります。

  1. VCRTest.php が autoload で読み込まれる
  2. メソッド内で \VCR\VCR::turnOn() を読んでいるが、すでにファイルはロードされてしまっているので、 file:// スキームのラップのタイミングが遅いので、コードの書き換えが起きない

最後に

完全に黒魔術な感じがしますが、runkituopz を使わずにコードを書き換えれてしまうので、テストであれば使うのはありかも?とは思います。

このやり方をつかって AOP やると面白いんじゃない?とも思いましたが、こういう書き換えコードをプロダクションに投入するとかはありなんですかね?

あと、これって書き換えられた後のコードが opcache の対象になるんですかね? そのあたりの挙動もちょっと追っかけてみると面白いかなと思っています。

kunit
福岡に移住して「弱くてニューゲーム」を始めた、駆け出しインフラエンジニア