Subcommand CLI 的核心結構是 <tool> <sub> [flags] [args],每層各自承擔獨立決策:dispatcher 決定走到哪個子命令、flag parser 只認該子命令的旗標命名空間、positional args 交給業務邏輯。flag.NewFlagSet 為每個子命令建立獨立 flag 命名空間,讓三層以內的 CLI 用 stdlib 就能乾淨解析;cobra 的說服點在 tab completion、generated help、hierarchical commands 等超出 flag 解析本身的領域,三層內走 stdlib 成本最低。

本章以 scripts/mdtools(blog 自己的 markdown 工具鏈,repo 內檔案)作為 concrete instance。讀者不需要事先熟悉 mdtools — 每段會先講通用 pattern,再用對應 code 示範一種可行實作。

基礎:為什麼需要 flag.NewFlagSet 而非 flag.Parse()

flag.Parse() 只解析一次全域 flag set。對只有一個命令的小工具(如 tool --input foo)夠用;但一旦進入 tool fmt --fix 這種 <tool> <subcommand> [flags] 結構,全域 flag set 就擋路:

  • --fixfmt 命令有意義,對 lint 命令沒有。
  • 各子命令可能共享 name(例如 --verbose)但預設值或語意不同。
  • help 輸出需要分子命令各自列自己的 flags。

flag.NewFlagSet 讓每個子命令擁有獨立的 flag 命名空間

1fs := flag.NewFlagSet("fmt", flag.ExitOnError)
2fix := fs.Bool("fix", false, "apply fixes in place")
3check := fs.Bool("check", false, "report-only")
4_ = fs.Parse(args) // args = os.Args[2:],已經跳過了子命令本身

fs.Parse(args) 只看傳進去的片段,不碰 os.Args 全域。這是撐起 subcommand CLI 的核心 API。

專案 Layout:main → cmd/ → internal/

Go 慣例的 CLI 專案結構是三層,對應三種責任:

 1scripts/mdtools/
 2├── main.go             ← 層 1:dispatcher,只做「看第一個參數分派到哪裡」
 3├── cmd/
 4│   ├── fmt.go          ← 層 2:每個子命令一個檔案,負責 flag 解析與呼叫 internal
 5│   ├── lint.go
 6│   ├── cards.go
 7│   └── migrate.go
 8└── internal/
 9    ├── mdfmt/          ← 層 3:純邏輯,不碰 flag、os.Args、os.Exit
10    ├── mdlint/
11    └── mdcards/

分層的目的是支援每層獨立的測試策略:

  • layer 1:幾乎不測,因為只是 switch
  • layer 2:integration test(給定 argv、確認 exit code 與 stdout)。
  • layer 3:unit test,純函式輸入輸出。後續模組的所有實作技術 — AST 整合idempotent 改寫graph 分析 — 都落在這層。

os.Exit / os.Args / os.Stderr 都擋在 layer 1-2,layer 3 就能用一般 table-driven test 測,不用起 subprocess。

Layer 1:main.go dispatcher

 1// scripts/mdtools/main.go
 2package main
 3
 4import (
 5	"fmt"
 6	"os"
 7
 8	"blog/scripts/mdtools/cmd"
 9)
10
11func main() {
12	if len(os.Args) < 2 {
13		usage()
14		os.Exit(2)
15	}
16
17	sub := os.Args[1]
18	args := os.Args[2:]
19
20	var exitCode int
21	switch sub {
22	case "fmt":
23		exitCode = cmd.Fmt(args)
24	case "lint":
25		exitCode = cmd.Lint(args)
26	case "cards":
27		exitCode = cmd.Cards(args)
28	case "migrate":
29		exitCode = cmd.Migrate(args)
30	case "-h", "--help", "help":
31		usage()
32	case "version":
33		fmt.Println("mdtools 0.1.0-dev")
34	default:
35		fmt.Fprintf(os.Stderr, "unknown subcommand: %q\n\n", sub)
36		usage()
37		exitCode = 2
38	}
39
40	os.Exit(exitCode)
41}

注意幾個 pattern:

  • dispatcher 不做 flag 解析args := os.Args[2:] 把剩下交給子命令。
  • 每個子命令回傳 int,dispatcher 統一呼叫 os.Exit。這讓子命令本身容易測(不會直接 kill 測試 process)。
  • -h / --help / help 三種寫法都接受。Unix 慣例。
  • unknown subcommand 進 exit code 2,保留 exit 1 給「有違規」的語義。

Layer 2:子命令入口

每個子命令一個檔案,結構類似:

 1// scripts/mdtools/cmd/fmt.go
 2package cmd
 3
 4import (
 5	"flag"
 6	"fmt"
 7	"os"
 8
 9	"blog/scripts/mdtools/internal/files"
10	"blog/scripts/mdtools/internal/mdfmt"
11	"blog/scripts/mdtools/internal/rules"
12)
13
14func Fmt(args []string) int {
15	fs := flag.NewFlagSet("fmt", flag.ExitOnError)
16	fix := fs.Bool("fix", false, "apply fixes in place")
17	check := fs.Bool("check", false, "report-only; non-zero on pending changes")
18	_ = fs.Parse(args)
19
20	if *check && *fix {
21		fmt.Fprintln(os.Stderr, "mdtools fmt: --fix and --check are mutually exclusive")
22		return 2
23	}
24	if !*check && !*fix {
25		*check = true // safe default
26	}
27
28	paths := fs.Args()
29	if len(paths) == 0 {
30		paths = []string{"content"}
31	}
32
33	cfg := rules.Default()
34	mdFiles, err := files.WalkMarkdown(paths)
35	if err != nil {
36		fmt.Fprintf(os.Stderr, "mdtools fmt: walk error: %v\n", err)
37		return 2
38	}
39
40	changed := 0
41	for _, path := range mdFiles {
42		result, err := mdfmt.FormatFile(path, cfg)
43		if err != nil {
44			fmt.Fprintf(os.Stderr, "mdtools fmt: %s: %v\n", path, err)
45			return 2
46		}
47		if !result.Changed() {
48			continue
49		}
50		changed++
51		if *fix {
52			if err := os.WriteFile(path, result.Fixed, 0o644); err != nil {
53				fmt.Fprintf(os.Stderr, "write %s: %v\n", path, err)
54				return 2
55			}
56			fmt.Printf("fixed: %s\n", path)
57		} else {
58			fmt.Printf("would fix: %s\n", path)
59		}
60	}
61
62	if *check && changed > 0 {
63		return 1 // CI-friendly: exit 1 means "things need fixing"
64	}
65	return 0
66}

要注意幾個設計決策:

  • flag 定義就在入口函式裡,不抽成 package 常數。每個子命令的 flag 獨立演化。
  • ExitOnErrorfs.Parse 遇到不合法 flag 直接 exit — 對 CLI 工具 OK,因為 parse 失敗本來就無法繼續。測試時要用 ContinueOnError 避免殺測試。
  • positional args 從 fs.Args() 取,不是 os.Argsfs.Parse 會把非 flag 的留在 fs.Args()。
  • 預設值走安全側*check = true when neither given)— 防止使用者意外執行破壞性動作。
  • exit code 分層語意:0 = 成功、1 = 有違規、2 = 工具本身失敗。CI script 能用 [[ $? -eq 1 ]] 區分。

Layer 3:internal 實作

Layer 3 是純邏輯,不知道任何 os / flag 的存在。這讓它能被 layer 2 呼叫、被 test 呼叫、也能在未來被其他 binary 或 library 重用:

 1// scripts/mdtools/internal/mdfmt/fixer.go
 2package mdfmt
 3
 4import (
 5	"bytes"
 6	"os"
 7
 8	"blog/scripts/mdtools/internal/rules"
 9)
10
11type FixResult struct {
12	Path     string
13	Original []byte
14	Fixed    []byte
15}
16
17func (r FixResult) Changed() bool {
18	return !bytes.Equal(r.Original, r.Fixed)
19}
20
21func FormatFile(path string, cfg rules.Config) (FixResult, error) {
22	data, err := os.ReadFile(path)
23	if err != nil {
24		return FixResult{}, err
25	}
26	fixed := applyAll(data, cfg)
27	return FixResult{Path: path, Original: data, Fixed: fixed}, nil
28}

FormatFile 回傳 (FixResult, error),不 os.Exit、不印訊息、不碰全域狀態。Test 可以直接給一個記憶體 []byteapplyAll 驗結果。

什麼時候該上 cobra

升級到 cobra 的判準是stdlib 能處理的負面複雜度已經超過 cobra 的學習成本。下表列五個實際觸發過團隊升級的訊號,每個都附展開說明。

訊號為什麼 stdlib 處理不好
命令層級超過 3 層(tool sub1 sub2 sub3 --flagdispatcher 變成多層 nested switch,flag 繼承需要手動維護
需要自動 shell completion(bash / zsh / fish)手寫 completion 腳本成本高;cobra / urfave-cli 有 generator
需要 markdown / man-page 形式的 help 輸出stdlib 只有基本 flag.Usage;cobra 有 doc package 能渲染
有多個 end-user 要閱讀 help(非開發者)stdlib 的 flag.Usage 格式樸素,降低使用者可讀性
大量共用 flag(–verbose / –log-level 每個命令都要)cobra 的 PersistentFlags 比手工在每個子命令重複宣告乾淨

命令層級超過 3 層kubectl get pods 只有兩層還撐得住;到 gh api repos owner/repo/pulls list --limit 10 就是四層(含 api 這個 namespace),dispatcher 裡巢狀 switch 開始難讀。信號:dispatcher 的 switch case 超過十個,或 case 裡面又呼叫另一個 switch。反例:即使只有兩層,若每層未來會繼續加,早上 cobra 可省後來重構。

需要自動 shell completion:end-user 會反覆打命令、需要 tab 補齊子命令與 flag 名稱時,這功能差很多。手寫 completion 腳本要處理三種 shell 的語法差異,成本高;cobra 一行 cobra.GenBashCompletion 就產生。信號:工具有外部使用者、或團隊已經裝 shell completion。反例:只在 CI 跑、人不會互動輸入。

man-page 形式的 help 輸出:Unix 社群期待工具有 man tool 級的文件。stdlib 只輸出簡單的 usage 字串,排版樸素;cobra 的 doc package 能生成 markdown / reStructuredText / man。信號:工具要 package 進系統(Homebrew、apt),或對外發佈。反例:公司內部用、README 夠用。

多 end-user 讀 help:工程師忍受樸素的 -h 輸出,但產品經理、SRE on-call 看不下。cobra 有明確的 long description、example 欄位,排版比 stdlib 好。信號:使用者包含非程式設計角色。反例:user 是同團隊工程師。

大量共用 flag--verbose--log-level--config 這類 flag 每個子命令都要用。stdlib 要在每個子命令重複 fs.Bool("verbose", ...);cobra 的 PersistentFlags 能繼承到所有 subcommand。信號:重複 flag 超過三個、或要 enforce 某個 flag 在所有 subcommand 都有。反例:flag 在每個子命令語意不同,共用反而製造混淆。

以上五個訊號在 mdtools 都沒命中(內部工具、單層 subcommand、工程師使用者),所以繼續走 stdlib。若未來 mdtools 對外釋出給讀者下載,就值得重新評估。判讀時機是設計當下,不是感覺「stdlib 開始髒」時 — 髒時通常已經晚。

常見陷阱

在 layer 3 直接呼叫 os.Exit

會破壞 test:test runner 呼叫 TestXxx 時,如果 subject code 裡 os.Exit(1),整個 test process 退出,其他 test 不跑。Layer 3 應回傳 error,讓 layer 2 決定怎麼退出。

用全域 var fs = flag.NewFlagSet(...) 宣告 flag

每次呼叫會累積狀態(flag 已經被定義過會 panic),並且兩個 test 同時跑會 race。定義 flag 要在函式裡。

忘記 ContinueOnError 就跑 test

ExitOnError 是 production 預設,但測試時會讓測試 process 整個退出。Table-driven test 要用:

1fs := flag.NewFlagSet(name, flag.ContinueOnError)
2fs.SetOutput(io.Discard) // 測試時不要印 usage 到 stderr

太早抽出「所有子命令共用的 flag」

PersistentFlags 概念在 stdlib 沒有,手動在每個子命令重複 fs.Bool("verbose", false, ...) 看似重複但其實可讀。一旦抽成共用 helper,就開始維護一個小框架 — 這時候用 cobra 反而更乾淨。

擴充路徑

  • 命令太多時分組tool fmt checktool fmt fix 的兩層 subcommand 可以用「每層一個 switch」展開,main → cmd.Fmt → cmd.FmtCheck。mdtools 的 migrate fix-links 就是這個模式(見 cmd/migrate.go)。
  • 共用 config loadingrules.Default() 這類邏輯放在 internal 裡,每個子命令呼叫;不要每個子命令自己 parse 配置檔。
  • 測試 layer 2:用 buffer 捕獲 stdout/stderr,傳入自定 args。參考 Go stdlib 的 testing/iotestbytes.Buffer

下一步

9.2 goldmark AST 入門 會看 mdtools 怎麼把 markdown 解析成可操作的結構,layer 3 內部怎麼組織 parser 整合。