Go 命令行解析 flag 包之通過子命令實現看 go 命令源碼

Go 命令行解析 flag 包之通過子命令實現看 go 命令源碼


上篇文章[1] 介紹了 flag 中如何擴展一個新的類型支持。本篇介紹如何使用 flag 實現子命令,總的來說,這篇才是這個系列的核心,前兩篇只是鋪墊。

前兩篇文章鏈接如下:

Go 命令行解析 flag 包之快速上手[2] Go 命令行解析 flag 包之擴展新類型[3]

希望看完本篇文章,如果再閱讀 go 命令的實現源碼,至少在整體結構上不會迷失方向了。

FlagSet

正式介紹子命令的實現之前,先了解下 flag 包中的一個類型,FlagSet,它表示了一個命令。

從命令的組成要素上看,一個命令由命令名、選項 Flag 與參數三部分組成。類似如下:

$ cmd --flag1 --flag2 -f=flag3 arg1 arg2 arg3

FlagSet 的定義也正符合了這一點,如下:

type FlagSet struct {
	// 打印命令的幫助信息
	Usage func()

	// 命令名稱
	name string
	parsed bool
	// 實際傳入的 Flag
	actual map[string]*Flag
	// 會被使用的 Flag,通過 Flag.Var() 加入到了 formal 中
	formal map[string]*Flag

	// 參數,Parse 解析命令行傳入的 []string,
	// 第一個不滿足 Flag 規則的(如不是 - 或 -- 開頭),
	// 從這個位置開始,後面都是
	args []string // arguments after flags
	// 發生錯誤時的處理方式,有三個選項,分別是
	// ContinueOnError 繼續
	// ExitOnError 退出
	// PanicOnError panic
	errorHandling ErrorHandling
	output io.Writer // nil means stderr; use out() accessor
}

包含字段有命令名 name,選項 Flag 有 formal 和 actual,參數 args。

如果有人說,FlagSet 是命令行實現的核心,還是比較認同的。之所以前面一直沒有提到它,主要是 flag 包為了簡化命令行的處理流程,在 FlagSet 上做了進一步的封裝,簡單的使用可以直接無視它的存在。

flag 中定義了一個全局的 FlagSet 類型變量,CommandLine,用它表示整個命令行。可以說,CommandLine是 FlagSet 的一個特例,它的使用模式較為固定,所以在它之上能提供了一套默認的函數。

前面已經用過的一些,比如下面這些函數。

func BoolVar(p *bool, name string, value bool, usage string) {
	CommandLine.Var(newBoolValue(value, p), name, usage)
}

func Bool(name string, value bool, usage string) *bool {
	return CommandLine.Bool(name, value, usage)
}

func Parse() {
	// Ignore errors; CommandLine is set for ExitOnError.
	CommandLine.Parse(os.Args[1:])
}

更多的,這裡不一一列舉了。

接下來,我們來脫掉這層外衣,梳理下命令行的整個處理流程吧。

流程解讀

CommandLine 的整個使用流程主要由三部分組成,分別是獲取命令名稱、定義命令中的實際選項和解析選項。

命令名稱在 CommandLine 創建的時候就已經指定了,如下:

CommandLine = NewFlagSet(os.Args[0], ExitOnError)

名稱由 os.Args[0] 指定,即命令行的第一個參數。除了命令名稱,同時指定的還有出錯時的處理方式,ExitOnError。

接著是定義命令中實際會用到的 Flag。

核心的代碼是 FlagSet.Var(),如下所示:

func (f *FlagSet) Var(value Value, name string, usage string) {
	// Remember the default value as a string; it won't change.
	flag := &Flag{name, usage, value, value.String()}

	// ...
	// 省略部分代碼
	// ...

	if f.formal == nil {
		f.formal = make(map[string]*Flag)
	}
	f.formal[name] = flag
}

之前使用過的 flag.BoolVar 和 flag.Bool 都是通過 CommandLine.Var(),即 FlagSet.Var(), 將 Flag 保存到 FlagSet.formal 中,以便於之後在解析的時候能將值成功設置到定義的變量中。

最後一步是從命令行中解析出選項 Flag。由於 CommandLine 表示的是整個命令行,所以它的選項和參數一定是從 os.Args[1:] 中解析。

flag.Parse 的代碼如下:

func Parse() {
	// Ignore errors; CommandLine is set for ExitOnError.
	CommandLine.Parse(os.Args[1:])
}

現在的重點是要了解 flag 中選項和參數的解析規則,如 gvg -v list,按什麼規則確定 -v 是一個 Flag,而 list 是參數的呢?

如果繼續向下追 Parse 的源碼,在 FlagSet.parseOne 中將發現 Flag 的解析規則。

func (f *FlagSet) ParseOne()
	if len(f.args) == 0 {
		return false, nil
	}
	s := f.args[0]
	if len(s) < 2 || s[0] != '-' {
		return false, nil
	}
	numMinuses := 1
	if s[1] == '-' {
		numMinuses++
		if len(s) == 2 { // "--" terminates the flags
			f.args = f.args[1:]
			return false, nil
		}
	}
	// ...
}

三種情況下會終止解析 Flag,分別是當命令行參數全部解析結束,即 len(f.args) == 0,或長度小於 2,但第一位字符不是 -,或者參數長度等於 2,且第二個字符是 -。之後的內容會繼續當作命令行參數處理。

如果沒有子命令,命令的解析工作到此就基本完成了,再往後就是業務代碼的開發了。那如果 CommandLine 還有子命令呢?

子命令

子命令和 CommandLine 無論是形式還是邏輯上,基本沒什麼差異。形式上,子命令同樣包含選項和參數,邏輯上,子命令的選項和參數的解析規則與 CommandLine 相同。

一個包含子命令的命令行,形式如下:

$ cmd --flag1 --flag2 subcmd --subflag1 --subflag2 arg1 arg2

從上面可以看出,如果 CommandLine 包含了子命令,可以理解為本身也就沒了參數,因為 CommandLine 的第一個參數即是子命令的名稱,而之後的參數要解析為子命令的選項參數了。

現在,子命令的實現就變得非常簡單了,創建一個新的 FlagSet,將 CommandLine 中的參數按前面介紹的流程重新處理一下。

第一步,獲取 CommandLine.Arg(0),檢查是否存在相應的子命令。

func main() {
	flag.Parse()
	if h {
		flag.Usage()
		return
	}

	cmdName := flag.Arg(0)
	switch cmdName {
	case "list":
		_ = list.Exec(cmdName, flag.Args()[1:])
	case "install":
		_ = install.Exec(cmdName, flag.Args()[1:])
	}
}

子命令的實現定義在另外一個包中,以 list 命令為例。 代碼如下:

var flagSet *flag.FlagSet

var origin string

func init() {
	flagSet = flag.NewFlagSet("list", flag.ExitOnError)
	val := newStringEnumValue("installed", &origin, []string{"installed", "local", "remote"})
	flagSet.Var(
		val, "origin",
		"the origin of version information, such as installed, local, remote",
	)
}

上面的代碼中,定義了 list 子命令的 FlagSet,並在 Init 方法為其增加了一個選項 Flag,origin。

Run 函數是真正執行業務邏輯的代碼。

func Run(args []string) error {
	if err := flagSet.Parse(args); err != nil {
		return err
	}

	fmt.Println("list --oriign", origin)
	return nil
}

最後的 Exec 函數組合 Init 和 Run 函數,已提供給 main 調用。

func Run(name string, args []string) error {
	Init(name)
	if err := Run(args); err != nil {
		return err
	}

	return nil
}

命令行的解析完成,如果子命令還有子命令,處理的邏輯依然相同。接下來的工作,就可以開始在 Run 函數中編寫業務代碼了。

Go 命令

現在,閱讀下 Go 命令的實現代碼吧。

由於大佬們寫的代碼是基於 flag 包實現純手工打造,沒用任何的框架,在可讀性上會有點差。

源碼位於

go/src/cmd/go/cmd/main.go[4] 下,其中在 base.Go 命令上初始化了 Go 支持的所有命令,如下:

base.Go.Commands = []*base.Command{
	bug.CmdBug,
	work.CmdBuild,
	clean.CmdClean,
	doc.CmdDoc,
	envcmd.CmdEnv,
	fix.CmdFix,
	fmtcmd.CmdFmt,
	generate.CmdGenerate,
	modget.CmdGet,
	work.CmdInstall,
	list.CmdList,
	modcmd.CmdMod,
	run.CmdRun,
	test.CmdTest,
	tool.CmdTool,
	version.CmdVersion,
	vet.CmdVet,

	help.HelpBuildmode,
	help.HelpC,
	help.HelpCache,
	help.HelpEnvironment,
	help.HelpFileType,
	modload.HelpGoMod,
	help.HelpGopath,
	get.HelpGopathGet,
	modfetch.HelpGoproxy,
	help.HelpImportPath,
	modload.HelpModules,
	modget.HelpModuleGet,
	modfetch.HelpModuleAuth,
	modfetch.HelpModulePrivate,
	help.HelpPackages,
	test.HelpTestflag,
	test.HelpTestfunc,
}

無論是 go 命令,還是它的子命令,都是 *base.Command 類型。可以看一下 *base.Command 的定義。

type Command struct {
	Run func(cmd *Command, args []string)

	UsageLine string
	Short string
	Long string

	Flag flag.FlagSet

	CustomFlags bool

	Commands []*Command
}

主要的字段有三個,分別是 Run,主要負責業務邏輯的處理,FlagSet,負責命令行的解析,以及 []*Command, 所支持的子命令。

再來看看 main 函數中的核心邏輯。如下:

BigCmdLoop:
for bigCmd := base.Go; ; {
	for _, cmd := range bigCmd.Commands {
		// ...
		// 主要邏輯代碼
		// ...
	}

	// 打印幫助信息
	helpArg := ""
	if i := strings.LastIndex(cfg.CmdName, " "); i >= 0 {
		helpArg = " " + cfg.CmdName[:i]
	}
	fmt.Fprintf(os.Stderr, "go %s: unknown command\nRun 'go help%s' for usage.\n", cfg.CmdName, helpArg)
	base.SetExitStatus(2)
	base.Exit()
}

從最頂層的 base.Go 開始,遍歷 Go 的所有子命令,如果沒有相應的命令,則打印幫助信息。

省略的那段主要邏輯代碼如下:

for _, cmd := range bigCmd.Commands {
	// 如果找不到命令,繼續下次循環
	if cmd.Name() != args[0] {
		continue
	}
	// 檢查是否存在子命令
	if len(cmd.Commands) > 0 {
		// 將 bigCmd 設置為當前的命令
		// 比如 go tool compile,cmd 即為 compile
		bigCmd = cmd
		args = args[1:]
		// 如果沒有命令參數,則說明不符合命令規則,打印幫助信息。
		if len(args) == 0 {
			help.PrintUsage(os.Stderr, bigCmd)
			base.SetExitStatus(2)
			base.Exit()
		}
		// 如果命令名稱是 help,打印這個命令的幫助信息
		if args[0] == "help" {
			// Accept 'go mod help' and 'go mod help foo' for 'go help mod' and 'go help mod foo'.
			help.Help(os.Stdout, append(strings.Split(cfg.CmdName, " "), args[1:]...))
			return
		}
		// 繼續處理子命令
		cfg.CmdName += " " + args[0]
		continue BigCmdLoop
	}
	if !cmd.Runnable() {
		continue
	}
	cmd.Flag.Usage = func() { cmd.Usage() }
	if cmd.CustomFlags {
		// 解析參數和選項 Flag
		// 自定義處理規則
		args = args[1:]
	} else {
		// 通過 FlagSet 提供的方法處理
		base.SetFromGOFLAGS(cmd.Flag)
		cmd.Flag.Parse(args[1:])
		args = cmd.Flag.Args()
	}

	// 執行業務邏輯
	cmd.Run(cmd, args)
	base.Exit()
	return
}

主要是幾個部分,分別是查找命令,檢查是否存在子命令,選項和參數的解析,以及最後是命令的執行。

通過 cmd.Name() != args[0] 判斷是否查找到了命令,如果找到則繼續向下執行。

通過 len(cmd.Commands) 檢查是否存在子命令,存在將 bigCmd 覆蓋,並檢查是否符合命令行是否符合規範,比如檢查 len(args[1:]) 如果為 0,則說明傳入的命令行沒有提供子命令。如果一切就緒,通過 continue 進行下一次循環,執行子命令的處理。

接著是命令選項和參數的解析。可以自定義處理規則,也可以直接使用 FlagSet.Parse 處理。

最後,調用 cmd.Run 執行邏輯處理。

總結

本文介紹了 Go 中如何通過 flag 實現子命令,從 FlagSet 這個結構體講起,通過 flag 包中默認提供的 CommandLine 梳理了 FlagSet 的處理邏輯。在基礎上,實現了子命令的相關功能。

本文最後,分析了 Go 源碼中 go 如何使用 flag 實現。因為是純粹使用 flag 包裸寫,讀起來稍微有點難度。本文只算是一個引子,至少幫助大家在大的方向不至於迷路,裡面更多的細節還需要自己挖掘。

參考資料

[1]

上篇文章: https://juejin.im/post/5ddb74d951882573461819f5

[2]

Go 命令行解析 flag 包之快速上手: https://juejin.im/post/5dd8f61cf265da7dde7687cb

[3]

Go 命令行解析 flag 包之擴展新類型: https://juejin.im/post/5ddb74d951882573461819f5

[4]

go/src/cmd/go/cmd/main.go: https://github.com/golang/go/blob/master/src/cmd/go/main.go


分享到:


相關文章: