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

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

前兩篇文章連接以下:github

Go 命令行解析 flag 包之快速上手
Go 命令行解析 flag 包之擴展新類型golang

但願看完本篇文章,若是再閱讀 go 命令的實現源碼,至少在總體結構上不會迷失方向了。數組

FlagSet

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

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

$ 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() 加入到了 formalformal 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 有 formalactual,參數 args函數

若是有人說,FlagSet 是命令行實現的核心,仍是比較認同的。之因此前面一直沒有提到它,主要是 flag 包爲了簡化命令行的處理流程,在 FlagSet 上作了進一步的封裝,簡單的使用能夠直接無視它的存在。oop

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

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

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.BoolVarflag.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 方法爲其增長了一個選項 Flagorigin

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

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

	fmt.Println("list --oriign", origin)
	return nil
}
複製代碼

最後的 Exec 函數組合 InitRun 函數,已提供給 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 下,經過 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 包裸寫,讀起來稍微有點難度。本文只算是一個引子,至少幫助你們在大的方向不至於迷路,裏面更多的細節還須要本身挖掘。


相關文章
相關標籤/搜索