一个内部工具的参数从三个长到十九个,--help 输出要滚三屏。有人提议加一个"配置文件"参数,于是变成了十九个命令行参数加一个 YAML——而且两者还能互相覆盖。
命令行参数是最容易被随意扩张的接口:加一个参数几乎没有成本,删一个却要协调所有调用方。等它长到需要配置文件时,问题已经不是"用什么库解析",而是这个工具该不该有这么多模式。
1. 参数是接口,不是旋钮
| 性质 | 参数 | 配置项 |
|---|---|---|
| 谁改 | 调用者(每次) | 维护者(偶尔) |
| 变更成本 | 高(影响所有调用脚本) | 低 |
| 可见性 | 高(--help 即文档) |
中(需要读文件) |
| 适合表达 | 这一次做什么 | 一般怎么运作 |
判断一个值该不该做参数,只要问:它会不会在每次调用时不同。会,做参数;不会(比如数据库地址),做配置。
还有一类常被错误参数化的东西:运行环境。--env prod、--region cn、--db-host ... 这类值几乎从不在同一次会话里变化,它们属于配置。把它们做成参数,结果是每个调用脚本都要重复一遍,而且一旦某台机器的配置写错,错误会随脚本扩散。
按这个判据,那十九个参数里通常只有三四个合格。
2. 参数的四种角色
| 角色 | 例子 | 设计要点 |
|---|---|---|
| 动作/子命令 | run / check / export |
应当互斥且数量少 |
| 目标 | --input / --output |
必须有默认或必填声明 |
| 行为修饰 | --dry-run / --force |
布尔,不叠加成组合爆炸 |
| 调优 | --batch-size / --concurrency |
尽量收敛到配置 |
第一行最关键:动作应该是子命令而不是布尔参数。用 --export --check 这种组合表达两个动作,会产生"两个都传了怎么办"的语义问题,而子命令天然互斥。
// 子命令优于布尔组合:语义互斥由结构保证
parser = argparse.ArgumentParser(prog="tool")
sub = parser.add_subparsers(dest="cmd", required=True)
run = sub.add_parser("run", help="执行任务")
run.add_argument("--input", required=True)
run.add_argument("--dry-run", action="store_true")
check = sub.add_parser("check", help="只校验不执行")
check.add_argument("--input", required=True)
3. 布尔参数会组合爆炸
n 个布尔参数有 2^n 种组合,其中大部分是无意义的。
| 写法 | 组合数 | 问题 |
|---|---|---|
| 6 个独立布尔 | 64 | 大部分组合无意义或危险 |
2 个互斥开关(--mode) |
2 | 语义清晰 |
| 子命令(3 个) | 3 | 天然互斥 |
布尔参数还有一个隐蔽成本:它让"非法组合"变成运行期错误。--export --check 同时给了,工具只能在运行时抛一个"不能同时使用"的异常;而两个子命令在设计上就不可能同时给出。
第三种不只是数量少,更重要的是每种组合都有明确的代码路径。布尔组合需要写大量 if 去排除非法组合,而这些排除逻辑本身就是缺陷来源。
4. 默认值与必填的诚实性
| 设计 | 风险 |
|---|---|
| 关键参数给"看起来合理"的默认值 | 忘记传时静默用错值 |
| 全部必填 | 每次调用都要写一长串 |
| 默认值 + 显式打印生效值 | 低 |
第三行是最实用的折中:允许默认,但执行时把生效值打印出来。这样"用了默认值"变成一件可见的事,而不是隐式的。
// 打印生效配置:默认值被使用时也看得见
args = parser.parse_args()
effective = {
"input": args.input,
"batch_size": args.batch_size, // 缺省 500
"concurrency": args.concurrency, // 缺省 4
}
print("生效参数:", json.dumps(effective, ensure_ascii=False))
// dry-run 时尤其必要:让人看到"如果真跑,会用这些值"
一个可用的落地形态是把参数分成三类并分别处理:
// 三类参数的默认策略不同
// 1) 目标类:必填,不给默认
parser.add_argument("--input", required=True)
// 2) 调优类:给默认,并打印生效值
parser.add_argument("--batch-size", type=int, default=500)
// 3) 危险类:默认关闭,且需要显式确认
parser.add_argument("--purge", action="store_true", help="删除源文件(不可恢复)")
// 危险参数在非 TTY 环境下要求 -y,避免 CI 中误触发
对于危险操作(删除、覆盖、批量写),默认值应该是最保守的那个,而不是最方便的那个。
5. 退出码与输出是接口的一部分
| 约定 | 含义 | 违反后果 |
|---|---|---|
| 0 = 成功 | 可被脚本串联 | 上游误判成功 |
| 非 0 = 失败,且分类 | 可区分可重试与不可重试 | 重试无意义 |
| stdout 放数据 | 可被管道消费 | 解析失败 |
| stderr 放日志 | 不污染数据流 | 数据被日志混入 |
| 无交互默认 | 可被 CI 调用 | 卡在交互提示上 |
输出分离也有具体的实践:数据默认写 stdout,人类可读的进度写 stderr。这样 tool export | jq 能直接工作,而进度信息不会污染管道。
最后一项最常被忽略:一个在本地用着很顺的工具,进了 CI 就卡住——因为它默认要问一句"确认继续吗"。非交互模式不是可选项,而是这个工具能否被自动化的前提。
// 退出码分类:让调用方能决定要不要重试
// 0 成功;2 参数错误(不可重试);3 数据问题(人工介入);4 临时失败(可重试)
if [ $# -lt 1 ]; then echo "缺少参数" >&2; exit 2; fi
一个检查参数数量是否过多的方法:把 --help 输出贴给一个没用过这个工具的人,让他完成一个具体任务。他需要问几个问题,就有几个参数是设计缺陷。
// 典型的三次追问,对应三个设计问题
问:--input 和 --source 有什么区别? → 命名不统一或概念重复
问:--mode 和 --dry-run 能一起用吗? → 布尔组合,应为子命令
问:--env 不传会怎样? → 默认值不可见,应打印生效值
6. 排错:五种"工具用起来别扭"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 参数记不住 | 数量过多 | 收敛到子命令 + 配置 |
| 调用脚本各不相同 | 无统一约定 | 固定必需项与命名 |
| CI 卡住 | 默认交互 | 加 --yes 或检测非 TTY |
| 输出无法解析 | 日志与数据混流 | stdout/stderr 分离 |
| 同名参数在不同版本含义变了 | 无兼容约定 | 保持语义稳定,加新参数 |
排错时还有一个高效的做法:看调用方脚本里参数的写法分布。如果同一个参数在不同脚本里取值差异极大,说明它其实是配置;如果某参数只在个别脚本里出现,说明它是"某人的特殊需求",应当考虑移出主接口。
第五项在内部工具里很常见:--force 原本表示"跳过确认",后来改成"忽略错误",于是老脚本的行为悄悄变了。参数语义一旦发布就是契约,改语义应当加新参数,而不是复用旧名字。
7. 边界:什么情况不必讲究
| 场景 | 为什么 | 做法 |
|---|---|---|
| 一次性脚本 | 不会被复用 | 位置参数即可 |
| 只被自己用的工具 | 无外部调用方 | 随意,但保留 --help |
| 已被封装成服务的入口 | 参数由服务层转换 | 不必对最终用户友好 |
| 参数固定(从不变化) | 无设计空间 | 写死或环境变量 |
还有一条与文档有关的边界:--help 就是文档,不该在别处再写一份。手写的用法文档必然滞后;而 --help 由代码生成,滞后只会发生在忘记改代码的情况下,那是另一类问题。
判断口径一句话:这个工具会被别人的脚本调用吗。会,参数与退出码就是契约;不会,怎么方便怎么来。
8. 自测
- 判断一个值该不该做命令行参数的那个问题是什么?按它筛,"十九个参数"通常剩几个?
- 为什么动作应该用子命令而不是布尔参数?布尔组合会带来什么具体问题?
- "允许默认但打印生效值"解决了哪一类事故?危险操作的默认值该往哪边偏?
- 退出码分类的价值是什么?如果所有失败都返回 1,调用方会失去什么能力?
- 为什么"非交互模式"是工具可被自动化的前提?典型症状是什么?
- 参数语义为什么一旦发布就是契约?改语义时正确做法是什么?