停止撰寫 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 不會讓你這麼做——該欄位在那個型別上不存在。

"但是解析器組合器..."

我知道,我知道。"解析器組合器"聽起來像是需要計算機科學學位才能理解的東西。

事實是:我沒有計算機科學學位。實際上,我沒有任何學位。但我已經使用解析器組合器多年了,因為它們實際上...並不那麼難?只是這個名稱讓它們聽起來比實際情況更可怕。

我一直在將它們用於其他事情——解析配置文件、領域特定語言等等。但直到我看到 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