引言
fopen、file_get_contents、fwrite 这些函数你每天都在用,但很多人没意识到:它们操作的不是「文件」,而是流(Stream)。流是一层抽象——fopen('php://memory') 打开的是内存、fopen('http://...') 打开的是网络、fopen('s3://...') 打开的是对象存储,同一套读写 API 适用于所有后端。这就是 PHP 流封装器(stream wrapper)机制的威力。
本文从流的抽象讲起,拆解内置封装器与流上下文(context),教你写自定义 streamWrapper(把任意后端挂成「文件系统」),再讲流过滤器、大文件流式处理、文件锁与原子写这些生产必备技能。
前置阅读:文件上传与图片处理 、生成器、Fiber 与协程调度 。
目录
- 1. 流:统一的 IO 抽象
- 2. 内置封装器与流上下文
- 3. 自定义 streamWrapper
- 4. 流过滤器
- 5. 大文件流式读写
- 6. 文件锁与原子写
- 7. 目录遍历与文件元数据
- 8. 性能与安全陷阱
- 延伸阅读
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:// | FTP | ftp://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-decode | Base64 |
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 | 远程代码执行 | 生产必须关闭 |
用户输入直接进 fopen | SSRF/路径穿越 | 白名单校验 |
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://要防反序列化。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。