PHP 文件读写完全指南:从入门到实战(含并发安全与大文件处理)
本文基于 PHP 8.x 编写,所有示例兼容 PHP 7.4+。
涵盖:基础读写、文件指针、文件锁、目录操作、CSV/JSON/日志实战、原子写入、编码坑点与安全防护。
一、为什么要系统学文件读写
PHP 的文件操作函数多到让人眼花缭乱:file_get_contents、fopen、fread、fgets、file、readfile、fwrite、file_put_contents、SplFileObject......
实际开发中,90% 的需求只需要掌握 两组函数:
| 场景 | 首选方案 |
|---|---|
| 一次性读完 / 写完小文件 | file_get_contents() / file_put_contents() |
| 大文件、逐行、边读边处理 | fopen() + fgets() 或 SplFileObject |
| 需要精细控制(定位、加锁、追加) | fopen() + fseek() / flock() |
记住一句话:能用一行函数解决的,就别写五行;需要控制的,一定要手动管理句柄。
二、环境准备与权限基础
在写代码之前,先确认三件事,能帮你省掉 80% 的 "Permission denied":
bash
# 1. 查看运行 PHP 的用户(Web 场景通常是 www-data / nginx / apache)
ps aux | grep -E 'php-fpm|nginx|httpd'
# 2. 查看目录属主与权限
ls -ld /var/www/html/storage
# 3. 修正属主与权限(目录必须可执行 x,才能进入)
chown -R www-data:www-data /var/www/html/storage
chmod -R 755 /var/www/html/storage # 目录
chmod -R 644 /var/www/html/storage/* # 文件
推荐权限约定:
- 目录:
0755(必要时0775配合同组用户) - 普通文件:
0644 - 含敏感数据(配置、密钥):
0600 - 永远不要 图省事用
0777
PHP 侧确认配置:
php
<?php
var_dump([
'open_basedir' => ini_get('open_basedir') ?: '未限制',
'allow_url_fopen' => ini_get('allow_url_fopen'),
'default_charset' => ini_get('default_charset'),
'upload_max_filesize' => ini_get('upload_max_filesize'),
'post_max_size' => ini_get('post_max_size'),
'memory_limit' => ini_get('memory_limit'),
]);
open_basedir会把文件操作限制在指定目录树内,越界直接报错。生产环境建议开启。
三、快速上手:一行读、一行写
3.1 读文件
php
<?php
$file = __DIR__ . '/data/hello.txt';
// 读整个文件为字符串
$content = file_get_contents($file);
// 读整个文件为数组(按行拆分,保留每行末尾换行符)
$lines = file($file);
// 不保留换行符 + 跳过空行
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
// 直接输出到浏览器(不占用内存,适合下载)
readfile($file);
3.2 写文件
php
<?php
$file = __DIR__ . '/data/hello.txt';
// 覆盖写入(文件不存在会自动创建,目录不存在则失败)
$bytes = file_put_contents($file, 'Hello PHP');
// 追加写入 + 独占锁(写日志的标准姿势)
file_put_contents($file, "新的一行\n", FILE_APPEND | LOCK_EX);
echo "写入了 {$bytes} 字节";
file_put_contents() 的第三个参数(位掩码):
| 常量 | 作用 |
|---|---|
FILE_APPEND |
追加到文件末尾,不覆盖 |
LOCK_EX |
写入期间加独占锁,防止并发互相覆盖 |
FILE_USE_INCLUDE_PATH |
在 include_path 中查找文件 |
FILE_IGNORE_NEW_LINES |
(配合 file())去掉每行末尾换行 |
FILE_SKIP_EMPTY_LINES |
(配合 file())跳过空行 |
⚠️ 注意:
LOCK_EX不能 与FILE_APPEND之外的场景混用保证原子性;多进程高并发下更稳的做法见第 9 节的「临时文件 + rename」。
四、文件读取详解
4.1 fopen + fread:控制读取长度
php
<?php
$file = __DIR__ . '/data/big.log';
$fp = fopen($file, 'rb'); // 'rb' = 二进制只读,跨平台推荐
if ($fp === false) {
throw new RuntimeException("无法打开文件:{$file}");
}
$chunk = fread($fp, 4096); // 读取最多 4096 字节
fclose($fp);
fread() 的第二参数是「最多读取的字节数」,读到文件末尾(EOF)会返回少于该值的内容或空字符串。
4.2 逐行读取:fgets
php
<?php
$fp = fopen($file, 'rb');
if (!$fp) {
exit('打开失败');
}
while (($line = fgets($fp)) !== false) { // 注意:必须用 !== false
// $line 包含行尾换行符
echo rtrim($line, "\r\n"), PHP_EOL;
}
fclose($fp);
经典坑 :
while ($line = fgets($fp))在遇到空行或内容为"0"的行时会提前中断,必须写成!== false。
4.3 用生成器优雅处理大文件
内存占用恒定,适合上 GB 的日志文件:
php
<?php
function readLines(string $file): Generator
{
$fp = fopen($file, 'rb');
if ($fp === false) {
throw new RuntimeException("打开失败:{$file}");
}
try {
while (($line = fgets($fp)) !== false) {
yield rtrim($line, "\r\n");
}
} finally {
fclose($fp); // 即使中途 break 也会执行
}
}
// 使用:统计包含 ERROR 的行数
$count = 0;
foreach (readLines('/var/log/app.log') as $line) {
if (str_contains($line, 'ERROR')) {
$count++;
}
}
echo "ERROR 行数:{$count}";
4.4 SplFileObject:面向对象的现代写法
php
<?php
$file = new SplFileObject($filePath, 'rb');
// 方式一:直接迭代
foreach ($file as $line) {
echo $line;
}
// 方式二:逐行控制
while (!$file->eof()) {
echo $file->fgets();
}
// 方式三:随机跳行(底层用 fseek,比循环快得多)
$file->seek(9999); // 跳到第 10000 行
echo $file->current();
// CSV 模式(见第 8 节)
$file->setFlags(SplFileObject::READ_CSV);
4.5 其他读取方式对比
php
<?php
// 从当前位置读到结尾并输出(常用于文件下载)
$fp = fopen($file, 'rb');
fpassthru($fp);
fclose($fp);
// 读取远程/任意 stream(需 allow_url_fopen=On)
$json = file_get_contents('https://api.example.com/data', false, stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 5,
'header' => "Accept: application/json\r\n",
],
]));
// 读取请求体(比 $HTTP_RAW_POST_DATA 高效,且不受内存限制影响)
$raw = file_get_contents('php://input');
五、文件写入详解
5.1 fopen 模式速查表(务必收藏)
| 模式 | 读 | 写 | 文件不存在 | 文件已存在 | 指针位置 |
|---|---|---|---|---|---|
r |
✅ | ❌ | 报错 | 保留 | 开头 |
r+ |
✅ | ✅ | 报错 | 保留 | 开头 |
w |
❌ | ✅ | 创建 | 清空 | 开头 |
w+ |
✅ | ✅ | 创建 | 清空 | 开头 |
a |
❌ | ✅ | 创建 | 保留 | 末尾 |
a+ |
✅ | ✅ | 创建 | 保留 | 末尾(写)/ 开头(读) |
x |
❌ | ✅ | 创建 | 失败(安全创建) | 开头 |
x+ |
✅ | ✅ | 创建 | 失败 | 开头 |
c |
❌ | ✅ | 创建 | 保留(不清空) | 开头 |
c+ |
✅ | ✅ | 创建 | 保留(不清空) | 开头 |
模式修饰符:
b:二进制模式。Windows 下必须加 ,否则换行会被转换、二进制文件会损坏。建议养成习惯写成'rb'、'wb'、'ab'。t:文本模式,仅在 Windows 下把\n自动转成\r\n。
x模式是很多人的盲区:它能在「文件已存在」时直接失败,天然适合做互斥创建(如生成唯一导出文件、防止重复初始化)。
5.2 标准写入流程
php
<?php
$file = __DIR__ . '/data/report.txt';
$fp = fopen($file, 'wb'); // 覆盖写;用 'ab' 则是追加
if ($fp === false) {
throw new RuntimeException("无法写入:{$file}");
}
fwrite($fp, "第一行" . PHP_EOL);
fwrite($fp, "第二行" . PHP_EOL);
fflush($fp); // 强制把缓冲区刷到磁盘(加锁场景下必做)
fclose($fp); // 关闭句柄,自动释放锁
fwrite() 的别名是 fputs(),返回写入的字节数,失败返回 false。
5.3 换行符:PHP_EOL 还是 "\n"?
php
<?php
echo PHP_EOL; // Linux/macOS: "\n";Windows: "\r\n"
- 写给本机看的日志/缓存 :用
PHP_EOL,跨平台不出错。 - 写需要跨系统交换的数据 (CSV、配置、协议报文):明确写
"\n",避免 Windows 服务器写出\r\n造成解析差异。
5.4 目录不存在?先建目录
file_put_contents() / fopen('w') 只会创建文件,不会创建目录。
php
<?php
function ensureDir(string $dir, int $mode = 0755): void
{
if (!is_dir($dir) && !mkdir($dir, $mode, true) && !is_dir($dir)) {
throw new RuntimeException("目录创建失败:{$dir}");
}
}
ensureDir(__DIR__ . '/data/logs/2026');
file_put_contents(__DIR__ . '/data/logs/2026/app.log', "ok\n", FILE_APPEND | LOCK_EX);
mkdir()第三个参数true表示递归创建。注意mkdir后加is_dir二次判断,可规避并发下的竞态报错。
六、文件指针:fseek / ftell / rewind
php
<?php
$fp = fopen($file, 'rb');
echo ftell($fp); // 当前位置:0
fseek($fp, 100); // 从开头向后移动 100 字节
echo ftell($fp); // 100
fseek($fp, -50, SEEK_END); // 定位到倒数第 50 字节(读日志尾部神器)
fseek($fp, 20, SEEK_CUR); // 从当前位置再向后 20 字节
rewind($fp); // 回到开头,等价于 fseek($fp, 0)
echo ftell($fp); // 0
实战:只读文件最后 N 行(类似 tail -n 20)
php
<?php
function tail(string $file, int $lines = 20): array
{
$fp = fopen($file, 'rb');
if ($fp === false) {
return [];
}
$buffer = 4096;
$pos = -1;
$data = '';
$lineCount = 0;
fseek($fp, 0, SEEK_END);
$size = ftell($fp);
while ($size > 0 && $lineCount < $lines + 1) {
$read = min($buffer, $size);
fseek($fp, -$read, SEEK_CUR);
$data = fread($fp, $read) . $data;
fseek($fp, -$read, SEEK_CUR);
$size -= $read;
$lineCount = substr_count($data, "\n");
}
fclose($fp);
return array_slice(explode("\n", trim($data)), -$lines);
}
print_r(tail('/var/log/app.log', 10));
七、并发安全:文件锁 flock
多进程/多请求同时写一个文件,不加锁必然丢数据 。flock() 提供咨询锁(advisory lock):
| 常量 | 含义 |
|---|---|
LOCK_SH |
共享锁(读锁),多个进程可同时持有 |
LOCK_EX |
独占锁(写锁),同一时间只有一个进程 |
LOCK_UN |
释放锁 |
LOCK_NB |
非阻塞,拿不到锁立即返回 false(可与上面按位或) |
php
<?php
$fp = fopen($file, 'cb'); // 'c' 不清空,配合锁做「读-改-写」
if (!flock($fp, LOCK_EX)) { // 阻塞式等待写锁
fclose($fp);
throw new RuntimeException('获取锁失败');
}
$content = stream_get_contents($fp); // 读
$content .= "追加内容" . PHP_EOL;
rewind($fp); // 回到开头
ftruncate($fp, 0); // 清空(准备整体重写)
fwrite($fp, $content);
fflush($fp); // 先刷盘
flock($fp, LOCK_UN); // 再解锁
fclose($fp); // 最后关闭
非阻塞锁(拿不到就放弃,避免请求堆积)
php
<?php
$fp = fopen($file, 'cb');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
fclose($fp);
exit('系统繁忙,请稍后重试');
}
// ... 业务写入 ...
flock($fp, LOCK_UN);
fclose($fp);
flock 使用要点:
- 加锁必须在
fopen之后、fwrite之前; fflush()要在flock(LOCK_UN)之前调用;fclose()会自动释放锁,但显式解锁更清晰;LOCK_EX对以r只读模式打开的句柄无效;- 在 NFS / 部分分布式文件系统 上不可靠,此时应改用 Redis 锁或数据库锁;
- Windows 下
flock是强制锁,Linux 下是咨询锁(需所有写方都调用才有效)。
八、实战场景
8.1 计数器:读-改-写(必须加锁)
php
<?php
function bumpCounter(string $file): int
{
$fp = fopen($file, 'cb');
if ($fp === false) {
throw new RuntimeException("无法打开计数器:{$file}");
}
flock($fp, LOCK_EX);
$current = (int) stream_get_contents($fp);
$next = $current + 1;
rewind($fp);
ftruncate($fp, 0);
fwrite($fp, (string) $next);
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
return $next;
}
echo '你是第 ' . bumpCounter(__DIR__ . '/data/counter.txt') . ' 位访客';
8.2 日志写入(自动分目录 + 自动加锁)
php
<?php
function writeLog(string $message, string $level = 'INFO', string $baseDir = __DIR__ . '/logs'): void
{
$dir = $baseDir . '/' . date('Ym');
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
throw new RuntimeException("日志目录不可写:{$dir}");
}
$file = $dir . '/app-' . date('Y-m-d') . '.log';
$line = sprintf(
"[%s] %s: %s%s",
date('Y-m-d H:i:s'),
strtoupper($level),
$message,
PHP_EOL
);
file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
}
writeLog('用户登录成功', 'info');
writeLog('数据库连接超时', 'error');
8.3 CSV 读写(含中文 Excel 乱码解决方案)
写 CSV:
php
<?php
$list = [
['姓名', '年龄', '城市'],
['张三', 28, '北京'],
['李四', 32, '上海'],
];
$fp = fopen(__DIR__ . '/data/users.csv', 'wb');
// 关键:写入 UTF-8 BOM,否则 Excel 打开中文乱码
fwrite($fp, "\xEF\xBB\xBF");
foreach ($list as $row) {
fputcsv($fp, $row); // 自动处理逗号转义与引号包裹
}
fclose($fp);
读 CSV:
php
<?php
$fp = fopen(__DIR__ . '/data/users.csv', 'rb');
if ($fp === false) {
exit('文件不存在');
}
$bom = fread($fp, 3);
if ($bom !== "\xEF\xBB\xBF") {
rewind($fp); // 没有 BOM 就退回开头
}
$header = fgetcsv($fp);
while (($row = fgetcsv($fp)) !== false) {
// fgetcsv 返回 null 表示空行,false 表示 EOF
if ($row === null || $row === [null]) {
continue;
}
$user = array_combine($header, $row);
print_r($user);
}
fclose($fp);
中文编码坑 :如果 CSV 源是 GBK(常见于国内系统导出),读取前需转码:
$row = array_map(fn($v) => mb_convert_encoding($v, 'UTF-8', 'GBK'), $row);
8.4 JSON 配置文件读写(原子写入)
php
<?php
class ConfigStore
{
private string $file;
public function __construct(string $file)
{
$this->file = $file;
$dir = dirname($file);
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
throw new RuntimeException("目录不可用:{$dir}");
}
}
public function get(string $key, mixed $default = null): mixed
{
if (!is_file($this->file)) {
return $default;
}
$json = file_get_contents($this->file);
$data = json_decode($json, true);
return is_array($data) ? ($data[$key] ?? $default) : $default;
}
public function set(string $key, mixed $value): void
{
$fp = fopen($this->file, 'cb');
if ($fp === false || !flock($fp, LOCK_EX)) {
throw new RuntimeException('无法锁定配置文件');
}
$json = stream_get_contents($fp);
$data = json_decode($json, true);
if (!is_array($data)) {
$data = [];
}
$data[$key] = $value;
rewind($fp);
ftruncate($fp, 0);
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
}
}
$config = new ConfigStore(__DIR__ . '/data/config.json');
$config->set('site_name', '元宝的博客');
echo $config->get('site_name');
JSON_UNESCAPED_UNICODE 保证中文不被转义成 \uXXXX,JSON_PRETTY_PRINT 让配置可读。
8.5 简单文件缓存
php
<?php
class FileCache
{
private string $dir;
public function __construct(string $dir)
{
$this->dir = rtrim($dir, '/');
if (!is_dir($this->dir) && !mkdir($this->dir, 0755, true) && !is_dir($this->dir)) {
throw new RuntimeException("缓存目录不可用");
}
}
public function get(string $key, int $ttl = 3600): mixed
{
$file = $this->path($key);
if (!is_file($file) || filemtime($file) + $ttl < time()) {
return null; // 不存在或已过期
}
$data = unserialize(file_get_contents($file));
return $data === false ? null : $data;
}
public function set(string $key, mixed $value): bool
{
return file_put_contents(
$this->path($key),
serialize($value),
LOCK_EX
) !== false;
}
public function delete(string $key): bool
{
$file = $this->path($key);
return is_file($file) ? unlink($file) : true;
}
private function path(string $key): string
{
return $this->dir . '/' . md5($key) . '.cache';
}
}
$cache = new FileCache(__DIR__ . '/data/cache');
$cache->set('user_1', ['name' => '张三']);
print_r($cache->get('user_1'));
8.6 上传文件落盘
php
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['avatar'])) {
$f = $_FILES['avatar'];
if ($f['error'] !== UPLOAD_ERR_OK) {
exit('上传错误码:' . $f['error']);
}
if ($f['size'] > 2 * 1024 * 1024) {
exit('文件超过 2MB');
}
// 校验真实 MIME,别信 $_FILES['type']
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($f['tmp_name']);
$allow = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($mime, $allow, true)) {
exit('不允许的文件类型:' . $mime);
}
$dir = __DIR__ . '/uploads/' . date('Ym') . '/';
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
exit('目录创建失败');
}
// 关键:文件名自己生成,绝不使用用户传入的原始文件名
$target = $dir . bin2hex(random_bytes(16)) . '.' . ($mime === 'image/png' ? 'png' : 'jpg');
if (!move_uploaded_file($f['tmp_name'], $target)) {
exit('保存失败');
}
echo '上传成功:' . htmlspecialchars(basename($target));
}
html
<form method="post" enctype="multipart/form-data">
<input type="file" name="avatar">
<button type="submit">上传</button>
</form>
九、进阶:原子写入与大文件处理
9.1 原子写入(临时文件 + rename)
问题:进程写到一半崩溃或被并发读取,读方会拿到残缺内容。
方案 :写临时文件 → 落盘 → rename() 原子替换。同一文件系统内 rename 是原子操作。
php
<?php
function atomicWrite(string $file, string $content, int $mode = 0644): bool
{
$dir = dirname($file);
$tmp = tempnam($dir, 'tmp_'); // 在目标目录创建临时文件(保证同分区)
if ($tmp === false) {
return false;
}
$fp = fopen($tmp, 'wb');
if ($fp === false) {
unlink($tmp);
return false;
}
fwrite($fp, $content);
fflush($fp);
fclose($fp);
chmod($tmp, $mode);
if (!rename($tmp, $file)) { // 原子替换
unlink($tmp);
return false;
}
return true;
}
atomicWrite(__DIR__ . '/data/config.json', json_encode(['a' => 1]));
tempnam()必须在目标文件同一目录 下创建,否则跨文件系统rename会退化成「复制 + 删除」,失去原子性。
9.2 流式复制超大文件(不爆内存)
不要用 file_get_contents() 读 2GB 文件(memory_limit 直接爆掉),改用流:
php
<?php
function copyBigFile(string $from, string $to): bool
{
$in = fopen($from, 'rb');
if ($in === false) {
return false;
}
$out = fopen($to, 'wb');
if ($out === false) {
fclose($in);
return false;
}
while (!feof($in)) {
// 一次只搬 8MB,内存占用恒定
fwrite($out, fread($in, 8 * 1024 * 1024));
}
fclose($in);
fclose($out);
return true;
}
copyBigFile('/data/big.zip', '/backup/big.zip');
PHP 8 起还可以用 stream_copy_to_stream()(PHP 8.3 支持设置长度):
php
<?php
$bytes = stream_copy_to_stream($in, $out);
9.3 用 php://temp 暂存大内容
超过 2MB 自动落临时文件,避免撑爆内存:
php
<?php
$handle = fopen('php://temp/maxmemory:2097152', 'r+');
fwrite($handle, $hugeString);
rewind($handle);
echo stream_get_contents($handle);
fclose($handle);
十、目录操作与遍历
php
<?php
// 创建(递归)
mkdir('/path/to/deep/dir', 0755, true);
// 删除文件
unlink('/path/file.txt');
// 删除目录(必须为空)
rmdir('/path/empty_dir');
// 重命名 / 移动
rename('/path/a.txt', '/path/b.txt');
// 复制
copy('/path/a.txt', '/path/b.txt');
// 遍历目录
foreach (scandir('/path') as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
echo $entry, PHP_EOL;
}
// 通配符匹配
foreach (glob(__DIR__ . '/logs/*.log') as $logFile) {
echo $logFile, PHP_EOL;
}
递归遍历:RecursiveDirectoryIterator
php
<?php
function scanAll(string $dir): Generator
{
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
/** @var SplFileInfo $file */
if ($file->isFile()) {
yield $file->getPathname() => $file->getSize();
}
}
}
foreach (scanAll(__DIR__) as $path => $size) {
echo sprintf("%10d %s\n", $size, $path);
}
递归删除非空目录
php
<?php
function deleteDir(string $dir): bool
{
if (!is_dir($dir)) {
return false;
}
$items = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST // 先子后父
);
foreach ($items as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
return rmdir($dir);
}
十一、文件信息查询
php
<?php
$file = __DIR__ . '/data/hello.txt';
file_exists($file); // 文件或目录是否存在
is_file($file); // 是普通文件吗(会缓存结果)
is_dir($file); // 是目录吗
is_readable($file); // 可读
is_writable($file); // 可写
is_executable($file); // 可执行
filesize($file); // 字节数
filemtime($file); // 最后修改时间(Unix 时间戳)
filectime($file); // inode 变更时间
fileatime($file); // 最后访问时间
pathinfo($file); // ['dirname','basename','extension','filename']
basename($file); // hello.txt
dirname($file); // /path/data
stat($file); // 完整信息数组
filetype($file); // file / dir / link ...
realpath($file); // 解析为绝对路径(不存在返回 false)
获取 MIME 类型(推荐 finfo,不要用已废弃的 mime_content_type):
php
<?php
$finfo = new finfo(FILEINFO_MIME_TYPE);
echo $finfo->file($file); // image/png
⚠️
stat缓存坑 :PHP 会缓存filesize()/file_exists()/is_file()等结果。若同一次请求内文件被外部改动,需先调用clearstatcache()再查询。
php
<?php
clearstatcache(true, $file); // 清除指定文件的缓存
十二、字符编码与中文乱码
黄金法则:全链路统一 UTF-8。
- PHP 文件本身保存为 UTF-8 无 BOM;
- 响应头声明编码(HTML 场景):
php
<?php
header('Content-Type: text/html; charset=utf-8');
- 数据库 / 文件 统一 UTF-8(MySQL 用
utf8mb4); - 中文文件名 在 Windows 下需注意:PHP 使用 ANSI 编码调用文件系统 API,可用
iconv('UTF-8', 'GBK', $name)转换;Linux 下一般无此问题; - CSV 给 Excel :必须加 UTF-8 BOM(
\xEF\xBB\xBF); - 字符串长度 :中文用
mb_strlen()、mb_substr(),别用strlen()(按字节算)。
php
<?php
mb_internal_encoding('UTF-8');
echo mb_strlen('你好世界'); // 4
echo strlen('你好世界'); // 12(UTF-8 下 3 字节/字)
echo mb_substr('你好世界', 0, 2); // 你好
十三、安全要点(必读)
13.1 防路径穿越(Directory Traversal)
php
<?php
$base = realpath(__DIR__ . '/data');
$user = $_GET['file'] ?? '';
$path = realpath($base . '/' . $user);
// 双重校验:解析成功 + 前缀必须在 base 内
if ($path === false || !str_starts_with($path, $base . DIRECTORY_SEPARATOR)) {
exit('非法文件路径');
}
echo htmlspecialchars(file_get_contents($path));
只做
str_replace('../', '', $user)是不安全的(....//等变形可绕过),务必用realpath()+ 前缀校验。
13.2 文件名不要信用户输入
php
<?php
// ❌ 危险
$target = '/uploads/' . $_FILES['f']['name'];
// ✅ 安全:只保留安全字符 或 自行生成
$safe = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($_FILES['f']['name']));
$safe = bin2hex(random_bytes(8)) . '.' . pathinfo($_FILES['f']['name'], PATHINFO_EXTENSION);
13.3 其他安全建议
- 禁止 把用户上传目录纳入 PHP 解析范围(
include/require用户输入路径 = 任意代码执行); - 生产环境开启
open_basedir限制文件操作范围; - 敏感配置文件权限设为
0600,且不要放在 Web 根目录; - 上传目录禁止执行权限(Nginx 层 deny PHP 解析);
- 校验文件真实类型(
finfo),不要只看扩展名。
十四、常见错误与排查
| 错误信息 | 原因 | 解决办法 |
|---|---|---|
failed to open stream: No such file or directory |
路径错误,或目录不存在 | 用 __DIR__ 拼绝对路径;先 mkdir(..., 0755, true) |
failed to open stream: Permission denied |
运行用户无权限 | chown / chmod;检查 SELinux |
file_put_contents(): Failed to open stream |
同上,或 open_basedir 限制 |
检查 ini_get('open_basedir') |
fwrite(): supplied resource is not a valid stream resource |
fopen 失败后仍使用句柄 |
fopen 后必须判 === false |
| 中文内容乱码 | 编码不统一 | 全链路 UTF-8;CSV 加 BOM |
| 并发写入丢数据 | 未加锁 | LOCK_EX 或原子写入 |
filesize() 结果不准 |
stat 缓存 | 调用 clearstatcache() |
| 写入后文件为空 | 用了 w 模式又没写 / 未 fflush |
检查模式;fflush + fclose |
rename(): Permission denied |
跨文件系统或权限不足 | 保证临时文件与目标同目录 |
| 内存溢出读大文件 | file_get_contents 一次性载入 |
改用 fgets 逐行或 php://temp |
统一错误处理模板:
php
<?php
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) {
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});
try {
$content = file_get_contents('/path/maybe-missing.txt');
} catch (ErrorException $e) {
error_log($e->getMessage());
$content = ''; // 降级处理
} finally {
restore_error_handler();
}
十五、函数速查表
| 分类 | 函数 | 说明 |
|---|---|---|
| 一键读写 | file_get_contents() |
读整个文件为字符串 |
file_put_contents() |
写字符串到文件(支持 FILE_APPEND/LOCK_EX) |
|
file() |
读整个文件为行数组 | |
readfile() |
直接输出文件到输出缓冲 | |
| 句柄操作 | fopen() |
打开文件或 URL |
fclose() |
关闭句柄 | |
fread() |
读取指定字节 | |
fgets() |
读取一行 | |
fgetss() |
读一行并过滤 HTML(PHP 7.3 废弃,8.0 移除) | |
fwrite() / fputs() |
写入 | |
fflush() |
刷新缓冲到磁盘 | |
feof() |
是否到文件末尾 | |
fgetc() |
读取单个字符 | |
| 指针 | fseek() / ftell() / rewind() |
定位 / 查询 / 复位 |
ftruncate() |
截断文件到指定长度 | |
| 锁 | flock() |
咨询锁 |
| 信息 | file_exists() / is_file() / is_dir() |
存在性判断 |
is_readable() / is_writable() |
权限判断 | |
filesize() / filemtime() / filectime() |
大小与时间 | |
stat() / clearstatcache() |
详细信息与缓存清理 | |
finfo_file() |
MIME 类型探测 | |
| 路径 | basename() / dirname() / pathinfo() |
路径解析 |
realpath() |
规范化绝对路径 | |
| 目录 | mkdir() / rmdir() / scandir() / glob() |
创建/删除/遍历 |
| 其他 | copy() / rename() / unlink() |
复制/移动/删除 |
move_uploaded_file() |
安全保存上传文件 | |
tempnam() / tmpfile() |
临时文件 | |
stream_copy_to_stream() |
流到流复制(大文件) | |
| CSV | fgetcsv() / fputcsv() / str_getcsv() |
CSV 读写 |
| OOP | SplFileObject / SplFileInfo |
面向对象文件操作 |
RecursiveDirectoryIterator |
递归目录遍历 |
十六、总结与最佳实践清单
✅ 该做的:
- 小文件用
file_get_contents()/file_put_contents(),大文件用fopen()+ 逐行读; - 写日志统一
FILE_APPEND | LOCK_EX; - 重要配置走「临时文件 +
rename()」原子写入; - 中文 CSV 加 UTF-8 BOM;
- 用
__DIR__拼绝对路径,避免相对路径踩坑; fopen()后必判=== false,逐行读必写!== false;- 二进制文件用
'rb'/'wb'模式; - 句柄用完
fclose(),配合try...finally更稳; - 用户可控路径必须
realpath()+ 前缀校验; - 目录先
mkdir(..., 0755, true)再写文件。
❌ 不该做的:
- 不要用
0777权限蒙混过关; - 不要一次性
file_get_contents()读超大文件; - 不要相信
$_FILES['type']或用户传入的文件名; - 不要让用户可控路径进入
include/require; - 不要在 Linux 上依赖
flock处理 NFS 场景; - 不要忽略
stat缓存带来的脏数据。
附:一键可运行的综合示例
php
<?php
declare(strict_types=1);
final class FileManager
{
public static function read(string $path): string
{
if (!is_file($path) || !is_readable($path)) {
throw new RuntimeException("文件不存在或不可读:{$path}");
}
$content = file_get_contents($path);
if ($content === false) {
throw new RuntimeException("读取失败:{$path}");
}
return $content;
}
public static function write(string $path, string $content, bool $append = false): int
{
self::ensureDir(dirname($path));
$flags = $append ? (FILE_APPEND | LOCK_EX) : LOCK_EX;
$bytes = file_put_contents($path, $content, $flags);
if ($bytes === false) {
throw new RuntimeException("写入失败:{$path}");
}
return $bytes;
}
public static function lines(string $path): Generator
{
if (!is_file($path)) {
return;
}
$fp = fopen($path, 'rb');
if ($fp === false) {
throw new RuntimeException("打开失败:{$path}");
}
try {
while (($line = fgets($fp)) !== false) {
yield rtrim($line, "\r\n");
}
} finally {
fclose($fp);
}
}
public static function atomicWrite(string $path, string $content): void
{
self::ensureDir(dirname($path));
$tmp = tempnam(dirname($path), 'tmp_');
if ($tmp === false) {
throw new RuntimeException('临时文件创建失败');
}
file_put_contents($tmp, $content, LOCK_EX);
if (!rename($tmp, $path)) {
unlink($tmp);
throw new RuntimeException("原子写入失败:{$path}");
}
}
private static function ensureDir(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
throw new RuntimeException("目录不可用:{$dir}");
}
}
}
// 使用
FileManager::write(__DIR__ . '/data/demo.txt', "第一行\n第二行\n");
FileManager::write(__DIR__ . '/data/demo.txt', "第三行\n", true);
FileManager::atomicWrite(__DIR__ . '/data/config.json', json_encode(['ok' => true]));
foreach (FileManager::lines(__DIR__ . '/data/demo.txt') as $i => $line) {
echo ($i + 1) . ': ' . $line . PHP_EOL;
}
💡 延伸阅读建议 :
php://流包装器、Stream Context、SplFileObject的READ_CSV模式、League\Flysystem文件系统抽象层(适合需要切换本地/OSS/S3 的项目)。
如果本文对你有帮助,欢迎点赞收藏 🌹 有问题留言区交流~