1. 程式人生 > 程式設計 >Go 命令列解析 flag 包之通過子命令實現看 go 命令原始碼

Go 命令列解析 flag 包之通過子命令實現看 go 命令原始碼

上篇文章 介紹了 flag 中如何擴充套件一個新的型別支援。本篇介紹如何使用 flag 實現子命令,總的來說,這篇才是這個系列的核心,前兩篇只是鋪墊。

前兩篇文章連結如下:

Go 命令列解析 flag 包之快速上手
Go 命令列解析 flag 包之擴充套件新型別

希望看完本篇文章,如果再閱讀 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() 加入到了 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

上做了進一步的封裝,簡單的使用可以直接無視它的存在。

flag 中定義了一個全域性的 FlagSet 型別變數,CommandLine,用它表示整個命令列。可以說,CommandLineFlagSet 的一個特例,它的使用模式較為固定,所以在它之上能提供了一套預設的函式。

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

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

func Bool(name string,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,usage string) {
	// Remember the default value as a string; it won't change.
	flag := &Flag{name,usage,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 包裸寫,讀起來稍微有點難度。本文只算是一個引子,至少幫助大家在大的方向不至於迷路,裡面更多的細節還需要自己挖掘。