☰
PHP写命令行脚本怎么传递参数
2026/10/3 3:51:56 网站建设 项目流程

前言

PHP 写 CLI(命令行接口,Command Line Interface)脚本,最让人抓狂的阶段往往是"参数收不到"。典型症状有三种:

第一种,脚本在终端里跑php sync.php --type=user,结果程序完全没反应,--type像是被空气吞了;第二种,参数放在前半段能收到,放在位置参数后面就收不到,比如php sync.php user --type=full里--type神秘失踪;第三种,函数里想读参数,写了$argv却提示未定义变量,明明在var_dump($argv)时代码还能跑。

这三种症状背后是三个不同的事实:PHP 的getopt()在遇到第一个非选项参数时就停止解析;$argv不是超全局变量,出了全局作用域就没了;而$argv本身只做"空格分词",不做任何选项语义解析。

本文把 PHP CLI 传参的三条路径——$argv/$argc、getopt()、标准输入(STDIN)——讲清楚,并给出一份可直接运行的完整脚本,涵盖短选项、长选项、可选值、位置参数、管道输入和退出码。示例最低要求 PHP 7.1(因为用到getopt()的第三个参数),在 PHP 8.x 上行为一致。

一、最底层的入口:$argv、$argc 与 $_SERVER

PHP 在 CLI SAPI(Server API)下会自动填充两个变量:$argc(参数个数)和$argv(参数数组)。规则非常简单:按空格切分,不做任何解析。

php report.php --type=user -v "hello world"

对应的$argv是:

[ 0 => 'report.php', // 脚本路径,永远在第 0 位 1 => '--type=user', 2 => '-v', 3 => 'hello world', // 带引号的部分被 shell 合并成一个参数 ]

$argc等于count($argv),即 4。这里有三条必须记住的性质:


  1. $argv[0]是脚本路径,不是第一个业务参数。写$argv[1]才拿到用户输入的第一个参数。用basename($argv[0])可以在 usage 提示里显示脚本名。

  2. $argv不是超全局变量。它只在全局作用域可用。在函数或类方法里必须global $argv;,或者更推荐的做法——用真正的超全局$_SERVER['argv'],它在任何作用域都能访问。

  3. PHP 不做任何解析。--type=user就是一个完整字符串,=两边不会自动拆开;-v也不会被理解成"verbose 开关"。要语义就得自己上getopt()或手写解析。


另外要区分 SAPI:$argv在 CLI 下总是可用(即使register_argc_argv关掉),但在 CGI/FPM 下,Web 请求的 query string 会被塞进$_SERVER['argv'](当register_argc_argv=On时),这就是历史上"Web 端也能读 argv"的怪现象。所以脚本开头加一句 SAPI 检查是稳妥的:

<?php declare(strict_types=1); if (PHP_SAPI !== 'cli') { fwrite(STDERR, "本脚本只能通过命令行运行\n"); exit(2); }

二、getopt():短选项与长选项的解析规则

getopt()的函数签名是:

getopt(string $short_options, array $long_options = [], int &$rest_index = null): array|false

短选项字符串用冒号表达"是否带值":

写法含义用法示例
f布尔开关,不带值-v
f:必须带值-f a.txt或-fa.txt
f::可选带值-f或-f a.txt

长选项用数组表达,规则相同:

写法含义用法示例
"verbose"布尔开关--verbose
"file:"必须带值--file=a.txt或--file a.txt
"output::"可选带值--output或--output=a.txt

返回值是一个关联数组;没带值的开关会返回false,所以判断开关是否出现要用array_key_exists(),不能用empty()——因为false和"不存在"在empty()眼里一样。

getopt()最坑的一条行为(官方文档明确写了):解析在遇到第一个非选项参数时结束,后面的内容一律丢弃。也就是:

php sync.php user --type=full # ^^^^ 第一个非选项参数,getopt 到这里就停了 # --type=full 被彻底忽略

从 PHP 7.1 起,getopt()的第三个参数$rest_index会收到"解析停在第几个位置",用它就能自己把剩下的位置参数捞回来:

$options = getopt('f:v', ['file:', 'verbose'], $restIndex); $positional = array_slice($argv, $restIndex); // 剩下的位置参数

getopt()还有一个返回值陷阱:解析失败时它返回false而不是空数组。所以判断必须写成if ($options === false),用if (!$options)会把"合法的空结果"一起判成失败。

三、标准输入与退出码:管道场景的正确姿势

CLI 脚本经常要进管道:cat ids.txt | php import.php --batch=100。这时参数走getopt(),数据走 STDIN。

CLI SAPI 会自动定义STDIN、STDOUT、STDERR三个常量(文件句柄),用法和fopen()返回的句柄一样:

// 一次读完整个 STDIN(适合小数据) $raw = stream_get_contents(STDIN); // 逐行读(适合大文件,内存占用恒定) while (($line = fgets(STDIN)) !== false) { $line = rtrim($line, "\r\n"); if ($line === '') { continue; } // 处理 $line }

三个约定必须守住:


  1. 业务数据只写 STDOUT,日志和进度写 STDERR。否则php export.php > data.csv导出的 CSV 里会混进"正在处理第 3 条"这类提示,下游解析直接崩。

  2. -作为文件名的约定:很多 Unix 工具用-表示"从 STDIN 读"。自己实现时保持一致,脚本就能自然融入管道。

  3. 退出码要有语义:0成功、1一般错误、2用法错误、其他值按业务自定义。用exit(1)显式设置,别让脚本"总是返回 0"——那样在 CI 或set -e的 shell 脚本里,失败会被当成成功。


四、实战:一份完整的 CLI 参数处理脚本

下面这份脚本可以直接保存为sync.php运行,覆盖短选项、长选项、可选值、位置参数、STDIN 管道和退出码:

#!/usr/bin/env php <?php declare(strict_types=1); // 最低要求:PHP 7.1(getopt 的 $rest_index 参数);在 PHP 8.x 上行为一致 if (PHP_SAPI !== 'cli') { fwrite(STDERR, "只能在 CLI 下运行\n"); exit(2); } /** * 用法提示统一走 STDERR,避免污染管道里的业务数据 */ function usage(string $script): void { $lines = [ "用法:php {$script} [选项] [目标...]", "选项:", " -f, --file=FILE 输入文件;写 - 表示从 STDIN 读", " -t, --type=TYPE 任务类型,默认 full", " -o, --output[=FILE] 输出文件,可省略值", " -v, --verbose 输出详细日志", " -h, --help 显示帮助", ]; fwrite(STDERR, implode(PHP_EOL, $lines) . PHP_EOL); exit(2); // 用法错误统一用退出码 2 } $script = basename($_SERVER['argv'][0] ?? 'sync.php'); // 1) 解析选项。遇到第一个非选项就停,并用 $restIndex 记住停在哪 $opts = getopt('f:t:o::vh', ['file:', 'type:', 'output::', 'verbose', 'help'], $restIndex); // getopt 失败返回 false,不是空数组 if ($opts === false) { usage($script); } // 2) 开关判断用 array_key_exists,不能用 empty(值可能是 false) if (array_key_exists('h', $opts) || array_key_exists('help', $opts)) { usage($script); } $verbose = array_key_exists('v', $opts) || array_key_exists('verbose', $opts); // 3) 取值时给默认值;短选项与长选项要一起看 $type = $opts['t'] ?? $opts['type'] ?? 'full'; $file = $opts['f'] ?? $opts['file'] ?? null; $output = $opts['o'] ?? $opts['output'] ?? null; // 4) 位置参数:第一个非选项之后的全部内容 $targets = array_slice($_SERVER['argv'], $restIndex); // 5) 可选值选项:没带值时是 false,用 === false 判断 $outputEnabled = ($output !== false); $outputFile = is_string($output) ? $output : 'php://stdout'; if ($file === null && $targets === [] && !$outputEnabled) { usage($script); } // 6) 数据来源:文件名、- 或没给时读 STDIN $lines = []; if ($file === null || $file === '-') { if (function_exists('stream_isatty') && stream_isatty(STDIN)) { fwrite(STDERR, "提示:未提供文件且 STDIN 是终端,跳过数据读取\n"); } else { while (($line = fgets(STDIN)) !== false) { $line = trim($line); if ($line !== '') { $lines[] = $line; } } } } else { if (!is_readable($file)) { fwrite(STDERR, "无法读取文件:{$file}\n"); exit(1); } $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; } if ($verbose) { fwrite(STDERR, sprintf( "[verbose] type=%s file=%s output=%s targets=%d stdin_lines=%d\n", $type, $file ?? '(stdin)', $outputEnabled ? $outputFile : '(off)', count($targets), count($lines) )); } // 7) 业务结果写 STDOUT $handle = $outputEnabled ? fopen($outputFile, 'wb') : fopen('php://stdout', 'wb'); if ($handle === false) { fwrite(STDERR, "无法打开输出:{$outputFile}\n"); exit(1); } foreach ($targets as $i => $target) { fwrite($handle, "{$type}\t{$target}\n"); } foreach ($lines as $line) { fwrite($handle, "data\t{$line}\n"); } if ($handle !== STDOUT) { fclose($handle); } exit(0); // 显式成功码

可以这样验证:

php sync.php -v --type=user alice bob printf 'id1\nid2\n' | php sync.php --file=- -o out.txt alpha php sync.php --help; echo "退出码=$?"

第二条命令会把alpha和两行 STDIN 数据一起写进out.txt,verbose 日志则出现在终端(STDERR)上,不会混进文件。

如果脚本选项继续膨胀(子命令、参数校验、自动补全),再考虑上 Symfony Console 这类组件;但在选项少于十个的场景,getopt()加上面这套骨架足够,而且零依赖。

常见坑点

1. 在函数里直接使用$argv

// ❌ $argv 不是超全局,函数里读到的是 null,报 "Undefined variable" function parseInput(): array { return array_slice($argv, 1); }
// ✅ 用超全局 $_SERVER['argv'],或把参数显式传进去 function parseInput(array $argv): array { return array_slice($argv, 1); } parseInput($_SERVER['argv']);

2. 把选项写在位置参数后面

# ❌ getopt 遇到 "user" 就停止解析,--type=full 被丢弃 php sync.php user --type=full
# ✅ 选项放前面;或者用 $rest_index 自己补捞后面的内容 php sync.php --type=full user

3. 用empty()判断开关是否出现

// ❌ 开关的值是 false,empty(false) 为 true,-v 被误判成没传 if (!empty($opts['v'])) { $verbose = true; }
// ✅ $verbose = array_key_exists('v', $opts);

4. 把f:写成f::,导致取值时拿到 false

// ❌ 可选值写法下,用户写 --file 不带值不会报错,$file 变成 false, // 后面 is_readable(false) 报错或悄悄读错文件 $opts = getopt('', ['file::']);
// ✅ 必填值就用单冒号,让 getopt 直接拒绝非法用法 $opts = getopt('', ['file:']);

5. 用!$opts判断 getopt 是否失败

// ❌ 用户一个选项都没传时 $opts 是空数组,被误判成解析失败 $opts = getopt('', ['verbose']); if (!$opts) { usage($script); }
// ✅ 失败只会返回 false $opts = getopt('', ['verbose']); if ($opts === false) { usage($script); }

6. 把日志打到 STDOUT,污染管道

# ❌ data.csv 里混进了进度信息,下游 csv 解析报错 php export.php > data.csv
// ✅ 日志走 STDERR,业务数据走 STDOUT fwrite(STDERR, "已处理 100 条\n"); fwrite(STDOUT, "{$row}\n");

7. 忘记设置退出码

# ❌ 脚本内部失败但返回 0,CI 与 set -e 都察觉不到 php import.php; echo $? # 0
// ✅ 失败路径显式给非零码 if (!is_readable($file)) { fwrite(STDERR, "文件不可读\n"); exit(1); }

8. 反复出现的开关想靠 getopt 计数

php build.php -v -v -v # 用户想表达"更啰嗦"
// ❌ getopt 不会累加,重复的短选项只保留最后一次,拿不到次数 $level = count((array)($opts['v'] ?? [])); // 恒为 0 或 1
// ✅ 想要"可叠加的详细度"就自己在解析后的原始 argv 里数,或用 -vvv 这种单参数约定 $level = 0; foreach ($_SERVER['argv'] as $arg) { if ($arg === '-v') { $level++; } } // 更稳的做法:显式提供 --verbose=1|2|3 这类带值选项

总结

场景推荐入口关键点
简单脚本,只要位置参数$_SERVER['argv']argv[0]是脚本名;$argv非超全局
需要-f/--file这类选项getopt()遇到第一个非选项即停止解析;失败返回false
需要选项后还有位置参数getopt()+$rest_indexPHP 7.1 起支持,用array_slice()捞回剩余
数据来自管道fgets(STDIN)业务数据走 STDOUT,日志走 STDERR
需要子命令、自动补全Symfony Console 等组件选项超过十个再考虑

一句话结论:PHP CLI 传参没有魔法,$argv只负责切分,getopt()只负责从前往后扫到第一个非选项为止。把"选项写前面、位置参数用$rest_index捞回、开关用array_key_exists判断、退出码显式设置"这四条当成规则,绝大多数"参数传不进去"的问题就消失了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询