Go 語(yǔ)言實(shí)戰(zhàn):命令行(3)CLI 框架
經(jīng)過(guò)前面兩期的介紹,相信大家已經(jīng)可以寫(xiě)簡(jiǎn)單的命令行程序,并且能夠使用命令行參數(shù)。
即使遇到一些困難,建立直觀認(rèn)識(shí)和了解關(guān)鍵詞之后,在網(wǎng)絡(luò)上搜索答案也變得相對(duì)容易。
接下來(lái)介紹 CLI 框架。
命令行程序的前兩期:
命令行框架
對(duì)于簡(jiǎn)單的功能,單個(gè) go 文件,幾個(gè)函數(shù),完全是足夠的。沒(méi)有必要為了像那么回事,硬要分很多個(gè)包,每個(gè)文件就兩行代碼。為了框架而框架,屬于過(guò)早優(yōu)化。
但反過(guò)來(lái)說(shuō),隨著往項(xiàng)目里不斷添加特性,代碼越來(lái)越多,如何更好地組織代碼,達(dá)到解耦和復(fù)用,就成了必須要考慮的問(wèn)題。
我們當(dāng)然可以把自己的思考,體現(xiàn)在項(xiàng)目的代碼組織上,乃至從中抽取一套框架。但一個(gè)深思熟慮,適應(yīng)各種場(chǎng)景變化的框架,還是有門(mén)檻、需要技術(shù)和經(jīng)驗(yàn)積累的。
更便捷的做法,是引入社區(qū)熱門(mén)的框架,利用里面提供的腳手架減少重復(fù)勞動(dòng),并從中學(xué)習(xí)它的設(shè)計(jì)。
對(duì)于 CLI 程序而言,我知道的最流行的框架有兩個(gè),分別是:
urfave/cli:https://github.com/urfave/cli cobra:https://github.com/spf13/cobra
cobra 的功能會(huì)更強(qiáng)大完善。它的作者 Steve Francia(spf13)是 Google 里面 go 語(yǔ)言的 product lead,同時(shí)也是 gohugo、viper 等知名項(xiàng)目的作者。
但強(qiáng)大的同時(shí),也意味著框架更大更復(fù)雜,在實(shí)現(xiàn)一些小規(guī)模的工具時(shí),反而會(huì)覺(jué)得殺雞牛刀。所以這里只介紹 cli 這個(gè)框架,有興趣的朋友可以自行了解 cobra ,原理大同小異。
urfave/cli 框架
cli 目前已經(jīng)開(kāi)發(fā)到了 v2.0+。推薦使用最新的穩(wěn)定版本。
這里使用 go module 模式,那么引入 cli 包只需要在代碼開(kāi)頭
import "github.com/urfave/cli/v2"
如果還不熟悉 go module,或者不知道最后面的 v2 代表什么,請(qǐng)看這篇文章:《golang 1.13 - module VS package》。
簡(jiǎn)單說(shuō),go module 使用語(yǔ)義化版本(semver),認(rèn)為主版本號(hào)變更是『不兼容變更(breaking changes)』,需要體現(xiàn)在導(dǎo)入路徑上。v0.x (不穩(wěn)定版本,可以不兼容)和 v1.x (默認(rèn))不需要標(biāo),v2.0 及以上的版本,都需要把主版本號(hào)標(biāo)在 module 路徑的最后。
但是注意,這個(gè) v2 既不對(duì)應(yīng)實(shí)際的文件目錄,也不影響包名。在這里,包名仍然是 cli。
根據(jù)作者提供的例子,實(shí)現(xiàn)一個(gè)最小的 CLI 程序看看:
// 為了編譯后不用改名,module name 直接就叫 boom
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "boom",
Usage: "make an explosive entrance",
Action: func(c *cli.Context) error {
fmt.Println("boom! I say!")
return nil
},
}
err := app.Run(os.Args)
if err != nil {
log.Fatal(err)
}
}
這段代碼實(shí)現(xiàn)了一個(gè)叫 boom 的程序,執(zhí)行的時(shí)候會(huì)輸出 "boom! I say!":
>go build
>boom
boom! I say!
另外,框架已經(jīng)自動(dòng)生成了默認(rèn)的幫助信息。在調(diào)用 help 子命令,或者發(fā)生錯(cuò)誤時(shí),會(huì)輸出:
>boom help
NAME:
boom - make an explosive entrance
USAGE:
boom.exe [global options] command [command options] [arguments...]
COMMANDS:
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--help, -h show help (default: false)
這段代碼做的事情很簡(jiǎn)單。初始化一個(gè) cli.App ,設(shè)置三個(gè)字段:
名字,就是 "boom"。 用途,也是一個(gè)字符串,會(huì)在 help 信息用到。 動(dòng)作,也就是執(zhí)行程序時(shí)具體執(zhí)行什么內(nèi)容。這里是輸出一個(gè)字符串。
運(yùn)行部分,將命令行參數(shù) os.Args 作為參數(shù)傳遞給 cli.App 的 Run() 方法,框架就會(huì)接管參數(shù)的解析和后續(xù)的命令執(zhí)行。
如果是跟著教程一路過(guò)來(lái),那么很可能這里是第一次引入第三方包。IDE 可以會(huì)同時(shí)提示好幾個(gè)關(guān)于 "github.com/urfave/cli/v2" 的錯(cuò)誤,例如:"github.com/urfave/cli/v2 is not in your go.mod file" 。
可以根據(jù) IDE 的提示修復(fù),或者執(zhí)行 go mod tidy ,或者直接等 go build 時(shí)自動(dòng)解決依賴。無(wú)論選擇哪一種,最終都會(huì)往 go.mod 里添加一行 require github.com/urfave/cli/v2 。
重構(gòu)
當(dāng)然,實(shí)現(xiàn)這么簡(jiǎn)單的功能,除了幫忙生成幫助信息,框架也沒(méi)什么用武之地。
接下來(lái)我們用框架把 gosrot 改造一下,在基本不改變功能的前提下,把 cli 包用上。
因?yàn)橛辛?cli 包處理參數(shù),我們就不用 flag 包了。(其實(shí) cli 里面用到了 flag 包。)
func main() {
app := &cli.App{
Name: "gosort",
Usage: "a simple command line sort tool",
Action: sortCmd,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "lex",
Aliases: []string{"l"},
Usage: "sort lexically",
Destination: &lex,
},
// unique 同為 BoolFlag,省略,請(qǐng)自行補(bǔ)完
// ...
&cli.StringFlag{
Name: "from",
Aliases: []string{"f"},
// `FILE` 是占位符,在幫助信息中會(huì)輸出 -f FILE input from FILE
// 用戶能更容易理解 FILE 的用途
Usage: "input from `FILE`",
Destination: &from,
},
// 省略剩余的 StringFlag...
},
}
err := app.Run(os.Args)
if err != nil {
log.Fatal(err)
}
}
cli 的 Flag 跟 flag 包類似,有兩種設(shè)置方法。既可以設(shè)置以后通過(guò) cli.Context 的方法讀取值:ctx.Bool("lex") (string 等其它類型以此類推)。也可以直接把變量地址設(shè)置到 Destination 字段,解析后直接訪問(wèn)對(duì)應(yīng)的變量。
這里為減少函數(shù)傳參,用了后者,把參數(shù)值存儲(chǔ)到全局(包級(jí))變量。
程序入口改為 cli.App 之后,原來(lái)的 main() 函數(shù)就改為 sortCmd ,作為 app 的 Action 字段。
// 增加 Context 參數(shù) 和返回 error,以滿足 cli.ActionFunc (Action 字段的類型)簽名
func sortCmd(ctx *cli.Context) error {
// 不再需要設(shè)置 flag 包
var strs []string
if from != "" {
if !isFile(from) {
return fmt.Errorf("%s is not a file", from)
}
buf, err := ioutil.ReadFile(from)
if err != nil {
return fmt.Errorf("read %s fail, caused by\n\t%w", from, err)
}
// 篇幅關(guān)系,省略... 參考之前兩期的內(nèi)容
}
// 省略...
if output == "" {
fmt.Println(res)
} else {
err := ioutil.WriteFile(output, []byte(res), 0666)
if err != nil {
return fmt.Errorf("write result to %s fail, caused by\n\t%w", output, err)
}
}
return nil
}
由于程序被封裝成了 cli.App ,程序的執(zhí)行交給框架處理, sortCmd 內(nèi)部不再自行調(diào)用 os.Exit(1) 退出,而是通過(guò)返回 error 類型,將錯(cuò)誤信息傳遞給上層處理。
這里主要使用 fmt.Errorf() 格式化錯(cuò)誤信息然后返回。從 1.13 開(kāi)始,fmt.Errorf() 提供了一個(gè)新的格式化動(dòng)詞 %w ,允許將底層的錯(cuò)誤信息,包裝在新的錯(cuò)誤信息里面,形成錯(cuò)誤信息鏈。后續(xù)可以通過(guò) errors 包的三個(gè)函數(shù) Is() , As() 和 Unwrap() ,對(duì)錯(cuò)誤信息進(jìn)行進(jìn)一步分析處理。
接下來(lái)編譯執(zhí)行
>go build
# 不同參數(shù)的含義參考上一期內(nèi)容
>gosort -h
NAME:
gosort - a simple command line sort tool
USAGE:
gosort [global options] command [command options] [arguments...]
COMMANDS:
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--lex, -l sort lexically (default: false)
--unique, -u remove duplicates (default: false)
--from FILE, -f FILE input from FILE
--output FILE, -o FILE output to FILE
--insep value, -i value input seperator
--outSep value, -s value output seperator (default: ",")
--help, -h show help (default: false)
>gosort -u -i=, -s=- 111,111,555,678,333,567,678
111-333-555-567-678
如果完全照著教程的思路重構(gòu),到這一步,你可能會(huì)發(fā)現(xiàn),代碼可以編譯和運(yùn)行,卻沒(méi)有輸出。這是因?yàn)橛幸粋€(gè)地方很容易忘記修改。 請(qǐng)嘗試自行找到問(wèn)題所在,并解決。
另起爐灶
框架除了解析參數(shù),自動(dòng)生成規(guī)范的幫助信息,還有一個(gè)主要的作用,是子命令(subcommand)的組織和管理。
gosort 主要圍繞一個(gè)目的(提交號(hào)的排序去重)展開(kāi),各項(xiàng)功能是組合而不是并列的關(guān)系,更適合作為參數(shù),而不是拆分成多個(gè)子命令。而且之前的開(kāi)發(fā)容易形成思維定勢(shì),下面我們另舉一例,不在 gosort 基礎(chǔ)上修改。
為了容易理解,接下來(lái)用大家比較熟悉的 git 做例子。篇幅關(guān)系,只展示項(xiàng)目可能的結(jié)構(gòu),不(可能)涉及具體的代碼實(shí)現(xiàn)。
首先,我們看一下 git 有哪些命令:
>git help
usage: git [--version] [--help] [-C <path>] [-c name=value]
[--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
[-p | --paginate | --no-pager] [--no-replace-objects] [--bare]
[--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
<command> [<args>]
These are common Git commands used in various situations:
start a working area (see also: git help tutorial)
clone Clone a repository into a new directory
init Create an empty Git repository or reinitialize an existing one
work on the current change (see also: git help everyday)
add Add file contents to the index
mv Move or rename a file, a directory, or a symlink
// 篇幅關(guān)系,省略余下內(nèi)容,你可以自己嘗試執(zhí)行 git help 查看
總的來(lái)說(shuō),就是有一系列的全局選項(xiàng)(global options,跟在 git 后面,command 之前),一系列子命令(subcommand),每個(gè)命令下面還有一些專屬的參數(shù)。
這樣的工具,有幾個(gè)特點(diǎn):
功能強(qiáng)大,子功能很多,無(wú)法用一個(gè)命令 + 若干參數(shù)完成,一般實(shí)現(xiàn)為多個(gè)子命令。 既有影響多數(shù)子命令的全局選項(xiàng),也有某些子命令專屬的選項(xiàng)。 子命令之間,既相互獨(dú)立,又共享一部分底層實(shí)現(xiàn)。
為了更好地組織程序,項(xiàng)目結(jié)構(gòu)可以是這樣子的:
│ go.mod
│ go.sum
│ main.go
│
├───cmd
│ add.go
│ clone.go
│ common.go
│ init.go
│ mv.go
| ......
│
└───pkg
├───hash
│ hash.go
│
├───zip
| zip.go
│
├───......
main.go 是程序入口,為了保持結(jié)構(gòu)清晰,這里只是初始化并運(yùn)行 cli.App :
package main
import (
"log"
"mygit/cmd"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "mygit",
Usage: "a free and open source distributed version control system",
Version: "v0.0.1",
UseShortOptionHandling: true,
Flags: cmd.GlobalOptions,
// Before 在任意命令執(zhí)行前執(zhí)行,這里用來(lái)處理全局選項(xiàng)
Before: cmd.LoadGlobalOptions,
// 同理,也可以定義 After 來(lái)執(zhí)行收尾操作
// After: xxx
Commands: cmd.Commands,
}
err := app.Run(os.Args)
if err != nil && err != cmd.ErrPrintAndExit {
log.Fatal(err)
}
}
具體的代碼實(shí)現(xiàn)放到 cmd 包,基本上一個(gè)子命令對(duì)應(yīng)一個(gè)源文件,代碼查找起來(lái)非常清晰。
common.go 存放 cmd 包的公共內(nèi)容:
package cmd
import (
"errors"
"fmt"
"github.com/urfave/cli/v2"
)
// Commands 將子命令統(tǒng)一暴露給 main 包
var Commands = []*cli.Command{
cloneCmd,
initCmd,
addCmd,
mvCmd,
// more subcommands ...
}
// GlobalOptions 將全局選項(xiàng)暴露給 main 包
var GlobalOptions = []cli.Flag{
&cli.PathFlag{
Name: "C",
Usage: "Run as if git was started in `path` instead of the current working directory",
},
&cli.PathFlag{
Name: "exec-path",
Usage: "`path` to wherever your core Git programs are installed",
},
&cli.BoolFlag{
Name: "html-path",
Usage: "Print the path, without trailing slash, where Git’s HTML documentation is installed and exit",
},
// 省略 man-path, info-path, paginate, no-pager...
// more ...
}
// ErrPrintAndExit 表示遇到需要打印信息并提前退出的情形,不需要打印錯(cuò)誤信息
var ErrPrintAndExit = errors.New("print and exit")
// LoadGlobalOptions 加載全局選項(xiàng)
var LoadGlobalOptions = func(ctx *cli.Context) error {
// 并非實(shí)際實(shí)現(xiàn),所以遇到對(duì)應(yīng)的參數(shù)只是輸出信息,方便觀察
// 全局選項(xiàng)既可以在這里讀取并設(shè)置全局狀態(tài)(如有)
// 也可以在具體實(shí)現(xiàn)處再通過(guò) ctx 讀取(參考 add)
if ctx.IsSet("C") {
fmt.Println("started path changed to", ctx.Path("C"))
}
// 省略 exec-path ...
if ctx.Bool("html-path") {
fmt.Println("html-path is xxx")
return ErrPrintAndExit
}
// 省略 man-path, info-path ...
if ctx.Bool("paginate") || !ctx.Bool("no-pager") {
fmt.Println("pipe output into pager like less")
} else {
fmt.Println("no pager")
}
return nil
}
// 子命令分組
const (
cmdGroupStart = "start a working area"
cmdGroupWork = "work on current change"
// ...
)
除了業(yè)務(wù)相關(guān)的公共邏輯放在 common.go,還有一些業(yè)務(wù)中立的底層公共類庫(kù),就可以放在 pkg 下面,例如 hash.go :
package hash
// MyHash 返回 source 的 hash 結(jié)果
func MyHash(source string) string {
// 這是一個(gè)假的實(shí)現(xiàn)
return "hash of " + source
}
看一下其中一個(gè)子命令 add 的代碼:
package cmd
import (
"fmt"
"mygit/pkg/hash"
"github.com/urfave/cli/v2"
)
var addCmd = &cli.Command{
Name: "add",
Usage: "Add file contents to the index",
Category: cmdGroupWork, // 子命令分組
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Be verbose",
},
&cli.BoolFlag{
Name: "force",
Aliases: []string{"f"},
Usage: "Allow adding otherwise ignored files",
},
// more options ...
},
Action: func(ctx *cli.Context) error {
// 僅輸出信息,查看效果,不是真實(shí)實(shí)現(xiàn)
// 這里也能讀取全局選項(xiàng)
if ctx.IsSet("C") {
// do something
}
items := ctx.Args().Slice()
if ctx.Bool("verbose") {
for _, item := range items {
fmt.Println("add", item, ", hash is [", hash.MyHash(item), "]")
}
}
fmt.Println("add", items, "successfully.")
return nil
},
}
擁有相同 Category 字段的命令會(huì)自動(dòng)分組。這里在 common.go 預(yù)定義了一系列的分組,然后直接引用。之所以不是直接用字面量,是因?yàn)樵诙嗵幰米置媪浚浅H菀壮鲥e(cuò),也不利于后續(xù)修改。
舉例說(shuō),如果不小心在組名里輸入多了一個(gè) "s" ,就會(huì)變成下面這樣:
COMMANDS:
help, h Shows a list of commands or help for one command
start a working area:
clone Clone a repository into a new directory
init Create an empty Git repository or reinitialize an existing one
work on current change:
add Add file contents to the index
work on current changes:
mv Move or rename a file, a directory, or a symlink
好了,一個(gè)連低仿都不算的 git 算是搭出一個(gè)空架子,編譯執(zhí)行看看:
>go build
# help 命令和 --help, --version 框架會(huì)自動(dòng)添加,如果不需要可以通過(guò)特定的字段關(guān)閉
>mygit help
pipe output into pager like less
NAME:
mygit - a free and open source distributed version control system
USAGE:
mygit [global options] command [command options] [arguments...]
VERSION:
v0.0.1
COMMANDS:
help, h Shows a list of commands or help for one command
start a working area:
clone Clone a repository into a new directory
init Create an empty Git repository or reinitialize an existing one
work on current change:
add Add file contents to the index
mv Move or rename a file, a directory, or a symlink
GLOBAL OPTIONS:
-C path Run as if git was started in path instead of the current working directory
--exec-path path path to wherever your core Git programs are installed
--html-path Print the path, without trailing slash, where Git’s HTML documentation is installed and exit (default: false)
--man-path Print the manpath (see man(1)) for the man pages for this version of Git and exit (default: false)
--info-path Print the path where the Info files documenting this version of Git are installed and exit (default: false)
--paginate, -p Pipe all output into less (or if set, $PAGER) if standard output is a terminal (default: false)
--no-pager Do not pipe Git output into a pager (default: false)
--help, -h show help (default: false)
--version, -v print the version (default: false)
# help 命令連子命令的幫助信息也自動(dòng)生成了
>mygit help add
pipe output into pager like less
NAME:
mygit add - Add file contents to the index
USAGE:
mygit add [command options] [arguments...]
CATEGORY:
work on current change
OPTIONS:
--verbose, -v Be verbose (default: false)
--force, -f Allow adding otherwise ignored files (default: false)
>mygit -C here add a b c
started path changed to here
pipe output into pager like less
started path changed to here
add [a b c] successfully.
>mygit add -v a b c
pipe output into pager like less
add a , hash is [ hash of a ]
add b , hash is [ hash of b ]
add c , hash is [ hash of c ]
add [a b c] successfully.
光看幫助信息是不是感覺(jué)還挺像回事。
希望通過(guò)這個(gè)粗糙的例子,能讓大家對(duì) urfave/cli 這個(gè)框架建立一點(diǎn)直觀的印象。
更多的例子、更詳細(xì)的字段用法,可以參考
項(xiàng)目主頁(yè):https://github.com/urfave/cli 文檔:https://pkg.go.dev/github.com/urfave/cli/v2
最后
在實(shí)際寫(xiě)過(guò)幾個(gè) go 程序之后,相信大家對(duì)于 go 已經(jīng)有一些直觀的認(rèn)識(shí)。與此同時(shí),前面只介紹了很少一部分語(yǔ)言特性,在實(shí)際編程中可能會(huì)產(chǎn)生各種疑惑。
推薦閱讀
