Go 语言实战:命令行(3)CLI 框架

共 26875字,需浏览 54分钟

 ·

2021-02-28 20:37

经过前面两期的介绍,相信大家已经可以写简单的命令行程序,并且能够使用命令行参数。

即使遇到一些困难,建立直观认识和了解关键词之后,在网络上搜索答案也变得相对容易。

接下来介绍 CLI 框架。

命令行程序的前两期:

命令行框架

对于简单的功能,单个 go 文件,几个函数,完全是足够的。没有必要为了像那么回事,硬要分很多个包,每个文件就两行代码。为了框架而框架,属于过早优化。

但反过来说,随着往项目里不断添加特性,代码越来越多,如何更好地组织代码,达到解耦和复用,就成了必须要考虑的问题。

我们当然可以把自己的思考,体现在项目的代码组织上,乃至从中抽取一套框架。但一个深思熟虑,适应各种场景变化的框架,还是有门槛、需要技术和经验积累的。

更便捷的做法,是引入社区热门的框架,利用里面提供的脚手架减少重复劳动,并从中学习它的设计。

对于 CLI 程序而言,我知道的最流行的框架有两个,分别是:

  • urfave/cli:https://github.com/urfave/cli
  • cobra:https://github.com/spf13/cobra

cobra 的功能会更强大完善。它的作者 Steve Francia(spf13)是 Google 里面 go 语言的 product lead,同时也是 gohugo、viper 等知名项目的作者。

但强大的同时,也意味着框架更大更复杂,在实现一些小规模的工具时,反而会觉得杀鸡牛刀。所以这里只介绍 cli 这个框架,有兴趣的朋友可以自行了解 cobra ,原理大同小异。

urfave/cli 框架

cli 目前已经开发到了 v2.0+。推荐使用最新的稳定版本。

这里使用 go module 模式,那么引入 cli 包只需要在代码开头

import "github.com/urfave/cli/v2"

如果还不熟悉 go module,或者不知道最后面的 v2 代表什么,请看这篇文章:《golang 1.13 - module VS package》。

简单说,go module 使用语义化版本(semver),认为主版本号变更是『不兼容变更(breaking changes)』,需要体现在导入路径上。v0.x (不稳定版本,可以不兼容)和 v1.x (默认)不需要标,v2.0 及以上的版本,都需要把主版本号标在 module 路径的最后。

但是注意,这个 v2 既不对应实际的文件目录,也不影响包名。在这里,包名仍然是 cli

根据作者提供的例子,实现一个最小的 CLI 程序看看:

// 为了编译后不用改名,module name 直接就叫 boom
package main

import (
    "fmt"
    "log"
    "os"

    "github.com/urfave/cli/v2"
)

func main() {
    app := &cli.App{
        Name: "boom",
        Usage: "make an explosive entrance",
        Action: func(c *cli.Context) error {
            fmt.Println("boom! I say!")
            return nil
        },
    }

    err := app.Run(os.Args)
    if err != nil {
        log.Fatal(err)
    }
}

这段代码实现了一个叫 boom 的程序,执行的时候会输出 "boom! I say!":

>go build
>boom
boom! I say!

另外,框架已经自动生成了默认的帮助信息。在调用 help 子命令,或者发生错误时,会输出:

>boom help
NAME:
   boom - make an explosive entrance

USAGE:
   boom.exe [global options] command [command options] [arguments...]

COMMANDS:
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --help, -h  show help (default: false)

这段代码做的事情很简单。初始化一个 cli.App ,设置三个字段:

  • 名字,就是 "boom"。
  • 用途,也是一个字符串,会在 help 信息用到。
  • 动作,也就是执行程序时具体执行什么内容。这里是输出一个字符串。

运行部分,将命令行参数 os.Args 作为参数传递给 cli.AppRun() 方法,框架就会接管参数的解析和后续的命令执行。

如果是跟着教程一路过来,那么很可能这里是第一次引入第三方包。IDE 可以会同时提示好几个关于 "github.com/urfave/cli/v2" 的错误,例如:"github.com/urfave/cli/v2 is not in your go.mod file" 。

可以根据 IDE 的提示修复,或者执行 go mod tidy ,或者直接等 go build 时自动解决依赖。无论选择哪一种,最终都会往 go.mod 里添加一行 require github.com/urfave/cli/v2

重构

当然,实现这么简单的功能,除了帮忙生成帮助信息,框架也没什么用武之地。

接下来我们用框架把 gosrot 改造一下,在基本不改变功能的前提下,把 cli 包用上。

因为有了 cli 包处理参数,我们就不用 flag 包了。(其实 cli 里面用到了 flag 包。)

func main() {
    app := &cli.App{
        Name:   "gosort",
        Usage:  "a simple command line sort tool",
        Action: sortCmd,
        Flags: []cli.Flag{
            &cli.BoolFlag{
                Name:        "lex",
                Aliases:     []string{"l"},
                Usage:       "sort lexically",
                Destination: &lex,
            },
            // unique 同为 BoolFlag,省略,请自行补完
            // ...
            &cli.StringFlag{
                Name:        "from",
                Aliases:     []string{"f"},
                // `FILE` 是占位符,在帮助信息中会输出 -f FILE    input from FILE
                // 用户能更容易理解 FILE 的用途
                Usage:       "input from `FILE`",
                Destination: &from,
            },
            // 省略剩余的 StringFlag...
        },
    }

    err := app.Run(os.Args)
    if err != nil {
        log.Fatal(err)
    }
}

cliFlagflag 包类似,有两种设置方法。既可以设置以后通过 cli.Context 的方法读取值:ctx.Bool("lex")string 等其它类型以此类推)。也可以直接把变量地址设置到 Destination 字段,解析后直接访问对应的变量。

这里为减少函数传参,用了后者,把参数值存储到全局(包级)变量。

程序入口改为 cli.App 之后,原来的 main() 函数就改为 sortCmd ,作为 appAction 字段。

// 增加 Context 参数 和返回 error,以满足 cli.ActionFunc (Action 字段的类型)签名
func sortCmd(ctx *cli.Context) error {
    // 不再需要设置 flag 包
    var strs []string
    if from != "" {
        if !isFile(from) {
            return fmt.Errorf("%s is not a file", from)
        }

        buf, err := ioutil.ReadFile(from)
        if err != nil {
            return fmt.Errorf("read %s fail, caused by\n\t%w", from, err)
        }

        // 篇幅关系,省略... 参考之前两期的内容
    }
    // 省略...

    if output == "" {
        fmt.Println(res)
    } else {
        err := ioutil.WriteFile(output, []byte(res), 0666)
        if err != nil {
            return fmt.Errorf("write result to %s fail, caused by\n\t%w", output, err)
        }
    }
    return nil
}

由于程序被封装成了 cli.App ,程序的执行交给框架处理, sortCmd 内部不再自行调用 os.Exit(1) 退出,而是通过返回 error 类型,将错误信息传递给上层处理。

这里主要使用 fmt.Errorf() 格式化错误信息然后返回。从 1.13 开始,fmt.Errorf() 提供了一个新的格式化动词 %w ,允许将底层的错误信息,包装在新的错误信息里面,形成错误信息链。后续可以通过 errors 包的三个函数 Is() , As()Unwrap() ,对错误信息进行进一步分析处理。

接下来编译执行

>go build
# 不同参数的含义参考上一期内容
>gosort -h
NAME:
   gosort - a simple command line sort tool

USAGE:
   gosort [global options] command [command options] [arguments...]

COMMANDS:
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --lex, -l                 sort lexically (default: false)
   --unique, -u              remove duplicates (default: false)
   --from FILE, -f FILE      input from FILE
   --output FILE, -o FILE    output to FILE
   --insep value, -i value   input seperator
   --outSep value, -s value  output seperator (default: ",")
   --help, -h                show help (default: false)
>gosort -u -i=, -s=- 111,111,555,678,333,567,678
111-333-555-567-678

如果完全照着教程的思路重构,到这一步,你可能会发现,代码可以编译和运行,却没有输出。这是因为有一个地方很容易忘记修改。 请尝试自行找到问题所在,并解决。

另起炉灶

框架除了解析参数,自动生成规范的帮助信息,还有一个主要的作用,是子命令(subcommand)的组织和管理。

gosort 主要围绕一个目的(提交号的排序去重)展开,各项功能是组合而不是并列的关系,更适合作为参数,而不是拆分成多个子命令。而且之前的开发容易形成思维定势,下面我们另举一例,不在 gosort 基础上修改。

为了容易理解,接下来用大家比较熟悉的 git 做例子。篇幅关系,只展示项目可能的结构,不(可能)涉及具体的代码实现。

首先,我们看一下 git 有哪些命令:

>git help
usage: git [--version] [--help] [-C <path>] [-c name=value]
           [--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
           [-p | --paginate | --no-pager] [--no-replace-objects] [--bare]
           [--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
           <command> [<args>]

These are common Git commands used in various situations:

start a working area (see also: git help tutorial)
   clone      Clone a repository into a new directory
   init       Create an empty Git repository or reinitialize an existing one

work on the current change (see also: git help everyday)
   add        Add file contents to the index
   mv         Move or rename a file, a directory, or a symlink
// 篇幅关系,省略余下内容,你可以自己尝试执行 git help 查看

总的来说,就是有一系列的全局选项(global options,跟在 git 后面,command 之前),一系列子命令(subcommand),每个命令下面还有一些专属的参数。

这样的工具,有几个特点:

  • 功能强大,子功能很多,无法用一个命令 + 若干参数完成,一般实现为多个子命令。
  • 既有影响多数子命令的全局选项,也有某些子命令专属的选项。
  • 子命令之间,既相互独立,又共享一部分底层实现。

为了更好地组织程序,项目结构可以是这样子的:

│   go.mod
│   go.sum
│   main.go

├───cmd
│       add.go
│       clone.go
│       common.go
│       init.go
│       mv.go
|       ......

└───pkg
    ├───hash
    │       hash.go
    │
    ├───zip
    |       zip.go
    │
    ├───......

main.go 是程序入口,为了保持结构清晰,这里只是初始化并运行 cli.App

package main

import (
    "log"
    "mygit/cmd"
    "os"

    "github.com/urfave/cli/v2"
)

func main() {
    app := &cli.App{
        Name:                   "mygit",
        Usage:                  "a free and open source distributed version control system",
        Version:                "v0.0.1",
        UseShortOptionHandling: true,
        Flags:                  cmd.GlobalOptions,
        // Before 在任意命令执行前执行,这里用来处理全局选项
        Before: cmd.LoadGlobalOptions,
        // 同理,也可以定义 After 来执行收尾操作
           // After: xxx
        Commands: cmd.Commands,
    }

    err := app.Run(os.Args)
    if err != nil && err != cmd.ErrPrintAndExit {
        log.Fatal(err)
    }
}

具体的代码实现放到 cmd 包,基本上一个子命令对应一个源文件,代码查找起来非常清晰。

common.go 存放 cmd 包的公共内容:

package cmd

import (
    "errors"
    "fmt"

    "github.com/urfave/cli/v2"
)

// Commands 将子命令统一暴露给 main 包
var Commands = []*cli.Command{
    cloneCmd,
    initCmd,
    addCmd,
    mvCmd,
    // more subcommands ...
}

// GlobalOptions 将全局选项暴露给 main 包
var GlobalOptions = []cli.Flag{
    &cli.PathFlag{
        Name:  "C",
        Usage: "Run as if git was started in `path` instead of the current working directory",
    },
    &cli.PathFlag{
        Name:  "exec-path",
        Usage: "`path` to wherever your core Git programs are installed",
    },
    &cli.BoolFlag{
        Name:  "html-path",
        Usage: "Print the path, without trailing slash, where Git’s HTML documentation is installed and exit",
    },
    // 省略 man-path, info-path, paginate, no-pager...
    // more ...
}

// ErrPrintAndExit 表示遇到需要打印信息并提前退出的情形,不需要打印错误信息
var ErrPrintAndExit = errors.New("print and exit")

// LoadGlobalOptions 加载全局选项
var LoadGlobalOptions = func(ctx *cli.Context) error {
    // 并非实际实现,所以遇到对应的参数只是输出信息,方便观察
    // 全局选项既可以在这里读取并设置全局状态(如有)
    // 也可以在具体实现处再通过 ctx 读取(参考 add)
    if ctx.IsSet("C") {
        fmt.Println("started path changed to", ctx.Path("C"))
    }
    // 省略 exec-path ...
    if ctx.Bool("html-path") {
        fmt.Println("html-path is xxx")
        return ErrPrintAndExit
    }
    // 省略 man-path, info-path ...
    if ctx.Bool("paginate") || !ctx.Bool("no-pager") {
        fmt.Println("pipe output into pager like less")
    } else {
        fmt.Println("no pager")
    }
    return nil
}

// 子命令分组
const (
    cmdGroupStart = "start a working area"
    cmdGroupWork  = "work on current change"
    // ...
)

除了业务相关的公共逻辑放在 common.go,还有一些业务中立的底层公共类库,就可以放在 pkg 下面,例如 hash.go

package hash

// MyHash 返回 source 的 hash 结果
func MyHash(source string) string {
    // 这是一个假的实现
    return "hash of " + source
}

看一下其中一个子命令 add 的代码:

package cmd

import (
    "fmt"
    "mygit/pkg/hash"

    "github.com/urfave/cli/v2"
)

var addCmd = &cli.Command{
    Name:     "add",
    Usage:    "Add file contents to the index",
    Category: cmdGroupWork, // 子命令分组
    Flags: []cli.Flag{
        &cli.BoolFlag{
            Name:    "verbose",
            Aliases: []string{"v"},
            Usage:   "Be verbose",
        },
        &cli.BoolFlag{
            Name:    "force",
            Aliases: []string{"f"},
            Usage:   "Allow adding otherwise ignored files",
        },
        // more options ...
    },
    Action: func(ctx *cli.Context) error {
        // 仅输出信息,查看效果,不是真实实现

        // 这里也能读取全局选项
        if ctx.IsSet("C") {
            // do something
        }
        items := ctx.Args().Slice()
        if ctx.Bool("verbose") {
            for _, item := range items {
                fmt.Println("add", item, ", hash is [", hash.MyHash(item), "]")
            }
        }
        fmt.Println("add", items, "successfully.")
        return nil
    },
}

拥有相同 Category 字段的命令会自动分组。这里在 common.go 预定义了一系列的分组,然后直接引用。之所以不是直接用字面量,是因为在多处引用字面量,非常容易出错,也不利于后续修改。

举例说,如果不小心在组名里输入多了一个 "s" ,就会变成下面这样:

COMMANDS:
   help, h  Shows a list of commands or help for one command
   start a working area:
     clone  Clone a repository into a new directory
     init   Create an empty Git repository or reinitialize an existing one
   work on current change:
     add  Add file contents to the index
   work on current changes:
     mv  Move or rename a file, a directory, or a symlink

好了,一个连低仿都不算的 git 算是搭出一个空架子,编译执行看看:

>go build
# help 命令和 --help, --version 框架会自动添加,如果不需要可以通过特定的字段关闭
>mygit help
pipe output into pager like less
NAME:
   mygit - a free and open source distributed version control system

USAGE:
   mygit [global options] command [command options] [arguments...]

VERSION:
   v0.0.1

COMMANDS:
   help, h  Shows a list of commands or help for one command
   start a working area:
     clone  Clone a repository into a new directory
     init   Create an empty Git repository or reinitialize an existing one
   work on current change:
     add  Add file contents to the index
     mv   Move or rename a file, a directory, or a symlink

GLOBAL OPTIONS:
   -C path           Run as if git was started in path instead of the current working directory
   --exec-path path  path to wherever your core Git programs are installed
   --html-path       Print the path, without trailing slash, where Git’s HTML documentation is installed and exit (default: false)
   --man-path        Print the manpath (see man(1)) for the man pages for this version of Git and exit (default: false)
   --info-path       Print the path where the Info files documenting this version of Git are installed and exit (default: false)
   --paginate, -p    Pipe all output into less (or if set$PAGERif standard output is a terminal (default: false)
   --no-pager        Do not pipe Git output into a pager (default: false)
   --help, -h        show help (default: false)
   --version, -v     print the version (default: false)


# help 命令连子命令的帮助信息也自动生成了
>mygit help add
pipe output into pager like less
NAME:
   mygit add - Add file contents to the index

USAGE:
   mygit add [command options] [arguments...]

CATEGORY:
   work on current change

OPTIONS:
   --verbose, -v  Be verbose (default: false)
   --force, -f    Allow adding otherwise ignored files (default: false)


>mygit -C here add a b c
started path changed to here
pipe output into pager like less
started path changed to here
add [a b c] successfully.


>mygit add -v a b c
pipe output into pager like less
add a , hash is [ hash of a ]
add b , hash is [ hash of b ]
add c , hash is [ hash of c ]
add [a b c] successfully.

光看帮助信息是不是感觉还挺像回事。

希望通过这个粗糙的例子,能让大家对 urfave/cli 这个框架建立一点直观的印象。

更多的例子、更详细的字段用法,可以参考

  • 项目主页:https://github.com/urfave/cli
  • 文档:https://pkg.go.dev/github.com/urfave/cli/v2

最后

在实际写过几个 go 程序之后,相信大家对于 go 已经有一些直观的认识。与此同时,前面只介绍了很少一部分语言特性,在实际编程中可能会产生各种疑惑。


推荐阅读


福利

我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。

浏览 150
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报