如何高效开发一个命令行工具
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())
}
}
然后呢,我们主程序启动的时候执行gox
或 gox help
都执行help命令:
func main() {
args := os.Args[1:]
if len(args) == 0 {
cmds["help"].Run()
}
...
}
嗯,就这些了,是不是很简单?本来就很简单。
小结
当然,除了这些,我们可能还希望为命令行工具添加shell auto-completion输入补全功能,提示信息的国际化、本地化,命令字扩展时的便利程度等,还是有些问题需要进一步考虑的。
我这里只是介绍下实现的一个大致思路,具体实践的时候倒并不一定要这么去实现,可以考虑下cobra,通过cobra来实现posix风格的命令行是很方便的。这些内容感兴趣的话可以自己了解下。
和本文内容接近的,可以参考我的一个工具rm-safe,希望对读者朋友有帮助!