停止撰寫 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 問題的答案。我只是說我厭倦了到處編寫相同的驗證程式碼,所以我建立了一個讓它變得不必要的工具。
接受或拒絕都可以。但是你即將編寫的那些驗證程式碼?你可能並不需要它。