流封装与文件系统

PHP 流封装与文件系统实战:流(Stream)抽象与 wrapper 机制、内置封装器(file/http/php://)与流上下文、自定义 streamWrapper 把 S3/内存挂成文件系统、流过滤器(zlib/convert)、大文件流式读写与 php://temp、文件锁 flock 与原子写 rename、目录遍历、性能与安全陷阱。

引言

fopen、file_get_contents、fwrite 这些函数你每天都在用,但很多人没意识到:它们操作的不是「文件」,而是流(Stream)。流是一层抽象——fopen('php://memory') 打开的是内存、fopen('http://...') 打开的是网络、fopen('s3://...') 打开的是对象存储,同一套读写 API 适用于所有后端。这就是 PHP 流封装器(stream wrapper)机制的威力。

本文从流的抽象讲起,拆解内置封装器与流上下文(context),教你写自定义 streamWrapper(把任意后端挂成「文件系统」),再讲流过滤器、大文件流式处理、文件锁与原子写这些生产必备技能。

前置阅读:文件上传与图片处理 、生成器、Fiber 与协程调度 。


目录


1. 流:统一的 IO 抽象

1.1 什么是流

流是一个有状态的字节序列,支持按顺序读写,内部维护一个「位置指针」。fread 从当前位置读、fseek 移动指针、ftell 查询位置——这套模型对文件、网络、内存、压缩流都成立。

$h = fopen('php://memory', 'r+');   // 内存流
fwrite($h, 'hello world');
rewind($h);                          // 指针回到开头
echo fread($h, 5);                   // "hello"
echo ftell($h);                      // 5
fclose($h);

1.2 流的三个要素

要素说明例子
封装器(wrapper)决定「怎么打开」file://、http://、php://
资源句柄(stream)打开的流实例fopen 返回值
过滤器(filter)读写时串接的处理层zlib.inflate、convert.base64-decode

URL 里的 scheme 部分就是封装器名:file:///etc/hosts 用 file 封装器,php://stdin 用 php 封装器。

1.3 为什么这层抽象重要

  • 换后端不改业务代码:本地文件 → S3,只需换 scheme;
  • 组合能力:压缩、加解密、编码作为过滤器叠加;
  • 统一工具:file_get_contents 能读 URL、能读内存、能读压缩流。
// 一个例子串起三件事:网络下载 → gzip 解压 → base64 解码
$raw = file_get_contents('https://example.com/data.txt.gz');
$text = gzdecode($raw);
$json = base64_decode($text);

记忆:流 = 有状态的字节序列 + 位置指针;封装器决定「怎么打开」、过滤器决定「怎么处理」;换 scheme 即换后端。


2. 内置封装器与流上下文

2.1 常用封装器

封装器用途示例
file://本地文件(默认)file:///var/log/app.log
http:// https://HTTP 读写https://api.example.com/x
ftp:// ftps://FTPftp://user:pass@host/f
php://特殊流(见下)php://stdin
data://内联数据data://text/plain;base64,SGk=
phar://PHAR 归档内文件phar://app.phar/src/A.php
zlib:// compress.zlib://压缩流compress.zlib://file.gz
glob://目录模式匹配glob://*.php

2.2 php:// 家族

file_get_contents('php://input');   // 原始请求体(POST 原始数据)
fopen('php://stdout', 'w');         // 标准输出(CLI)
$tmp = fopen('php://temp', 'r+');   // 内存/临时文件混合(超过阈值自动落盘)
$mem = fopen('php://memory', 'r+'); // 纯内存

php://temp 是处理「可能很大、又不想先落盘」数据的最佳工具:小数据驻留内存,超过阈值(默认 2MB,可通过 php://temp/maxmemory:5242880 调整)自动转临时文件。

2.3 流上下文

上下文(context)用来给封装器传参数,比如 HTTP 的 header、method、超时:

$ctx = stream_context_create([
    'http' => [
        'method'  => 'POST',
        'header'  => "Content-Type: application/json\r\n",
        'content' => json_encode(['q' => 'php']),
        'timeout' => 5,               // 秒
        'ignore_errors' => true,      // 4xx/5xx 也返回 body
    ],
]);

$resp = file_get_contents('https://api.example.com/search', false, $ctx);

默认的 http 封装器没有连接池、不支持并发,只适合轻量场景;正经的 HTTP 客户端应交给 cURL 或 Guzzle。

记忆:scheme 选封装器、context 传参数;php://temp 是「内存优先、超限落盘」的临时流;默认 http 封装器不适合高并发。


3. 自定义 streamWrapper

3.1 注册一个封装器

实现 streamWrapper 接口(或其方法子集),再用 stream_wrapper_register 注册:

<?php
final class MemoryWrapper
{
    public $context;
    private string $buffer = '';
    private int $pos = 0;

    public function stream_open(string $path, string $mode, int $options, ?string &$opened): bool
    {
        return true;
    }

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

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

    public function stream_tell(): int { return $this->pos; }
    public function stream_seek(int $offset, int $whence): bool { /* ... */ return true; }
    public function stream_eof(): bool { return $this->pos >= strlen($this->buffer); }
    public function stream_stat(): array { return []; }
}

stream_wrapper_register('mem', MemoryWrapper::class);
file_put_contents('mem://test', 'hello');
echo file_get_contents('mem://test');   // "hello"

3.2 实际用途

  • 把对象存储挂成文件系统:注册 s3:// 封装器后,file_get_contents('s3://bucket/key') 直接读对象存储;
  • 虚拟只读文件系统:把数据库里的配置暴露成 config://app.yaml;
  • 测试替身:用内存封装器替代真实文件,测试不碰磁盘;
  • 加密文件系统:读写时透明加解密。

3.3 必须实现的目录方法

若希望 scandir、is_dir 等函数也工作,还要实现 dir_opendir、url_stat、dir_readdir 等方法。url_stat 决定 file_exists/filesize 是否可用,最容易被漏掉。

记忆:自定义 streamWrapper = 实现 stream_* 方法 + stream_wrapper_register;url_stat 决定 file_exists/filesize 是否生效。


4. 流过滤器

4.1 内置过滤器

// 读时自动解压
$h = fopen('compress.zlib://file.gz', 'r');
// 等价于附加 zlib.inflate 过滤器

// 写时自动压缩
$out = fopen('out.gz', 'w');
stream_filter_append($out, 'zlib.deflate', STREAM_FILTER_WRITE);
fwrite($out, str_repeat('data', 1000));
fclose($out);

常用内置过滤器:

过滤器作用
string.rot13演示用
string.toupper / string.tolower大小写
zlib.deflate / zlib.inflate压缩/解压
convert.base64-encode / convert.base64-decodeBase64
convert.iconv.UTF-8.ISO-8859-1字符集转换

4.2 自定义过滤器

final class UpperFilter extends php_user_filter
{
    public function filter($in, $out, &$consumed, bool $closing): int
    {
        while ($bucket = stream_bucket_make_writeable($in)) {
            $bucket->data = strtoupper($bucket->data);
            $consumed += $bucket->datalen;
            stream_bucket_append($out, $bucket);
        }
        return PSFS_PASS_ON;
    }
}

stream_filter_register('app.upper', UpperFilter::class);
$h = fopen('php://temp', 'r+');
stream_filter_append($h, 'app.upper', STREAM_FILTER_WRITE);
fwrite($h, 'hello');
rewind($h);
echo stream_get_contents($h);   // "HELLO"

4.3 过滤器链的顺序

stream_filter_append 可叠加多个过滤器,顺序即处理顺序。读流时过滤器从外到内、写流时从内到外,叠加顺序错了会得到乱码。

记忆:过滤器在流上做透明变换(压缩/编码/加密);用 stream_filter_append 叠加,注意读写方向与顺序。


5. 大文件流式读写

5.1 不要整块读

// 坏:1GB 文件一次性进内存
$data = file_get_contents('huge.log');    // 可能 OOM

// 好:分块读
$h = fopen('huge.log', 'rb');
try {
    while (!feof($h)) {
        $chunk = fread($h, 8192);          // 每次 8KB
        process($chunk);
    }
} finally {
    fclose($h);
}

5.2 按行读与生成器

按行处理文本时,fgets 配合生成器最省内存:

function lines(string $file): Generator
{
    $h = fopen($file, 'rb');
    try {
        while (($line = fgets($h)) !== false) {
            yield $line;
        }
    } finally {
        fclose($h);   // 即使提前 break 也会关闭
    }
}

finally 保证生成器被提前中断时句柄仍会关闭——这是生成器读文件必须养成的习惯。

5.3 直接回传大文件

下载大文件时不必读进 PHP,用 readfile / fpassthru 直接送内核缓冲:

// 下载:不占 PHP 内存
header('Content-Type: application/octet-stream');
header('Content-Length: ' . filesize($path));
readfile($path);   // 内核层面搬运

5.4 定位与随机读

大文件要「跳读」某段时用 fseek,避免从头发读:

$h = fopen('big.bin', 'rb');
fseek($h, 1024 * 1024, SEEK_SET);   // 跳到第 1MB
$block = fread($h, 512);

记忆:大文件用 fread 分块或 fgets + 生成器;下载用 readfile;随机读用 fseek——核心都是「别把整个文件装进内存」。


6. 文件锁与原子写

6.1 flock 排他锁

多进程同时写同一文件会互相覆盖。flock 提供建议性锁:

$h = fopen('counter.txt', 'c+');
if (flock($h, LOCK_EX)) {           // 排他锁,阻塞直到拿到
    $n = (int) stream_get_contents($h);
    rewind($h);
    ftruncate($h, 0);
    fwrite($h, (string) ($n + 1));
    fflush($h);
    flock($h, LOCK_UN);             // 释放
}
fclose($h);

LOCK_SH 是共享锁(多读),LOCK_EX 是排他锁(单写)。flock 是建议性的——不调用它的进程不受约束。

6.2 原子写:写临时文件再 rename

要「要么全成功、要么看不到半成品」,用「写临时文件 + 原子 rename」:

function atomicWrite(string $path, string $content): void
{
    $tmp = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
    file_put_contents($tmp, $content, LOCK_EX);
    rename($tmp, $path);   // 同一文件系统内 rename 是原子操作
}

rename 在同一文件系统内是原子的:读者要么看到旧文件、要么看到新文件,绝不会看到写了一半的内容。这是配置文件、缓存文件、状态文件写入的标准姿势。

6.3 锁与原子写的取舍

场景推荐
多进程计数/追加flock + fflush
整体替换文件内容临时文件 + rename
跨主机文件锁失效,用 Redis 分布式锁

跨主机时 flock 完全无效,必须换分布式锁(如 Redis)。

记忆:同机用 flock 排他锁;整体替换用「临时文件 + rename」保证原子;跨主机改用分布式锁。


7. 目录遍历与文件元数据

7.1 遍历目录

$it = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator('/app/src', FilesystemIterator::SKIP_DOTS),
    RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($it as $file) {
    if ($file->getExtension() !== 'php') continue;
    echo $file->getPathname(), PHP_EOL;
}

RecursiveDirectoryIterator 比手写 scandir 递归更省内存,且支持 FilesystemIterator 的过滤标志。

7.2 常用元数据函数

函数返回
filesize字节大小
filemtime修改时间(Unix 时间戳)
fileperms权限位
is_file / is_dir / is_readable类型与可读性
pathinfo路径拆分(dirname/basename/extension)
realpath规范化绝对路径

这些函数的结果会被 PHP 缓存(stat cache),同一请求内反复调用不会重复 stat,但文件在请求中被修改时读到的是缓存值——需要时用 clearstatcache() 清空。

7.3 路径安全

拼接用户输入到路径时,务必防目录穿越:

$base = '/app/storage/';
$name = basename($_GET['file']);            // 去掉 ../ 等
$path = realpath($base . $name);
if ($path === false || !str_starts_with($path, realpath($base))) {
    throw new \RuntimeException('非法路径');
}

记忆:遍历用 RecursiveDirectoryIterator;元数据函数有 stat 缓存;路径拼接必须 basename + realpath 前缀校验防穿越。


8. 性能与安全陷阱

8.1 常见陷阱

陷阱后果对策
file_get_contents 读大文件OOM分块或 readfile
忘记 fclose句柄泄漏try/finally 或生成器
启用 allow_url_include远程代码执行生产必须关闭
用户输入直接进 fopenSSRF/路径穿越白名单校验
phar:// 反序列化反序列化漏洞校验上传文件、禁用 phar 反序列化

8.2 allow_url_fopen / allow_url_include

allow_url_fopen = On     ; 允许 file_get_contents 读 URL(通常需要)
allow_url_include = Off  ; 生产必须关闭,否则 include 可执行远程代码

8.3 性能建议

  • 小文件用 file_get_contents(一次系统调用),大文件用流;
  • 频繁小文件读用 opcache 无关,但可用 APCu 缓存解析结果;
  • 批量文件操作考虑 DirectoryIterator 而非 glob(后者在超大目录下会构造完整数组);
  • 大目录用 scandir 会一次性返回全部条目,改用迭代器逐个处理。

记忆:生产关 allow_url_include;用户输入进路径必做白名单;大文件走流、小文件可整读;phar:// 要防反序列化。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. 图像与文档处理
  2. gRPC 与 Protobuf 服务
  3. 内存管理与垃圾回收