停止编写 CLI 验证代码。第一次就正确解析。

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub
我有个坏习惯。当某件事让我烦恼多次后,我最终会为它构建一个库。这次,问题出在 CLI 验证代码上。
我花了大量时间阅读其他人的代码。开源项目、工作相关的代码、凌晨 2 点偶然发现的 GitHub 仓库。我不断注意到这样一件事:每个 CLI 工具都在某处藏着相同的丑陋验证代码。你知道是哪种:
if (!opts.server && opts.port) {
throw new Error("--port requires --server flag");
}
if (opts.server && !opts.port) {
opts.port = 3000; // default port
}
// 等等,如果他们传递 --port 但没有值怎么办?
// 如果端口超出范围怎么办?
// 如果...
这些代码并不难写。问题是它们无处不在。每个项目。每个 CLI 工具。相同的模式,略有不同的风格。依赖于其他选项的选项。不能一起使用的标志。只在特定模式下有意义的参数。
真正让我受不了的是:我们多年前就为其他类型的数据解决了这个问题。只是...没有为 CLI 解决。
验证的问题
有一篇博客文章完全改变了我对解析的看法。它是 Alexis King 写的 Parse, don't validate(解析,而非验证)。要点是什么?不要将数据解析为松散类型然后检查它是否有效。直接将其解析为只能有效的类型。
想想看。当你从 API 获取 JSON 时,你不会仅仅将其解析为 any
然后编写一堆 if
语句。你会使用像 Zod 这样的工具直接将其解析为你想要的形状。无效数据?解析器拒绝它。完成。
但对于 CLI 呢?我们将参数解析为一堆属性,然后花接下来的 100 行检查这堆东西是否有意义。这是本末倒置。
所以,我构建了 Optique。不是因为世界迫切需要另一个 CLI 解析器(它不需要),而是因为我厌倦了看到——和编写——到处都是相同的验证代码。
我厌倦了验证的三种模式
依赖选项
这种情况无处不在。你有一个选项,只有在另一个选项启用时才有意义。
旧方法?解析所有内容,然后检查:
const opts = parseArgs(process.argv);
if (!opts.server && opts.port) {
throw new Error("--port requires --server");
}
if (opts.server && !opts.port) {
opts.port = 3000;
}
// 更多验证可能潜伏在其他地方...
使用 Optique,你只需描述你想要的:
const config = withDefault(
object({
server: flag("--server"),
port: option("--port", integer()),
workers: option("--workers", integer())
}),
{ server: false }
);
以下是 TypeScript 为 config
类型推断的结果:
type Config =
| { readonly server: false }
| { readonly server: true; readonly port: number; readonly workers: number }
类型系统现在理解当 server
为 false 时,port
字面上不存在。不是 undefined
,不是 null
——它根本不在那里。尝试访问它,TypeScript 会对你大喊大叫。不需要运行时验证。
互斥选项
另一个经典案例。选择一种输出格式:JSON、YAML 或 XML。但绝对不能同时选两种。
我过去会写这样的混乱代码:
if ((opts.json ? 1 : 0) + (opts.yaml ? 1 : 0) + (opts.xml ? 1 : 0) > 1) {
throw new Error('Choose only one output format');
}
(别评判我,你也写过类似的东西。)
现在呢?
const format = or(
map(option("--json"), () => "json" as const),
map(option("--yaml"), () => "yaml" as const),
map(option("--xml"), () => "xml" as const)
);
or()
组合器意味着只有一个会成功。结果只是 "json" | "yaml" | "xml"
。一个字符串。不是三个需要处理的布尔值。
环境特定需求
生产环境需要认证。开发环境需要调试标志。Docker 需要与本地不同的选项。你知道这种情况。
与其使用验证迷宫,不如直接描述每个环境:
const envConfig = or(
object({
env: constant("prod"),
auth: option("--auth", string()), // 在生产环境中必需
ssl: option("--ssl"),
monitoring: option("--monitoring", url())
}),
object({
env: constant("dev"),
debug: optional(option("--debug")), // 在开发环境中可选
verbose: option("--verbose")
})
);
生产环境中没有认证?解析器立即失败。尝试在开发模式下访问 --auth
?TypeScript 不会让你这么做——该字段在那个类型上不存在。
"但是解析器组合器..."
我知道,我知道。"解析器组合器"听起来像是需要计算机科学学位才能理解的东西。
事实是:我没有计算机科学学位。实际上,我没有任何学位。但我已经使用解析器组合器多年了,因为它们实际上...并不那么难?只是这个名字让它们听起来比实际情况更可怕。
我一直在将它们用于其他事情——解析配置文件、DSL 等。但直到我看到 Haskell 的 optparse-applicative 之前,我从未意识到可以将它们用于 CLI 解析。那是一个真正的"等等,当然"时刻。就像,为什么我们要用其他方式来做这件事?
事实证明这非常简单。解析器只是一个函数。组合器只是接受解析器并返回新解析器的函数。就是这样。
// 这是一个解析器
const port = option("--port", integer());
// 这也是一个解析器(由更小的解析器组成)
const server = object({
port: port,
host: option("--host", string())
});
// 仍然是一个解析器(一直都是解析器)
const config = or(server, client);
没有单子。没有范畴论。只有函数。平凡而美丽的函数。
TypeScript 完成繁重工作
这里有一点仍然感觉像是作弊:我不再为我的 CLI 配置编写类型。TypeScript 只是...自己搞定了。
const cli = or(
command("deploy", object({
action: constant("deploy"),
environment: argument(string()),
replicas: option("--replicas", integer())
})),
command("rollback", object({
action: constant("rollback"),
version: argument(string()),
force: option("--force")
}))
);
// TypeScript 自动推断出这个类型:
type Cli =
| {
readonly action: "deploy"
readonly environment: string
readonly replicas: number
}
| {
readonly action: "rollback"
readonly version: string
readonly force: boolean
}
TypeScript 知道如果 action
是 "deploy"
,那么 environment
存在但 version
不存在。它知道 replicas
是一个 number
。它知道 force
是一个 boolean
。我没有告诉它这些。
这不仅仅是关于良好的自动完成(尽管是的,自动完成很棒)。这是关于在问题发生前捕获它们。在某处忘记处理新选项?代码将无法编译。
对我来说实际发生了什么变化
我已经自己试用这个库几周了。一些真实感受:
我现在删除代码。 不是重构。是删除。那些曾经占我 CLI 代码 30% 的验证逻辑?消失了。每次这样做都感觉很奇怪。
重构不再可怕。 想知道什么通常让我感到恐惧吗?改变 CLI 接收参数的方式。比如从 --input file.txt
改为仅仅使用 file.txt
作为位置参数。使用传统解析器,你需要到处寻找验证逻辑。而现在呢?你更改解析器定义,TypeScript 立即显示每个出错的地方,你修复它们,完成。过去需要一小时的"我是否捕获了所有内容?"现在变成了"修复红色波浪线然后继续"。
我的 CLI 变得更高级。 当添加复杂选项关系不再意味着编写复杂验证时,你只是...添加它们。互斥组?当然可以。上下文相关选项?为什么不呢。解析器处理这一切。
可重用性也是真实的:
const networkOptions = object({
host: option("--host", string()),
port: option("--port", integer())
});
// 到处重用,以不同方式组合
const devServer = merge(networkOptions, debugOptions);
const prodServer = merge(networkOptions, authOptions);
const testServer = merge(networkOptions, mockOptions);
但老实说?最大的变化是信任。如果它编译通过,CLI 逻辑就能工作。不是"可能工作"或"除非有人传递奇怪参数否则工作"。它就是能工作。
你应该关心吗?
如果你正在编写一个只接受一个参数的 10 行脚本,你不需要这个。使用 process.argv[2]
就完事了。
但如果你曾经:
- 让验证逻辑与你的实际选项不同步
- 在生产环境中发现某些选项组合会导致崩溃
- 花了一个下午追踪为什么
--verbose
与--json
一起使用时会出错 - 第五次编写相同的"选项 A 需要选项 B"检查
那么是的,也许你也厌倦了这些事情。
公平警告:Optique 还很年轻。我仍在摸索,API 可能会有一些变化。但核心理念——解析,而非验证——是坚实的。而且我已经几个月没有编写验证代码了。
仍然感觉很奇怪。好的那种奇怪。
试试看或者不试
如果这引起了你的共鸣:
我并不是说 Optique 是所有 CLI 问题的答案。我只是说我厌倦了到处编写相同的验证代码,所以我构建了一个让它变得不必要的工具。
接受它或者不接受。但是你即将编写的那些验证代码?你可能并不需要它。