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; // デフォルトポート
}
// ちょっと待って、--port に値を渡さない場合は?
// ポートが範囲外だったら?
// もし...
このコードを書くのが難しいわけではありません。問題は、これがどこにでもあることです。すべてのプロジェクト。すべての CLI ツール。同じパターンで、少しずつ異なる味付け。他のオプションに依存するオプション。一緒に使えないフラグ。特定のモードでしか意味をなさない引数。
そして本当に気になったのは:私たちは他のタイプのデータに対してこの問題を何年も前に解決していたということです。ただ...CLI に対しては解決していなかったのです。
バリデーションの問題点
私のパース(構文解析)に対する考え方を完全に変えたブログ記事があります。 Alexis King によるバリデーションではなくパースをです。要点は?データを緩い型にパースしてから有効かどうかをチェックするのではなく、有効な状態しか取り得ない型に直接パースするということです。
考えてみてください。API から JSON を取得するとき、単に any
としてパースしてから一連の if
文を書くわけではありません。Zod のようなものを使って、欲しい形に直接パースします。無効なデータ?パーサーがそれを拒否します。終わり。
しかし CLI では?引数をプロパティの集まりにパースして、その後100行かけてそのバッグが意味をなすかどうかをチェックします。これは逆転しています。
そういうわけで、私は Optique を作りました。世界が別の CLI パーサーを切実に必要としていたからではなく(そうではありません)、同じバリデーションコードをあらゆる場所で見る—そして書く—ことにうんざりしていたからです。
バリデーションに飽き飽きしていた3つのパターン
依存オプション
これはどこにでもあります。別のオプションが有効な場合にのみ意味を持つオプションがあります。
従来の方法?すべてをパースしてからチェックします:
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。ただし、2つは絶対に選べません。
以前は次のようなコードを書いていました:
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()
コンビネータは、正確に1つだけが成功することを意味します。結果は単に "json" | "yaml" | "xml"
です。3つのブール値をやりくりするのではなく、単一の文字列です。
環境固有の要件
本番環境には認証が必要です。開発環境にはデバッグフラグが必要です。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 はすぐに壊れるすべての場所を表示し、それらを修正すれば完了です。以前は「すべてを捕捉できたか?」という1時間の作業が、今では「赤い波線を修正して次に進む」だけです。
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行のスクリプトを書いていて、1つの引数しか取らないなら、これは必要ありません。
process.argv[2]
を使って終わりにしましょう。
しかし、もしあなたが:
- バリデーションロジックが実際のオプションと同期しなくなったことがある
- 特定のオプションの組み合わせが爆発することを本番環境で発見した
--json
と一緒に使うと--verbose
が壊れる理由を追跡するのに午後を費やした- 「オプション A にはオプション B が必要」というチェックを5回目に書いた
なら、あなたもこれにうんざりしているかもしれません。
公平な警告:Optique はまだ若いです。まだ物事を整理している段階で、API は少し変わるかもしれません。しかし、核となるアイデア—バリデーションではなくパース—それは確固たるものです。そして、私は数ヶ月間バリデーションコードを書いていません。
まだ奇妙な感じがします。良い意味で奇妙です。
試すも試さないも自由
これが共感を呼ぶなら:
私は Optique がすべての CLI 問題の答えだとは言っていません。ただ、どこでも同じバリデーションコードを書くことにうんざりしていたので、それを不要にするものを作ったというだけです。
使うも使わないも自由です。しかし、これから書こうとしているバリデーションコード?おそらくそれは必要ないでしょう。