Go 每日一庫之 cobra

darjun發表於2020-01-18

簡介

cobra是一個命令列程式庫,可以用來編寫命令列程式。同時,它也提供了一個腳手架, 用於生成基於 cobra 的應用程式框架。非常多知名的開源專案使用了 cobra 庫構建命令列,如KubernetesHugoetcd等等等等。 本文介紹 cobra 庫的基本使用和一些有趣的特性。

關於作者spf13,這裡多說兩句。spf13 開源不少專案,而且他的開源專案質量都比較高。 相信使用過 vim 的都知道spf13-vim,號稱 vim 終極配置。 可以一鍵配置,對於我這樣的懶人來說絕對是福音。他的viper是一個完整的配置解決方案。 完美支援 JSON/TOML/YAML/HCL/envfile/Java properties 配置檔案等格式,還有一些比較實用的特性,如配置熱更新、多查詢目錄、配置儲存等。 還有非常火的靜態網站生成器hugo也是他的作品。

快速使用

第三方庫都需要先安裝,後使用。下面命令安裝了cobra生成器程式和 cobra 庫:

$ go get github.com/spf13/cobra/cobra
複製程式碼

如果出現了golang.org/x/text庫找不到之類的錯誤,需要手動從 GitHub 上下載該庫,再執行上面的安裝命令。我以前寫過一篇部落格搭建 Go 開發環境提到了這個方法。

我們實現一個簡單的命令列程式 git,當然這不是真的 git,只是模擬其命令列。最終還是通過os/exec庫呼叫外部程式執行真實的 git 命令,返回結果。 所以我們的系統上要安裝 git,且 git 在可執行路徑中。目前我們只新增一個子命令version。目錄結構如下:

▾ get-started/
    ▾ cmd/
        helper.go
        root.go
        version.go
    main.go
複製程式碼

root.go

package cmd

import (
  "errors"

  "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command {
  Use: "git",
  Short: "Git is a distributed version control system.",
  Long: `Git is a free and open source distributed version control system
designed to handle everything from small to very large projects 
with speed and efficiency.`,
  Run: func(cmd *cobra.Command, args []string) {
    Error(cmd, args, errors.New("unrecognized command"))
  },
}

func Execute() {
  rootCmd.Execute()
}
複製程式碼

version.go

package cmd

import (
  "fmt"
  "os"

  "github.com/spf13/cobra"
)

var versionCmd = &cobra.Command {
  Use: "version",
  Short: "version subcommand show git version info.",
  
  Run: func(cmd *cobra.Command, args []string) {
    output, err := ExecuteCommand("git", "version", args...)
    if err != nil {
      Error(cmd, args, err)
    }

    fmt.Fprint(os.Stdout, output)
  },
}

func init() {
  rootCmd.AddCommand(versionCmd)
}
複製程式碼

main.go檔案中只是呼叫命令入口:

package main

import (
  "github.com/darjun/go-daily-lib/cobra/get-started/cmd"
)

func main() {
  cmd.Execute()
}
複製程式碼

為了編碼方便,在helpers.go中封裝了呼叫外部程式和錯誤處理函式:

package cmd

import (
  "fmt"
  "os"
  "os/exec"

  "github.com/spf13/cobra"
)

func ExecuteCommand(name string, subname string, args ...string) (string, error) {
  args = append([]string{subname}, args...)

  cmd := exec.Command(name, args...)
  bytes, err := cmd.CombinedOutput()

  return string(bytes), err
}

func Error(cmd *cobra.Command, args []string, err error) {
  fmt.Fprintf(os.Stderr, "execute %s args:%v error:%v\n", cmd.Name(), args, err)
  os.Exit(1)
}
複製程式碼

每個 cobra 程式都有一個根命令,可以給它新增任意多個子命令。我們在version.goinit函式中將子命令新增到根命令中。

編譯程式。注意,不能直接go run main.go,這已經不是單檔案程式了。如果強行要用,請使用go run .

$ go build -o main.exe
複製程式碼

cobra 自動生成的幫助資訊,very cool:

$ ./main.exe -h
Git is a free and open source distributed version control system
designed to handle everything from small to very large projects
with speed and efficiency.

Usage:
  git [flags]
  git [command]

Available Commands:
  help        Help about any command
  version     version subcommand show git version info.

Flags:
  -h, --help   help for git

Use "git [command] --help" for more information about a command.
複製程式碼

單個子命令的幫助資訊:

$ ./main.exe version -h
version subcommand show git version info.

Usage:
  git version [flags]

Flags:
  -h, --help   help for version
複製程式碼

呼叫子命令:

$ ./main.exe version
git version 2.19.1.windows.1
複製程式碼

未識別的子命令:

$ ./main.exe clone
Error: unknown command "clone" for "git"
Run 'git --help' for usage.
複製程式碼

編譯時可以將main.exe改成git,用起來會更有感覺?。

$ go build -o git
$ ./git version
git version 2.19.1.windows.1
複製程式碼

使用 cobra 構建命令列時,程式的目錄結構一般比較簡單,推薦使用下面這種結構:

▾ appName/
    ▾ cmd/
        cmd1.go
        cmd2.go
        cmd3.go
        root.go
    main.go
複製程式碼

每個命令實現一個檔案,所有命令檔案存放在cmd目錄下。外層的main.go僅初始化 cobra。

特性

cobra 提供非常豐富的功能:

  • 輕鬆支援子命令,如app serverapp fetch等;
  • 完全相容 POSIX 選項(包括短、長選項);
  • 巢狀子命令;
  • 全域性、本地層級選項。可以在多處設定選項,按照一定的順序取用;
  • 使用腳手架輕鬆生成程式框架和命令。

首先需要明確 3 個基本概念:

  • 命令(Command):就是需要執行的操作;
  • 引數(Arg):命令的引數,即要操作的物件;
  • 選項(Flag):命令選項可以調整命令的行為。

下面示例中,server是一個(子)命令,--port是選項:

hugo server --port=1313

下面示例中,clone是一個(子)命令,URL是引數,--bare是選項:

git clone URL --bare

命令

在 cobra 中,命令和子命令都是用Command結構表示的。Command有非常多的欄位,用來定製命令的行為。 在實際中,最常用的就那麼幾個。我們在前面示例中已經看到了Use/Short/Long/Run

Use指定使用資訊,即命令怎麼被呼叫,格式為name arg1 [arg2]name為命令名,後面的arg1為必填引數,arg3為可選引數,引數可以多個。

Short/Long都是指定命令的幫助資訊,只是前者簡短,後者詳盡而已。

Run是實際執行操作的函式。

定義新的子命令很簡單,就是建立一個cobra.Command變數,設定一些欄位,然後新增到根命令中。例如我們要新增一個clone子命令:

package cmd

import (
  "fmt"
  "os"

  "github.com/spf13/cobra"
)

var cloneCmd = &cobra.Command {
  Use: "clone url [destination]",
  Short: "Clone a repository into a new directory",
  Run: func(cmd *cobra.Command, args []string) {
    output, err := ExecuteCommand("git", "clone", args...)
    if err != nil {
      Error(cmd, args, err)
    }

    fmt.Fprintf(os.Stdout, output)
  },
}

func init() {
  rootCmd.AddCommand(cloneCmd)
}
複製程式碼

其中Use欄位clone url [destination]表示子命令名為clone,引數url是必須的,目標路徑destination可選。

我們將程式編譯為mygit可執行檔案,然後將它放到$GOPATH/bin中。我喜歡將$GOPATH/bin放到$PATH中,所以可以直接呼叫mygit命令了:

$ go build -o mygit
$ mv mygit $GOPATH/bin
$ mygit clone https://github.com/darjun/leetcode
Cloning into 'leetcode'...
複製程式碼

大家可以繼續新增命令。但是我這邊只是偷了個懶,將操作都轉發到實際的 git 去執行了。這確實沒什麼實際的用處。 有這個思路,試想一下,我們可以結合多個命令實現很多有用的工具,例如打包工具?。

選項

cobra 中選項分為兩種,一種是永久選項,定義它的命令和其子命令都可以使用。通過給根命令新增一個選項定義全域性選項。 另一種是本地選項,只能在定義它的命令中使用。

cobra 使用pflag解析命令列選項。pflag使用上基本與flag相同,該系列文章有一篇介紹flag庫的,Go 每日一庫之 flag

flag一樣,儲存選項的變數也需要提前定義好:

var Verbose bool
var Source string
複製程式碼

設定永久選項:

rootCmd.PersistentFlags().BoolVarP(&Verbose, "verbose", "v", false, "verbose output")
複製程式碼

設定本地選項:

localCmd.Flags().StringVarP(&Source, "source", "s", "", "Source directory to read from")
複製程式碼

兩種引數都是相同的,長選項/短選項名、預設值和幫助資訊。

下面,我們通過一個案例來演示選項的使用。

假設我們要做一個簡單的計算器,支援加、減、乘、除操作。並且可以通過選項設定是否忽略非數字引數,設定除 0 是否報錯。 顯然,前一個選項應該放在全域性選項中,後一個應該放在除法命令中。程式結構如下:

▾ math/
    ▾ cmd/
        add.go
        divide.go
        minus.go
        multiply.go
        root.go
    main.go
複製程式碼

這裡展示divide.goroot.go,其它命令檔案都類似。完整程式碼我放在GitHub上了。

divide.go

var (
  dividedByZeroHanding int // 除 0 如何處理
)
var divideCmd = &cobra.Command {
  Use: "divide",
  Short: "Divide subcommand divide all passed args.",
  Run: func(cmd *cobra.Command, args []string) {
    values := ConvertArgsToFloat64Slice(args, ErrorHandling(parseHandling))
    result := calc(values, DIVIDE)
    fmt.Printf("%s = %.2f\n", strings.Join(args, "/"), result)
  },
}

func init() {
  divideCmd.Flags().IntVarP(&dividedByZeroHanding, "divide_by_zero", "d", int(PanicOnDividedByZero), "do what when divided by zero")

  rootCmd.AddCommand(divideCmd)
}
複製程式碼

root.go

var (
  parseHandling int
)

var rootCmd = &cobra.Command {
  Use: "math",
  Short: "Math calc the accumulative result.",
  Run: func(cmd *cobra.Command, args []string) {
    Error(cmd, args, errors.New("unrecognized subcommand"))
  },
}

func init() {
  rootCmd.PersistentFlags().IntVarP(&parseHandling, "parse_error", "p", int(ContinueOnParseError), "do what when parse arg error")
}

func Execute() {
  rootCmd.Execute()
}
複製程式碼

divide.go中定義瞭如何處理除 0 錯誤的選項,在root.go中定義瞭如何處理解析錯誤的選項。選項列舉如下:

const (
  ContinueOnParseError  ErrorHandling = 1 // 解析錯誤嘗試繼續處理
  ExitOnParseError      ErrorHandling = 2 // 解析錯誤程式停止
  PanicOnParseError     ErrorHandling = 3 // 解析錯誤 panic
  ReturnOnDividedByZero ErrorHandling = 4 // 除0返回
  PanicOnDividedByZero  ErrorHandling = 5 // 除0 painc
)
複製程式碼

其實命令的執行邏輯並不複雜,就是將引數轉為float64。然後執行相應的運算,輸出結果。

測試程式:

$ go build -o math
$ ./math add 1 2 3 4
1+2+3+4 = 10.00

$ ./math minus 1 2 3 4
1-2-3-4 = -8.00

$ ./math multiply 1 2 3 4
1*2*3*4 = 24.00

$ ./math divide 1 2 3 4
1/2/3/4 = 0.04
複製程式碼

預設情況,解析錯誤被忽略,只計算格式正確的引數的結果:

$ ./math add 1 2a 3b 4
1+2a+3b+4 = 5.00

$ ./math divide 1 2a 3b 4
1/2a/3b/4 = 0.25
複製程式碼

設定解析失敗的處理,2 表示退出程式,3 表示 panic(看上面的列舉):

$ ./math add 1 2a 3b 4 -p 2
invalid number: 2a

$ ./math add 1 2a 3b 4 -p 3
panic: strconv.ParseFloat: parsing "2a": invalid syntax

goroutine 1 [running]:
github.com/darjun/go-daily-lib/cobra/math/cmd.ConvertArgsToFloat64Slice(0xc00004e300, 0x4, 0x6, 0x3, 0xc00008bd70, 0x504f6b, 0xc000098600)
    D:/code/golang/src/github.com/darjun/go-daily-lib/cobra/math/cmd/helper.go:58 +0x2c3
github.com/darjun/go-daily-lib/cobra/math/cmd.glob..func1(0x74c620, 0xc00004e300, 0x4, 0x6)
    D:/code/golang/src/github.com/darjun/go-daily-lib/cobra/math/cmd/add.go:14 +0x6d
github.com/spf13/cobra.(*Command).execute(0x74c620, 0xc00004e1e0, 0x6, 0x6, 0x74c620, 0xc00004e1e0)
    D:/code/golang/src/github.com/spf13/cobra/command.go:835 +0x2b1
github.com/spf13/cobra.(*Command).ExecuteC(0x74d020, 0x0, 0x599ee0, 0xc000056058)
    D:/code/golang/src/github.com/spf13/cobra/command.go:919 +0x302
github.com/spf13/cobra.(*Command).Execute(...)
    D:/code/golang/src/github.com/spf13/cobra/command.go:869
github.com/darjun/go-daily-lib/cobra/math/cmd.Execute(...)
    D:/code/golang/src/github.com/darjun/go-daily-lib/cobra/math/cmd/root.go:45
main.main()
    D:/code/golang/src/github.com/darjun/go-daily-lib/cobra/math/main.go:8 +0x35
複製程式碼

至於除 0 選項大家自己試試。

細心的朋友應該都注意到了,該程式還有一些不完善的地方。例如這裡如果輸入非數字引數,該引數也會顯示在結果中:

$ ./math add 1 2 3d cc
1+2+3d+cc = 3.00
複製程式碼

感興趣可以自己完善一下~

腳手架

通過前面的介紹,我們也看到了其實 cobra 命令的框架還是比較固定的。這就有了工具的用武之地了,可極大地提高我們的開發效率。

前面安裝 cobra 庫的時候也將腳手架程式安裝好了。下面我們介紹如何使用這個生成器。

使用cobra init命令建立一個 cobra 應用程式:

$ cobra init scaffold --pkg-name github.com/darjun/go-daily-lib/cobra/scaffold
複製程式碼

其中scaffold為應用程式名,後面通過pkg-name選項指定包路徑。生成的程式目錄結構如下:

▾ scaffold/
    ▾ cmd/
        root.go
    LICENSE
    main.go
複製程式碼

這個專案結構與之前介紹的完全相同,也是 cobra 推薦使用的結構。同樣地,main.go也僅僅是入口。

root.go中,工具額外幫我們生成了一些程式碼。

在根命令中新增了配置檔案選項,大部分應用程式都需要配置檔案:

func init() {
  cobra.OnInitialize(initConfig)

  rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.scaffold.yaml)")
  rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
複製程式碼

在初始化完成的回撥中,如果發現該選項為空,則預設使用主目錄下的.scaffold.yaml檔案:

func initConfig() {
  if cfgFile != "" {
    viper.SetConfigFile(cfgFile)
  } else {
    home, err := homedir.Dir()
    if err != nil {
      fmt.Println(err)
      os.Exit(1)
    }

    viper.AddConfigPath(home)
    viper.SetConfigName(".scaffold")
  }

  viper.AutomaticEnv()

  if err := viper.ReadInConfig(); err == nil {
    fmt.Println("Using config file:", viper.ConfigFileUsed())
  }
}
複製程式碼

這裡用到了我前幾天介紹的go-homedir庫。配置檔案的讀取使用了 spf13 自己的開源專案viper(毒龍?真是起名天才)。

除了程式碼檔案,cobra 還生成了一個 LICENSE 檔案。

現在這個程式還不能做任何事情,我們需要給它新增子命令,使用cobra add命令:

$ cobra add date
複製程式碼

該命令在cmd目錄下新增了date.go檔案。基本結構已經搭好了,剩下的就是修改一些描述,新增一些選項了。

我們現在實現這樣一個功能,根據傳入的年、月,列印這個月的日曆。如果沒有傳入選項,使用當前的年、月。

選項定義:

func init() {
  rootCmd.AddCommand(dateCmd)

  dateCmd.PersistentFlags().IntVarP(&year, "year", "y", 0, "year to show (should in [1000, 9999]")
  dateCmd.PersistentFlags().IntVarP(&month, "month", "m", 0, "month to show (should in [1, 12]")
}
複製程式碼

修改dateCmdRun函式:

Run: func(cmd *cobra.Command, args []string) {
  if year < 1000 && year > 9999 {
    fmt.Fprintln(os.Stderr, "invalid year should in [1000, 9999], actual:%d", year)
    os.Exit(1)
  }

  if month < 1 && year > 12 {
    fmt.Fprintln(os.Stderr, "invalid month should in [1, 12], actual:%d", month)
    os.Exit(1)
  }

  showCalendar()
}
複製程式碼

showCalendar函式就是利用time提供的方法實現的,這裡就不贅述了。感興趣可以去我的 GitHub 上檢視實現。

看看程式執行效果:

$ go build -o main.exe
$ ./main.exe date
  Sun  Mon  Tue  Wed  Thu  Fri  Sat
                   1    2    3    4
    5    6    7    8    9   10   11
    12   13   14   15   16   17   18
    19   20   21   22   23   24   25
    26   27   28   29   30   31

$ ./main.exe date --year 2019 --month 12
  Sun  Mon  Tue  Wed  Thu  Fri  Sat
    1    2    3    4    5    6    7
    8    9   10   11   12   13   14
   15   16   17   18   19   20   21
   22   23   24   25   26   27   28
   29   30   31
複製程式碼

可以再為這個程式新增其他功能,試一試吧~

其他

cobra 提供了非常豐富的特性和定製化介面,例如:

  • 設定鉤子函式,在命令執行前、後執行某些操作;
  • 生成 Markdown/ReStructed Text/Man Page 格式的文件;
  • 等等等等。

由於篇幅限制,就不一一介紹了。有興趣可自行研究。cobra 庫的使用非常廣泛,很多知名專案都有用到,前面也提到過這些專案。 學習這些專案是如何使用 cobra 的,可以從中學習 cobra 的特性和最佳實踐。這也是學習開源專案的一個很好的途徑。

文中所有示例程式碼都已上傳至我的 GitHub,Go 每日一庫github.com/darjun/go-d…

參考

  1. cobra GitHub 倉庫

我的部落格

歡迎關注我的微信公眾號【GoUpUp】,共同學習,一起進步~

Go 每日一庫之 cobra

本文由部落格一文多發平臺 OpenWrite 釋出!

相關文章