停止编写 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 问题的答案。我只是说我厌倦了到处编写相同的验证代码,所以我构建了一个让它变得不必要的工具。

接受它或者不接受。但是你即将编写的那些验证代码?你可能并不需要它。

19
3
1

3 comments

If you have a fediverse account, you can comment on this article from your own instance. Search https://hackers.pub/ap/articles/0199203f-a6e9-7f90-9462-0af136538c9c on your instance and reply to it.

1
1
1