-
Notifications
You must be signed in to change notification settings - Fork 0
Golang命令行工具实践
#golang
这篇文章对比了两套golang中流行的命令行package:spf13/cobra
和 urfave/cli
, 举例说明了各个方案的优缺点,cobra只能通过外部变量进行flag和hook的传值。同时给出了对已有的cobra命令行的改进方案。
根据github上的star数量还有其他人的推荐,大致有两种比较流行的CLI方案
- spf13/cobra: 后文简称cobra
- urfave/cli: 后文简称cli
Cobra被各大开源项目使用,包括Kubernetes, Docker, 等等。官方文档中介绍了大量功能(例如flag和config的绑定,flag的继承, hook函数等),覆盖面很广,但是各个功能之间的分离感较强(后面会举例说明原因)。cli没有cobra那么流行,但是在github上也有相当多的star。官方文档中介绍和功能和cobra基本相同,但是参数的传递实现的更为优雅。个人不是很喜欢cobra的编码风格,更加偏向使用CLI。
省略无关代码
cobra
func main() {
var echoTimes int # <1>
var cmdTimes = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
for i := 0; i < echoTimes; i++ { # <3>
fmt.Println("Echo: " + strings.Join(args, " "))
}
},
}
cmdTimes.Flags().IntVarP(&echoTimes, "times", "t", 1, "times to echo the input") # <2>
}
<1> 声明flag绑定的参数echoTimes
<2> 从命令行读取flag的值,保存到echoTimes
<3> 使用echoTimes
从上述步骤中可以看出,cobra只能通过变量的范围传递参数,不符合我心中的最佳实践。
再来看下cli的代码
app.Commands = []*cli.Command{
{
Flags: []cli.Flag{
&cli.IntFlag{Name: "echoTimes", Aliases: []string{"t"}},
},
Action: func(c *cli.Context) error {
echoTimes := c.Int("echoTimes") # <1>
for i := 0; i < echoTimes; i++ {
fmt.Println("Echo: " + strings.Join(args, " "))
}
return nil
},
},
}
<1> 这里的设计很不错,避免了用户自己进行类型转换
这里可以看到cli中,flag直接定义在command结构体中,Action中通过context可以取到flag中定义的值,当然tradeoff就是如果flag定义有错误,只有在运行时才能检查到。
Cobra和CLI都提供了Hook的功能,所谓的hook指在action前后添加钩子函数。
cobra代码
func main() {
var rootCmd = &cobra.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)
},
}
}
cobra有四个hook, PersistentPreRun,PreRun,PostRun,和PersistentPostRun, PersistentXXX可以继承。 +
注意这里的函数签名,cmd和args不能和Run之间传递变量。
cli代码
func main() {
app := &cli.App{
Before: func(c *cli.Context) error {
fmt.Fprintf(c.App.Writer, "brace for impact\\n")
return nil
},
After: func(c *cli.Context) error {
fmt.Fprintf(c.App.Writer, "did we lose anyone?\\n")
return nil
},
Action: func(c *cli.Context) error {
return nil
},
}
}
cli定义了Before和After两个hook,和Action之间可以通过context传递变量。这两个hook也可以继承给子命令。
关于hook间传值,在cobra的 issue/563 中也有人提出过相关的讨论,也给出了proposal, 但是作者一直没有回应。 +
目前的解决办法是通过外部变量传递,参考代码
func loadBackendEnsureUser(env *Env) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
...
}
}
func closeBackend(env *Env, runE func(cmd *cobra.Command, args []string) error) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
...
}
}
func newAddCommand() *cobra.Command {
env := newEnv()
cmd := &cobra.Command{
PreRunE: loadBackendEnsureUser(env), // <1>
RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { // <2>
return runAdd(env, options)
}),
}
}
- <1> loadBackendEnsureUser函数中装载env变量
- <2> wrap原有的RunE的函数签名
func(cmd *cobra.Command, args []string) error
, 在closeBackend函数中传递PreRunE中装载好的env
两个package的其他特性基本都大同小异,不值得再花时间讨论。 但是根据上面提到的功能对比,我们可以发现,cobra只能通过外部变量的方式来传递值,cli可以通过context的方式传递值。 如果是新开项目,我个人更偏向使用cli。如果想在公司原有的cobra代码上进行改造,推荐使用后续章节中介绍的自定义
如果不想受到package功能的约束,可以定义自己的结构体,给自定义的结构体添加hook之类的功能, 把cobra当做一个命令行封装工具使用。
代码
package main
import "context"
type Context struct {
context.Context
valueStore map[string]interface{}
}
type Script struct {
Before func(c *Context) error
Action func(c *Context) error
After func(c *Context) error
}
func (s *Script) Run() error {
c := &Context{valueStore: map[string]interface{}{}}
if s.Before != nil {
if err := s.Before(c); err != nil {
return err
}
}
if err := s.Action(c); err != nil {
return err
}
if s.After != nil {
if err := s.After(c); err != nil {
return err
}
}
return nil
}
这里的valueStore只是一个例子,由于这里的Context由我们自己维护,我们可以根据实际情况避免使用interface{}.
示例
package main
import (
"fmt"
"github.com/spf13/cobra"
)
func ExampleScript() {
NewScript := func(action func(c *Context) error) *Script {
return &Script{
Before: func(c *Context) error {
c.valueStore["test"] = "test"
return nil
},
Action: action,
}
}
cmd := &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
NewScript(func(c *Context) error {
fmt.Fprintf(cmd.OutOrStdout(), c.valueStore["test"].(string))
return nil
}).Run()
return nil
},
}
cmd.Execute()
// Output:
// test
}
这里我们可以完全不使用cobra提供的hook功能,自己用30行代码就能定义一个简单的hook,这样在不修改package的情况下,使用更为优雅的传值方式。
有几种测试方案
- 只测试核心代码,忽略外面包装的command代码
- 连command一起测试
参考 How to test CLI commands made with Go and Cobra
The testing sample of cobra command line.
Sample Code
package main
import (
"fmt"
"github.com/spf13/cobra"
)
func NewRootCmd() *cobra.Command {
var in string
cmd := &cobra.Command{
Use: "hugo",
Short: "Hugo is a very fast static site generator",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintf(cmd.OutOrStdout(), in)
return nil
},
}
cmd.Flags().StringVar(&in, "in", "", "This is a very important input.")
return cmd
}
Sample Test
package main
import (
"bytes"
"io/ioutil"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_ExecuteCommand(t *testing.T) {
assert := assert.New(t)
cmd := NewRootCmd()
b := bytes.NewBufferString("")
cmd.SetOut(b)
input := "test"
cmd.SetArgs([]string{"--in", input})
err := cmd.Execute()
assert.NoError(err)
output, err := ioutil.ReadAll(b)
assert.NoError(err)
assert.Equal(string(output), input)
}
目前提到的命令行之间的命令基本是相互独立的,还没有涉及到交互式命令。