用途
这是一套编写 Symfony 控制台命令的规范与技能。它把命令定位为“翻译层”,与控制器类似:解析输入、调用一个服务、格式化输出,仅此而已。理由不只是整洁:写在 __invoke() 里的规则只能通过启动控制台、构造输入并回读缓冲区文本来测试;同样的规则放进服务,只需调用方法并断言返回值。留在命令里的每一个业务决策,都只会通过字符串被测试。
按版本选择写法
以 vendor/ 为准,选择前先确认哪种形式存在。
- Symfony 8.1+:一个类对应一个命令族,每个命令一个 public 方法,#[AsCommand] 标注在每个方法上。
- Symfony 7.3 – 8.0:每个命令一个可调用(invokable)类,#[AsCommand] 标注在类上,__invoke() 使用 #[Argument] / #[Option]。
- Symfony 6.4 – 7.2:每个命令一个继承 Command 的类,#[AsCommand] 标注在类上,使用 configure() + execute()。
命令族形式是目标形态:app:user:create 与 app:user:delete 共享构造函数、共享输入约定,可以放在一起阅读。自动配置会为每个带注解的方法打上服务标签,容器为每个方法构建一个 Command。该类不得继承 Command,两者混用会在编译期抛出异常;方法必须是 public 且非静态。
继承 Command 并未废弃,在 8.1 仍可用,configure() / execute() 也未标记移除,只是不再是新代码的写法;对于可调用命令无法完成的一件事(信号),它仍是正确答案。
输入从签名推断
参数与选项定义来自参数类型和默认值,有两个意外之处:
- 名称会转为 kebab-case。int $bookId 在命令行是 <book-id>,?string $olderThan 是 --older-than,且不会有任何提示。
- 选项参数必须有默认值,否则容器抛错。这也正是它成为选项而非参数的原因。
示例:
#[Argument] array $shelves = [], // <shelves>... (variadic)
#[Option] ?string $olderThan = null, // --older-than=OLDER-THAN
#[Option] int $batchSize = 500, // --batch-size=BATCH-SIZE [default: 500]
#[Option] bool $force = false, // --force
#[Option] bool $report = true, // --report|--no-report (negatable)
SymfonyStyle、InputInterface、OutputInterface、Application 和 Cursor 按类型注入,不要声明为参数,也不要手工构建 SymfonyStyle。始终返回 int:Command::SUCCESS(0)、Command::FAILURE(1)、Command::INVALID(2),返回其它值会抛出 TypeError。
破坏性命令:默认演练
删除、清空、匿名化或覆盖类的命令默认只报告,只有加 --force 才真正执行。不加 --force 时打印将要做的事并退出 SUCCESS——成功的演练就是成功。
这是刻意不采用交互式确认。$io->confirm() 在传入 --no-interaction 时会被跳过,而 CI、部署脚本和调度器正是这样运行一切的。提示框只保护终端前的人,--force 两者都保护,因为安全路径是默认路径。
计数要在操作前后各查一次,来自两次独立查询。
长时间或定时命令必须加锁
LockableTrait 随 symfony/console 提供,但锁组件不随附:composer require symfony/lock。否则该 trait 会在生产环境首次执行时于运行时抛出“To enable the locking feature you must install the symfony/lock component.”。
if (!$this->lock('app:digest:send')) {
$io->warning('Already running, skipping this run.');
return Command::SUCCESS; // not FAILURE — a skipped run is the design working
}
try {
// …
return Command::SUCCESS;
} finally {
$this->release();
}
三个易踩的点:用 finally 而不是结尾处调用 release(),异常逃逸会让锁保持占用,flock 会在进程死亡时释放,数据库存储则不会,下一次运行将被阻塞直到 TTL 过期;在命令族中必须显式指定锁名,因为无参数的 $this->lock() 会读取类上的 #[AsCommand] 属性,而命令族类没有该属性,会抛出 Lock name missing;LockableTrait 会忽略 LOCK_DSN,它自建信号量或 flock 存储,二者都局限于单机,在多台服务器上不构成锁,应在构造函数中把自动装配的 LockFactory 赋给 $this->lockFactory。命令自身的调度(#[AsCronTask]、#[AsPeriodicTask]、worker)属于 symfony-yoandev-async,锁属于本技能,因为它是命令的属性。
长时间运行的命令与内存
Doctrine 的工作单元会保留它水合的每个实体的引用,遍历 50 万行就会持有 50 万个对象直到退出,内存线性增长直至触及 memory_limit。
foreach ($this->repository->iterateStaleReviews() as $i => $review) { // toIterable()
$this->archiver->archive($review);
if (0 === $i % 500) {
$em->flush();
$em->clear(); // drop everything hydrated so far
}
}
$em->clear() 会分离一切,包括你仍持有的实体,因此清理后要重新获取,绝不要跨边界保留引用。查询属于仓储层(QueryBuilder 结果上的 toIterable()),批处理属于服务层。
能被重定向到文件的输出
SymfonyStyle 提供 title()、section()、success()、warning()、error()、table()、progressIterate(),应使用它们,不要手写带横线的 writeln()。
关键规则:定时命令的输出会在数周后被不认识它的人在日志文件中阅读。打印运行参数,打印计数而非形容词,把诊断信息放到详细度之后:
$io->writeln(\sprintf('Purging shelves older than %s (batch %d)', $olderThan, $batchSize));
if ($io->isVerbose()) {
$io->writeln(\sprintf(' · shelf #%d (%d books)', $shelf->getId(), $count));
}
日志文件里的进度条是一堵控制字符墙:要加以保护,或以 --no-ansi 运行定时调用。错误通过 $io->getErrorStyle() 输出到 stderr,这使调用方能区分报告与失败。
测试:服务做单元测试,命令做冒烟测试
命令不承载规则,因此没有值得详尽测试的东西。冒烟测试证明的是接线成立:服务 id 能解析、参数能绑定、退出码正确。runCommand() 来自 ConsoleCommandAssertionsTrait,KernelTestCase 已使用它,并会为你启动内核。8.1 之前可构建 Symfony\Bundle\FrameworkBundle\Console\Application 并使用 CommandTester::execute()。破坏性命令是例外,需要真实覆盖:演练不触碰数据库,--force 会触碰,并断言退出码。
常见问题对照
- bin/console list 未列出命令:类继承 Command 并使用方法级 #[AsCommand],或方法是 private/static。
- The option ... must declare a default value:某个 #[Option] 参数没有默认值。
- --my-option not recognised:参数 $myOption 会生成 kebab-case 的 --my-option。
- 缺少参数时退出码为 1 而非 2:输入绑定失败属于 FAILURE,INVALID 用于你对已成功解析值的校验。
- LogicException: To enable the locking feature…:未安装 symfony/lock。
- Lock name missing:在方法级命令族中调用了无名的 $this->lock()。
- 加了锁仍有两份在跑:LockableTrait 默认存储是按机器的,需注入配置好的 LockFactory。
- N 行后 Allowed memory size exhausted:Doctrine 身份映射,用 toIterable() 加周期性 clear()。
- SIGTERM 在写入中途杀掉命令:SignalableCommandInterface 在可调用命令上不触发,这一个命令请使用 extends Command。
- 删除的行数多于报告:计数与删除在同一次查询中完成,或两次过滤条件不同。应计数、执行、再计数。
参考文件
- references/writing-commands.md:编写任何命令时阅读,包含三种版本相关形式、完整输入推断表、输出与详细度、信号、两套测试 API。
- references/destructive-and-scheduled.md:涉及删除或覆盖、定时或长时间运行的一切时阅读,包含演练/--force 模式端到端、锁存储与陷阱、Doctrine 分批内存、不可逆命令的测试。
适用场景与限制
适用于 Symfony 项目中的控制台命令开发,尤其是破坏性操作、定时任务与大批量数据处理。限制:锁组件需额外安装且默认存储为单机,跨服务器需自行注入 LockFactory;可调用命令不支持 SignalableCommandInterface。