iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0

上一篇把 Flag 定位成「對動作的修飾」,這篇接著 Cobra 裡實作縮寫及型別。延續 建構 CLI 子命令與輸入參數 裡的 mytool user create <name>。這裡,我們讓範例再多一個可以設定角色的功能:加入 --role 參數來指定新使用者的角色:

mytool user create alice --role admin

name 是操作對象,--role 是可省略的執行選項;省略時使用預設角色。

在 Cobra 裡幫 createCmd 加這個 Flag:

// cmd/user/create.go
var createCmd = &cobra.Command{
	Use:   "create <name>",
	Short: "建立新使用者",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		name := args[0]
		role, _ := cmd.Flags().GetString("role")
		fmt.Printf("建立使用者:%s(角色:%s)\n", name, role)
		return nil
	},
}

func init() {
	createCmd.Flags().StringP("role", "r", "user", "使用者角色")
}

這裡 createCmd.Flags() 掛的是 local Flag,也就是只有 mytool user create 能用。

縮寫(Shorthand)

除了完整名稱 --role,也可以搭配縮寫 -r 使用,兩種寫法效果相同:

mytool user create alice --role admin
mytool user create alice -r admin

Cobra 不會自動擷取長名稱的第一個字母作為縮寫。若要支援縮寫,必須呼叫帶 P(Shorthand)的函式並明確傳入縮寫字元:

// 不帶縮寫:只支援 --role
createCmd.Flags().String("role", "user", "使用者角色")

// 帶縮寫:第二個參數傳入 "r",同時支援 --role 與 -r
createCmd.Flags().StringP("role", "r", "user", "使用者角色")

StringP 的第二個參數 "r" 就是縮寫,只能使用單一字元,慣例採用小寫。

所有型別都有對應帶 P 的版本,例如 BoolPIntPStringArrayP 等。

命名縮寫時盡量與業界常見慣例一致,降低使用者的記憶成本:

縮寫 常見對應
-v --verbose
-o --output
-f --file
-n --name--dry-run
-p --port
-c --config
-h --help(Cobra 自動保留)

另外要注意的是縮寫在同一個 Command 下必須唯一,重複定義會 panic:

// 這樣會 panic:-o 被定義了兩次
flags.StringP("output", "o", "", "output file")
flags.StringP("origin", "o", "", "origin url")  // ❌

如果不需要縮寫的 Flag 直接用不帶 P 的版本就好,不一定每個 Flag 都要有縮寫。

Flag 型別

Cobra 會依照定義 Flag 時選用的型別解析命令列文字。例如 StringP 保留字串,IntP 只接受整數,BoolP 則把 Flag 是否出現解析成布林值。讀取時使用對應的 GetStringGetIntGetBool

flags.StringP("config", "c", "", "config file")
flags.IntP("port", "p", 8080, "port number")
flags.BoolP("verbose", "v", false, "verbose mode")
flags.Float64P("ratio", "r", 1.0, "ratio value")
flags.StringArrayP("tag", "t", nil, "標籤(可重複)")

Bool Flag 通常用來控制功能是否開啟。預設值是 false 時,命令列出現 --verbose 就會變成 true,不用在後面再寫 true

./mytool              # verbose = false
./mytool --verbose    # verbose = true

StringArrayP 定義的 Flag 可以在同一條命令中出現多次。每次出現都加入一個值,適合接收標籤、檔案名稱等數量不固定的資料:

./mytool --tag=a --tag=b --tag=c

呼叫 cmd.Flags().GetStringArray("tag") 會取得 []string{"a", "b", "c"}

Local vs Persistent Flag

Cobra 的 Flag 依據作用範圍分為兩種:

  • Local Flagcmd.Flags()):只對定義它的該層命令生效。
  • Persistent Flagcmd.PersistentFlags()):對該層命令以及底下所有的子命令都生效。
func init() {
    // 所有子命令都能用 --verbose
    rootCmd.PersistentFlags().BoolP("verbose", "v", false, "enable verbose output")
    // 只有 rootCmd 本身能用 --output
    rootCmd.Flags().StringP("output", "o", "", "output file")
}
mytool --verbose           # ✅
mytool serve --verbose     # ✅  子命令也能用
mytool serve --output      # ❌  只有 root 能用

常見放在 rootCmd.PersistentFlags() 的 Flag:--config--log-level--verbose--dry-run

Count Flag

Count Flag 是重複使用同一個 Flag 來累加次數,幾乎只用在 -v / -vvv

./mytool -v        # verbose level 1
./mytool -vv       # verbose level 2
./mytool -vvv      # verbose level 3
var verbosity int

func init() {
    rootCmd.Flags().CountVarP(&verbosity, "verbose", "v", "增加 verbose 等級")
}

傳結構化資料(如 JSON):Flag 帶路徑,不是塞內容

user create 如果要接收的不是單一名稱,而是一整個 JSON({"name":"alice","role":"admin"}),直接把 JSON 字串塞進 arg 或 Flag 的值都不好用——兩種都要處理 Shell 的引號跳脫,JSON 一長就難維護、容易打錯:

mytool user create '{"name":"alice","role":"admin"}'
mytool user create --data '{"name":"alice","role":"admin"}'

慣例做法是用 Flag 帶檔案路徑,命令自己讀檔案內容再解析:

var createCmd = &cobra.Command{
	Use:   "create",
	Short: "建立新使用者",
	RunE: func(cmd *cobra.Command, args []string) error {
		path, _ := cmd.Flags().GetString("file")
		data, err := os.ReadFile(path)
		if err != nil {
			return err
		}
		var u User
		if err := json.Unmarshal(data, &u); err != nil {
			return fmt.Errorf("解析 %s 失敗:%w", path, err)
		}
		fmt.Printf("建立使用者:%+v\n", u)
		return nil
	},
}

func init() {
	createCmd.Flags().StringP("file", "f", "", "使用者資料的 JSON 檔案路徑")
	createCmd.MarkFlagRequired("file")
}

-f 的值是 "-" 時,程式改讀 os.Stdin;其他值則當成檔案路徑:

var r io.Reader
if path == "-" {
	// -f -:從 stdin 讀取
	r = os.Stdin
} else {
	// -f user.json:開啟指定檔案
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()
	r = f
}
data, err := io.ReadAll(r)

r 統一表示資料來源,因此 io.ReadAll 不需要區分資料來自 stdin 或檔案。

使用 -f - 時,管道左側命令的輸出會成為 mytool 的 stdin:

# 把本機檔案內容送進 stdin
cat user.json | mytool user create -f -

# 把 HTTP 回應內容送進 stdin
curl https://api.example.com/users/1 | mytool user create -f -

Go 知識補充:底線忽略變數、init 函式、指標綁定與 io.Reader 介面

如果對本篇範例中出現的 Go 語法不熟悉,以下為相關特性的補充說明:

1. 底線 _(Blank Identifier)與忽略回傳值

Go 規定所有宣告的變數都必須被使用,否則編譯不會通過。若呼叫的函式回傳多個值(例如 (string, error)),但特定情境下確定不需要處理該錯誤時,可以使用 _ 忽略:

role, _ := cmd.Flags().GetString("role")

這裡的 GetString 會回傳解析到的字串與 error。因為 role Flag 已經在 init() 中註冊過,呼叫時必定能找到該 Flag,因此使用 _ 忽略 error 變數。

2. init() 函式與自動執行機制

init() 是 Go 語言保留的特殊函式。每個 package 內可以有多個 init() 函式,它們會在程式啟動、main() 執行之前自動被呼叫:

func init() {
	createCmd.Flags().StringP("role", "r", "user", "使用者角色")
}

Cobra 利用這個特性,讓各個命令檔(如 create.go)在載入時自動將 Flag 掛載至對應的 Command 上,不需要在 main() 裡手動撰寫長長的初始化流程。

3. 指標(Pointer)與變數綁定 &

CountVarP 這種帶有 Var 字尾的 Cobra 函式,需要傳入變數的記憶體位址(指標 *int),而不是傳入數值本身:

var verbosity int

func init() {
	rootCmd.Flags().CountVarP(&verbosity, "verbose", "v", "增加 verbose 等級")
}

verbosity 前加上取址符號 &,代表傳入該變數的記憶體位址。這樣當 Cobra 解析命令列參數時,可以直接寫入修改外部宣告的 verbosity 變數。

4. io.Reader 介面與資料抽象化

Go 的 io.Reader 是一個介面(interface),定義了標準的讀取行為 Read(p []byte) (n int, err error)。只要實作了這個方法的型別,都可以指派給 io.Reader 變數:

var r io.Reader
if path == "-" {
	r = os.Stdin
} else {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()
	r = f
}
data, err := io.ReadAll(r)

os.Stdinos.Open 回傳的 *os.File 都實作了 io.Reader 介面。因此 r 可以統一代表資料來源,讓後續的 io.ReadAll(r) 不必關心資料是來自標準輸入(stdin)還是本機檔案。

下一篇mytool 現在能吃 Flag(輸入)、用 fmt.Println 印結果(輸出)。這組輸入輸出不是只有你的程式懂——Shell 也看得懂,而且能拿去跟別的命令串接。下一篇來看這件事的運作方式:管道與重導向 (Pipeline & Redirection)。


上一篇
建構 CLI 子命令與輸入參數
下一篇
管道與重導向 (Pipeline & Redirection)
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言