PHP文件读写完全指南

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 使用要点:

  1. 加锁必须在 fopen 之后、fwrite 之前;
  2. fflush() 要在 flock(LOCK_UN) 之前调用;
  3. fclose() 会自动释放锁,但显式解锁更清晰;
  4. LOCK_EX 对以 r 只读模式打开的句柄无效;
  5. 在 NFS / 部分分布式文件系统 上不可靠,此时应改用 Redis 锁或数据库锁;
  6. 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。

  1. PHP 文件本身保存为 UTF-8 无 BOM;
  2. 响应头声明编码(HTML 场景):
php 复制代码
<?php
header('Content-Type: text/html; charset=utf-8');
  1. 数据库 / 文件 统一 UTF-8(MySQL 用 utf8mb4);
  2. 中文文件名 在 Windows 下需注意:PHP 使用 ANSI 编码调用文件系统 API,可用 iconv('UTF-8', 'GBK', $name) 转换;Linux 下一般无此问题;
  3. CSV 给 Excel :必须加 UTF-8 BOM(\xEF\xBB\xBF);
  4. 字符串长度 :中文用 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 递归目录遍历

十六、总结与最佳实践清单

✅ 该做的:

  1. 小文件用 file_get_contents() / file_put_contents(),大文件用 fopen() + 逐行读;
  2. 写日志统一 FILE_APPEND | LOCK_EX;
  3. 重要配置走「临时文件 + rename()」原子写入;
  4. 中文 CSV 加 UTF-8 BOM;
  5. 用 __DIR__ 拼绝对路径,避免相对路径踩坑;
  6. fopen() 后必判 === false,逐行读必写 !== false;
  7. 二进制文件用 'rb' / 'wb' 模式;
  8. 句柄用完 fclose(),配合 try...finally 更稳;
  9. 用户可控路径必须 realpath() + 前缀校验;
  10. 目录先 mkdir(..., 0755, true) 再写文件。

❌ 不该做的:

  1. 不要用 0777 权限蒙混过关;
  2. 不要一次性 file_get_contents() 读超大文件;
  3. 不要相信 $_FILES['type'] 或用户传入的文件名;
  4. 不要让用户可控路径进入 include / require;
  5. 不要在 Linux 上依赖 flock 处理 NFS 场景;
  6. 不要忽略 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 的项目)。

如果本文对你有帮助,欢迎点赞收藏 🌹 有问题留言区交流~

相关推荐
坤坤子吖1 小时前
Linux网络编程:TCP服务器的多进程与多线程实现
linux·服务器·开发语言·网络·c++·笔记·学习
一晌小贪欢2 小时前
Python基础:第 09 篇 · while 循环:重复直到不满足
开发语言·c++·python·数据可视化·面向对象·python办公
xiaoye-duck2 小时前
《C++ 项目:手写大模型接入 SDK》LLMManager架构设计与实现
开发语言·网络·c++·语言模型
一条大祥脚3 小时前
CuTe 与 CuTe DSL:从 C++ 模板元编程到 Python DSL 的 GPU 编程演进
开发语言·c++·python·cutlass·cute·cute dsl
Martina_03213 小时前
AI生成3D场景从白模换成精模后镜头全偏了?用6步检查单位、枢轴与层级替换
图像处理·人工智能·3d·ai·自然语言处理·材质·1024程序员节
FRing033 小时前
vue2干货总结8(路由模块封装、router-link、声明式导航、路由重定向、编程式导航)
开发语言·前端·vue.js·vue2
旖旎夜光3 小时前
【C++ 基础入门】C++ 多态(上):虚函数、重写、override/final 与抽象类
开发语言·c++·笔记·学习
starzy19904 小时前
深入理解Scala嵌套函数:作用域封装之道
开发语言·scala
Sakura_j_h_4 小时前
从零写一个 MCP stdio Server:用 Python 校验博客文章格式
开发语言·python·自动化·ai工具·mcp·json-rpc