iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0

上一篇建好的 mytool 目前只有根命令,執行後只會印出說明文字。這篇我們要讓它開始處理具體工作:先建立第一個子命令、讓它接收並驗證位置參數,再進一步組織成多層子命令結構。

範例程式碼位於 cli-sample/mytool/。在 cmd/ 底下新增 greet.go,寫第一個子命令。這個命令負責印出問候語,執行 mytool greet 時在終端機輸出一行問候訊息:

// cmd/greet.go
package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
)

var greetCmd = &cobra.Command{
	Use:   "greet",
	Short: "印出問候語",
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Println("Hello! 這是一個最簡單的子命令。")
	},
}

func init() {
	rootCmd.AddCommand(greetCmd)
}

Run 裡面放的是子命令執行時會執行的程式碼。當使用者在命令列輸入 mytool greet 時,Cobra 就會執行這個函式。

函式接收的 cmd 是當前命令物件,args 則是接在命令後面的位置參數。如果邏輯可能失敗、需要回傳錯誤,實務上會把 Run 改成 RunE,直接回傳 error 讓 Cobra 處理。

init() 是 Go 的特殊函式,同一個 package 裡的 init() 會在 main() 執行前自動執行。main.go import cmd package 時,這裡的 init() 會把 greetCmd 掛到 rootCmd 底下,因此不需要在 main.go 手動註冊。

重新 build 再執行一次:

go build -o mytool
./mytool

執行後會發現 Available Commands 這時多了一行:

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  greet       印出問候語
  help        Help about any command

除了剛註冊的 greet,清單中另外兩個是 Cobra 自動內建的命令:help 用來顯示說明,completion 則用來產生 Shell 的自動完成腳本。

執行 greet,確認子命令已經註冊:

./mytool greet
Hello! 這是一個最簡單的子命令。

讓子命令接收位置參數

接著讓 greet 接收使用者輸入的名字。執行 mytool greet Evan 時,印出 Hello, Evan!。將 cmd/greet.go 修改如下:

// cmd/greet.go

var greetCmd = &cobra.Command{
	Use:   "greet <name>",
	Short: "印出問候語",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		name := args[0]
		fmt.Printf("Hello, %s!\n", name)
		return nil
	},
}

重新編譯並傳入名字測試:

$ go build -o mytool
$ mytool greet Evan
Hello, Evan!

RunE 函式中,傳進來的第二個參數 args 是型別為 []string 的字串切片。使用者接在命令後面的位置參數會依序存入切片中,透過 args[0] 就能拿到第一個位置參數 "Evan"

Args 驗證參數數量

如果使用者直接執行 mytool greet 忘記傳名字,或是傳了多個名字,Cobra 會因為設定了 Args: cobra.ExactArgs(1) 而自動回報錯誤:

$ mytool greet
Error: accepts 1 arg(s), received 0

$ mytool greet Evan Bob
Error: accepts 1 arg(s), received 2

Args 欄位負責檢查參數數量。在沒有設定 Args 時,預設接受任意數量的參數;如果命令內部直接存取 args[0],應明確設定驗證器,避免使用者未傳參數時發生 index out of range 錯誤。

Cobra 內建了幾種常用的參數數量驗證器:

驗證器 行為
cobra.ExactArgs(n) 必須剛好有 n 個參數
cobra.MinimumNArgs(n) 至少要有 n 個參數
cobra.MaximumNArgs(n) 最多只能有 n 個參數
cobra.RangeArgs(min, max) 參數數量必須介於指定範圍
cobra.NoArgs 不允許傳入任何參數
cobra.ArbitraryArgs 接受任意數量的參數

在 Help 語法中標明參數

Use: "greet <name>" 是寫給使用者看的語法提示。字串裡的 greet 是命令名稱,後面的 <name> 則是提示使用者輸入內容的參數標籤,會在 --help 中顯示:

$ mytool greet --help
Usage:
  mytool greet <name> [flags]

要注意的是,<name> 只是 Help 的說明文字,Cobra 本身並不會讀取 <name> 來驗證參數數量;真正的數量驗證仍然是由 Args 欄位處理。

RunE 檢查參數內容

接著檢查參數內容(例如確認輸入的是否為數字),寫在 RunE 函式內自行檢查。驗證失敗時直接回傳 error,Cobra 就會自動把錯誤訊息印出並終止執行:

RunE: func(cmd *cobra.Command, args []string) error {
	count, err := strconv.Atoi(args[1])
	if err != nil {
		return fmt.Errorf("第二個參數必須是數字,你輸入的是:%s", args[1])
	}
	// ...
},

此外,Cobra 傳給命令的參數型別固定是 []string。即使使用者輸入的是數字,程式收到的仍是字串,因此範例使用 strconv.Atoi 完成轉換。

重新編譯並執行:

go build -o mytool
./mytool greet Evan
Hello, Evan!

接著試一下錯誤的情況,當沒有傳入名字時,ExactArgs(1) 會在執行 RunE 前回報錯誤:

./mytool greet
Error: accepts 1 arg(s), received 0

建立巢狀子命令

當工具包含一組相關的操作時,可以將它們組織成兩層的巢狀結構。例如替 mytool 加入使用者功能時,以 user 作為父命令,底下包含 create(建立)與 list(列表):

mytool user create
mytool user list

cmd/user/ 新增 user.go,建立 user 父命令:

// cmd/user/user.go
package user

import "github.com/spf13/cobra"

var UserCmd = &cobra.Command{
	Use:   "user",
	Short: "管理使用者",
	// 不寫 Run:mytool user 直接印出 help,列出底下有哪些命令
}

UserCmd 大寫開頭——這個變數需要從 cmd/root.go 拿到,所以要 export。接著在 cmd/user/ 新增 create.go,建立接收使用者名稱的 create 子命令:

// cmd/user/create.go
package user

import (
	"fmt"
	"github.com/spf13/cobra"
)

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

create 現在只有一個 arg,一次只能建一個使用者。要一次建多個——mytool user create alice bob charlie——把 Args 換成 cobra.MinimumNArgs(1)RunE 裡改成迴圈跑過 args

var createCmd = &cobra.Command{
	Use:   "create <name>...",
	Short: "建立新使用者",
	Args:  cobra.MinimumNArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		for _, name := range args {
			fmt.Printf("建立使用者:%s\n", name)
		}
		return nil
	},
}

這種寫法只適用於同質性的批次輸入——每個 arg 都是同一種東西(使用者名稱),順序不重要,就像 rm a.txt b.txt c.txt 一次刪多個檔案,如果要傳的是「名字、角色、年齡」這種彼此不同質的值,不該塞進多個 arg,要改用具名的 Flag。

// cmd/user/list.go
package user

import (
	"fmt"
	"github.com/spf13/cobra"
)

var listCmd = &cobra.Command{
	Use:   "list",
	Short: "列出所有使用者",
	Args:  cobra.NoArgs,
	RunE: func(cmd *cobra.Command, args []string) error {
		fmt.Println("alice\nbob\ncharlie")
		return nil
	},
}

集中註冊各層子命令

有了 UserCmdcreateCmdlistCmd,需要把它們串起來。每個資源的子命令,統一掛在那個資源的 user.goinit() 裡:

// cmd/user/user.go
package user

import "github.com/spf13/cobra"

var UserCmd = &cobra.Command{
	Use:   "user",
	Short: "管理使用者",
}

func init() {
	UserCmd.AddCommand(createCmd) // user 底下有什麼,看這裡
	UserCmd.AddCommand(listCmd)
}

根命令也一樣——所有一層的子命令,集中在 root.goinit() 裡:

// cmd/root.go
func init() {
	rootCmd.AddCommand(user.UserCmd)
}

完成後的目錄結構:

cmd/
├── root.go          ← rootCmd + 掛 user
└── user/
    ├── user.go      ← UserCmd + 掛 create, list
    ├── create.go    ← createCmd(小寫,不 export)
    └── list.go      ← listCmd(小寫,不 export)

執行效果:

$ mytool user --help
管理使用者

Usage:
  mytool user [command]

Available Commands:
  create      建立新使用者
  list        列出所有使用者

$ mytool user create alice
建立使用者:alice

$ mytool user create
Error: accepts 1 arg(s), received 0

Go 知識補充:Struct 字面值、Slice、匿名函式與多重回傳

如果在閱讀前面程式碼時對某些 Go 語法感到陌生,以下為本篇出現的核心機制說明:

1. Struct 字面值(Struct Literal)與 {}

Go 語言沒有傳統物件導向語言的 class 關鍵字與繼承機制,而是使用 struct(結構體)來組合與封裝資料欄位。

在 Go 中建立結構體實例時,Type{Field: Value} 稱為 Struct 字面值:

var greetCmd = &cobra.Command{
	Use:   "greet",
	Short: "印出問候語",
}
  • 沒有 classcobra.Command 本身就是一個 struct 型別,不需要像其他語言一樣使用 new 類別實例化。
  • {}大括號:用來包住要初始化的欄位與對應數值。未顯式指派的欄位會自動設為 Go 的零值(Zero Value,如 ""nil)。
  • 最前面的 &:代表取址符號。這行程式碼會在記憶體建立 cobra.Command 結構體,並回傳其記憶體位址(指標 *cobra.Command),確保整棵命令樹共享同一份命令實例。

2. 匿名函式(Anonymous Function)

Go 將函式視為First-class citizen,代表函式可以像字串或數字一樣存入變數,或是作為 struct 的欄位型別。

RunE 欄位的型別為 func(cmd *cobra.Command, args []string) error。宣告時直接賦予一個沒有名字的函式:

RunE: func(cmd *cobra.Command, args []string) error {
	fmt.Println("執行命令")
	return nil
},
  • 前方的 func(...) error:宣告函式的參數與回傳型別。
  • 後方的 {}:包住該匿名函式的實際執行邏輯(Function Body)。這個函式在建立 Command 時不會立即執行,而是交由 Cobra 在比對到命令時呼叫。

3. Slice(切片)與 range 迴圈

RunE 接收到的 args 型別是 []string(字串切片)。Slice 是 Go 用來處理同型別動態長度序列的資料結構:

  • 索引存取:使用 args[0] 讀取第一個位置參數。
  • range 遍歷:需要處理多個參數時,搭配 range 迴圈依序取出:
for _, name := range args {
	fmt.Printf("建立使用者:%s\n", name)
}

range 每次迴圈會同時回傳 (索引, 數值)。因為 Go 規定宣告的變數必須使用,若不需要索引值,可以用 _(底線 / Blank Identifier)忽略。

4. 多重回傳值(Multiple Return Values)與 Error 檢查

Go 支援函式回傳多個數值,標準庫普遍採用「回傳結果 + error」的設計:

count, err := strconv.Atoi(args[0])
if err != nil {
	return fmt.Errorf("數量必須是數字:%s", args[0])
}

strconv.Atoi 會同時回傳轉換後的整數與 error

  • err != nil:代表轉換失敗(例如傳入非數字字串),此時可封裝錯誤訊息並由 RunE 向上回傳。
  • err == nil:代表轉換成功,可以安心使用 count 變數。

下一篇:命令和 arg 都寫好了,接著把 Flag 的實作補完——縮寫、型別、local vs persistent → 用 Cobra 定義 Flag:縮寫、型別與作用範圍。


上一篇
建立第一個 CLI 專案
下一篇
設計 CLI Flag 的型別、縮寫與作用範圍
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言