前言
fgets()是 PHP 文件系统函数族里最常用的「按行读取」函数,它从文件指针(file pointer)里读出一行并返回。很多人第一次用它时会带着两个想当然的假设:以为它像file_get_contents()一样能把整个文件读进来,或者以为它返回的内容里已经不含换行符了。事实上它每次只读一行,而且把行尾的换行符原样保留在返回值里——这两点决定了它后面几乎所有用法。
另一个高频误解是把fgets()返回的false一律理解成「文件结束」。PHP 官方手册的说明是:文件指针里没有更多数据时返回false,发生错误时同样返回false。两种情况的返回值完全一样,必须借助feof()才能区分。这正是很多「文件读到一半就悄悄停住、还不报错」的诡异 bug 的根源。
本文按官方手册讲清fgets()的签名与length参数的确切语义,说明它和fread()、fgetc()、file()、stream_get_line()各自的分工,并给出逐行处理大文件时真正安全的写法。
一、函数签名与参数语义
以 PHP 官方手册为准,当前签名是:
fgets(resource $stream, ?int $length = null): string | false
三个要点:
- 第一个参数是流资源,不是文件名字符串。必须先用
fopen()、fsockopen()、popen()之类得到一个有效的、尚未被fclose()关闭的流资源。直接把路径传给fgets()会得到TypeError。
length是「上限」而不是「目标长度」。手册原文:读取在「已读满length - 1字节」「遇到换行符(换行符会被包含在返回值中)」「遇到 EOF」三者中最先发生的那一个时结束。注意是length - 1,因为它要为末尾的终止符留位置。
length是可选的,当前签名里它的类型可空(?int)。省略它或传null时,会一直读到行尾为止,没有上限。老代码里常见的fgets($fp, 4096)是「最多读 4095 字节」的意思。
fgets($fp)(省略length) | 读到换行符或 EOF,长度不设上限 |
fgets($fp, 4096) | 最多读 4095 字节;超长行会被切成多段返回 |
fgets($fp, 2) | 每次最多 1 字节,退化成逐字节读取,很慢 |
length传0或负数在历史版本上的语义并不一致,官方也没有把它们写成受支持的用法,正确做法就是要么省略、要么传一个正常的正整数。
二、返回值:false的两种含义
这是fgets()最容易踩的地方。手册写得很清楚:
- 文件指针里没有更多数据可读时,返回
false;
- 发生错误时,也返回
false。
也就是说仅凭返回值无法判断是「正常读完了」还是「出错了」。正确姿势是循环结束后用feof()复核一次:
<?php // 适用于 PHP 8.0+
$fp = fopen(__DIR__ . '/input.txt', 'rb');
if ($fp === false) {
exit("打开文件失败\n");
}
$lines = 0;
while (($line = fgets($fp)) !== false) {
$lines++;
}
fclose($fp);
if (!feof($fp)) {
// 走到这里说明 fgets() 是因为出错才返回 false 的
exit("读取过程中发生错误\n");
}
echo "共读取 {$lines} 行\n";
另外要记住:fgets()不会剥离换行符。除了文件最后一行可能没有换行符以外,其余每行返回值末尾都带着\n(Windows 上文本文件里是\r\n)。所以做等值比较前必须自己裁剪:
$line = rtrim($line, "\r\n");
三、和其他读取函数的分工
PHP 里读文件的手段不止一个,选错了不是「跑不动」,而是「数据被悄悄切碎」:
fgets() | 一行(可设上限) | 保留 | 逐行处理大文件、按行读 socket |
fread() | 指定字节数 | 不涉及 | 二进制数据、固定长度块 |
stream_get_line() | 到指定分隔符 | 分隔符被去掉 | 自定义分隔符(如\0) |
file() | 整个文件按行拆开 | 保留(可用FILE_IGNORE_NEW_LINES去掉) | 小文件一次性读成数组 |
file_get_contents() | 整个文件 | 不涉及 | 小文件整体读取 |
判断标准很简单:文件大小是否可控。如果文件可能是几百 MB 的日志,唯一合理的做法就是fgets()(或fread())配合流式处理;file()和file_get_contents()会把整个文件塞进内存。
四、实战示例
1. 逐行读取并裁剪
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
$path = __DIR__ . '/access.log';
$fp = fopen($path, 'rb');
if ($fp === false) {
fwrite(STDERR, "无法打开文件: {$path}" . PHP_EOL);
exit(1);
}
$lineNo = 0;
$bytes = 0;
while (($line = fgets($fp, 8192)) !== false) {
$lineNo++;
$bytes += strlen($line);
// fgets() 保留了行尾换行,比较前先裁掉
echo $lineNo . ': ' . rtrim($line, "\r\n") . PHP_EOL;
}
if (!feof($fp)) {
fwrite(STDERR, '读取过程中出现错误' . PHP_EOL);
}
fclose($fp);
echo "共 {$lineNo} 行,累计 {$bytes} 字节" . PHP_EOL;
这段代码无论日志多大,内存占用都是恒定的:每一轮循环只保留一行(最多 8191 字节)。
2. 处理超长行:分段拼接回完整的行
给length设上限以后,一个超过上限的长行会被切成多段返回。想「既限制单次读取的字节数,又拿到完整的行」,就自己判断是否读到换行符再来拼接:
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
function processLine(string $line): void
{
echo '行长度: ' . strlen(rtrim($line, "\r\n")) . PHP_EOL;
}
$fp = fopen(__DIR__ . '/data.txt', 'rb');
$buffer = '';
while (($part = fgets($fp, 8192)) !== false) {
$buffer .= $part;
if (str_ends_with($part, "\n")) {
processLine($buffer); // 读满一整行才处理
$buffer = '';
}
}
if ($buffer !== '') {
processLine($buffer); // 文件最后一行可能没有换行符
}
fclose($fp);
str_ends_with()是 PHP 8.0 才有的函数;在 PHP 7.x 上可以用substr($part, -1) === "\n"代替。
3. 从标准输入读数据
fgets()不限于文件,php://stdin也是合法的流。写命令行小工具时很常用:
<?php // 适用于 PHP 8.0+
$stdin = fopen('php://stdin', 'r');
if ($stdin === false) {
exit("无法打开标准输入\n");
}
while (($line = fgets($stdin)) !== false) {
$line = rtrim($line, "\r\n");
if ($line === '') {
continue;
}
echo strtoupper($line), PHP_EOL;
}
fclose($stdin);
直接运行echo "hello" | php demo.php就能看到输出。
常见坑点
- ❌ 用
feof()当循环条件:while (!feof($fp)) { $line = fgets($fp); echo $line; }——feof()只有在读操作撞到文件末尾之后才为真,所以循环会多跑一轮,把false当字符串输出;如果文件本身就是空的,循环体还会先执行一次。✅while (($line = fgets($fp)) !== false) { ... }。
- ❌ 用松散比较判断结束:
while ($line = fgets($fp))或if (!$line)—— 内容为"0\n"的一行会被 PHP 的松散比较判成假值,循环提前退出,数据静默丢失。✅ 一律用恒等比较!== false。
- ❌ 以为
fgets()会自动去掉换行 —— 结果字符串里"\n"或"\r\n"都还在,$line === 'admin'这类判断永远不成立。✅rtrim($line, "\r\n");如果连行首行尾的空格也想去掉再用trim()。
- ❌ 用
fgets()读二进制文件(图片、压缩包、pack()出来的数据)—— 二进制里任何一个0x0A字节都会被当成行结束,数据被切得乱七八糟。✅ 二进制用fread()或stream_get_contents()。
- ❌ 不加
length去读机器生成的日志 —— 只要文件里有一行没有换行符(比如某个程序把整个请求体当一行写进去),fgets()就会一路读到内存耗尽。✅ 传入上限(如 8192),超长行自己拼接。
- ❌ 继续用
fgetss()去掉行里的 HTML 标签 —— 这个函数已在PHP 8.0.0 中被移除。✅ 用strip_tags(fgets($fp));更好的做法是根本不要在读取阶段「清洗」HTML,输出时再按上下文转义。
- ❌ 依赖
auto_detect_line_endings这个 INI 选项去识别老式 Mac 的\r换行 —— 该选项自PHP 8.1.0 起废弃。✅ 自己处理:读回来统一rtrim($line, "\r\n"),或把\r也当作行分隔符处理。
- ❌ 忘了
fclose(),或者用file_get_contents()读 1 GB 的日志 —— 前者会一直占着文件句柄(Windows 上还会导致该文件无法被删除或重命名),后者直接把整个文件搬进内存。✅ 读完立即fclose()(或用try/finally保证执行),大文件一律走fgets()流式处理。
总结
| 签名 | fgets(resource $stream, ?int $length = null),返回字符串或false |
length语义 | 最多读length - 1字节;省略或传null表示不设上限 |
| 换行符 | 保留在返回值里,比较前必须自己rtrim() |
| 结束判断 | 用!== false,不要用!feof()或松散比较 |
| EOF 与错误 | 都返回false,靠循环结束后的feof()区分 |
| 适用场景 | 大文件逐行处理、socket 按行读、标准输入 |
一句话总结:fgets()的核心就是「一行、带换行符、可能失败」这三件事,只要把length当作上限、把false当两种含义处理、并且记得自己裁剪换行符,它就是一个既省内存又稳定的读取手段。