如何構建現代化的 Go 命令行工具?

在 Go 項目開發中,我們需要編寫 main 函數,並編譯成爲二進制文件,部署啓動服務。有多種方式可以開發一個 main 函數。例如你可以手擼一個 main 函數,並在 main 函數中處理命令行參數,配置文件解析,應用初始化等操作。如下所示:

package main

import (
 "flag"
 "fmt"
)

func main() {
 // 解析命令行參數
 option1 := flag.String("option1", "default_value", "Description of option 1")
 option2 := flag.Int("option2", 0, "Description of option 2")
 flag.Parse()

 // 執行簡單的業務邏輯
 fmt.Println("Option 1 value:", *option1)
 fmt.Println("Option 2 value:", *option2)

 // 在這裏添加您的業務邏輯代碼
}

雖然可以手擼一個應用,但是 開發效率低下,要處理各種場景,開發出來的應用還不怎麼優雅。爲了,解決這些問題,社區湧現出了大批優秀的應用開發框架,例如:kingpin、cli、cobra 等。開發者可以直接複用這些應用開發框架,來構建優秀的命令行工具,這也是當前開發應用時,採用最多的方式。

當前社區最受歡迎的應用開發框架是 cobra。本文就來詳細介紹下 cobra 框架的功能及使用方式。

Cobra 包介紹

Cobra 是一個可以創建強大的現代 CLI 應用程序的庫,它還提供了一個可以生成應用和命令文件的程序的命令行工具:cobra-cli。有許多大型項目都是用 cobra 來構建他們的應用程序,例如:kubernetes、Docker、Etcd、Rkt、Hugo 等。Cobra 具有很多特性,一些核心特性如下:

Cobra 建立在 commands、arguments 和 flags 結構之上。commands 代表命令,arguments 代表非選項參數,flags 代表選項參數(也叫標誌)。一個好的應用程序應該是易懂的,用戶可以清晰的知道如何去使用這個應用程序。應用程序通常遵循如下模式:APPNAME VERB NOUN --ADJECTIVE 或者 APPNAME COMMAND ARG --FLAG,例如:

git clone URL --bare # clone 是一個命令,URL 是一個非選項參數,bare 是一個選項參數

這裏,VERB 代表動詞,NOUN 代碼名詞,ADJECTIVE 代表形容詞。

cobra-cli 命令安裝

Cobra 提供了一個 cobra-cli 命令,用來初始化一個應用程序併爲其添加命令,方便我們開發基於 Cobra 的應用。cobra-cli 命令安裝方法如下:

$ go install github.com/spf13/cobra-cli@latest

cobra-cli 命令提供了 4 個子命令:

cobra-cli 命令還提供了一些全局的參數:

Cobra 使用方法

在構建 cobra 應用時,我們可以自行組織代碼目錄結構,但 cobra 建議如下目錄結構:

▾ appName/
    ▾ cmd/
        add.go
        your.go
        commands.go
        here.go
      main.go

main.go 文件目的只有一個,初始化 cobra 應用:

package main

import (
  "{pathToYourApp}/cmd"
)

func main() {
  cmd.Execute()
}

Cobra 包常用功能

Cobra 包提供了多種功能,可以讓你構建一個優秀的應用。本小節,來詳細介紹 cobra 包支持的核心功能,及使用方式。

使用 cobra-cli 命令生成應用程序並添加子命令

我們可以選擇使用 cobra-cli 命令行工具,來快速生成一個應用程序,併爲其添加子命令,然後基於生成的代碼進行二次開發,提高開發效率,具體步驟如下:

  1. 生成應用程序

可以使用 cobra-cli init 命令初始化一個應用程序,然後我們就可以基於這個 Demo 程序做二次開發,提高開發效率。如下命令可以初始化一個新的應用程序:

$ mkdir -p cobrademo && cd cobrademo && go mod init
$ cobra-cli init --license=MIT --viper
$ ls
cmd  go.mod  go.sum  LICENSE  main.go

提示:如果遇到錯誤 Error: invalid character '{' after top-level value)'}',可參考:https://github.com/spf13/cobra-cli/issues/26。

當一個應用程序被初始化之後,就可以給這個應用程序添加一些命令:

$ cobra-cli add serve
$ cobra-cli add config
$ cobra-cli add create -p 'configCmd' # 此命令的父命令的變量名(默認爲 'rootCmd')
$ ls cmd/
config.go  create.go  root.go  serve.go

執行 cobra-cli add 之後,會在 cmd 目錄下生成命令源碼文件。cobra-cli add 不僅可以添加命令,也可以添加子命令,例如上面的例子,通過 cobra-cli add create -p 'configCmd'config 命令添加了 create 子命令,-p 指定子命令的父命令:<父命令>Cmd

  1. 編譯並執行

在生成完命令後,可以直接執行 go build 命令編譯應用程序:

$ go build -v .
$ go work use .
$ ./cobrademo -h
A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.

Usage:
  cobrademo [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  config      A brief description of your command
  help        Help about any command
  serve       A brief description of your command

Flags:
      --config string   config file (default is $HOME/.cobrademo.yaml)
  -h, --help            help for cobrademo
  -t, --toggle          Help message for toggle

Use "cobrademo [command] --help" for more information about a command.

$ ./cobrademo config -h
......
Usage:
  cobrademo config [flags]
  cobrademo config [command]

Available Commands:
  create      A brief description of your command

Flags:
  -h, --help   help for config

Global Flags:
      --config string   config file (default is $HOME/.cobrademo.yaml)

Use "cobrademo config [command] --help" for more information about a command.

這裏需要注意:命令名稱要是 camelCase 格式,而不是 snake_case / snake-case 格式,如果不是駝峯格式,cobra 會報錯。

  1. 配置 cobra

cobra 在生成應用程序時,也會在當前目錄下生成 LICENSE 文件,並且會在生成的 Go 源碼文件中,添加 LICENSE Header,LICENSE 和 LICENSE Header 的內容可以通過 cobra 配置文件進行配置,默認的配置文件爲:~/.cobra.yaml,例如:

author: Steve Francia <spf@spf13.com>
year: 2020
license:
  header: This file is part of CLI application foo.
  text: |
    {{ .copyright }}

    This is my license. There are many like it, but this one is mine.
    My license is my best friend. It is my life. I must master it as I must
    master my life.

在如上例子中,{{ .copyright }} 的具體內容會根據 authoryear 生成,根據此配置生成的 LICENSE 文件內容爲:

Copyright © 2020 Steve Francia <spf@spf13.com>
 
This is my license. There are many like it, but this one is mine.
My license is my best friend. It is my life. I must master it as I must
master my life.
 
LICENSE Header爲 :
/*
Copyright © 2020 Steve Francia <spf@spf13.com>
This file is part of CLI application foo.
*/

我們也可以使用內建的 licenses,內建的 licenses 有:GPLv2, GPLv3, LGPL, AGPL, MIT, 2-Clause BSD or 3-Clause BSD。例如,我們使用 MIT license:

$ cobra-cli init --license=MIT

使用 cobra 庫創建命令

如果要用 cobra 庫編碼實現一個應用程序,需要首選創建一個空的 main.go 文件和一個 rootCmd 文件,之後可以根據需要添加其它命令。具體步驟如下;

  1. 創建 rootCmd
$ mkdir -p cobrademolib && cd cobrademolib

通常情況下,我們會將 rootCmd 放在文件 cmd/root.go 中:

package cmd

import (
 "fmt"
 "os"

 "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
 Use:   "hugo",
 Short: "Hugo is a very fast static site generator",
 Long: `A Fast and Flexible Static Site Generator built with
         love by spf13 and friends in Go.
     Complete documentation is available at http://hugo.spf13.com`,
 Run: func(cmd *cobra.Command, args []string) {
  // Do Stuff Here
 },
}

func Execute() {
 if err := rootCmd.Execute(); err != nil {
  fmt.Println(err)
  os.Exit(1)
 }
}

還可以在 init() 函數中定義標誌和處理配置,例如:cmd/helper.go

package cmd

import (
 "fmt"
 "os"

 homedir "github.com/mitchellh/go-homedir"
 "github.com/spf13/cobra"
 "github.com/spf13/viper"
)

var (
 cfgFile     string
 projectBase string
 userLicense string
)

func init() {
 cobra.OnInitialize(initConfig)
 rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cobra.yaml)")
 rootCmd.PersistentFlags().StringVarP(&projectBase, "projectbase", "b", "", "base project directory eg. github.com/spf13/")
 rootCmd.PersistentFlags().StringP("author", "a", "YOUR NAME", "Author name for copyright attribution")
 rootCmd.PersistentFlags().StringVarP(&userLicense, "license", "l", "", "Name of license for the project (can provide `licensetext` in config)")
 rootCmd.PersistentFlags().Bool("viper", true, "Use Viper for configuration")
 viper.BindPFlag("author", rootCmd.PersistentFlags().Lookup("author"))
 viper.BindPFlag("projectbase", rootCmd.PersistentFlags().Lookup("projectbase"))
 viper.BindPFlag("useViper", rootCmd.PersistentFlags().Lookup("viper"))
 viper.SetDefault("author", "NAME HERE <EMAIL ADDRESS>")
 viper.SetDefault("license", "apache")
}

func initConfig() {
 // Don't forget to read config either from cfgFile or from home directory!
 if cfgFile != "" {
  // Use config file from the flag.
  viper.SetConfigFile(cfgFile)
 } else {
  // Find home directory.
  home, err := homedir.Dir()
  if err != nil {
   fmt.Println(err)
   os.Exit(1)
  }

  // Search config in home directory with name ".cobra" (without extension).
  viper.AddConfigPath(home)
  viper.SetConfigName(".cobra")
 }

 if err := viper.ReadInConfig(); err != nil {
  fmt.Println("Can't read config:", err)
  os.Exit(1)
 }
}
  1. 創建 main.go

我們還需要一個 main 函數來 調用 rootCmd,通常我們會創建一個 main.go 文件,在 main.go 中調用 rootCmd.Execute() 來執行命令:

package main

import (
  "{pathToYourApp}/cmd"
)

func main() {
  cmd.Execute()
}

需要注意,main.go 中不建議放很多代碼,通常只需要調用 cmd.Execute() 即可。

  1. 添加命令

除了 rootCmd,我們還可以調用 AddCommand 添加其它命令,通常情況下,我們會把其它命令的源碼文件放在 cmd/ 目錄下,例如,我們添加一個 version 命令,可以創建 cmd/version.go 文件,內容爲:

package cmd

import (
 "fmt"

 "github.com/spf13/cobra"
)

func init() {
 rootCmd.AddCommand(versionCmd)
}

var versionCmd = &cobra.Command{
 Use:   "version",
 Short: "Print the version number of Hugo",
 Long:  `All software has versions. This is Hugo's`,
 Run: func(cmd *cobra.Command, args []string) {
  fmt.Println("Hugo Static Site Generator v0.9 -- HEAD")
 },
}

本示例中,我們通過調用 rootCmd.AddCommand(versionCmd)rootCmd 命令添加了一個 versionCmd 命令。

  1. 編譯並運行

替換 main.go{pathToYourApp} 爲對應的路徑,例如本示例中 pathToYourAppgithub.com/nosbelm/miniblogdemo/cobrademolib

$ go mod init
$ go work use .
$ go mod tidy
$ go build -v .
$ ./cobrademolib -h

通過步驟 1、2、3 我們就成功創建和添加了 cobra 應用程序和其命令。

使用標誌

cobra 可以跟 pflag 結合使用,實現強大的標誌功能。使用步驟如下:

  1. 使用持久化的標誌

標誌可以是 “持久的”,這意味着該標誌可用於它所分配的命令以及該命令下的每個子命令。可以在 rootCmd 上定義持久標誌:

rootCmd.PersistentFlags().BoolVarP(&Verbose, "verbose", "v", false, "verbose output")
  1. 使用本地標誌

也可以分配一個本地標誌,本地標誌只能在其所綁定的命令上使用:

rootCmd.Flags().StringVarP(&Source, "source", "s", "", "Source directory to read from")

--source 標誌只能在 rootCmd 上引用,而不能在 rootCmd 的子命令上引用。

  1. 將標誌綁定到 viper

我們可以將標誌綁定到 viper,這樣就可以使用 viper.Get() 獲取標誌的值。

var author string

func init() {
  rootCmd.PersistentFlags().StringVar(&author, "author", "YOUR NAME", "Author name for copyright attribution")
  viper.BindPFlag("author", rootCmd.PersistentFlags().Lookup("author"))
}
  1. 設置標誌爲必選

默認情況下,標誌是可選的,我們也可以設置標誌位必選,當設置標誌位必選,但是沒有提供標誌時,cobra 會報錯。

rootCmd.Flags().StringVarP(&Region, "region", "r", "", "AWS region (required)")
rootCmd.MarkFlagRequired("region")
  1. 非選項參數驗證

在使用命令的過程中,經常會傳入非選項參數,並且需要對這些非選項參數進行驗證,cobra 提供了機制來對非選項參數進行驗證。可以使用 CommandArgs 字段來驗證非選項參數。cobra 也內置了一些驗證函數,具體見下表:

slcs7j

使用自定義驗證函數,示例如下:

var cmd = &cobra.Command{
  Short: "hello",
  Args: cobra.MinimumNArgs(1), // 使用內置的驗證函數
  Run: func(cmd *cobra.Command, args []string) {
    fmt.Println("Hello, World!")
  },
}

也可以自定義驗證函數,示例如下:

var cmd = &cobra.Command{
  Short: "hello",
  // Args: cobra.MinimumNArgs(10), // 使用內置的驗證函數
  Args: func(cmd *cobra.Command, args []string) error { // 自定義驗證函數
    if len(args) < 1 {
      return errors.New("requires at least one arg")
    }
    if myapp.IsValidColor(args[0]) {
      return nil
    }
    return fmt.Errorf("invalid color specified: %s", args[0])
  },
  Run: func(cmd *cobra.Command, args []string) {
    fmt.Println("Hello, World!")
  },
}

Help 命令

在使用應用程序時,我們需要知道該應用程序的調用方法,所以需要有一個 Help 命令或者選項參數,Cobra 的強大之處也在於所有我們需要的功能 cobra 都已經幫我們實現好了。在用 cobra 構建應用程序時,cobra 會自動爲應用程序添加一個幫助命令,當用戶運行 app help 時會調用此方法。此外,當 help 的輸入爲其它命令時,會打印該命令的用法,比如有一個叫做 create 的命令,當調用 app help create 時,會打印 create 的幫助信息。cobra 也會給每個命令自動添加 --help 標誌。例如:

$ cobra-cli help
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.

Usage:
  cobra-cli [command]

Available Commands:
  add         Add a command to a Cobra Application
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command
  init        Initialize a Cobra Application

Flags:
  -a, --author string    author name for copyright attribution (default "YOUR NAME")
      --config string    config file (default is $HOME/.cobra.yaml)
  -h, --help             help for cobra-cli
  -l, --license string   name of license for the project
      --viper            use Viper for configuration

Use "cobra-cli [command] --help" for more information about a command.

我們也可以定義自己的 help 命令。使用如下函數,可以定義 help 命令:

cmd.SetHelpCommand(cmd *Command)
cmd.SetHelpFunc(f func(*Command, []string))
cmd.SetHelpTemplate(s string)

使用信息

當用戶提供無效標誌或無效命令時,cobra 會打印出 usage 信息。例如:

$ cobra-cli --invalid
Error: unknown flag: --invalid
Usage:
  cobra-cli [command]

Available Commands:
  add         Add a command to a Cobra Application
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command
  init        Initialize a Cobra Application

Flags:
  -a, --author string    author name for copyright attribution (default "YOUR NAME")
      --config string    config file (default is $HOME/.cobra.yaml)
  -h, --help             help for cobra-cli
  -l, --license string   name of license for the project
      --viper            use Viper for configuration

Use "cobra-cli [command] --help" for more information about a command.

help 一樣,我們也可以自定義 usage,通過如下看函數可以自定義 usage:

cmd.SetUsageFunc(f func(*Command) error)
cmd.SetUsageTemplate(s string)

version 標誌

如果在 rootCmd 命令上設置了 Version 字段,Cobra 會添加持久的 --version 標誌。運行應用程序時,指定了 --version 標誌,應用程序會使用 Version 模板將版本打印到 stdout。可以使用 cmd.SetVersionTemplate(s string) 函數自定義 Version 模板。

PreRun and PostRun Hooks

在運行 Run 函數時我們可以運行一些鉤子函數,比如 PersistentPreRunPreRun 函數在 Run 函數之前執行。PersistentPostRunPostRunRun 函數之後執行。如果子命令沒有指定 Persistent*Run 函數,則子命令將會繼承父命令的 Persistent*Run 函數。這些函數的運行順序如下:

  1. PersistentPreRun

  2. PreRun

  3. Run

  4. PostRun

  5. PersistentPostRun

注意父級的 PreRun 只會在父級命令運行時調用,子命令時不會調用的。

下面是使用所有這些函數的兩個命令的示例。執行子命令時,它將運行 rootCmd 命令的 PersistentPreRun,但不運行 rootCmd 命令的 PersistentPostRun

package main

import (
  "fmt"

  "github.com/spf13/cobra"
)

func main() {

  var rootCmd = &cobra.Command{
    Use:   "root [sub]",
    Short: "My root command",
    PersistentPreRun: func(cmd *cobra.Command, args []string) {
      fmt.Printf("Inside rootCmd PersistentPreRun with args: %v\n", args)
    },
    PreRun: func(cmd *cobra.Command, args []string) {
      fmt.Printf("Inside rootCmd PreRun with args: %v\n", args)
    },
    Run: func(cmd *cobra.Command, args []string) {
      fmt.Printf("Inside rootCmd Run with args: %v\n", args)
    },
    PostRun: func(cmd *cobra.Command, args []string) {
      fmt.Printf("Inside rootCmd PostRun with args: %v\n", args)
    },
    PersistentPostRun: func(cmd *cobra.Command, args []string) {
      fmt.Printf("Inside rootCmd PersistentPostRun with args: %v\n", args)
    },
  }

  var subCmd = &cobra.Command{
    Use:   "sub [no options!]",
    Short: "My subcommand",
    PreRun: func(cmd *cobra.Command, args []string) {
      fmt.Printf("Inside subCmd PreRun with args: %v\n", args)
    },
    Run: func(cmd *cobra.Command, args []string) {
      fmt.Printf("Inside subCmd Run with args: %v\n", args)
    },
    PostRun: func(cmd *cobra.Command, args []string) {
      fmt.Printf("Inside subCmd PostRun with args: %v\n", args)
    },
    PersistentPostRun: func(cmd *cobra.Command, args []string) {
      fmt.Printf("Inside subCmd PersistentPostRun with args: %v\n", args)
    },
  }

  rootCmd.AddCommand(subCmd)

  rootCmd.SetArgs([]string{""})
  rootCmd.Execute()
  fmt.Println()
  rootCmd.SetArgs([]string{"sub", "arg1", "arg2"})
  rootCmd.Execute()
}

執行後,輸出如下:

Inside rootCmd PersistentPreRun with args: []
Inside rootCmd PreRun with args: []
Inside rootCmd Run with args: []
Inside rootCmd PostRun with args: []
Inside rootCmd PersistentPostRun with args: []

Inside rootCmd PersistentPreRun with args: [arg1 arg2]
Inside subCmd PreRun with args: [arg1 arg2]
Inside subCmd Run with args: [arg1 arg2]
Inside subCmd PostRun with args: [arg1 arg2]
Inside subCmd PersistentPostRun with args: [arg1 arg2]

命令建議

Cobra 還有很多其它有用的特性,比如當我們輸入的命令有誤時,cobra 會根據註冊的命令,推算出可能的命令。例如,當 unknown command 錯誤發生時,Cobra 將自動打印建議的命令:

$ hugo srever
Error: unknown command "srever" for "hugo"

Did you mean this?
        server

Run 'hugo --help' for usage.

根據註冊的每個子命令自動建議並使用 Levenshtein distance 實現。每個匹配最小距離爲 2(忽略大小寫)的註冊命令將顯示爲建議。如果需要在命令中禁用建議或調整字符串距離,可以使用:

command.DisableSuggestions = true

或者:

command.SuggestionsMinimumDistance = 1

需要注意,Levenshtein distance(編輯距離)是針對二個字符串(例如英文字)的差異程度的量化量測,量測方式是看至少需要多少次的處理才能將一個字符串變成另一個字符串。

還可以使用 SuggestFor 屬性顯式設置要爲其指定命令的名稱,例如:

// configCmd represents the config command
var configCmd = &cobra.Command{
    Use:   "config",
    Short: "A brief description of your command",
    Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
    SuggestFor: []string{"cfg", "conf"},
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("config called")
    },
}

執行 newApp cfg

$ ./newApp cfg
Error: unknown command "cfg" for "newApp"

Did you mean this?
 config

Run 'newApp --help' for usage.
unknown command "cfg" for "newApp"

Did you mean this?
 config

總結

Go 語言開發中,都需要構建一個可執行的二進制文件。我們可以通過不同的方式來構建這個二進制程序。例如:可以首先 main 函數、可以藉助一些第三方的命令行框架包來實現。當前用的最多的是使用第三方的命令行框架來實現。命令行框架有很多,當前最受環境的是 cobra 包。

本文詳細介紹了 cobra 包的常見功能和使用方式。這些功能足以支撐你開發一個優秀的命令行工具或者 Go 應用。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/3w9_W9X69jv1UsfyosgepA