如何高效开发一个命令行工具

Posted 2020-06-26 10:38 +0800 by ZhangJie ‐ 3 min read

分享:  

我经常会开发一些命令行工具来协助处理一些事情,如开发一个代码生成工具快速生成服务代码,或者开发一个工具来方便管理github上的工具,或者开发一个工具rm来替换掉不安全的rm,等等。

命令行工具开发过程中,比较常见的一个问题就是对功能进行分组,开发多个命令不利于使用,在命令中支持子命令是一个更常见、更友好的做法,如go build,go tool,go pprof,等等。我们还希望为不同的子命令添加不同的命令行选项,如go build -gcflags=go pprof --seconds=,等等。

如何支持子命令字呢?

假如我们开发一个命令行程序 gox,我们希望能为它添加一个子命令gox create来创建一个完整的服务工程,包括自动生成工程下的代码。

那如何为命令行程序gox添加这个子命令字呢?

gox是shell搜索路径定位到的程序,create只能是shell传递给进程的一个普通参数,在gox程序启动之后只能从os.Args来获取该参数,以及后续gox create -protofile= -protodir的参数-protofile-protodir

然后呢,为了方便以后扩展其他子命令,我们最好将subcmd进行一下抽象,通过一个Command interface{}约定好一个subcmd必须要完成那些操作。接口并不是为了抽象而抽象,而是用来清晰地表明要做什么。

// Command what does a command do
type Command interface{

    // PreRun run before the command logic execution
    PreRun() error

    // Run run the command logic
    Run() error

    // PostRun run after the command logic execution
    PostRun() error
}

// BaseCommand basic implemention
//
// this BaseCommand could be embeded into a customized subcmd
type BaseCommand struct{
}

func (bc *BaseCommand) PreRun() error {
    return nil
}

func (bc *BaseCommand) Run() error {
    panic("implement me")
}

func (bc *BaseCommand) PostRun() error {
    return nil
}

Command接口定义了一个command应该干什么,然后也可以提供一个基本的Command实现BaseCommand,它提供了一些基本的操作可以供后续复用,后面我们要扩展其他子命令字的时候,通过将该BaseCommand嵌入,可以少实现几个函数,这也是go里面提倡的通过组合来实现继承。

现在我们实现一个CreateCmd:


type CreateCmd struct {
    *BaseCommand
}

func NewCreateCmd() Command {
    return &CreateCmd{
        &BaseCommand{},
    }
}

func (c *CreateCmd) Run() error {

    println("create cmd running")

    // execute the logic of create cmd

    println("create cmd finished")
}

那我们怎么在执行gox create的时候运行CreateCmd.Run()方法呢?

var cmds map[string]Command = {
    "create": NewCreateCmd,
}

func main() {

    args := os.Args[1:]
    if len(args) == 0 {
        panic("invalid subcmd")
    }

    cmd, ok := cmds[args[0]]
    if !ok {
        panic(fmt.Errorf("cmd: %s not registered", args[0]))
    }

    if err := cmd.PreRun(); err != nil {
        panic(err)
    }
    
    if err := cmd.Run(); err != nil {
        panic(err)
    }

    if err := cmd.PostRun(); err != nil {
        panic(err)
    }

}

是不是很简单?本来就很简单 :)

如何为子命令字添加不同的选项呢?

那现在要给各个子命令字添加独立的命令行选项怎么办呢?比如gox create的命令参数和gox update的命令行参数是不同的,那怎么办呢?你当然可以根据os.Args[1:]来解析,想怎么解析都可以,我们这里讨论如何借助go标准库提供的flag包来解析。

大家可能都使用过flag.Parse()来解析命令行参数,这个函数其实是将os.Args[1:]中的参数解析完后填充到一个默认的flagset。如果要为不同的子命令添加不同的命令行选项,那么为每个子命令创建独立的flagset就可以了。各个子命令使用自己的flagset来执行flagset.Parse()代替flag.Parse()就可以了。

就这么简单,我们对前面的程序进行一点调整:

Command接口增加命令参数解析接口:

// Command what does a command do
type Command interface{

    // ParseFlags parse flags into command's own flagset
    ParseFlags(os.Args)

    ...
}

BaseCommand 添加一个参数解析的方法,给自定义子命令字复用

// BaseCommand basic implemention
//
// this BaseCommand could be embeded into a customized subcmd
type BaseCommand struct{
    flagSet *flag.FlagSet
}

func (bc *BaseCommand) ParseFlags(args os.Args) error {
    return bc.flagset.Parse(args)
}

...

为create子命令创建独立的flagset来解析参数

func NewCreateCmd() error {

    fs := flag.NewFlagSet("create", flag.PanicOnError),
    fs.String("protofile", "", "protofile to process")
    fs.String("protodir", "", "protofile to search)"

    return &CreateCmd{
        &BaseCommand{
            flagSet: fs,
        }
    }
}

程序启动的时候统一解析命令行参数:

func main() {

    ...

    // parse the flags
    if err := cmd.ParseFlags(args[1:]; err != nil {
        panic(err)
    }

    ...
}

这样就完成了,是不是很简单,本来就很简单。

如何显示命令帮助信息?

当然了,只能运行命令还不行,有多少注册的子命令可执行?每个子命令有什么命令行参数呢?我们还需要能够显示命令行的帮助信息。

这个怎么实现呢?各个子命令需要能够指明命令的使用帮助:

  • 一个简单的表述,以供我们显示gox包含的各个子命令字的使用信息;
  • 一个详细的描述,以供我们显示gox help create时的各个选项的帮助信息;

我们的代码简单做下调整就可以支持到。

添加Usage、UsageLong方法:

type Command interface{

    ...

    // 返回简单的帮助信息
    Usage() string
    
    // 返回详细的帮助信息
    UsageLong() string
}

然后为BaseCommand添加两个字段:

type BaseCommand struct{
    ...
    Usage string
    UsageLong string
}

...

func (bc *BaseCommand) Usage() string {
    return bc.Usage
}

func (bc *BaseCommand) UsageLong() string {
    return bc.UsageLong
}

为createCmd添加帮助信息:

func NewCreateCmd() Command {

    fs := flag.NewFlagSet("create", flag.PanicOnError),
    fs.String("protofile", "", "protofile to process")
    fs.String("protodir", "", "protofile to search)"

    return &CreateCmd{
        &BaseCommand{
            flagSet: fs,
            Usage: 'create a project',
            UsageLong: 'create a project quickly.\n\n'+ fs.FlagUsages(),
        }
    }

}

然后呢,为了能够使用帮助信息,我们需要添加一个help命令字:

type HelpCmd struct{
    cmd string
}

func NewHelpCmd() Command {
    return &HelpCmd{
        &BaseCommand{},
    }
}

func (c *HelpCmd) ParseFlags(args os.Args) error {
    cmd = args[1:]
}

func (c *HelpCmd) Run() error {

    // help specific subcmd
    if len(c.cmd) != 0 {
        v, ok := cmds[c.cmd]
        if !ok {
            return fmt.Errorf("cmd: %s not registered", c.cmd)
        }
        println(v.UsageLong())
    }

    // help all subcmds
    for _, v := range cmds {
        println(v.Usage())
    }
}

然后呢,我们主程序启动的时候执行goxgox help都执行help命令:

func main() {

    args := os.Args[1:]
    if len(args) == 0 {
        cmds["help"].Run()
    }
    ...
}

嗯,就这些了,是不是很简单?本来就很简单。

小结

当然,除了这些,我们可能还希望为命令行工具添加shell auto-completion输入补全功能,提示信息的国际化、本地化,命令字扩展时的便利程度等,还是有些问题需要进一步考虑的。

我这里只是介绍下实现的一个大致思路,具体实践的时候倒并不一定要这么去实现,可以考虑下cobra,通过cobra来实现posix风格的命令行是很方便的。这些内容感兴趣的话可以自己了解下。

和本文内容接近的,可以参考我的一个工具rm-safe,希望对读者朋友有帮助!