2. 子命令路由设计
为什么需要子命令路由
当 CLI 工具从单一功能扩展到多个功能时,把所有参数都塞进一个命令会让解析逻辑迅速失控。子命令路由的核心思路是:第一层只负责识别“要执行哪个动作”,第二层才解析该动作自己的参数。这样每个子命令的参数空间彼此隔离,帮助信息也能按命令粒度输出。
本节实现一个 task 工具,支持三个子命令:add(新增任务)、list(列出任务)、done(标记完成)。重点在于分发结构,以及当用户输入未知子命令时给出可操作的报错。
路由结构设计
路由表用字典把子命令名映射到处理函数,主函数只做三件事:取第一个位置参数、查表、调用。未知命令不抛裸异常,而是打印可用命令列表并以非零码退出。
完整实现
import sys
TASKS = []
def cmd_add(args):
if not args:
print("用法: task add <描述>", file=sys.stderr)
return 2
TASKS.append({"desc": args[0], "done": False})
print(f"已添加: {args[0]}")
return 0
def cmd_list(args):
if not TASKS:
print("暂无任务")
return 0
for i, t in enumerate(TASKS, 1):
mark = "x" if t["done"] else " "
print(f"[{mark}] {i}. {t['desc']}")
return 0
def cmd_done(args):
if not args or not args[0].isdigit():
print("用法: task done <编号>", file=sys.stderr)
return 2
idx = int(args[0]) - 1
if idx < 0 or idx >= len(TASKS):
print(f"编号超出范围: {args[0]}", file=sys.stderr)
return 2
TASKS[idx]["done"] = True
print(f"已完成: {TASKS[idx]['desc']}")
return 0
ROUTES = {
"add": cmd_add,
"list": cmd_list,
"done": cmd_done,
}
def main(argv):
if len(argv) < 2:
print("用法: task <add|list|done> [参数]", file=sys.stderr)
return 2
cmd = argv[1]
handler = ROUTES.get(cmd)
if handler is None:
print(f"未知命令: {cmd}", file=sys.stderr)
print(f"可用命令: {' / '.join(ROUTES)}", file=sys.stderr)
return 2
return handler(argv[2:])
if __name__ == "__main__":
sys.exit(main(sys.argv))
关键设计点
- 路由表与处理函数分离:
ROUTES只做名称到函数的映射,新增子命令只需加一行,主函数无需改动。 - 参数切片:
argv[2:]把子命令之后的参数交给对应 handler,各 handler 只关心自己的参数格式。 - 退出码约定:成功返回 0,用法错误或未知命令返回 2,便于脚本判断。
- 报错信息可操作:未知命令时同时打印错误命令和可用命令列表,用户无需再查帮助。
验证方式
| 输入 | 预期输出 | 退出码 |
|---|---|---|
task add "写文档" | 已添加: 写文档 | 0 |
task list | [ ] 1. 写文档 | 0 |
task done 1 | 已完成: 写文档 | 0 |
task lst | 未知命令: lst / 可用命令: add / list / done | 2 |
task | 用法: task <add|list|done> [参数] | 2 |
如果后续子命令数量增多,可以把
ROUTES 改为装饰器注册模式,例如 @route("add"),让每个 handler 自己声明命令名,进一步降低主函数的维护成本。