3、标准输入输出:STDIN、STDOUT、STDERR的概念与使用、文件描述符操作、实战:交互式命令行工具
聊到命令行工具开发,有三个东西你绕不开——STDIN、STDOUT、STDERR。
说白了,它们就是程序跟外界打交道的三个通道。我刚开始写PHP脚本时,总觉得这玩意儿太基础,不就是打印个东西嘛。直到有一次写了个批量处理脚本,日志和错误混在一起,排查问题排查到崩溃……嗯,从那以后我才真正重视这三个流。
3.1 三个标准流:它们到底是什么?
每个PHP进程启动时,系统会自动给它打开三个文件描述符:
| 名称 | 文件描述符编号 | 默认指向 | 用途 |
|---|---|---|---|
| STDIN | 0 | 键盘输入 | 读取用户输入或管道数据 |
| STDOUT | 1 | 终端屏幕 | 输出正常结果 |
| STDERR | 2 | 终端屏幕 | 输出错误信息 |
你想想看,这三个流本质上就是三个文件句柄。PHP提供了三个预定义常量来操作它们:
STDIN—— 对应fopen('php://stdin', 'r')STDOUT—— 对应fopen('php://stdout', 'w')STDERR—— 对应fopen('php://stderr', 'w')
核心区别:STDOUT和STDERR都输出到屏幕,但它们是独立的流。重定向时,你可以把正常输出和错误输出分开处理。
3.2 实战操作:怎么用这三个流?
先看一个最简单的例子:
#!/usr/bin/env php
<?php
// 写入标准输出
fwrite(STDOUT, "这是一条正常消息\n");
// 写入标准错误
fwrite(STDERR, "这是一条错误消息\n");
// 从标准输入读取
echo "请输入你的名字:";
$name = trim(fgets(STDIN));
fwrite(STDOUT, "你好,{$name}!\n");
运行这个脚本,你会看到两条消息都显示在屏幕上。但如果你用重定向:
$ php script.php > output.txt
$ cat output.txt
这是一条正常消息
你好,张三!
看到了吗?错误消息没有进文件,它直接打印到屏幕上了。这就是STDERR的妙用。
我的习惯:所有调试信息、警告、错误一律走STDERR。这样用户重定向输出时,不会把垃圾信息混进结果文件里。
3.3 文件描述符操作:不只是读写那么简单
PHP里操作文件描述符,最常用的就是fopen()配合php://协议。我个人项目中经常这样用:
<?php
// 打开标准输入(只读)
$stdin = fopen('php://stdin', 'r');
// 打开标准输出(只写)
$stdout = fopen('php://stdout', 'w');
// 打开标准错误(只写)
$stderr = fopen('php://stderr', 'w');
// 使用示例
fwrite($stdout, "正常输出\n");
fwrite($stderr, "错误输出\n");
// 读取一行输入
$line = fgets($stdin);
为什么要用fopen()而不是直接用常量?因为有时候你需要更精细的控制,比如设置流上下文、非阻塞读取等。
我曾经踩过的坑:在循环里直接使用fgets(STDIN)而不检查返回值。如果管道关闭了,fgets()会返回false,导致死循环。一定要加判断:
while (($line = fgets(STDIN)) !== false) {
// 处理输入
}
3.4 实战:构建一个交互式命令行工具
光说不练假把式。我们来写一个简单的交互式任务管理器。这个工具支持添加任务、列出任务、标记完成。
#!/usr/bin/env php
<?php
class TaskManager
{
private array $tasks = [];
private string $dataFile;
public function __construct(string $dataFile = 'tasks.json')
{
$this->dataFile = $dataFile;
$this->loadTasks();
}
public function run(): void
{
fwrite(STDOUT, "=== 简易任务管理器 ===\n");
while (true) {
fwrite(STDOUT, "\n请选择操作:\n");
fwrite(STDOUT, "1. 添加任务\n");
fwrite(STDOUT, "2. 列出任务\n");
fwrite(STDOUT, "3. 标记完成\n");
fwrite(STDOUT, "4. 退出\n");
fwrite(STDOUT, "请输入选项 [1-4]: ");
$choice = trim(fgets(STDIN));
switch ($choice) {
case '1':
$this->addTask();
break;
case '2':
$this->listTasks();
break;
case '3':
$this->completeTask();
break;
case '4':
fwrite(STDOUT, "再见!\n");
exit(0);
default:
fwrite(STDERR, "无效选项,请重新输入\n");
}
}
}
private function addTask(): void
{
fwrite(STDOUT, "请输入任务描述:");
$description = trim(fgets(STDIN));
if (empty($description)) {
fwrite(STDERR, "任务描述不能为空\n");
return;
}
$this->tasks[] = [
'id' => count($this->tasks) + 1,
'description' => $description,
'completed' => false,
'created_at' => date('Y-m-d H:i:s')
];
$this->saveTasks();
fwrite(STDOUT, "任务添加成功!\n");
}
private function listTasks(): void
{
if (empty($this->tasks)) {
fwrite(STDOUT, "暂无任务\n");
return;
}
fwrite(STDOUT, "\n当前任务列表:\n");
fwrite(STDOUT, str_repeat('-', 50) . "\n");
foreach ($this->tasks as $task) {
$status = $task['completed'] ? '✅' : '⬜';
fwrite(STDOUT, sprintf(
"%s [%d] %s (创建于: %s)\n",
$status,
$task['id'],
$task['description'],
$task['created_at']
));
}
}
private function completeTask(): void
{
fwrite(STDOUT, "请输入要标记完成的任务ID:");
$id = (int) trim(fgets(STDIN));
foreach ($this->tasks as &$task) {
if ($task['id'] === $id) {
$task['completed'] = true;
$this->saveTasks();
fwrite(STDOUT, "任务 #{$id} 已标记完成!\n");
return;
}
}
fwrite(STDERR, "未找到ID为 {$id} 的任务\n");
}
private function loadTasks(): void
{
if (file_exists($this->dataFile)) {
$data = file_get_contents($this->dataFile);
$this->tasks = json_decode($data, true) ?? [];
}
}
private function saveTasks(): void
{
file_put_contents(
$this->dataFile,
json_encode($this->tasks, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
}
}
// 启动交互式工具
$manager = new TaskManager();
$manager->run();
这个例子展示了几个关键点:
- STDIN 读取用户的选择和输入
- STDOUT 输出菜单和正常信息
- STDERR 输出错误提示(比如无效选项、空描述)
- 使用
exit(0)正常退出,非零值表示异常退出
我建议:所有用户交互的提示信息走STDOUT,所有校验失败、异常情况走STDERR。这样如果别人把你的工具集成到脚本里,可以通过重定向轻松分离正常输出和错误日志。
3.5 重定向实战:用好这三个流
在Shell中,你可以这样玩:
# 只保存正常输出
$ php task.php > output.log
# 只保存错误输出
$ php task.php 2> error.log
# 分别保存
$ php task.php > output.log 2> error.log
# 合并到一个文件
$ php task.php > all.log 2>&1
# 丢弃错误输出
$ php task.php 2> /dev/null
你想想看,如果你的工具把所有输出都混在一起,用户想提取正常结果就得手动过滤,多麻烦。
核心原则:
- 正常结果 → STDOUT
- 错误/警告/调试 → STDERR
- 用户输入 → STDIN
坚持这个原则,你的工具才能被优雅地集成到Unix管道和脚本中。
3.6 避坑指南
最后分享几个我踩过的坑:
- 缓冲区问题:在交互式工具中,记得调用
ob_implicit_flush(true)或ob_end_flush(),否则用户可能看不到提示信息。 - 编码问题:从STDIN读取中文时,确保终端和PHP脚本编码一致。我习惯在脚本开头加
setlocale(LC_ALL, 'zh_CN.UTF-8'); - 非阻塞读取:如果需要实现超时检测,可以用
stream_set_blocking(STDIN, false),但要注意这会改变读取行为。 - 管道模式:当输入来自管道时,
fgets(STDIN)会在管道关闭时返回false,务必检查返回值。
嗯,标准输入输出这块儿,说白了就是三个文件描述符的事儿。但用好它们,你的命令行工具会专业一大截。下一章我们聊聊参数解析,到时候你会看到这些流怎么跟参数配合,做出更强大的工具。