[PHP]stream_wrapper_register完全解説|カスタムストリームラッパーで独自プロトコルを実装する方法

PHP

はじめに

PHPでは file_get_contents('https://...')fopen('php://memory', 'r+') のように、プロトコルスキーム(https://php://など)を使ってあらゆるリソースをファイル操作と同じAPIで扱えます。この仕組みを支えているのが「ストリームラッパー」です。

stream_wrapper_register() を使うと、独自のプロトコルスキームを定義し、fopen()file_get_contents()glob()などすべてのファイル関数と透過的に連携するカスタムラッパーを作成できます。

「自前のプロトコルを実装する」というと難しそうですが、仕組みを理解すれば非常に強力なツールです。テスト用仮想ファイルシステム、暗号化透過レイヤー、クラウドストレージの抽象化など、応用範囲は広大です。


関数の基本情報

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

シグネチャ

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

パラメータ

パラメータ説明
$protocolstring登録するスキーム名(例:myprotomyproto://...として使用)
$classstringラッパーを実装するクラス名(完全修飾名)
$flagsintSTREAM_IS_URLを指定するとURLラッパーとして扱われる(省略可)

関連関数

関数用途
stream_wrapper_unregister()登録済みラッパーの解除
stream_wrapper_restore()組み込みラッパーの復元
stream_get_wrappers()登録済みラッパー一覧の取得

ストリームラッパークラスが実装すべきメソッド

カスタムラッパークラスはクラス名を渡すだけで、特定のインターフェースのimplementsは不要です。ただし、使用するPHP関数に応じて以下のメソッドを実装する必要があります。

ファイル操作系

メソッド対応するPHP関数
stream_open()fopen()
stream_read()fread() / fgets()
stream_write()fwrite()
stream_tell()ftell()
stream_eof()feof()
stream_seek()fseek()
stream_close()fclose()
stream_flush()fflush()
stream_stat()fstat()

ディレクトリ操作系

メソッド対応するPHP関数
dir_opendir()opendir()
dir_readdir()readdir()
dir_rewinddir()rewinddir()
dir_closedir()closedir()

ファイルシステム操作系

メソッド対応するPHP関数
url_stat()stat() / file_exists() / is_file() / is_dir()
unlink()unlink()
rename()rename()
mkdir()mkdir()
rmdir()rmdir()

ポイント: すべて実装する必要はなく、使う機能だけ実装すればOKです。未実装メソッドを呼ぶとWarningが出ます。


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

サンプル1:インメモリ文字列ストリーム

最もシンプルな例。文字列をストリームとして扱うラッパーです。

<?php
declare(strict_types=1);

class StringStreamWrapper
{
    /** @var resource ストリームコンテキスト(PHPが自動でセット) */
    public mixed $context;

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

    public function stream_open(
        string $path,
        string $mode,
        int $options,
        ?string &$openedPath
    ): bool {
        // パスからデータを取得: string://Hello%20World
        $url = parse_url($path);
        $this->data = urldecode($url['host'] ?? '');
        $this->position = 0;
        return true;
    }

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

    public function stream_write(string $data): int
    {
        $left  = substr($this->data, 0, $this->position);
        $right = substr($this->data, $this->position + strlen($data));
        $this->data = $left . $data . $right;
        $this->position += strlen($data);
        return strlen($data);
    }

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

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

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

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

    public function stream_close(): void {}
}

// 登録
stream_wrapper_register('str', StringStreamWrapper::class);

// 使用例
$fp = fopen('str://Hello%20World', 'r');
echo fread($fp, 5);   // Hello
echo fread($fp, 6);   // World(スペース含む)
fclose($fp);

// file_get_contentsでも使える
echo file_get_contents('str://PHPカスタムラッパー');

実行結果:

Hello World
PHPカスタムラッパー

解説: stream_wrapper_register()strスキームを登録すると、str://...という形式でストリームが開けます。file_get_contents()など通常のファイル関数がそのまま動作します。


サンプル2:インメモリ仮想ファイルシステム

テストやキャッシュに使える、メモリ上の仮想ファイルシステムです。

<?php
declare(strict_types=1);

class MemoryFilesystemWrapper
{
    public mixed $context;

    /** @var array<string, string> パス→内容のストレージ */
    private static array $storage = [];

    private string $currentPath = '';
    private int $position = 0;
    private string $mode = 'r';

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

        if (str_contains($mode, 'w')) {
            self::$storage[$this->currentPath] = '';
        } elseif (str_contains($mode, 'a')) {
            self::$storage[$this->currentPath] ??= '';
            $this->position = strlen(self::$storage[$this->currentPath]);
        } elseif (!isset(self::$storage[$this->currentPath])) {
            return false; // 読み取りで存在しないファイル
        }

        return true;
    }

    public function stream_read(int $count): string|false
    {
        $data = self::$storage[$this->currentPath] ?? '';
        $chunk = substr($data, $this->position, $count);
        $this->position += strlen($chunk);
        return $chunk;
    }

    public function stream_write(string $data): int
    {
        $current = self::$storage[$this->currentPath] ?? '';
        $left  = substr($current, 0, $this->position);
        $right = substr($current, $this->position + strlen($data));
        self::$storage[$this->currentPath] = $left . $data . $right;
        $this->position += strlen($data);
        return strlen($data);
    }

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

    public function stream_eof(): bool
    {
        return $this->position >= strlen(self::$storage[$this->currentPath] ?? '');
    }

    public function stream_close(): void {}

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

    public function url_stat(string $path, int $flags): array|false
    {
        $normalized = $this->normalizePath($path);
        if (!isset(self::$storage[$normalized])) {
            return false;
        }
        $size = strlen(self::$storage[$normalized]);
        return array_fill_keys(
            ['dev','ino','mode','nlink','uid','gid','rdev','size',
             'atime','mtime','ctime','blksize','blocks'],
            0
        ) + ['size' => $size, 'mode' => 0100644];
    }

    public function unlink(string $path): bool
    {
        $normalized = $this->normalizePath($path);
        if (!isset(self::$storage[$normalized])) {
            return false;
        }
        unset(self::$storage[$normalized]);
        return true;
    }

    private function normalizePath(string $path): string
    {
        // "mem://path/to/file" → "/path/to/file"
        return preg_replace('#^mem://#', '', $path) ?? $path;
    }
}

stream_wrapper_register('mem', MemoryFilesystemWrapper::class);

// 書き込み
file_put_contents('mem://config.json', json_encode(['debug' => true]));
file_put_contents('mem://data/users.csv', "id,name\n1,Alice\n2,Bob");

// 読み取り
$config = json_decode(file_get_contents('mem://config.json'), true);
var_dump($config['debug']); // bool(true)

// ファイル存在確認
var_dump(file_exists('mem://config.json'));       // bool(true)
var_dump(file_exists('mem://not_found.txt'));     // bool(false)

// 削除
unlink('mem://config.json');
var_dump(file_exists('mem://config.json'));       // bool(false)

実行結果:

bool(true)
bool(true)
bool(false)
bool(false)

解説: 静的プロパティ$storageでメモリ上にファイルを保持します。url_stat()を実装することでfile_exists()is_file()なども動作します。ユニットテストでの仮想ファイルシステムとして非常に有用です。


サンプル3:透過的な暗号化ストリームラッパー

読み書き時に自動でAES暗号化・復号を行うラッパーです。

<?php
declare(strict_types=1);

class EncryptedFileWrapper
{
    public mixed $context;

    private const CIPHER = 'aes-256-cbc';
    private const KEY    = 'my-32-byte-secret-key-1234567890'; // 本番は環境変数で管理
    private const IV_LEN = 16;

    private string $path = '';
    private string $buffer = '';
    private int $position = 0;
    private bool $isWriteMode = false;

    public function stream_open(
        string $path,
        string $mode,
        int $options,
        ?string &$openedPath
    ): bool {
        // "enc://実際のファイルパス"
        $realPath = substr($path, strlen('enc://'));
        $this->path = $realPath;
        $this->isWriteMode = str_contains($mode, 'w');
        $this->position = 0;

        if (!$this->isWriteMode) {
            if (!file_exists($realPath)) {
                return false;
            }
            $raw = file_get_contents($realPath);
            if ($raw === false || strlen($raw) < self::IV_LEN) {
                return false;
            }
            $iv        = substr($raw, 0, self::IV_LEN);
            $encrypted = substr($raw, self::IV_LEN);
            $decrypted = openssl_decrypt(
                $encrypted, self::CIPHER, self::KEY,
                OPENSSL_RAW_DATA, $iv
            );
            $this->buffer = $decrypted !== false ? $decrypted : '';
        } else {
            $this->buffer = '';
        }

        return true;
    }

    public function stream_read(int $count): string|false
    {
        $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);
        return strlen($data);
    }

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

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

    public function stream_flush(): bool
    {
        if (!$this->isWriteMode) {
            return true;
        }
        $iv        = random_bytes(self::IV_LEN);
        $encrypted = openssl_encrypt(
            $this->buffer, self::CIPHER, self::KEY,
            OPENSSL_RAW_DATA, $iv
        );
        if ($encrypted === false) {
            return false;
        }
        return file_put_contents($this->path, $iv . $encrypted) !== false;
    }

    public function stream_close(): void
    {
        $this->stream_flush();
    }

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

stream_wrapper_register('enc', EncryptedFileWrapper::class);

// 暗号化して書き込み
$fp = fopen('enc:///tmp/secret.enc', 'w');
fwrite($fp, 'パスワード: t0p-s3cr3t');
fclose($fp);

// 復号して読み取り(透過的)
$content = file_get_contents('enc:///tmp/secret.enc');
echo $content . "\n"; // パスワード: t0p-s3cr3t

// 実際のファイルは暗号化バイナリ
$raw = file_get_contents('/tmp/secret.enc');
echo strlen($raw) . " bytes (encrypted)\n"; // バイナリデータ

実行結果:

パスワード: t0p-s3cr3t
48 bytes (encrypted)

解説: enc://スキームを通すだけで暗号化・復号が透過的に行われます。アプリケーションコードは通常のファイル操作と同じように書けるため、セキュリティレイヤーを後付けで追加できます。


サンプル4:ディレクトリ操作対応の仮想ストレージ

opendir()readdir()に対応したラッパーです。

<?php
declare(strict_types=1);

class VirtualDirectoryWrapper
{
    public mixed $context;

    private static array $files = [
        'virtual://documents/report.pdf'  => 'PDFコンテンツ',
        'virtual://documents/readme.txt'  => 'README内容',
        'virtual://images/logo.png'       => 'バイナリデータ',
    ];

    private string $currentPath = '';
    private int $position = 0;
    private array $dirEntries = [];
    private int $dirPosition = 0;
    private string $readBuffer = '';
    private int $readPosition = 0;

    // --- ファイル操作 ---

    public function stream_open(
        string $path,
        string $mode,
        int $options,
        ?string &$openedPath
    ): bool {
        $this->currentPath = rtrim($path, '/');
        if (!isset(self::$files[$this->currentPath])) {
            return false;
        }
        $this->readBuffer   = self::$files[$this->currentPath];
        $this->readPosition = 0;
        return true;
    }

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

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

    public function stream_tell(): int { return $this->readPosition; }
    public function stream_close(): void {}
    public function stream_stat(): array { return ['size' => strlen($this->readBuffer)]; }

    // --- ディレクトリ操作 ---

    public function dir_opendir(string $path, int $options): bool
    {
        $prefix = rtrim($path, '/') . '/';
        $this->dirEntries = [];

        foreach (array_keys(self::$files) as $filePath) {
            if (!str_starts_with($filePath, $prefix)) {
                continue;
            }
            $relative = substr($filePath, strlen($prefix));
            if (!str_contains($relative, '/')) {
                $this->dirEntries[] = $relative;
            }
        }

        $this->dirPosition = 0;
        return true;
    }

    public function dir_readdir(): string|false
    {
        if ($this->dirPosition >= count($this->dirEntries)) {
            return false;
        }
        return $this->dirEntries[$this->dirPosition++];
    }

    public function dir_rewinddir(): bool
    {
        $this->dirPosition = 0;
        return true;
    }

    public function dir_closedir(): bool
    {
        $this->dirEntries = [];
        return true;
    }

    // url_stat: file_exists, is_file, is_dirに対応
    public function url_stat(string $path, int $flags): array|false
    {
        $normalized = rtrim($path, '/');

        if (isset(self::$files[$normalized])) {
            return ['mode' => 0100644, 'size' => strlen(self::$files[$normalized])]
                + array_fill_keys(['dev','ino','nlink','uid','gid','rdev',
                                   'atime','mtime','ctime','blksize','blocks'], 0);
        }

        // ディレクトリ判定
        $prefix = $normalized . '/';
        foreach (array_keys(self::$files) as $f) {
            if (str_starts_with($f, $prefix)) {
                return ['mode' => 0040755, 'size' => 0]
                    + array_fill_keys(['dev','ino','nlink','uid','gid','rdev',
                                       'atime','mtime','ctime','blksize','blocks'], 0);
            }
        }

        return false;
    }
}

stream_wrapper_register('virtual', VirtualDirectoryWrapper::class);

// ディレクトリ一覧
$dh = opendir('virtual://documents');
while (($entry = readdir($dh)) !== false) {
    echo $entry . "\n";
}
closedir($dh);

// ファイル存在確認
var_dump(is_file('virtual://documents/readme.txt')); // bool(true)
var_dump(is_dir('virtual://documents'));              // bool(true)

// ファイル読み取り
echo file_get_contents('virtual://documents/readme.txt'); // README内容

実行結果:

report.pdf
readme.txt
bool(true)
bool(true)
README内容

解説: dir_opendir()dir_readdir()を実装することでopendir()readdir()が動作します。url_stat()でファイル/ディレクトリの属性を返すことでis_file()is_dir()も機能します。


サンプル5:既存ラッパーのオーバーライドとリストア

組み込みのラッパー(file://など)を一時的に差し替えるテクニックです。

<?php
declare(strict_types=1);

/**
 * テスト用:file://を差し替えてファイルアクセスをモックする
 */
class MockFileWrapper
{
    public mixed $context;

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

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

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

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

        if (isset(self::$mocks[$realPath])) {
            $this->buffer   = self::$mocks[$realPath];
            $this->position = 0;
            return true;
        }

        // モックがなければ本物のファイルシステムへフォールバック
        stream_wrapper_restore('file');
        $handle = fopen($path, $mode);
        stream_wrapper_unregister('file');
        stream_wrapper_register('file', self::class);

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

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

// モックデータを登録
MockFileWrapper::mock('/app/config.php', '<?php return ["env" => "test"];');

// アプリケーションコードは変更不要
$content = file_get_contents('/app/config.php');
echo $content . "\n"; // <?php return ["env" => "test"];

// 元に戻す
stream_wrapper_unregister('file');
stream_wrapper_restore('file');

MockFileWrapper::clearMocks();
echo "file://ラッパーを復元しました\n";

実行結果:

<?php return ["env" => "test"];
file://ラッパーを復元しました

解説: stream_wrapper_unregister()で既存ラッパーを一時解除し、差し替えるテクニックです。テストフレームワーク(vfsStreamなど)でよく使われるパターンです。stream_wrapper_restore()で元のラッパーに戻せます。


サンプル6:ログ付きデバッグラッパー

既存ストリームへのアクセスをすべてログ記録する監視ラッパーです。

<?php
declare(strict_types=1);

class LoggingStreamWrapper
{
    public mixed $context;

    private mixed $handle = null;
    private string $path = '';

    private static array $log = [];

    public static function getLogs(): array
    {
        return self::$log;
    }

    private static function record(string $operation, string $detail = ''): void
    {
        self::$log[] = sprintf(
            '[%s] %s %s',
            date('H:i:s'),
            $operation,
            $detail
        );
    }

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

        stream_wrapper_restore('file');
        $this->handle = fopen($realPath, $mode);
        stream_wrapper_unregister('file');
        // ここでは再登録しない(log://スキームは別名なので不要)

        self::record('OPEN', "{$realPath} mode={$mode}");
        return $this->handle !== false;
    }

    public function stream_read(int $count): string|false
    {
        $data = fread($this->handle, $count);
        self::record('READ', "{$count} bytes requested, got " . strlen((string)$data));
        return $data;
    }

    public function stream_write(string $data): int
    {
        $written = fwrite($this->handle, $data);
        self::record('WRITE', "{$written} bytes");
        return $written ?: 0;
    }

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

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

    public function stream_close(): void
    {
        self::record('CLOSE', $this->path);
        if ($this->handle) {
            fclose($this->handle);
        }
    }

    public function stream_stat(): array
    {
        return fstat($this->handle) ?: [];
    }
}

stream_wrapper_register('log', LoggingStreamWrapper::class);

// 使用例
$tmpFile = tempnam(sys_get_temp_dir(), 'log_test_');
file_put_contents($tmpFile, "Line1\nLine2\nLine3\n");

$fp = fopen("log://{$tmpFile}", 'r');
while (!feof($fp)) {
    $line = fgets($fp);
}
fclose($fp);

foreach (LoggingStreamWrapper::getLogs() as $entry) {
    echo $entry . "\n";
}
unlink($tmpFile);

実行結果:

[12:34:56] OPEN /tmp/log_testXXXXXX mode=r
[12:34:56] READ 8192 bytes requested, got 18
[12:34:56] READ 8192 bytes requested, got 0
[12:34:56] CLOSE /tmp/log_testXXXXXX

解説: log://スキームを通すだけで、すべての読み書きが自動記録されます。デバッグ・パフォーマンス計測・監査ログなど、アプリケーションコードを変えずにI/O監視を追加できます。


登録済みラッパーの確認

// 現在登録されているすべてのラッパーを確認
print_r(stream_get_wrappers());

出力例(標準的な環境):

Array
(
    [0] => https
    [1] => ftps
    [2] => compress.zlib
    [3] => php
    [4] => file
    [5] => glob
    [6] => data
    [7] => http
    [8] => ftp
    [9] => compress.bz2
    [10] => phar
    [11] => zip     ← 拡張機能によって変わる
    [12] => str     ← 登録したカスタムラッパー
)

よくある落とし穴

① $flags 定数を間違える

// ❌ 文字列で渡すとエラー
stream_wrapper_register('myp', MyWrapper::class, 'STREAM_IS_URL');

// ✅ 定数で渡す
stream_wrapper_register('myp', MyWrapper::class, STREAM_IS_URL);

② url_stat() を実装しないと file_exists() が動かない

// url_stat() 未実装の場合
file_exists('myp://path');   // 常にfalse
is_file('myp://path');       // 常にfalse

file_exists()is_file() を使う場合は必ず url_stat() を実装しましょう。

③ 同名プロトコルの二重登録はエラー

stream_wrapper_register('mem', WrapperA::class);
stream_wrapper_register('mem', WrapperB::class); // false を返す(失敗)

// 上書きしたい場合は一度解除する
stream_wrapper_unregister('mem');
stream_wrapper_register('mem', WrapperB::class);

④ stream_open() の戻り値を忘れない

// ❌ 失敗してもtrueを返すとfopen()がリソースを返してしまう
public function stream_open(...): bool {
    // 何らかのエラー処理
    return true; // ← 誤り
}

// ✅ エラー時はfalseを返す
public function stream_open(...): bool {
    if (/* エラー条件 */) {
        return false;
    }
    return true;
}

⑤ マルチバイト文字列のバイト数に注意

// strlen() はバイト数、mb_strlen() は文字数
$data = 'こんにちは'; // UTF-8: 1文字=3バイト
strlen($data);    // 15(バイト数)
mb_strlen($data); // 5(文字数)

// stream_read/writeではstrlen()(バイト数)を使うこと

まとめ

ポイント内容
主な用途独自プロトコルスキームの定義・仮想ファイルシステム・透過的変換レイヤー
最低限の実装stream_open stream_read stream_eof stream_close
file_exists()対応url_stat()の実装が必要
ディレクトリ操作dir_opendir系メソッドを追加実装
既存ラッパーの差し替えstream_wrapper_unregister() → 登録 → stream_wrapper_restore()
テストへの応用仮想FSやモックラッパーとして非常に有用

stream_wrapper_register()は、PHPのストリームアーキテクチャを最大限に活かすための強力な拡張ポイントです。暗号化・圧縮・クラウドストレージ・テスト用モックなど、アプリケーションコードを変えずに入出力の振る舞いを差し替えられるのが最大の魅力です。


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

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