PHP 文件
Reading, writing, existence checks, directory listing, and concurrent write hazards.
Reading and writing small files
<?php
$path = __DIR__ . "/demo.txt";
file_put_contents($path, "第一行\n第二行\n");
echo file_exists($path) ? "文件存在" : "文件不存在", "\n";
echo filesize($path), "\n";
echo file_get_contents($path);
unlink($path);
echo file_exists($path) ? "文件存在" : "文件不存在", "\n";文件存在
20
第一行
第二行
文件不存在filesize returns bytes: 9 + 1 + 9 + 1 = 20. Each Chinese character is 3 bytes in UTF-8.
Reading line by line
Logs and CSVs must not be slurped whole. fgets reads one line at a time with constant memory.
<?php
$path = __DIR__ . "/log.txt";
file_put_contents($path, "2023-11-15 INFO 启动\n2023-11-15 WARN 磁盘\n2023-11-15 ERROR 失败\n");
$fp = fopen($path, "r");
if ($fp === false) {
exit("打不开文件");
}
$count = 0;
while (($line = fgets($fp)) !== false) {
$line = trim($line);
if ($line === "") {
continue;
}
if (str_contains($line, "ERROR")) {
echo "命中: $line\n";
}
$count++;
}
fclose($fp);
echo "共 $count 行\n";
unlink($path);命中: 2023-11-15 ERROR 失败
共 3 行!== false in while (($line = fgets($fp)) !== false) must be strict — != can end the loop early on an empty line.Open modes
| Mode | Read | Write | On existing file |
|---|---|---|---|
| r | 是 | 否 | 从开头读 |
| r+ | 是 | 是 | 从开头读写,不截断 |
| w | 否 | 是 | 清空再写 |
| w+ | 是 | 是 | 清空再读写 |
| a | 否 | 是 | 追加到末尾 |
| a+ | 是 | 是 | 追加写,可读 |
| x | 否 | 是 | 文件已存在则失败 |
| b | — | — | 二进制模式,附加在末尾如 rb |
x creates exclusively and fails if the file exists — safer than check-then-open, which races.
Locked writes
Concurrent writes interleave and corrupt data. Use flock with an exclusive lock.
<?php
$path = __DIR__ . "/counter.txt";
file_put_contents($path, "0");
$fp = fopen($path, "r+");
if ($fp === false) {
exit("打不开");
}
if (flock($fp, LOCK_EX)) {
$n = (int)stream_get_contents($fp);
$n++;
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, (string)$n);
fflush($fp);
flock($fp, LOCK_UN);
}
fclose($fp);
echo file_get_contents($path), "\n";
unlink($path);1flock is unreliable on NFS and differs on Windows. Shared state like counters belongs in a database or Redis; file locks suit only low-concurrency local use.Directory operations
<?php
$dir = __DIR__ . "/tmpdir";
@mkdir($dir);
file_put_contents($dir . "/a.txt", "a");
file_put_contents($dir . "/b.txt", "b");
$items = scandir($dir);
print_r($items);
foreach (glob($dir . "/*.txt") as $f) {
echo basename($f), "\n";
}
array_map("unlink", glob($dir . "/*.txt"));
rmdir($dir);
echo is_dir($dir) ? "目录还在" : "目录已删", "\n";Array
(
[0] => .
[1] => ..
[2] => a.txt
[3] => b.txt
)
a.txt
b.txt
目录已删scandir includes . and .. — skip them. glob supports wildcards and suits extension filters.
Path safety
Anywhere users influence a path (download, preview, upload) is a directory-traversal hotspot. Block ?file=../../etc/passwd.
<?php
function safePath(string $base, string $name): ?string {
if (str_contains($name, "\0")) {
return null;
}
$real = realpath($base . "/" . $name);
if ($real === false) {
return null;
}
$baseReal = realpath($base);
if ($baseReal === false || !str_starts_with($real, $baseReal . DIRECTORY_SEPARATOR)) {
return null;
}
return $real;
}
var_dump(safePath(__DIR__, "demo.txt") !== null);
var_dump(safePath(__DIR__, "../etc/passwd"));
var_dump(safePath(__DIR__, "a/../../x"));bool(false)
NULL
NULLThe first is false because demo.txt does not exist. The key cases are two and three: traversal is blocked by the prefix check.
realpath() resolves symlinks, so a prefix check on the resolved path is sound. Stripping .. as a string is not — ....// slips through.读写小文件
<?php
$path = __DIR__ . "/demo.txt";
file_put_contents($path, "第一行\n第二行\n");
echo file_exists($path) ? "文件存在" : "文件不存在", "\n";
echo filesize($path), "\n";
echo file_get_contents($path);
unlink($path);
echo file_exists($path) ? "文件存在" : "文件不存在", "\n";文件存在
20
第一行
第二行
文件不存在filesize 返回字节数:第一行 9 字节 + 换行 1 + 第二行 9 字节 + 换行 1 = 20。中文按 UTF-8 算 3 字节一个字。
按行读取
日志、CSV 这类大文件不能整个读进内存。fgets 一次读一行,内存恒定。
<?php
$path = __DIR__ . "/log.txt";
file_put_contents($path, "2023-11-15 INFO 启动\n2023-11-15 WARN 磁盘\n2023-11-15 ERROR 失败\n");
$fp = fopen($path, "r");
if ($fp === false) {
exit("打不开文件");
}
$count = 0;
while (($line = fgets($fp)) !== false) {
$line = trim($line);
if ($line === "") {
continue;
}
if (str_contains($line, "ERROR")) {
echo "命中: $line\n";
}
$count++;
}
fclose($fp);
echo "共 $count 行\n";
unlink($path);命中: 2023-11-15 ERROR 失败
共 3 行while (($line = fgets($fp)) !== false) 里的 !== false **不能写成 !=**。读到空行时 fgets 返回 "\n",松散比较下可能被判为假,导致提前结束。打开模式
| 模式 | 读 | 写 | 文件已存在时 |
|---|---|---|---|
| r | 是 | 否 | 从开头读 |
| r+ | 是 | 是 | 从开头读写,不截断 |
| w | 否 | 是 | 清空再写 |
| w+ | 是 | 是 | 清空再读写 |
| a | 否 | 是 | 追加到末尾 |
| a+ | 是 | 是 | 追加写,可读 |
| x | 否 | 是 | 文件已存在则失败 |
| b | — | — | 二进制模式,附加在末尾如 rb |
x 模式是「独占创建」,文件已存在就返回 false。做锁文件、临时文件时用它比「先 file_exists 再 fopen」可靠——后者存在竞态。
加锁写入
多个请求同时写同一个文件,内容会互相覆盖、出现半截数据。用 flock 加排他锁。
<?php
$path = __DIR__ . "/counter.txt";
file_put_contents($path, "0");
$fp = fopen($path, "r+");
if ($fp === false) {
exit("打不开");
}
if (flock($fp, LOCK_EX)) {
$n = (int)stream_get_contents($fp);
$n++;
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, (string)$n);
fflush($fp);
flock($fp, LOCK_UN);
}
fclose($fp);
echo file_get_contents($path), "\n";
unlink($path);1flock 在 NFS 上不可靠,在 Windows 上语义也有差异。计数器、队列这类共享状态应该放数据库或 Redis,文件锁只适合低并发的本地场景。目录操作
<?php
$dir = __DIR__ . "/tmpdir";
@mkdir($dir);
file_put_contents($dir . "/a.txt", "a");
file_put_contents($dir . "/b.txt", "b");
$items = scandir($dir);
print_r($items);
foreach (glob($dir . "/*.txt") as $f) {
echo basename($f), "\n";
}
array_map("unlink", glob($dir . "/*.txt"));
rmdir($dir);
echo is_dir($dir) ? "目录还在" : "目录已删", "\n";Array
(
[0] => .
[1] => ..
[2] => a.txt
[3] => b.txt
)
a.txt
b.txt
目录已删scandir 结果包含 . 和 ..,遍历时要跳过。glob 支持通配符,更适合按扩展名筛选。
路径安全
用户能影响文件路径的地方(下载、预览、上传)是目录穿越的重灾区。?file=../../etc/passwd 这种请求必须挡住。
<?php
function safePath(string $base, string $name): ?string {
if (str_contains($name, "\0")) {
return null;
}
$real = realpath($base . "/" . $name);
if ($real === false) {
return null;
}
$baseReal = realpath($base);
if ($baseReal === false || !str_starts_with($real, $baseReal . DIRECTORY_SEPARATOR)) {
return null;
}
return $real;
}
var_dump(safePath(__DIR__, "demo.txt") !== null);
var_dump(safePath(__DIR__, "../etc/passwd"));
var_dump(safePath(__DIR__, "a/../../x"));bool(false)
NULL
NULL第一条返回 false 是因为 demo.txt 不存在(realpath 对不存在的路径返回 false)。关键在第二三条:.. 穿越被 str_starts_with 拦住了。
realpath() 会把符号链接解析掉,所以用「解析后必须以基目录开头」判断是可靠的。只做字符串替换(删掉 ..)是不够的——....// 这类变形能绕过。