使用 PHP 构建命令行工具:从 $argv 到 Symfony Console 与 Cron 集成

19 阅读5分钟

1. 引言:为什么 PHP 也能写 CLI

提到 PHP,很多人的第一反应是「Web 后端语言」。事实上 PHP 从诞生之初就自带 CLI SAPI(Server API),可以像 Python、Node.js 一样编写命令行脚本。无论是数据迁移、定时任务、日志清理,还是内部运维工具,PHP CLI 都是一个被严重低估的选择。

本文将从最基础的 $argv / $argc 讲起,逐步过渡到工业级的 Symfony Console 组件,最后把脚本接入 Cron 实现定时调度。读完之后,你应该能独立写出一个结构清晰、可维护、可调度的 PHP 命令行工具。

2. CLI 模式与 Web 模式的区别

在动手写代码之前,先搞清楚两种运行模式的本质差异,能避免很多「为什么在浏览器里能跑、命令行里报错」的坑。

维度Web 模式(FPM/Apache)CLI 模式
入口每个 HTTP 请求触发一次脚本手动或调度器执行一次脚本
生命周期请求结束即销毁进程运行到脚本结束
输入$_GET、$_POST、请求体$argv、$argc、STDIN
输出HTTP 响应体STDOUT / STDERR
超时受 max_execution_time 限制默认无限制(0)
用户身份Web 服务器用户(如 www-data)当前登录用户
环境变量由 Web 服务器注入继承 Shell 环境

几个关键点值得单独强调:

  • php_sapi_name() 可以判断当前运行模式,返回 cli 或 fpm-fcgi 等值。
  • $_SERVER['argv'] 在 CLI 下可用,在 Web 下通常不存在。
  • 输出缓冲:CLI 下 echo 直接输出到终端,不会经过 HTTP 缓冲;但如果你用了 ob_start(),记得手动 flush()。
  • 标准输入输出:CLI 下可以用 STDIN、STDOUT、STDERR 三个常量,Web 下它们不可用。

一个常见的兼容写法:

if (PHP_SAPI !== 'cli') {
    fwrite(STDERR, "该脚本只能在命令行下运行\n");
    exit(1);
}

两种模式的运行路径差异,可以用一张图直观对比:

flowchart TD
  A["用户请求"] --> B{"运行模式判断"}
  B -->|"HTTP 请求"| C["Web 模式 FPM/Apache"]
  B -->|"命令行执行"| D["CLI 模式"]
  C --> C1["读取 $_GET / $_POST"]
  C1 --> C2["输出 HTTP 响应体"]
  C2 --> C3["请求结束即销毁"]
  D --> D1["读取 $argv / $argc / STDIN"]
  D1 --> D2["输出 STDOUT / STDERR"]
  D2 --> D3["进程运行到脚本结束"]

3. 最原始的入口:argv与argv 与 argc

PHP CLI 会把命令行参数原样放进两个预定义变量:

  • $argv:参数数组,$argv[0] 是脚本文件名,后面依次是传入的参数。
  • $argc:参数个数,等于 count($argv)。

写一个最简单的脚本 hello.php:

<?php
// hello.php
var_dump($argv);
echo "参数个数: {$argc}\n";

执行:

php hello.php foo bar --name=张三

输出:

array(4) {
  [0]=> string(9) "hello.php"
  [1]=> string(3) "foo"
  [2]=> string(3) "bar"
  [3]=> string(11) "--name=张三"
}
参数个数: 4

可以看到,PHP 本身不会帮你解析 --name=张三 这种选项,它只是把整串当作一个普通参数。要真正解析选项,需要自己写逻辑,或者借助 getopt():

<?php
// 支持 -n 和 --name 两种写法
$options = getopt("n:", ["name:"]);
$name = $options['name'] ?? $options['n'] ?? '世界';
echo "你好,{$name}\n";
php greet.php --name=张三
php greet.php -n 李四

getopt() 足够应付简单场景,但它有几个明显短板:不支持子命令、不支持参数校验、帮助信息要手写、错误提示不友好。一旦工具稍微复杂一点,就会迅速失控。这正是 Symfony Console 存在的意义。

4. 使用 Symfony Console 组件

Symfony Console 是 Symfony 框架的独立组件,可以脱离框架单独使用,是 PHP 生态里最成熟的 CLI 解决方案之一。

4.1 安装

composer require symfony/console

4.2 最小可运行示例

<?php
// bin/app.php
require __DIR__ . '/../vendor/autoload.php';

use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class GreetCommand extends Command
{
    protected static $defaultName = 'greet';

    protected function configure(): void
    {
        $this
            ->setDescription('向指定用户打招呼')
            ->addArgument('name', InputArgument::REQUIRED, '用户名称')
            ->addOption('times', 't', InputOption::VALUE_REQUIRED, '重复次数', 1);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $name  = $input->getArgument('name');
        $times = (int) $input->getOption('times');

        for ($i = 0; $i < $times; $i++) {
            $output->writeln("<info>你好,{$name}!</info>");
        }

        return Command::SUCCESS;
    }
}

$app = new Application('Demo CLI', '1.0.0');
$app->add(new GreetCommand());
$app->run();

执行效果:

php bin/app.php greet 张三 --times=3
你好,张三!
你好,张三!
你好,张三!

php bin/app.php 不带参数时会自动列出所有命令,php bin/app.php greet --help 会输出自动生成的帮助信息——这些都不需要你写一行代码。

4.3 输入:参数与选项

Symfony Console 把输入分成两类:

  • Argument(参数):位置相关,如 greet 张三 中的 张三。
  • Option(选项):带前缀,如 --times=3 或 -t 3。

常用模式:

// 必填参数
->addArgument('file', InputArgument::REQUIRED, '待处理文件路径')

// 可选参数,带默认值
->addArgument('format', InputArgument::OPTIONAL, '输出格式', 'json')

// 数组参数
->addArgument('ids', InputArgument::IS_ARRAY, 'ID 列表')

// 布尔开关
->addOption('dry-run', null, InputOption::VALUE_NONE, '只演练不执行')

// 需要值的选项
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, '条数上限', 100)

// 可重复的选项
->addOption('tag', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, '标签')

读取时分别用 getArgument() 和 getOption()。

4.4 输出:格式化与样式

OutputInterface 提供了丰富的输出能力,最常用的是标签式样式:

$output->writeln('<info>操作成功</info>');
$output->writeln('<comment>注意:文件已存在</comment>');
$output->writeln('<error>执行失败:权限不足</error>');

内置标签包括 info(绿)、comment(黄)、error(红底白字)、question(青)。还可以组合:

$output->writeln('<info>成功</info> <comment>跳过 3 条</comment>');

需要更精细的控制时,可以用 SymfonyStyle,它封装了表格、进度条、提问等常用交互:

use Symfony\Component\Console\Style\SymfonyStyle;

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $io = new SymfonyStyle($input, $output);

    $io->title('数据导入工具');
    $io->text('正在读取源文件...');

    $io->table(
        ['ID', '名称', '状态'],
        [
            [1, '订单 A', '成功'],
            [2, '订单 B', '失败'],
        ]
    );

    $io->progressStart(100);
    for ($i = 0; $i < 100; $i++) {
        usleep(10000);
        $io->progressAdvance();
    }
    $io->progressFinish();

    if (!$io->confirm('确认继续执行?', false)) {
        $io->warning('已取消');
        return Command::FAILURE;
    }

    $io->success('全部完成');
    return Command::SUCCESS;
}

4.5 退出码与错误处理

命令行工具的退出码非常重要,Cron、CI、Shell 脚本都依赖它判断成败:

  • Command::SUCCESS(0):成功
  • Command::FAILURE(1):业务失败
  • Command::INVALID(2):参数错误

抛出异常时,Symfony Console 会自动捕获并输出红色错误信息,同时返回非零退出码。如果希望自定义异常展示,可以实现 renderThrowable() 或直接 try/catch 后 $output->writeln()。

5. 编写可执行脚本

一个合格的 CLI 脚本,除了逻辑正确,还要在工程化上做几件事。

5.1 添加 Shebang 并赋予执行权限

#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';
// ...
chmod +x bin/app.php
./bin/app.php greet 张三

用 #!/usr/bin/env php 而不是 #!/usr/bin/php,可以让脚本在不同环境下自动找到 PHP 解释器。

5.2 目录结构建议

project/
├── bin/
│   └── app.php          # 入口,只负责组装 Application
├── src/
│   └── Command/
│       ├── ImportCommand.php
│       └── CleanupCommand.php
├── composer.json
└── vendor/

入口文件保持极简,业务逻辑全部放进 src/Command,每个命令一个类,便于测试和复用。

5.3 日志与输出分离

CLI 工具的输出分两类:给人看的(进度、提示)走 STDOUT,给机器看的(错误、告警)走 STDERR。这样在 Cron 里重定向时才能正确区分:

./bin/app.php import data.csv > /var/log/import.log 2> /var/log/import.err

在 Symfony Console 中,$output 默认写 STDOUT;需要写 STDERR 时用 ConsoleOutputInterface:

use Symfony\Component\Console\Output\ConsoleOutputInterface;

if ($output instanceof ConsoleOutputInterface) {
    $output->getErrorOutput()->writeln('<error>写入失败</error>');
}

5.4 加锁防止重复执行

定时任务最怕「上一次还没跑完,下一次又启动了」。用文件锁是最简单的方案:

$lockFile = sys_get_temp_dir() . '/import.lock';
$fp = fopen($lockFile, 'c');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
    fwrite(STDERR, "已有实例在运行,退出\n");
    exit(1);
}

try {
    // 业务逻辑
} finally {
    flock($fp, LOCK_UN);
    fclose($fp);
}

6. 与 Cron 集成

脚本写好后,交给 Cron 定时执行是最常见的落地方式。

6.1 基础写法

crontab -e
# 每天凌晨 2 点执行数据导入
0 2 * * * /usr/bin/php /var/www/project/bin/app.php import /data/daily.csv >> /var/log/import.log 2>&1

五个时间字段依次是:分、时、日、月、周。几个常用示例:

*/5 * * * *    # 每 5 分钟
0 * * * *      # 每小时整点
0 3 * * 1      # 每周一凌晨 3 点
0 0 1 * *      # 每月 1 号零点

6.2 必须注意的坑

  • 使用绝对路径:Cron 的 PATH 极简,php 可能找不到,务必写 /usr/bin/php 或 which php 的结果。
  • 工作目录不同:Cron 默认在用户家目录执行,脚本里涉及相对路径的地方要用 __DIR__ 或 chdir()。
  • 环境变量缺失:.env 文件不会自动加载,需要在脚本里显式读取,或在 crontab 顶部声明。
  • 输出重定向:不重定向的话,Cron 会尝试发邮件,长期积累可能撑爆 /var/mail。
  • 时区:Cron 使用系统时区,PHP 脚本里最好显式 date_default_timezone_set()。

一个更稳妥的 crontab 写法:

SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
APP_ENV=production

0 2 * * * cd /var/www/project && /usr/bin/php bin/app.php import /data/daily.csv >> /var/log/import.log 2>&1

6.3 用 Symfony Console 命令配合 Cron

由于 Symfony Console 命令本身就是可执行脚本,Cron 里直接调用即可。建议给每个定时任务单独建一个命令,并在命令内部做好日志记录:

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $logger = new \Monolog\Logger('cron');
    $logger->pushHandler(new \Monolog\Handler\StreamHandler('/var/log/cron.log'));

    try {
        $logger->info('任务开始');
        // 业务逻辑
        $logger->info('任务结束');
        return Command::SUCCESS;
    } catch (\Throwable $e) {
        $logger->error('任务失败: ' . $e->getMessage());
        return Command::FAILURE;
    }
}

7. 小结

回顾一下本文的脉络:

  1. PHP CLI 与 Web 模式在入口、生命周期、输入输出上都有本质差异,写脚本前先判断 PHP_SAPI。
  2. $argv / $argc 是最原始的入口,getopt() 能应付简单选项,但复杂场景力不从心。
  3. Symfony Console 提供了参数、选项、帮助、样式、进度条、退出码等一整套能力,是构建可维护 CLI 工具的首选。
  4. 可执行脚本要注意 Shebang、目录结构、STDOUT/STDERR 分离和文件锁。
  5. 接入 Cron 时,绝对路径、工作目录、环境变量、输出重定向、时区这五个坑必须提前规避。

把这套组合用熟之后,你会发现 PHP 在运维脚本、数据管道、内部工具这些场景里,其实相当顺手。