[PHP]stream_register_wrapper完全ガイド|カスタムストリームラッパーを自作してfopen・file_get_contentsを拡張する

PHP

はじめに

PHPの fopen('http://...')fopen('php://memory') は、裏側でストリームラッパーという仕組みが動いています。stream_register_wrapper() を使うと、独自のプロトコルスキームを定義し、fopen('myproto://...') のような独自URLで読み書きできるカスタムストリームラッパーを作成できます。

データベースをファイルのように読み書きする・暗号化ストレージを透過的に扱う・設定ファイルを仮想パスで提供するなど、PHPのストリームAPIを通じてあらゆるデータソースを統一インターフェースで操作できるようになります。

補足: stream_register_wrapper()stream_wrapper_register()エイリアスです。動作は完全に同一です。本記事では stream_wrapper_register() を主軸に解説します。


関数の基本情報

項目内容
関数名stream_register_wrapper() / stream_wrapper_register()(エイリアス)
対応バージョンPHP 4.3.2 以降
返り値bool(成功時 true、失敗時 false
カテゴリストリーム関数

構文

stream_register_wrapper(string $protocol, string $class, int $flags = 0): bool

パラメータ

パラメータ説明
$protocolstring登録するプロトコル名(fopen('proto://...')proto 部分)
$classstringラッパーを実装したクラス名(文字列で渡す)
$flagsintSTREAM_IS_URL1)を指定するとリモートURLとして扱われる。省略時はローカル扱い

返り値

  • true:登録成功
  • false:同名プロトコルが既に登録済み、またはクラスが存在しない場合

ラッパークラスで実装すべきメソッド

カスタムラッパークラスは特定の基底クラスを継承する必要はありませんが、PHPが呼び出す所定のメソッド名を実装する必要があります。

メソッド対応する操作必須
stream_open()fopen()
stream_read()fread() / fgets()
stream_write()fwrite()書き込み時
stream_tell()ftell()
stream_eof()feof()
stream_seek()fseek() / rewind()シーク対応時
stream_close()fclose()
stream_stat()fstat()任意
url_stat()stat() / file_exists() / is_file() など任意
dir_opendir()opendir()ディレクトリ対応時
dir_readdir()readdir()ディレクトリ対応時
dir_closedir()closedir()ディレクトリ対応時
mkdir()mkdir()任意
rename()rename()任意
unlink()unlink()任意

$context プロパティ

ラッパークラスのインスタンスには $context プロパティが自動的に設定されます。stream_context_get_options() でコンテキストのオプションを読み取れます。


基本的な使い方

<?php

class HelloWrapper
{
    private int    $position = 0;
    private string $content  = '';

    public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
    {
        // パスからホスト名を取り出す: hello://world → "world"
        $url           = parse_url($path);
        $this->content = "Hello, {$url['host']}!";
        $this->position = 0;
        return true;
    }

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

    public function stream_write(string $data): int { return 0; }
    public function stream_tell(): int  { return $this->position; }
    public function stream_eof(): bool  { return $this->position >= strlen($this->content); }
    public function stream_seek(int $offset, int $whence): bool
    {
        $len = strlen($this->content);
        $this->position = match ($whence) {
            SEEK_SET => $offset,
            SEEK_CUR => $this->position + $offset,
            SEEK_END => $len + $offset,
            default  => $this->position,
        };
        return true;
    }
    public function stream_close(): void {}
}

stream_wrapper_register('hello', HelloWrapper::class);

echo file_get_contents('hello://PHP');    // → Hello, PHP!
echo file_get_contents('hello://World');  // → Hello, World!

実践的なクラスベースの活用例


例1:インメモリ仮想ファイルシステム(MemfsWrapper)

memfs:// スキームで、プロセス内の連想配列をファイルシステムとして扱うインメモリ仮想FSラッパーです。テスト用のファイルI/Oモックとして活用できます。

<?php

class MemfsStorage
{
    /** @var array<string, string> パス => コンテンツ */
    private static array $files = [];

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

    public static function get(string $path): ?string
    {
        return self::$files[$path] ?? null;
    }

    public static function has(string $path): bool
    {
        return isset(self::$files[$path]);
    }

    public static function delete(string $path): void
    {
        unset(self::$files[$path]);
    }

    public static function keys(): array
    {
        return array_keys(self::$files);
    }
}

class MemfsWrapper
{
    private string $path     = '';
    private string $buffer   = '';
    private int    $position = 0;
    private string $mode     = '';

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

        if (str_contains($mode, 'r')) {
            $content = MemfsStorage::get($this->path);
            if ($content === null) return false;
            $this->buffer = $content;
        } elseif (str_contains($mode, 'a')) {
            $this->buffer = MemfsStorage::get($this->path) ?? '';
            $this->position = strlen($this->buffer);
        } else {
            $this->buffer = '';
        }
        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_write(string $data): int
    {
        $len = strlen($data);
        $this->buffer    = substr($this->buffer, 0, $this->position)
                         . $data
                         . substr($this->buffer, $this->position + $len);
        $this->position += $len;
        MemfsStorage::put($this->path, $this->buffer);
        return $len;
    }

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

    public function stream_seek(int $offset, int $whence): bool
    {
        $len = strlen($this->buffer);
        $this->position = match ($whence) {
            SEEK_SET => $offset,
            SEEK_CUR => $this->position + $offset,
            SEEK_END => $len + $offset,
            default  => $this->position,
        };
        return $this->position >= 0;
    }

    public function stream_stat(): array
    {
        $size = strlen($this->buffer);
        return ['size' => $size, 'mode' => 0100644];
    }

    public function url_stat(string $path, int $flags): array|false
    {
        $key = substr($path, strlen('memfs://'));
        if (!MemfsStorage::has($key)) return false;
        $size = strlen(MemfsStorage::get($key));
        return array_fill(0, 13, 0) + ['size' => $size, 7 => $size];
    }

    public function unlink(string $path): bool
    {
        $key = substr($path, strlen('memfs://'));
        MemfsStorage::delete($key);
        return true;
    }
}

stream_wrapper_register('memfs', MemfsWrapper::class);

// 使用例
file_put_contents('memfs://config.json', '{"debug":true,"version":"1.0"}');
file_put_contents('memfs://users.csv',   "id,name\n1,山田\n2,鈴木\n");

echo file_get_contents('memfs://config.json') . PHP_EOL;
// → {"debug":true,"version":"1.0"}

$fp = fopen('memfs://users.csv', 'r');
while (($line = fgets($fp)) !== false) {
    echo trim($line) . PHP_EOL;
}
fclose($fp);
// → id,name
// → 1,山田
// → 2,鈴木

echo "存在確認: " . (file_exists('memfs://config.json') ? 'YES' : 'NO') . PHP_EOL; // YES
unlink('memfs://config.json');
echo "削除後  : " . (file_exists('memfs://config.json') ? 'YES' : 'NO') . PHP_EOL; // NO

例2:透過的なAES暗号化ラッパー(CryptoWrapper)

crypto:// スキームで、読み書き時に自動的に AES-256-CBC 暗号化・復号を行うラッパーです。アプリケーションコードを変更せずにファイルを暗号化保存できます。

<?php

class CryptoWrapper
{
    private const CIPHER    = 'AES-256-CBC';
    private const IV_LEN    = 16;
    private const KEY_LEN   = 32;

    private string $realPath = '';
    private string $buffer   = '';
    private int    $position = 0;
    private string $mode     = '';

    /** コンテキストから暗号鍵を取得 */
    private function getKey(): string
    {
        $opts = stream_context_get_options($this->context);
        $key  = $opts['crypto']['key'] ?? str_repeat("\x00", self::KEY_LEN);
        return str_pad(substr($key, 0, self::KEY_LEN), self::KEY_LEN, "\x00");
    }

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

        if (str_contains($mode, 'r') && file_exists($this->realPath)) {
            $raw       = file_get_contents($this->realPath);
            $iv        = substr($raw, 0, self::IV_LEN);
            $encrypted = substr($raw, self::IV_LEN);
            $decrypted = openssl_decrypt($encrypted, self::CIPHER, $this->getKey(), OPENSSL_RAW_DATA, $iv);
            $this->buffer = $decrypted !== false ? $decrypted : '';
        } else {
            $this->buffer = '';
        }
        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_write(string $data): int
    {
        $len             = strlen($data);
        $this->buffer    = substr($this->buffer, 0, $this->position) . $data;
        $this->position += $len;

        // 書き込みのたびに暗号化してディスクに保存
        $iv        = random_bytes(self::IV_LEN);
        $encrypted = openssl_encrypt($this->buffer, self::CIPHER, $this->getKey(), OPENSSL_RAW_DATA, $iv);
        file_put_contents($this->realPath, $iv . $encrypted);

        return $len;
    }

    public function stream_tell(): int  { return $this->position; }
    public function stream_eof(): bool  { return $this->position >= strlen($this->buffer); }
    public function stream_close(): void {}
    public function stream_seek(int $offset, int $whence): bool
    {
        $len = strlen($this->buffer);
        $this->position = match ($whence) {
            SEEK_SET => $offset,
            SEEK_CUR => $this->position + $offset,
            SEEK_END => $len + $offset,
        };
        return true;
    }
    public function stream_stat(): array { return []; }
}

stream_wrapper_register('crypto', CryptoWrapper::class);

// 使用例
$key = hash('sha256', 'my_secret_password', true); // 32バイト鍵
$ctx = stream_context_create(['crypto' => ['key' => $key]]);

// 暗号化して書き込み
file_put_contents('crypto:///tmp/secret.enc', '機密データ: ユーザーID=12345', 0, $ctx);

// 復号して読み取り
$content = file_get_contents('crypto:///tmp/secret.enc', false, $ctx);
echo $content . PHP_EOL;
// → 機密データ: ユーザーID=12345

// ディスク上は暗号化されているので直接読むと文字化け
echo "ディスク上: " . bin2hex(substr(file_get_contents('/tmp/secret.enc'), 0, 16)) . "..." . PHP_EOL;

@unlink('/tmp/secret.enc');

例3:設定ファイル仮想ラッパー(ConfigWrapper)

config:// スキームで、PHP配列として定義された設定値をファイルのように読み取れる仮想ラッパーです。設定管理ライブラリとの統合や依存性注入に活用できます。

<?php

class ConfigRegistry
{
    /** @var array<string, mixed> */
    private static array $configs = [];

    public static function set(string $key, mixed $value): void
    {
        self::$configs[$key] = $value;
    }

    public static function get(string $key): mixed
    {
        return self::$configs[$key] ?? null;
    }

    public static function has(string $key): bool
    {
        return array_key_exists($key, self::$configs);
    }

    public static function keys(): array
    {
        return array_keys(self::$configs);
    }
}

class ConfigWrapper
{
    private string $buffer   = '';
    private int    $position = 0;

    public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
    {
        // config://database/host → キー "database/host"
        $key    = substr($path, strlen('config://'));
        $value  = ConfigRegistry::get($key);

        if ($value === null) return false;

        // 値の型に応じてシリアライズ
        $this->buffer = match (true) {
            is_array($value)  => json_encode($value, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
            is_bool($value)   => $value ? 'true' : 'false',
            default           => (string)$value,
        };
        $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_write(string $data): int
    {
        // JSON なら配列としてデシリアライズして保存
        $decoded = json_decode($data, true);
        $key     = ''; // stream_open で保存しておく必要がある(例示の簡略化)
        return strlen($data);
    }

    public function stream_tell(): int  { return $this->position; }
    public function stream_eof(): bool  { return $this->position >= strlen($this->buffer); }
    public function stream_close(): void {}
    public function stream_seek(int $offset, int $whence): bool
    {
        $len = strlen($this->buffer);
        $this->position = match ($whence) {
            SEEK_SET => $offset,
            SEEK_CUR => $this->position + $offset,
            SEEK_END => $len + $offset,
            default  => $this->position,
        };
        return true;
    }

    public function url_stat(string $path, int $flags): array|false
    {
        $key  = substr($path, strlen('config://'));
        if (!ConfigRegistry::has($key)) return false;
        $size = strlen((string)ConfigRegistry::get($key));
        $stat = array_fill(0, 13, 0);
        $stat[7] = $stat['size'] = $size;
        return $stat;
    }
}

stream_wrapper_register('config', ConfigWrapper::class);

// 設定値を登録
ConfigRegistry::set('database/host',     'db.example.com');
ConfigRegistry::set('database/port',     5432);
ConfigRegistry::set('database/options',  ['timeout' => 30, 'charset' => 'utf8']);
ConfigRegistry::set('app/debug',         false);
ConfigRegistry::set('app/name',          'MyApplication');

// ファイルのように設定値を読み取る
echo file_get_contents('config://database/host')    . PHP_EOL; // db.example.com
echo file_get_contents('config://database/port')    . PHP_EOL; // 5432
echo file_get_contents('config://app/debug')        . PHP_EOL; // false
echo file_get_contents('config://database/options') . PHP_EOL;
// {
//     "timeout": 30,
//     "charset": "utf8"
// }

// file_exists で存在確認も可能
var_dump(file_exists('config://database/host'));  // bool(true)
var_dump(file_exists('config://nonexistent/key')); // bool(false)

例4:データベーステーブルファイルラッパー(DbTableWrapper)

dbtable:// スキームで、データベースのテーブルをCSVファイルのように読み書きできるラッパーです。既存のファイルI/Oコードをそのままデータベース操作に転用できます。

<?php

class InMemoryDatabase
{
    /** @var array<string, array[]> テーブル名 => レコード配列 */
    private static array $tables = [];

    public static function createTable(string $table, array $columns): void
    {
        self::$tables[$table] = [];
    }

    public static function insert(string $table, array $row): void
    {
        self::$tables[$table][] = $row;
    }

    public static function selectAll(string $table): array
    {
        return self::$tables[$table] ?? [];
    }

    public static function truncate(string $table): void
    {
        self::$tables[$table] = [];
    }

    public static function hasTable(string $table): bool
    {
        return isset(self::$tables[$table]);
    }
}

class DbTableWrapper
{
    private string $table    = '';
    private string $buffer   = '';
    private int    $position = 0;
    private string $mode     = '';

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

        if (str_contains($mode, 'r')) {
            // SELECT → CSVに変換してバッファに格納
            $rows = InMemoryDatabase::selectAll($this->table);
            if (empty($rows)) {
                $this->buffer = '';
                return true;
            }
            $fp = fopen('php://memory', 'w+');
            // ヘッダー行
            fputcsv($fp, array_keys($rows[0]));
            foreach ($rows as $row) {
                fputcsv($fp, $row);
            }
            $this->buffer = stream_get_contents($fp, offset: 0);
            fclose($fp);
        } elseif ($mode === 'w') {
            // 書き込み前にテーブルを空にする
            if (InMemoryDatabase::hasTable($this->table)) {
                InMemoryDatabase::truncate($this->table);
            }
            $this->buffer = '';
        } elseif ($mode === 'a') {
            $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_write(string $data): int
    {
        $this->buffer .= $data;
        $this->position += strlen($data);

        // 完全な行(改行終端)が来たらINSERT
        $lines = explode("\n", $this->buffer);
        if (count($lines) >= 2) {
            $headers = null;
            foreach (array_slice($lines, 0, -1) as $line) {
                if (trim($line) === '') continue;
                $fp  = fopen('php://memory', 'r+');
                fwrite($fp, $line);
                rewind($fp);
                $row = fgetcsv($fp);
                fclose($fp);

                if ($row === false) continue;
                if ($headers === null) {
                    $headers = $row;
                } else {
                    InMemoryDatabase::insert($this->table, array_combine($headers, $row));
                }
            }
            $this->buffer = end($lines); // 未完の最終行を残す
        }
        return strlen($data);
    }

    public function stream_tell(): int  { return $this->position; }
    public function stream_eof(): bool  { return $this->position >= strlen($this->buffer); }
    public function stream_close(): void {}
    public function stream_seek(int $offset, int $whence): bool
    {
        $len = strlen($this->buffer);
        $this->position = match ($whence) {
            SEEK_SET => $offset,
            SEEK_CUR => $this->position + $offset,
            SEEK_END => $len + $offset,
        };
        return true;
    }
    public function stream_stat(): array { return []; }
}

stream_wrapper_register('dbtable', DbTableWrapper::class);

// テーブルとデータを準備
InMemoryDatabase::createTable('products', ['id', 'name', 'price']);
InMemoryDatabase::insert('products', ['id' => 1, 'name' => 'Apple',  'price' => 120]);
InMemoryDatabase::insert('products', ['id' => 2, 'name' => 'Banana', 'price' => 80]);
InMemoryDatabase::insert('products', ['id' => 3, 'name' => 'Cherry', 'price' => 350]);

// テーブルをCSVとして読み取る
echo "=== products テーブル ===" . PHP_EOL;
$fp = fopen('dbtable://products', 'r');
while (($row = fgetcsv($fp)) !== false) {
    echo implode(' | ', $row) . PHP_EOL;
}
fclose($fp);
// → id | name | price
// → 1 | Apple | 120
// → 2 | Banana | 80
// → 3 | Cherry | 350

// CSVを書き込んでテーブルにINSERT
$fp = fopen('dbtable://products', 'w');
fputcsv($fp, ['id', 'name', 'price']);
fputcsv($fp, [10, 'Durian',  800]);
fputcsv($fp, [11, 'Elderberry', 500]);
fclose($fp);

echo PHP_EOL . "=== 書き込み後 ===" . PHP_EOL;
foreach (InMemoryDatabase::selectAll('products') as $row) {
    echo implode(' | ', $row) . PHP_EOL;
}

例5:ロギング付きパススルーラッパー(LoggingWrapper)

logged:// スキームで、既存のファイルI/O操作をすべてログに記録するパススルーラッパーです。ファイルアクセスの監査ログや、デバッグ時のI/O追跡に使えます。

<?php

class IoLogger
{
    private static array $log = [];

    public static function record(string $operation, string $path, mixed $detail = null): void
    {
        self::$log[] = [
            'time'      => microtime(true),
            'operation' => $operation,
            'path'      => $path,
            'detail'    => $detail,
        ];
    }

    public static function getLog(): array  { return self::$log; }
    public static function clear(): void    { self::$log = []; }

    public static function printLog(): void
    {
        foreach (self::$log as $entry) {
            $detail = $entry['detail'] !== null ? " ({$entry['detail']})" : '';
            echo sprintf("[%s] %s: %s%s\n",
                date('H:i:s', (int)$entry['time']),
                $entry['operation'],
                $entry['path'],
                $detail
            );
        }
    }
}

class LoggingWrapper
{
    private $realFp   = null;
    private string $realPath = '';

    public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
    {
        // logged:///tmp/file.txt → /tmp/file.txt
        $this->realPath = substr($path, strlen('logged://'));
        $this->realFp   = fopen($this->realPath, $mode);

        IoLogger::record('open', $this->realPath, "mode={$mode}");
        return $this->realFp !== false;
    }

    public function stream_read(int $count): string
    {
        $data = fread($this->realFp, $count);
        IoLogger::record('read', $this->realPath, strlen($data) . ' bytes');
        return $data !== false ? $data : '';
    }

    public function stream_write(string $data): int
    {
        $written = fwrite($this->realFp, $data);
        IoLogger::record('write', $this->realPath, $written . ' bytes');
        return $written !== false ? $written : 0;
    }

    public function stream_tell(): int
    {
        return ftell($this->realFp) ?: 0;
    }

    public function stream_eof(): bool
    {
        return feof($this->realFp);
    }

    public function stream_seek(int $offset, int $whence): bool
    {
        IoLogger::record('seek', $this->realPath, "offset={$offset}");
        return fseek($this->realFp, $offset, $whence) === 0;
    }

    public function stream_close(): void
    {
        IoLogger::record('close', $this->realPath);
        fclose($this->realFp);
    }

    public function stream_stat(): array
    {
        IoLogger::record('stat', $this->realPath);
        return fstat($this->realFp) ?: [];
    }

    public function url_stat(string $path, int $flags): array|false
    {
        $realPath = substr($path, strlen('logged://'));
        IoLogger::record('url_stat', $realPath);
        return @stat($realPath) ?: false;
    }
}

stream_wrapper_register('logged', LoggingWrapper::class);

// 使用例
$testFile = '/tmp/logging_test.txt';
file_put_contents($testFile, "Line 1\nLine 2\nLine 3\n");

IoLogger::clear();

$fp = fopen("logged://{$testFile}", 'r+');
fread($fp, 6);
fseek($fp, 0);
fwrite($fp, 'LINE 1');
fclose($fp);

echo "=== I/O ログ ===" . PHP_EOL;
IoLogger::printLog();
unlink($testFile);

// 出力例:
// === I/O ログ ===
// [10:00:00] open: /tmp/logging_test.txt (mode=r+)
// [10:00:00] read: /tmp/logging_test.txt (6 bytes)
// [10:00:00] seek: /tmp/logging_test.txt (offset=0)
// [10:00:00] write: /tmp/logging_test.txt (6 bytes)
// [10:00:00] close: /tmp/logging_test.txt

例6:圧縮ロータリーファイルラッパー(RotatingWrapper)

rotating:// スキームで、一定サイズを超えたら自動的に古いファイルをローテーションしながら書き込む、ログローテーション対応ラッパーです。

<?php

class RotatingWrapper
{
    private $fp          = null;
    private string $basePath  = '';
    private int    $maxBytes  = 0;
    private int    $maxFiles  = 0;

    public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
    {
        $this->basePath = substr($path, strlen('rotating://'));

        // コンテキストから設定を取得
        $opts            = stream_context_get_options($this->context)['rotating'] ?? [];
        $this->maxBytes  = (int)($opts['max_bytes']  ?? 1_048_576); // デフォルト 1MB
        $this->maxFiles  = (int)($opts['max_files']  ?? 5);

        $this->fp = fopen($this->basePath, $mode);
        return $this->fp !== false;
    }

    public function stream_write(string $data): int
    {
        // サイズチェック:超えたらローテーション
        $currentSize = filesize($this->basePath) ?: 0;
        if ($currentSize + strlen($data) > $this->maxBytes) {
            $this->rotate();
        }
        return fwrite($this->fp, $data) ?: 0;
    }

    private function rotate(): void
    {
        fclose($this->fp);

        // 古いファイルをシフト: .4 → 消去, .3 → .4, ... .1 → .2
        for ($i = $this->maxFiles - 1; $i >= 1; $i--) {
            $old = "{$this->basePath}.{$i}";
            $new = "{$this->basePath}." . ($i + 1);
            if (file_exists($old)) {
                if ($i >= $this->maxFiles - 1) {
                    unlink($old);
                } else {
                    rename($old, $new);
                }
            }
        }
        rename($this->basePath, "{$this->basePath}.1");

        $this->fp = fopen($this->basePath, 'a');
    }

    public function stream_read(int $count): string
    {
        return fread($this->fp, $count) ?: '';
    }

    public function stream_tell(): int  { return ftell($this->fp) ?: 0; }
    public function stream_eof(): bool  { return feof($this->fp); }
    public function stream_close(): void { if ($this->fp) fclose($this->fp); }
    public function stream_seek(int $offset, int $whence): bool
    {
        return fseek($this->fp, $offset, $whence) === 0;
    }
    public function stream_stat(): array { return fstat($this->fp) ?: []; }
}

stream_wrapper_register('rotating', RotatingWrapper::class);

// 使用例
$logPath = '/tmp/rotating_app.log';
$ctx     = stream_context_create([
    'rotating' => ['max_bytes' => 100, 'max_files' => 3], // テスト用に100バイトで
]);

$fp = fopen("rotating://{$logPath}", 'a', false, $ctx);

for ($i = 1; $i <= 10; $i++) {
    fwrite($fp, "[INFO] ログエントリ #{$i} - " . date('H:i:s') . "\n");
}
fclose($fp);

echo "現在のログファイル:" . PHP_EOL;
foreach (glob($logPath . '*') as $file) {
    echo "  " . basename($file) . " (" . filesize($file) . " bytes)" . PHP_EOL;
    unlink($file);
}

例7:既存ラッパーのオーバーライド(OverrideWrapper)

stream_wrapper_unregister()stream_wrapper_restore() を使って、既存の file:// ラッパーを一時的に独自実装に差し替えるテクニックです。テスト用のファイルI/Oモックに活用できます。

<?php

class MockFileWrapper
{
    /** @var array<string, string> モックファイルの内容 */
    public static array $mocks = [];

    /** @var array<string, string[]> アクセス記録 */
    public static array $accessLog = [];

    private string $path   = '';
    private string $buffer = '';
    private int    $pos    = 0;

    public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
    {
        // file:///path/to/file → /path/to/file
        $this->path = str_replace('file://', '', $path);
        self::$accessLog[$this->path][] = "open:{$mode}";

        if (str_contains($mode, 'r')) {
            if (!isset(self::$mocks[$this->path])) {
                // モックなければ実ファイルを読む
                stream_wrapper_restore('file');
                $content = @file_get_contents($this->path);
                stream_wrapper_unregister('file');
                stream_wrapper_register('file', self::class);
                $this->buffer = $content !== false ? $content : '';
            } else {
                $this->buffer = self::$mocks[$this->path];
            }
        } else {
            $this->buffer = '';
        }
        $this->pos = 0;
        return true;
    }

    public function stream_read(int $count): string
    {
        self::$accessLog[$this->path][] = "read:{$count}";
        $chunk    = substr($this->buffer, $this->pos, $count);
        $this->pos += strlen($chunk);
        return $chunk;
    }

    public function stream_write(string $data): int
    {
        self::$accessLog[$this->path][] = "write:" . strlen($data);
        $this->buffer .= $data;
        $this->pos    += strlen($data);
        self::$mocks[$this->path] = $this->buffer; // モックに保存
        return strlen($data);
    }

    public function stream_tell(): int  { return $this->pos; }
    public function stream_eof(): bool  { return $this->pos >= strlen($this->buffer); }
    public function stream_close(): void {}
    public function stream_seek(int $offset, int $whence): bool
    {
        $len = strlen($this->buffer);
        $this->pos = match ($whence) {
            SEEK_SET => $offset,
            SEEK_CUR => $this->pos + $offset,
            SEEK_END => $len + $offset,
        };
        return true;
    }
    public function stream_stat(): array
    {
        $size = strlen($this->buffer);
        return array_merge(array_fill(0, 13, 0), ['size' => $size, 7 => $size]);
    }
    public function url_stat(string $path, int $flags): array|false
    {
        $key = str_replace('file://', '', $path);
        if (!isset(self::$mocks[$key])) return false;
        $size = strlen(self::$mocks[$key]);
        return array_merge(array_fill(0, 13, 0), ['size' => $size, 7 => $size]);
    }
}

// file:// ラッパーをモックに差し替える
stream_wrapper_unregister('file');
stream_wrapper_register('file', MockFileWrapper::class);

// モックを定義
MockFileWrapper::$mocks['/app/config.ini'] = "[database]\nhost=mock-db\nport=5432\n";
MockFileWrapper::$mocks['/app/secret.key'] = 'mock-secret-key-for-testing';

// 通常のファイル関数で透過的にアクセス
echo file_get_contents('/app/config.ini') . PHP_EOL;
echo file_get_contents('/app/secret.key') . PHP_EOL;

file_put_contents('/app/output.log', "テスト出力\n");
echo "書き込み確認: " . MockFileWrapper::$mocks['/app/output.log'] . PHP_EOL;

// アクセスログを確認
foreach (MockFileWrapper::$accessLog as $path => $ops) {
    echo "{$path}: " . implode(', ', $ops) . PHP_EOL;
}

// 元の file:// ラッパーに戻す
stream_wrapper_unregister('file');
stream_wrapper_restore('file');

echo "file:// を元に戻しました" . PHP_EOL;

関連する関数との比較

関数役割
stream_register_wrapper() / stream_wrapper_register()カスタムラッパーをプロトコルとして登録
stream_wrapper_unregister()既存ラッパーを一時的に登録解除
stream_wrapper_restore()組み込みラッパーを元に戻す
stream_get_wrappers()登録済みラッパー名の一覧取得
stream_filter_register()ストリームのフィルターを登録(ラッパーより下位の変換処理)
stream_is_local()ストリームがローカルリソースか判定

ラッパーとフィルターの違い

ラッパーフィルター
定義するものfopen() で使う新しいプロトコル既存ストリーム上のデータ変換処理
登録関数stream_wrapper_register()stream_filter_register()
使用例fopen('myproto://...')stream_filter_append($fp, 'myfilter')
スコープストリーム全体の読み書き制御データの変換・加工のみ

注意点とベストプラクティス

1. stream_register_wrapper() は stream_wrapper_register() のエイリアス

2つは完全に同一の動作をします。PHP 公式ドキュメントでは stream_wrapper_register() が正式名称として扱われています。

2. 既存プロトコルを上書きするには事前に stream_wrapper_unregister() が必要

file://http:// などの組み込みラッパーを差し替えたい場合は、事前に stream_wrapper_unregister() で解除してから登録し直します(例7参照)。

stream_wrapper_unregister('file');
stream_wrapper_register('file', MyFileWrapper::class);
// 元に戻す
stream_wrapper_unregister('file');
stream_wrapper_restore('file');

3. $context プロパティはインスタンスに自動設定される

ラッパーメソッド内では $this->context でストリームコンテキストにアクセスできます。stream_context_get_options() でオプションを取得してラッパーの動作をカスタマイズできます。

4. url_stat() の実装でファイル関連関数が機能するようになる

file_exists()is_file()filesize() などを独自スキームで動作させるには url_stat() の実装が必要です。

public function url_stat(string $path, int $flags): array|false
{
    // $flags が STREAM_URL_STAT_QUIET の場合は警告を出さない
    $size = ...; // 仮想ファイルのサイズを返す
    $stat = array_fill(0, 13, 0);
    $stat[7] = $stat['size'] = $size;
    return $stat;
}

まとめ

ポイント内容
基本動作カスタムクラスを任意のプロトコル名でストリームシステムに登録
エイリアスstream_register_wrapper() = stream_wrapper_register()
$flagsSTREAM_IS_URL でリモートURL扱いにする(stream_is_local()false になる)
必須メソッドstream_open / stream_read / stream_tell / stream_eof / stream_close
コンテキスト$this->context から stream_context_get_options() でオプション取得
解除・復元stream_wrapper_unregister() / stream_wrapper_restore() と組み合わせて使う
活用シーン仮想FS・暗号化ストレージ・設定ラッパー・DBテーブルのファイルAPI化・テストモック・I/O監査ログ

stream_register_wrapper() は PHP のストリームアーキテクチャの頂点に立つ API です。一度マスターすれば、あらゆるデータソースを fopen()file_get_contents()fread() という統一インターフェースで操作できるようになります。

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