[PHP]stream_wrapper_restore完全解説|組み込みストリームラッパーを安全に復元する方法

PHP

はじめに

PHPの stream_wrapper_unregister() を使うと、file://http:// といった組み込みのストリームラッパーを一時的に解除することができます。これはテスト用モックや透過的なラッパーの差し替えで便利な反面、「解除したあとどうやって元に戻すのか?」という問題が生じます。

stream_wrapper_restore() は、まさにその「元に戻す」ための関数です。stream_wrapper_unregister() で解除した組み込みラッパーだけを復元できます。

シンプルな関数ですが、正しく使わないとファイルアクセスが根本から壊れるリスクがあります。本記事では仕組みと安全な使い方を丁寧に解説します。


関数の基本情報

項目内容
関数名stream_wrapper_restore()
利用可能バージョンPHP 5.0以降
所属ストリーム関数(Stream Functions)
戻り値bool(成功時true、失敗時false
拡張機能不要(コア関数)

シグネチャ

stream_wrapper_restore(string $protocol): bool

パラメータ

パラメータ説明
$protocolstring復元するプロトコルスキーム名(例:filehttpftp

戻り値の詳細

状況戻り値
組み込みラッパーの復元に成功true
指定プロトコルが組み込みラッパーでないfalse
すでに登録済みで復元不要false(+Warning)
解除されていないラッパーを復元しようとしたfalse

3兄弟関数の関係

stream_wrapper_restore() は以下の3関数とセットで使います。

stream_wrapper_register()   → カスタムラッパーを新規登録
stream_wrapper_unregister() → 登録済みラッパーを解除(組み込みも可)
stream_wrapper_restore()    → 解除した組み込みラッパーを復元

典型的なフロー

【通常状態】
  file:// → PHP組み込みラッパー

  ↓ stream_wrapper_unregister('file')

【解除状態】
  file:// → 未登録(fopen('file://...')はエラー)

  ↓ stream_wrapper_register('file', MockWrapper::class)

【差し替え状態】
  file:// → MockWrapper(モック)

  ↓ stream_wrapper_unregister('file')
  ↓ stream_wrapper_restore('file')

【復元状態】
  file:// → PHP組み込みラッパー(元通り)

重要: stream_wrapper_restore() が復元できるのは組み込みラッパーのみです。stream_wrapper_register() で登録したカスタムラッパーは復元できません(stream_wrapper_register() で再登録する必要があります)。


組み込みラッパー一覧(復元可能なもの)

プロトコル用途
fileローカルファイルシステム
httpHTTPリクエスト
httpsHTTPSリクエスト
ftpFTPアクセス
ftpsFTPS(TLS)アクセス
phpphp://stdinphp://memoryなど
zlib / compress.zlibgzip圧縮
compress.bz2bzip2圧縮
dataRFC 2397 データURI
globファイルグロブ
pharPHARアーカイブ

実践サンプル集(PHP 8.x対応)

サンプル1:基本的な解除と復元

最もシンプルな使い方。file:// を解除して復元するパターンです。

<?php
declare(strict_types=1);

// 現在の登録済みラッパーを確認
echo "解除前: " . (in_array('file', stream_get_wrappers()) ? '登録済み' : '未登録') . "\n";

// file:// ラッパーを解除
$result = stream_wrapper_unregister('file');
echo "unregister: " . ($result ? 'OK' : 'NG') . "\n";
echo "解除後: " . (in_array('file', stream_get_wrappers()) ? '登録済み' : '未登録') . "\n";

// 解除中はローカルファイルアクセスが壊れる
// file_get_contents('/etc/hostname'); // → Warning: failed to open stream

// file:// ラッパーを復元
$result = stream_wrapper_restore('file');
echo "restore: " . ($result ? 'OK' : 'NG') . "\n";
echo "復元後: " . (in_array('file', stream_get_wrappers()) ? '登録済み' : '未登録') . "\n";

// 復元後はファイルアクセスが正常に戻る
$hostname = trim(file_get_contents('/etc/hostname') ?: '(読み取り不可)');
echo "hostname: {$hostname}\n";

実行結果:

解除前: 登録済み
unregister: OK
解除後: 未登録
restore: OK
復元後: 登録済み
hostname: myserver

解説: stream_wrapper_unregister()で解除中はfile_get_contents()などローカルファイルへのアクセスがすべてエラーになります。stream_wrapper_restore()で安全に元通りにできます。


サンプル2:クラスベースのラッパー差し替えと安全な復元

テスト用モックラッパーへの差し替えを、try-finallyで確実に復元するパターンです。

<?php
declare(strict_types=1);

/**
 * テスト用のファイルモックラッパー
 */
class FileSystemMock
{
    public mixed $context;

    private static array $mocks = [];
    private string $buffer = '';
    private int $position = 0;

    public static function addMock(string $path, string $content): void
    {
        self::$mocks[$path] = $content;
    }

    public static function reset(): void
    {
        self::$mocks = [];
    }

    public function stream_open(
        string $path,
        string $mode,
        int $options,
        ?string &$openedPath
    ): bool {
        $realPath = preg_replace('#^file://#', '', $path) ?? $path;

        if (!isset(self::$mocks[$realPath])) {
            trigger_error("Mock not found: {$realPath}", E_USER_WARNING);
            return false;
        }

        $this->buffer   = self::$mocks[$realPath];
        $this->position = 0;
        return true;
    }

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

    public function stream_eof(): bool
    {
        return $this->position >= strlen($this->buffer);
    }

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

    public function stream_stat(): array
    {
        return ['size' => strlen($this->buffer)]
            + array_fill_keys(
                ['dev','ino','mode','nlink','uid','gid','rdev',
                 'atime','mtime','ctime','blksize','blocks'], 0
            );
    }

    public function url_stat(string $path, int $flags): array|false
    {
        $realPath = preg_replace('#^file://#', '', $path) ?? $path;
        if (!isset(self::$mocks[$realPath])) {
            return false;
        }
        return ['size' => strlen(self::$mocks[$realPath]), 'mode' => 0100644]
            + array_fill_keys(
                ['dev','ino','nlink','uid','gid','rdev',
                 'atime','mtime','ctime','blksize','blocks'], 0
            );
    }
}

/**
 * テスト実行ヘルパー
 */
function withMockedFileSystem(array $mocks, callable $test): void
{
    // モックを設定
    foreach ($mocks as $path => $content) {
        FileSystemMock::addMock($path, $content);
    }

    // file:// をモックに差し替え
    stream_wrapper_unregister('file');
    stream_wrapper_register('file', FileSystemMock::class);

    try {
        $test();
    } finally {
        // 必ず復元(例外が発生しても安全)
        stream_wrapper_unregister('file');
        stream_wrapper_restore('file');
        FileSystemMock::reset();
    }
}

// テスト実行
withMockedFileSystem(
    ['/app/config.json' => '{"env":"test","debug":true}'],
    function () {
        $json   = file_get_contents('/app/config.json');
        $config = json_decode($json, true);

        echo "env: {$config['env']}\n";          // env: test
        echo "debug: " . ($config['debug'] ? 'true' : 'false') . "\n"; // debug: true
        echo "exists: " . (file_exists('/app/config.json') ? 'yes' : 'no') . "\n"; // exists: yes
    }
);

// テスト後は本物のファイルシステムが戻っている
echo "復元確認: " . (in_array('file', stream_get_wrappers()) ? 'OK' : 'NG') . "\n";

実行結果:

env: test
debug: true
exists: yes
復元確認: OK

解説: try-finallyブロックでstream_wrapper_restore()を呼ぶことで、テスト中に例外が発生しても確実に組み込みラッパーが復元されます。これはテストコードで最も重要なパターンです。


サンプル3:複数ラッパーの一括管理クラス

複数のラッパーを差し替える場合を安全に管理するユーティリティクラスです。

<?php
declare(strict_types=1);

/**
 * ストリームラッパーのスタック管理
 * 複数のラッパーを差し替えて確実に復元する
 */
class StreamWrapperManager
{
    /** @var array<string, string|null> プロトコル => 差し替えたクラス名 */
    private array $replaced = [];

    /**
     * 組み込みラッパーをカスタムクラスで差し替える
     */
    public function replace(string $protocol, string $wrapperClass): self
    {
        if (!in_array($protocol, stream_get_wrappers(), true)) {
            throw new RuntimeException(
                "プロトコル '{$protocol}' は登録されていません"
            );
        }

        stream_wrapper_unregister($protocol);
        stream_wrapper_register($protocol, $wrapperClass);
        $this->replaced[$protocol] = $wrapperClass;

        return $this;
    }

    /**
     * 指定プロトコルだけ復元
     */
    public function restore(string $protocol): self
    {
        if (!isset($this->replaced[$protocol])) {
            return $this;
        }

        stream_wrapper_unregister($protocol);
        $result = stream_wrapper_restore($protocol);

        if ($result) {
            unset($this->replaced[$protocol]);
        } else {
            throw new RuntimeException(
                "'{$protocol}' の復元に失敗しました"
            );
        }

        return $this;
    }

    /**
     * 差し替えたすべてのラッパーを復元
     */
    public function restoreAll(): void
    {
        foreach (array_keys($this->replaced) as $protocol) {
            $this->restore($protocol);
        }
    }

    /**
     * 差し替え済みプロトコルの一覧
     */
    public function getReplacedProtocols(): array
    {
        return array_keys($this->replaced);
    }

    public function __destruct()
    {
        // GCのタイミングでも復元を試みる(フェイルセーフ)
        if (!empty($this->replaced)) {
            $this->restoreAll();
        }
    }
}

// --- 使用例 ---

class DummyHttpWrapper
{
    public mixed $context;
    private string $buffer = '';
    private int $position = 0;

    public function stream_open(string $path, string $mode, int $options, ?string &$op): bool
    {
        $this->buffer   = "HTTP/1.1 200 OK\r\n\r\nDummy response for: {$path}";
        $this->position = 0;
        return true;
    }
    public function stream_read(int $count): string
    {
        $chunk = substr($this->buffer, $this->position, $count);
        $this->position += strlen($chunk);
        return $chunk;
    }
    public function stream_eof(): bool { return $this->position >= strlen($this->buffer); }
    public function stream_close(): void {}
    public function stream_stat(): array { return []; }
}

$manager = new StreamWrapperManager();

try {
    $manager->replace('http', DummyHttpWrapper::class);
    $manager->replace('https', DummyHttpWrapper::class);

    echo "差し替え中: " . implode(', ', $manager->getReplacedProtocols()) . "\n";

    $response = file_get_contents('http://example.com/api');
    echo $response . "\n";

} finally {
    $manager->restoreAll();
    echo "復元済み: " . implode(', ', ['http', 'https']) . "\n";
}

実行結果:

差し替え中: http, https
HTTP/1.1 200 OK

Dummy response for: http://example.com/api
復元済み: http, https

解説: 複数のラッパーを差し替える場合はこのような管理クラスを用意すると安全です。__destruct()でのフェイルセーフ復元も組み込んでいます。


サンプル4:HTTP/HTTPSラッパーのモック切り替え

外部APIのテストでよく使う、HTTP通信をモックするパターンです。

<?php
declare(strict_types=1);

class HttpMockWrapper
{
    public mixed $context;

    private static array $responses = [];
    private string $buffer = '';
    private int $position = 0;

    public static function setResponse(string $urlPattern, string $body, int $status = 200): void
    {
        self::$responses[$urlPattern] = compact('body', 'status');
    }

    public static function clearAll(): void
    {
        self::$responses = [];
    }

    public function stream_open(
        string $path,
        string $mode,
        int $options,
        ?string &$openedPath
    ): bool {
        foreach (self::$responses as $pattern => $response) {
            if (str_contains($path, $pattern) || fnmatch($pattern, $path)) {
                $this->buffer   = $response['body'];
                $this->position = 0;
                return true;
            }
        }

        // マッチなし:空レスポンス
        $this->buffer   = '';
        $this->position = 0;
        return true;
    }

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

    public function stream_eof(): bool { return $this->position >= strlen($this->buffer); }
    public function stream_close(): void {}
    public function stream_stat(): array { return []; }
}

/**
 * HTTPモックを使ったテスト実行
 */
function withHttpMock(array $mocks, callable $callback): mixed
{
    foreach ($mocks as $pattern => $body) {
        HttpMockWrapper::setResponse($pattern, $body);
    }

    stream_wrapper_unregister('http');
    stream_wrapper_unregister('https');
    stream_wrapper_register('http', HttpMockWrapper::class);
    stream_wrapper_register('https', HttpMockWrapper::class);

    try {
        return $callback();
    } finally {
        stream_wrapper_unregister('http');
        stream_wrapper_unregister('https');
        stream_wrapper_restore('http');
        stream_wrapper_restore('https');
        HttpMockWrapper::clearAll();
    }
}

// テスト:外部APIをモック
$result = withHttpMock(
    [
        'api.example.com/users' => json_encode([
            ['id' => 1, 'name' => 'Alice'],
            ['id' => 2, 'name' => 'Bob'],
        ]),
        'api.example.com/status' => json_encode(['status' => 'ok']),
    ],
    function () {
        $users  = json_decode(file_get_contents('https://api.example.com/users'), true);
        $status = json_decode(file_get_contents('https://api.example.com/status'), true);

        return [
            'user_count' => count($users),
            'status'     => $status['status'],
        ];
    }
);

echo "ユーザー数: {$result['user_count']}\n"; // ユーザー数: 2
echo "ステータス: {$result['status']}\n";     // ステータス: ok

// モック終了後はhttps通信が本物に戻っている
echo "https登録確認: " . (in_array('https', stream_get_wrappers()) ? 'OK' : 'NG') . "\n";

実行結果:

ユーザー数: 2
ステータス: ok
https登録確認: OK

解説: httphttpsを両方モックして、テスト後に両方をstream_wrapper_restore()で復元します。try-finallyによる復元の保証が特に重要です。


サンプル5:復元失敗のハンドリング

stream_wrapper_restore()が失敗するケースとその対処です。

<?php
declare(strict_types=1);

/**
 * 安全に復元を試みるユーティリティ
 */
function safeRestoreWrapper(string $protocol): bool
{
    // ① すでに登録済みなら復元不要
    if (in_array($protocol, stream_get_wrappers(), true)) {
        echo "[{$protocol}] すでに登録済みのため復元不要\n";
        return true;
    }

    // ② 組み込みラッパーのリスト(PHP標準)
    $builtinWrappers = [
        'file', 'http', 'https', 'ftp', 'ftps',
        'php', 'zlib', 'data', 'glob', 'phar',
        'compress.zlib', 'compress.bz2',
    ];

    if (!in_array($protocol, $builtinWrappers, true)) {
        echo "[{$protocol}] 組み込みラッパーではないため復元不可\n";
        return false;
    }

    // ③ 復元を試みる
    $result = stream_wrapper_restore($protocol);

    if ($result) {
        echo "[{$protocol}] 復元成功\n";
    } else {
        echo "[{$protocol}] 復元失敗(不明なエラー)\n";
    }

    return $result;
}

// --- 各種ケースのテスト ---

// ケース1:正常に解除→復元
stream_wrapper_unregister('ftp');
safeRestoreWrapper('ftp');   // [ftp] 復元成功

// ケース2:解除していないラッパーを復元しようとする
safeRestoreWrapper('http');  // [http] すでに登録済みのため復元不要

// ケース3:カスタムラッパーは復元不可
stream_wrapper_register('myapp', stdClass::class); // ダミー
safeRestoreWrapper('myapp'); // [myapp] 組み込みラッパーではないため復元不可
stream_wrapper_unregister('myapp');

// ケース4:存在しないプロトコル
safeRestoreWrapper('nonexistent'); // [nonexistent] 組み込みラッパーではないため復元不可

実行結果:

[ftp] 復元成功
[http] すでに登録済みのため復元不要
[myapp] 組み込みラッパーではないため復元不可
[nonexistent] 組み込みラッパーではないため復元不可

解説: stream_wrapper_restore() を呼ぶ前に stream_get_wrappers() でチェックすることで不要な呼び出しを避けられます。カスタムラッパーには効果がないことも明示的に確認しています。


サンプル6:PHPUnitスタイルのテストケースへの組み込み

実際のユニットテストでの使い方を想定した実装例です。

<?php
declare(strict_types=1);

/**
 * ストリームラッパーのモック機能を提供するトレイト
 * PHPUnitのTestCaseなどで use する想定
 */
trait StreamWrapperMockTrait
{
    /** @var array<string, string> 解除したプロトコル一覧 */
    private array $unregisteredWrappers = [];

    /**
     * 組み込みラッパーをモックに差し替える
     */
    protected function mockWrapper(string $protocol, string $mockClass): void
    {
        if (in_array($protocol, stream_get_wrappers(), true)) {
            stream_wrapper_unregister($protocol);
            $this->unregisteredWrappers[] = $protocol;
        }
        stream_wrapper_register($protocol, $mockClass);
    }

    /**
     * テスト後のクリーンアップ(tearDown相当)
     */
    protected function restoreAllWrappers(): void
    {
        // カスタム登録分を解除
        foreach ($this->unregisteredWrappers as $protocol) {
            if (in_array($protocol, stream_get_wrappers(), true)) {
                stream_wrapper_unregister($protocol);
            }
        }

        // 組み込みを復元
        foreach ($this->unregisteredWrappers as $protocol) {
            stream_wrapper_restore($protocol);
        }

        $this->unregisteredWrappers = [];
    }
}

/**
 * テスト用スタブラッパー
 */
class StubFileWrapper
{
    public mixed $context;
    private static string $content = '';
    private int $pos = 0;

    public static function setContent(string $content): void
    {
        self::$content = $content;
    }

    public function stream_open(string $path, string $mode, int $options, ?string &$op): bool
    {
        $this->pos = 0;
        return true;
    }
    public function stream_read(int $count): string
    {
        $c = substr(self::$content, $this->pos, $count);
        $this->pos += strlen($c);
        return $c;
    }
    public function stream_eof(): bool { return $this->pos >= strlen(self::$content); }
    public function stream_close(): void {}
    public function stream_stat(): array { return ['size' => strlen(self::$content)]; }
    public function url_stat(string $path, int $flags): array
    {
        return ['size' => strlen(self::$content), 'mode' => 0100644]
            + array_fill_keys(
                ['dev','ino','nlink','uid','gid','rdev',
                 'atime','mtime','ctime','blksize','blocks'], 0
            );
    }
}

/**
 * サンプルテストケース(PHPUnitなしでの動作確認)
 */
class ConfigLoaderTest
{
    use StreamWrapperMockTrait;

    private function setUp(): void
    {
        StubFileWrapper::setContent('{"app_name":"MyApp","version":"2.0"}');
        $this->mockWrapper('file', StubFileWrapper::class);
    }

    private function tearDown(): void
    {
        $this->restoreAllWrappers();
    }

    public function testLoadConfig(): void
    {
        $this->setUp();

        try {
            // テスト対象の処理
            $json   = file_get_contents('/app/config.json');
            $config = json_decode($json, true);

            // アサーション
            assert($config['app_name'] === 'MyApp', 'app_name mismatch');
            assert($config['version'] === '2.0', 'version mismatch');
            echo "✅ testLoadConfig: PASS\n";
        } finally {
            $this->tearDown();
        }

        // テスト後にfile://が復元されていることを確認
        assert(
            in_array('file', stream_get_wrappers(), true),
            'file wrapper not restored!'
        );
        echo "✅ file:// wrapper restored: PASS\n";
    }
}

$test = new ConfigLoaderTest();
$test->testLoadConfig();

実行結果:

✅ testLoadConfig: PASS
✅ file:// wrapper restored: PASS

解説: トレイトとして切り出すことで、複数のテストクラスからラッパーモック機能を再利用できます。tearDown()相当の処理でstream_wrapper_restore()を確実に呼び、テストの副作用が残らないようにしています。


よくある落とし穴

① カスタムラッパーにstream_wrapper_restore()は効かない

stream_wrapper_register('myproto', MyWrapper::class);
stream_wrapper_unregister('myproto');

stream_wrapper_restore('myproto'); // false(復元不可)
// → stream_wrapper_register() で再登録が必要

② 解除せずにrestore()を呼ぶとWarning

// file:// が登録済みのまま
stream_wrapper_restore('file'); // false + E_NOTICE/Warning
// 事前に stream_get_wrappers() でチェックを

③ try-finallyなしだと例外時に復元されない

// ❌ 危険:例外でfinallyが実行されない
stream_wrapper_unregister('file');
stream_wrapper_register('file', MockWrapper::class);
doSomethingThatMightThrow(); // ← ここで例外
stream_wrapper_restore('file'); // 実行されない!

// ✅ 安全:finallyで必ず復元
stream_wrapper_unregister('file');
stream_wrapper_register('file', MockWrapper::class);
try {
    doSomethingThatMightThrow();
} finally {
    stream_wrapper_unregister('file');
    stream_wrapper_restore('file');
}

④ 複数ラッパーを差し替えたとき復元順序を逆にする

stream_wrapper_unregister('http');
stream_wrapper_unregister('https');
stream_wrapper_register('http', MockWrapper::class);
stream_wrapper_register('https', MockWrapper::class);

// ✅ 登録と逆順で解除・復元するのが安全
stream_wrapper_unregister('https');
stream_wrapper_unregister('http');
stream_wrapper_restore('https');
stream_wrapper_restore('http');

まとめ

ポイント内容
用途stream_wrapper_unregister()で解除した組み込みラッパーの復元
復元できるものPHP組み込みラッパーのみ(カスタムは不可)
復元前の手順カスタムで差し替えていた場合は先にunregister()してからrestore()
安全な使い方try-finallyでテスト後の復元を保証
チェック方法stream_get_wrappers()で登録状態を事前確認
主な活用場面ユニットテストのモック・一時的なラッパー差し替え

stream_wrapper_restore()自体は非常にシンプルな関数ですが、stream_wrapper_unregister()とセットで使い、try-finallyで復元を保証するというパターンを徹底することが、安全なストリームラッパー操作の要です。


PHP 8.x / 執筆時点の最新安定版にて動作確認済み

タイトルとURLをコピーしました