打开工具之旅

1.1 打开工具之旅

我想,绝大部分工程师,都会想拥有一个属于自己的工具集,那一定是一件很酷又非常有趣的事情。因为它在给你带来极大的生活和工作效率提高的同时也能给你带来不少的成就感,更重要的是在你持续不断的维护迭代你的项目的时候,你的技术也会得到磨炼,而你遇到的问题,别人可能也有,当你更进一步地开源出去了,也有可能会给别人带来非常大的帮助,事实上,GitHub 里许许多多的优秀个人开源项目也是这么来的,这必然是一件一举多得的事情。

因此在本章节中,我们将做一个简单的通用工具集,这是最直接的方式,我们用它解决在平时工作中经常会遇到的一些小麻烦,让我们不再去借助其它的快捷网站,让我们自己的产品为自己服务,不断的迭代它。

1.1 用什么

本次工具类别的项目我们会在一开始使用标准库 flag 来作为引子,标准库 flag 是在 Go 语言中的一个大利器,它主要的功能是实现了命令行参数的解析,能够让我们在开发的过程中非常方便的解析和处理命令行参数,是一个需要必知必会的基础标准库,因此在本章我们会先对标准库 flag 进行基本的讲解。

在后续项目的具体开发和进一步拓展中,我们将使用开源项目 Cobra 来协助我们快速构建我们的 CLI 应用程序,Cobra 的主要功能是创建功能强大的现代 CLI 应用程序,同时也是一个生成应用程序和命令文件的程序。它非常的便捷和强大,目前市面上许多的著名的 Go 语言开源项目都是使用 Cobra 来构建的,例如:Kubernetes、Hugo、etcd、Docker 等等,是非常可靠的一个开源项目。

1.2 初始化项目

开始之前,我们通过如下命令初始化 tour 项目(若为 Windows 系统,可根据实际情况自行调整项目的路径),执行如下命令:

$ mkdir -p $HOME/go-programming-tour-book/tour 
$ cd $HOME/go-programming-tour-book/tour
$ go mod init github.com/go-programming-tour-book/tour

在执行命令完毕后,我们就已经完成了初始化项目的第一步,各命令的含义如下:

  • 确定本书的项目工作路径,并循环递归创建 tour 项目目录。
  • 切换当前工作区到 tour 项目目录下。
  • 初始化项目的 Go modules,设置项目的模块路径。

需要注意的一点是,我们在依赖管理上使用的是 Go modules 的模式(详细介绍可见附录),也就是系统环境变量 GO111MODULE 为 auto 或 on(开启状态),若你在初始化 Go modules 时出现了相关错误提示,应当将 Go modules 开启,如下命令:

$ go env -w GO111MODULE=on

执行这条命令后,Go 工具链将会将系统环境变量 GO111MODULE 设置为 on,但是需要注意的是语句 go env -w 并不支持覆写,你手动进行 export GO111MODULE=on 设置亦可。

另外若是初次使用 Go modules,建议设置国内镜像代理,否则会出现外网模块拉不下来的问题,设置命令如下:

$ go env -w GOPROXY=https://goproxy.cn,direct

1.3 示例

1.3.1 flag 基本使用和长短选项

我们编写一个简单的示例,用于了解标准库 flag 的基本使用,代码如下:

func main() {
	var name string
	flag.StringVar(&name, "name", "Go 语言编程之旅", "帮助信息")
	flag.StringVar(&name, "n", "Go 语言编程之旅", "帮助信息")
	flag.Parse()

	log.Printf("name: %s", name)
}

通过上述代码,我们调用标准库 flag 的 StringVar 方法实现了对命令行参数 name 的解析和绑定,其各个形参的含义分别为命令行标识位的名称、默认值、帮助信息。针对命令行参数,其支持如下三种命令行标志语法,分别如下:

  • -flag:仅支持布尔类型。
  • -flag x :仅支持非布尔类型。
  • -flag=x:均支持

同时 flag 标准库还提供了多种类型参数绑定的方式,根据各自的应用程序使用情况选用即可,接下来我们运行该程序,检查输出结果与预想的是否一致,如下:

$ go run main.go -name=eddycjy -n= 煎鱼
name: 煎鱼

我们可以发现输出的结果是最后一个赋值的变量,也就是 -n

你可能会有一些疑惑,为什么长短选项要分开两次调用,一个命令行参数的标志位有长短选项,是常规需求,这样子岂不是重复逻辑,有没有优化的办法呢。

实际上标准库 flag 并不直接支持该功能,但是我们可以通过其它第三方库来实现这个功能,这块我们在后续也会使用到。

1.3.3 子命令的实现

在我们日常使用的 CLI 应用中,另外一个最常见的功能就是子命令的使用,一个工具它可能包含了大量相关联的功能命令以此形成工具集,可以说是刚需,那么这个功能在标准库 flag 中可以如何实现呢,如下述示例:

var name string

func main() {
	flag.Parse()

	args := flag.Args()
	if len(args) <= 0 {
		return
	}
	
	switch args[0] {
	case "go":
		goCmd := flag.NewFlagSet("go", flag.ExitOnError)
		goCmd.StringVar(&name, "name", "Go 语言", "帮助信息")
		_ = goCmd.Parse(args[1:])
	case "php":
		phpCmd := flag.NewFlagSet("php", flag.ExitOnError)
		phpCmd.StringVar(&name, "n", "PHP 语言", "帮助信息")
		_ = phpCmd.Parse(args[1:])
	}

	log.Printf("name: %s", name)
}

在上述代码中,我们首先调用了 flag.Parse 方法,将命令行解析为定义的标志,便于我们后续的参数使用。

另外由于我们需要处理子命令的情况,因此我们调用了 flag.NewFlagSet 方法,该方法会返回带有指定名称和错误处理属性的空命令集给我们去使用,相当于就是创建一个新的命令集了去支持子命令了。

这里需要特别注意的是 flag.NewFlagSet 方法的第二个参数是 ErrorHandling,用于指定处理异常错误的情况处理,其内置提供以下三种模式:

const (
	// 返回错误描述
	ContinueOnError ErrorHandling = iota 
	// 调用 os.Exit(2) 退出程序
	ExitOnError       
	// 调用 panic 语句抛出错误异常
	PanicOnError                       
)

接下来我们运行针对子命令的示例程序,对正确和异常场景进行检查,如下:

$ go run main.go go -name=eddycjy
name: eddycjy

$ go run main.go php -n= 煎鱼   
name: 煎鱼

$ go run main.go go -n=eddycjy
flag provided but not defined: -n
Usage of go:
  -name string
    	帮助信息 (default "Go 语言")
exit status 2

通过输出结果可以知道这段示例程序已经准确的识别了不同的子命令,并且因为我们 ErrorHandling 传递的是 ExitOnError 级别,因此在识别到传递的命令行参数标志是未定义时,会进行直接退出程序并提示错误信息。

1.4 分析

从使用上来讲,标准库 flag 非常方便,一个简单的 CLI 应用很快就搭建起来了,但是它又是怎么实现的呢,我们一起来深入看看,要做到知其然知其所以然,肯定非常有意思,整体分析流程如下:

image

1.4.1 flag.Parse

首先我们看到 flag.Parse 方法,它总是在所有命令行参数注册的最后进行调用,函数功能是解析并绑定命令行参数,我们一起看看其内部实现:

var CommandLine = NewFlagSet(os.Args[0], ExitOnError)

func Parse() {
	CommandLine.Parse(os.Args[1:])
}

该方法是调用 NewFlagSet 方法实例化一个新的空命令集,然后通过调用 os.Args 作为外部参数传入。

但这里需要注意一个点,Parse 方法使用的是 CommandLine 变量,它所默认传入的 ErrorHandling 是 ExitOnError,也就是如果在解析时遇到异常或错误,就会直接退出程序,因此如果你的应用程序不希望解析命令行参数失败,就导致应用启动中断的话,需要进行额外的处理。

1.4.2 FlagSet.Parse

接下来是 FlagSet.Parse,其主要承担了 parse 方法的异常分流处理,如下:

func (f *FlagSet) Parse(arguments []string) error {
	f.parsed = true
	f.args = arguments
	for {
		seen, err := f.parseOne()
		if seen {
			continue
		}
		if err == nil {
			break
		}
		switch f.errorHandling {
		case ContinueOnError:
			return err
		case ExitOnError:
			os.Exit(2)
		case PanicOnError:
			panic(err)
		}
	}
	return nil
}

该方法是对解析方法的进一步封装,实质的解析逻辑放在 parseOne 中,而解析过程中遇到的一些特殊情况,例如:重复解析、异常处理等,均直接由该方法处理,这实际上是一个分层明显,结构清晰的方法设计,很值得大家去参考。

1.4.3 FlagSet.parseOne

最后会流转到命令行解析的核心方法 FlagSet.parseOne 下进行处理,如下:

func (f *FlagSet) parseOne() (bool, error) {
	if len(f.args) == 0 {
		return false, nil
	}
	s := f.args[0]
	if len(s) < 2 || s[0] != '-' {
		return false, nil
	}
	numMinuses := 1
	if s[1] == '-' {
		numMinuses++
		if len(s) == 2 { // "--" terminates the flags
			f.args = f.args[1:]
			return false, nil
		}
	}
	name := s[numMinuses:]
	if len(name) == 0 || name[0] == '-' || name[0] == '=' {
		return false, f.failf("bad flag syntax: %s", s)
	}
	...
}

在上述代码中,我们可以看到主要是针对一些不符合命令行参数绑定规则的校验处理,大致分为以下四种情况:

  • 如果命令行参数长度为 0。
  • 如果遇到长度小于 2 或不满足 flag 标识符”-“。
  • 如果 flag 标志位为”–“的情况下,则中断处理,并跳过该字符,也就是后续会以”-“进行处理。
  • 如果在处理 flag 标志位后,取到的参数名不符合规则,也将中断处理,例如:go run main.go go ---name=eddycjy,就会导致返回 bad flag syntax 的错误提示。

在定位命令行参数节点上,采用的依据是根据”-“的索引定位解析出上下的参数名(name)和参数的值(value),部分核心代码如下:

func (f *FlagSet) parseOne() (bool, error) {
	f.args = f.args[1:]
	hasValue := false
	value := ""
	for i := 1; i < len(name); i++ { // equals cannot be first
		if name[i] == '=' {
			value = name[i+1:]
			hasValue = true
			name = name[0:i]
			break
		}
	}
	...
}

最后在设置参数值上,会对值类型进行判断,若是布尔类型,则调用定制的 boolFlag 类型进行判断和处理,最后通过该 flag 所提供的 Value.Set 方法将参数值设置到对应的 flag 中去,核心代码如下:

func (f *FlagSet) parseOne() (bool, error) {
	if fv, ok := flag.Value.(boolFlag); ok && fv.IsBoolFlag() {
		if hasValue {
			if err := fv.Set(value); err != nil {
				return false, f.failf("invalid boolean value %q for -%s: %v", value, name, err)
			}
		} else {
			if err := fv.Set("true"); err != nil {
				return false, f.failf("invalid boolean flag %s: %v", name, err)
			}
		}
	} else {
		...
		if err := flag.Value.Set(value); err != nil {
			return false, f.failf("invalid value %q for flag -%s: %v", value, name, err)
		}
	}
}

1.5 自定义参数类型

刚刚看到上述的分析后,不知道你是否注意到,flag 的命令行参数类型是可以自定义的,也就是我们的 Value.Set 方法,我们只需要实现其对应的 Value 相关的两个接口就可以了,如下:

type Value interface {
	String() string
	Set(string) error
}

我们将原先的字符串变量 name 修改为类别别名,并为其定义符合 Value 的两个结构体方法,示例代码如下:

type Name string

func (i *Name) String() string {
	return fmt.Sprint(*i)
}

func (i *Name) Set(value string) error {
	if len(*i) > 0 {
		return errors.New("name flag already set")
	}

	*i = Name("eddycjy:" + value)
	return nil
}

func main() {
	var name Name
	flag.Var(&name, "name", "帮助信息")
	flag.Parse()

	log.Printf("name: %s", name)
}

该示例最终的输出结果为 name: eddycjy:Go 语言编程之旅 ,也就是只要我们实现了 Value 的 String 和 Set 方法,就可以进行定制化,然后无缝地接入我们的命令行参数的解析中,这就是 Go 语言的接口设计魅力之处。

1.6 小结

我们初步介绍了本章的一个基本思路,并对我们最常用的标准库 flag 进行了介绍和使用说明,标准库 flag 的使用将始终穿插在所有的章节中,因为我们常常会需求读取外部命令行的参数,例如像是启动端口号、日志路径设置等等,非常常用。



本图书由 煎鱼 ©2020 版权所有,所有文章采用知识署名-非商业性使用-禁止演绎 4.0 国际进行许可。