独自ストリームラッパーでファイルI/Oを差し替える

ストリームラッパーとは、fopen()file_get_contents()から使える独自のプロトコル(memory://など)を、PHPのクラスとして自分で実装する仕組みです。既存コードのファイルI/Oを、呼び出し側を変えずに丸ごと差し替えられます。

レガシーなコードを触っていると、あちこちでfile_get_contents($path)が直に書かれている場面に出くわします。テストを書きたいのに実ファイルが要る、という例のやつですね。関数を全部インターフェース越しに書き直すのが正しいのは分かっていても、影響範囲が読めなくて手が止まる。

そういうとき、パス文字列だけを差し替えて済ませられるのがストリームラッパーです。今日はそのあたりの実装と、書いてみると必ず引っかかるポイントを並べておきます。

何が嬉しいのか

PHPのファイル系関数は、パスの先頭にあるスキーム://を見て、どのラッパーに処理を投げるかを決めています。php://memoryphar://が動くのもこの仕組みのおかげです。ここに自分のスキームを割り込ませられる、というのがストリームラッパーの正体です。

つまり、呼び出し側のコードはfile_get_contents()のままでいい。渡すパスを/var/data/report.csvからmemory://report.csvに変えるだけで、実体をメモリ上の配列にすり替えられます。テストでの差し替え、S3などのリモートストレージの抽象化、書き込みを監視するプロキシ、あたりが実務での使いどころだと思います。

最小のラッパーはどこまで書けばいいのか

PHPマニュアルのstreamWrapperは「実在しないクラスのプロトタイプ」です。継承するインターフェースがあるわけではなく、必要なメソッドだけを持つクラスを作って登録します。使わないメソッドは実装しないほうがいいとマニュアルに明記されています(実装すると、本来出るはずのエラーが出なくなるため)。

読み書きだけなら、stream_open / stream_read / stream_write / stream_eof / stream_tell / stream_close で足ります。以下はメモリ上に「ファイル」を持つ簡易ラッパーです。

<?php
declare(strict_types=1);

final class MemoryStream
{
    /** @var array<string, string> パス => 中身 */
    private static array $files = [];

    /** コンテキストを受け取るため public 必須 */
    public $context;

    private string $path = '';
    private int $position = 0;

    public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
    {
        $this->path = $path;
        $this->position = 0;

        if (str_starts_with($mode, 'r')) {
            // r / r+ は既存であることが前提
            if (!isset(self::$files[$path])) {
                if ($options & STREAM_REPORT_ERRORS) {
                    trigger_error("memory: {$path} は存在しません", E_USER_WARNING);
                }
                return false;
            }
        } elseif (str_starts_with($mode, 'w')) {
            self::$files[$path] = ''; // w は切り詰め
        } else {
            self::$files[$path] ??= '';
            if (str_starts_with($mode, 'a')) {
                $this->position = strlen(self::$files[$path]);
            }
        }

        return true;
    }

    public function stream_read(int $count): string
    {
        $chunk = substr(self::$files[$this->path], $this->position, $count);
        $this->position += strlen($chunk);
        return $chunk;
    }

    public function stream_write(string $data): int
    {
        $current = self::$files[$this->path];
        self::$files[$this->path] =
            substr($current, 0, $this->position)
            . $data
            . substr($current, $this->position + strlen($data));

        $this->position += strlen($data);
        return strlen($data); // 書いたバイト数を返すこと
    }

    public function stream_eof(): bool
    {
        return $this->position >= strlen(self::$files[$this->path]);
    }

    public function stream_tell(): int
    {
        return $this->position;
    }

    public function stream_close(): void
    {
    }
}

登録はstream_wrapper_register()で、第1引数がスキーム名、第2引数がクラス名です。

<?php
if (!in_array('memory', stream_get_wrappers(), true)) {
    stream_wrapper_register('memory', MemoryStream::class);
}

file_put_contents('memory://report.csv', "id,name\n1,taro\n");
echo file_get_contents('memory://report.csv');
// id,name
// 1,taro

既存のコードに一切手を入れずに、ファイルの実体だけが消えました。テストの前後でself::$filesを空にすれば、後片付けも一瞬です。

なぜ状態を static に置くのか

ここが最初のハマりどころです。ラッパーのインスタンスは、ストリーム操作が始まるたびにPHPが勝手に生成します。fopen()のたびに別のオブジェクトになるので、インスタンスプロパティに「ファイルの中身」を持たせると、次に開いたときには消えています。

だから「ストレージ本体」はstaticプロパティか、外部のシングルトンに置く。インスタンスプロパティに置いていいのは、そのハンドル固有の状態(現在位置、開いているパス)だけです。コンストラクタに引数を渡せない点も同じ理由で、依存はstaticかコンテキスト経由で渡すことになります。

file_exists() が false を返すのはなぜか

読み書きは動いたのにfile_exists('memory://report.csv')false、というのは必ず一度は踏みます。file_exists()is_file()filesize()といったstat系の関数はurl_stat()を呼ぶので、これを実装しないと何も答えられないわけです。開いているハンドルに対するfstat()stream_stat()のほうを見ます。

返すのはstat()と同じ形式の配列、つまり数値キーと文字列キーの両方を持つ配列です。特にmodeにはファイル種別のビットが要ります。通常ファイルを表す0100000が入っていないと、is_file()falseのままです。

<?php
    public function url_stat(string $path, int $flags): array|false
    {
        if (!isset(self::$files[$path])) {
            // STREAM_URL_STAT_QUIET が立っていたら警告を出さない
            return false;
        }
        return self::buildStat(strlen(self::$files[$path]));
    }

    public function stream_stat(): array|false
    {
        return self::buildStat(strlen(self::$files[$this->path]));
    }

    private static function buildStat(int $size): array
    {
        $stat = [
            'dev' => 0, 'ino' => 0,
            'mode' => 0100666, // 0100000 = 通常ファイル
            'nlink' => 0, 'uid' => 0, 'gid' => 0, 'rdev' => 0,
            'size' => $size,
            'atime' => 0, 'mtime' => 0, 'ctime' => 0,
            'blksize' => -1, 'blocks' => -1,
        ];

        // 数値キー 0〜12 と文字列キーの両方を持たせる
        return array_merge(array_values($stat), $stat);
    }

array_merge()は数値キーを振り直すので、この一行でstat()互換の形になります。sizeを正しく返しておくとfilesize()も素直に動きますし、includeのようにサイズを見る処理にも耐えられるようになります。

なおurl_stat()$flagsにはSTREAM_URL_STAT_QUIETSTREAM_URL_STAT_LINKが渡ってきます。前者が立っているときにtrigger_error()で騒ぐと、file_exists()が警告まみれになるので、エラー出力の判断はこのフラグで分けます。

コンテキストで依存を渡す

コンストラクタが使えない代わりに用意されているのが、public $contextです。fopen()の第4引数に渡したコンテキストが、PHPによってこのプロパティに入れられます。プロパティ名も可視性も固定なので、privateにすると値が入りません。

<?php
$context = stream_context_create([
    'memory' => ['readonly' => true],
]);

// 第3引数は use_include_path、第4引数がコンテキスト
$fp = fopen('memory://report.csv', 'r', false, $context);

// stream_open の中では
$options = $this->context !== null
    ? stream_context_get_options($this->context)
    : [];
$readonly = $options['memory']['readonly'] ?? false;

コンテキストが渡されなかった場合、$this->contextnullになります。いきなりstream_context_get_options()に食わせるとTypeErrorになるので、上のようにnullチェックを挟んでおくのが安全です。

登録まわりの落とし穴

同じスキーム名を二重に登録しようとすると、stream_wrapper_register()は警告を出してfalseを返します。テストのたびに登録処理が走る構成だと、二回目以降で必ず踏むので、stream_get_wrappers()で確認してから登録するのが定石です。解除はstream_wrapper_unregister()で、テストのtearDownに入れておきます。

もうひとつ、filehttpのような組み込みスキームも、いったんstream_wrapper_unregister()してから自前のクラスで登録し直せます。強力ですが、requireを含めたあらゆるファイルアクセスが自分のクラスを通るようになるので、影響範囲は相当広いです。やるならstream_wrapper_restore()で必ず元に戻すこと。個人的には、テストの一部を通すために本番同然のI/O経路を書き換えるのは割に合わないと思っていて、独自スキームを足すほうに寄せています。

それと、第3引数のフラグは既定の0(ローカルストリーム扱い)のままにしておくのが無難です。STREAM_IS_URLを付けるとURLプロトコルとして扱われ、allow_url_fopenなど設定側の制約が絡んでくる場面があります。テスト用途で使うぶんには、わざわざURL扱いにする理由はあまりないはずです。

まとめ

ストリームラッパーは、既存コードのパス文字列だけを差し替えてI/Oを乗っ取れる仕組みです。読み書きだけなら数十行で書けますが、url_stat()を忘れるとfile_exists()が黙ってfalseを返しますし、状態をインスタンスプロパティに置くと次のfopen()で消えます。この二つを知っているかどうかで、はまる時間がだいぶ違うと思います。

もちろん本命は依存性注入でI/Oを抽象化することで、これは最後の手段に近い道具です。ただ、書き換えられないコードが目の前にあるときの逃げ道として、引き出しに入れておくと助かる日が来ます。

よくある質問

Q. php://memory があるのに、わざわざ自作する意味はありますか?
A. php://memoryは「1本の匿名ストリーム」なので、パス名で複数のファイルを区別したり、file_exists()に答えたりはできません。既存コードがパス文字列を受け取る作りになっている場合は、ラッパーのほうが差し替えやすいです。

Q. テストでファイル操作を差し替えたいだけなら、自作すべきですか?
A. vfsStreamのように仮想ファイルシステムを提供する既製ライブラリがあるので、まずはそちらを検討するのがいいと思います。自作が向くのは、独自プロトコルとして意味を持たせたいときや、細かい挙動を自分で決めたいときです。

Q. 実装すべきメソッドが分からなくなったら?
A. 呼ばれるはずのメソッドが未実装だと、PHPは「そのラッパーはその操作に対応していない」と扱って警告を出します。file_exists()ならurl_stat()fstat()filesize()ならstream_stat()unlink()ならunlink()、と操作から逆引きしていくのが早いです。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です