はじめに
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
パラメータ
| パラメータ | 型 | 説明 |
|---|---|---|
$protocol | string | 登録するプロトコル名(fopen('proto://...') の proto 部分) |
$class | string | ラッパーを実装したクラス名(文字列で渡す) |
$flags | int | STREAM_IS_URL(1)を指定するとリモート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() |
$flags | STREAM_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() という統一インターフェースで操作できるようになります。
