[ Go语言编程之旅 ] 命令行应用:打造属于自己的工具集

前述

因为工作原因,一直没有机会使用Go来写一些项目。那我就想不如写个博客吧!在寻找使用 gin 开发博客的文章时,第一次看到了 煎鱼 的系列文章,学到了了很多。最近 煎鱼 出了新书 《Go语言编程之旅》,是实践类的内容,这很适合我,所以有了这次的学习记录,希望做到系列记录。

工具之旅

绝大部分工程师都想拥有一个属于自己的工具集,因为它能够在提高工作效率的同时,给我们带来一定的成就感。更重要的是,在持续不断地维护、迭代项目的同时,我们的技术也会得到磨炼,因为我们遇到的问题,极有可能是共性问题,也就是说,别人也有可能遇到。事实上,GitHub 里的许许多多的优秀个人开源项目就是这样产生的,因而开源工具集是一件一举多得的事情。

在本章中,我们将做一个简单的通用工具集,用它解决在平时工作中经常遇到的一些小麻烦,而不再借助其他快捷网站,即让我们自己的产品为自己服务,并不断地迭代它。

标准库 flag

标准库 flag 是 Go 语言中的一大利器,它主要功能是实现命令行参数的解析,让我们在开发过程中非常方便地解析和处理命令行,是一个需要必知必会的基础标准库。

初始化项目

# 创建项目路径
$ mkdir -p $HOME/go-programming-tour-book/tour

# 进入项目路径
$ cd $HOME/go-programming-tour-book/tour

# 初始化项目的 Go modules,设置项目模块路径
$ go mod init github.com/go-programming-tour-book/tour

# 保证系统环境变量 GO111MODULE=on or auto,因为我们使用的是 Go moduls 管理依赖
$ go env -w GO111MODULE=on

# 初次使用 Go modules 建议设置国内镜像代理
$ go env -w GOPOXY=https://gopoxy.cn,direct

示例

1. 标准库 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 并不直接支持该功能,但是我们可以通过其他第三方库来实现这个功能,具体实现方法在本书后面介绍。

2. 子命令的使用

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

var name string
// 作者的示例好像有问题,这是我修改后的
func main(){
    flag.Parse()
    args := flag.Args()
    switch args[0] {
    case "go":
        goCmd := flag.NewFlagSet("go",flag.ExitOnError)
        goCmd.StringVar(&name,"name","Go 语言","帮助信息")

        if err := goCmd.Parse(args[1:]); err != nil{
            log.Println(err)
        }
    case "php":
        phpCmd := flag.NewFlagSet("php",flag.ExitOnError)
        phpCmd.StringVar(&name,"n","Php 语言","帮助信息")

        if err := phpCmd.Parse(args[1:]); err != nil{
            log.Println(err)
        }
    }

    log.Printf("name: %s\n",name)

}

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

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

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

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

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

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

分析

从使用上来讲,标准库 flag 非常方便,一个简单的 CLI 应用很快就搭建起来了,但是它是如何实现的呢?我们一起来深入看看,做到知其然并知其所以然。整体分析流程图如下所示:

标准库 flag 分析流程图

1. flag.Parse

在流程图中,首先看到的是 flag.Parse(简称 Parse 方法)。它总是在所有命令行参数注册的最后进行调用,其功能是解析并绑定命令行参数。下面一起看看其内部实现:

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

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

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

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

2. FlagSet.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
}

FlagSet.Parse 是对解析方法的进一步封装,实际上解析逻辑放在了 parseOne 中,而解析过程中遇到的一些特殊情况,如重复解析、异常处理等,均直接由 FlagSet.Parse 进行处理。实际上,这是一个分层明细、结构清晰的方法设计,值得我们参考。

3. FlagSet.parseOne

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 提供的 Vlaue.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)
        }
    }
}

定义参数类型

在前面的分析中,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:煎鱼 ,也就是说,只要我们实现了 Value 的 String 方法和 Set 方法,就可以进行定制化,然后无缝地接入我们的命令行参数的解析中,这就是 Go 语言的接口设计魅力之处。

小结

本节我们对常用的标准库 flag 进行了简要的介绍。标准库 flag 的使用将始终穿插在本书的所有章节中,因为我们需要常常读取外部命令行的参数,例如启动端口号、日志路径设置等。

单词格式转换

在日常生活和工作中,我们经常会看到一些以单词命名的字符串,然后将其转为各种格式的命名。例如,在程序中,我们原本已经定义了某个命名,但可能需要将其转为一个或多个 const 常量,这时如果手动一个个地修改,那就太繁琐了,不仅有可能改错,而且工作效率低下。

实际上,我们可以通过编写一个小工具来实现这个功能,一来能够满足自己的需求,二来也能不断迭代,甚至满足一些定制化需求。下面我们就开始打造属于自己的工具集,首先搭建工具集的项目架子,然后实现一个工具,使其可以实现多种单词格式转换的功能。

安装 Cobra

安装本项目依赖的基础库 Cobra,以便快速搭建 CLI 应用程序。在项目根目录中执行如下命令:

$ go get -u github.com/spf13/cobra@v1.0.0

初始化 cmd 和 word 子命令

下面对项目目录进行初始化,目录结构如下所示:

tour
├─ main.go
├─ go.mod
├─ go.sum
├─ cmd
├─ internal
└─ pkg

在本项目中创建入口文件 main.go,并新增三个目录,分别是 cmd、internal 和 pkg。

首先,在 cmd 目录下新建 word.go 文件,用于放置单词格式转换的子命令 word,并在其中写入如下代码:

package cmd

import "github.com/spf13/cobra"

var wordCmd = &cobra.Command{
	Use:   "Word",
	Short: "单词格式转换",
	Long:  "支持多种单词格式转换",
	Run:   func(cmd *cobra.Command, args []string) {},
}

func init() {}

然后,在 cmd 目录下新建 root.go 文件,用于放置根命令,并在其中写入如下代码:

package cmd

import "github.com/spf13/cobra"

var rootCmd = &cobra.Command{
	Use:   "",
	Short: "",
	Long:  "",
}

func Execute() error {
	return rootCmd.Execute()
}

func init() {
	rootCmd.AddCommand(wordCmd)
}

最后,在启动 main.go 文件中,写入如下运行代码:

package main

import (
	"log"

	"github.com/go-programming-tour-book/tour/cmd"
)

func main() {
	err := cmd.Execute()
	if err != nil {
		log.Fatalf("cmd.Execute err: %v", err)
	}
}

单词转换

在功能上,我们需要对单词转换类型进行编码,具体如下:

  • 单词全部转为小写。
  • 单词全部转为大写。
  • 下划线单词转为大写驼峰单词。
  • 下划线单词转为小写驼峰单词。
  • 驼峰单词转为下划线单词。

在项目的 internal 目录下,新建 word 目录及文件,并在 word.go 中写入后面的章节代码,目录结构如下所示:

|
├─ internal
|  └─ word
|     └─ word.go

1. 单词全部转为小写/大写

把单词全部转为小写或大写的实现方法非常简单,直接调用标准库 strings 中的 ToLower 方法或 ToUpper 方法进行转换即可,其原生方法的作用就是把单词转为小写或大写,代码如下:

func ToUpper(s string) string {
    return strings.ToUpper(s)
}

func ToLower(s string) string {
    return strings.ToLower(s)
}

2. 下划线单词转大写驼峰单词

把以下划线命名的单词转为大写驼峰单词的主体逻辑是,把下划线替换为空格字符,然后再把所有字符修改为其对应的首字母大写形式,最后把空格字符替换为空,就可以确保各个部分返回的是首字母大写的字符串了,代码如下:

func UnderscoreToUpperCamelCase(s string) string {
    s = strings.Replace(s,"_"," ",-1)
    s = strings.Title(s)
    return strings.Replace(s," ","",-1)
}

3. 下划线单词转小写驼峰单词

把以下划线命名的单词转为小写驼峰单词的主体逻辑是,直接复用大写驼峰单词的转换方法,然后对其首字母进行处理。在该方法中,我们直接将字符串的第一位取出来,然后利用 unicode.ToLower 进行转换,代码如下:

func UnderscoreToLowerCamelCase(s string) string {
    s = UnderscoreToUpperCamelCase(s)
    return string(unicode.ToLower(rune(s[0]))) + s[1:]
}

4. 驼峰单词转下划线单词

这里直接使用 govalidator 库提供的转换方法把大写或小写驼峰单词转为下划线单词。主体逻辑是将字符转为小写的同时添加下划线。比较特殊的一点在于,如果当前字符不是小写字母、下划线或数字,那么在处理时将对 segment 置空,保证其每一段的区间转换都是正确的代码如下:

func CamelCaseToUnderscore(s string) string {
    var output []rune
    for i, r := range s {
        if i == 0 {
            output = append(output, unicode.ToLower(r))
            continue
        }
        if unicode.IsUpper(r) {
            output = append(output, "_")
        }
        output = append(output, unicode.ToLower(r))
    }
    return string(output)
}

word 子命令

在完成了单词的各个转换方法后,我们开始编写 word 子命令,将其对应的方法集成到 Command 中。打开项目下的 cmd/word.go 文件,定义目前单词所支持的转换模式枚举值,新增如下代码:

const (
	ModeUpper                      = iota + 1 // 全部单词转为大写
	ModeLower                                 // 全部单词转为小写
	ModeUnderscoreToUpperCamelcase            // 下划线单词转为大写驼峰单词
	ModeUnderscoreToLowerCamelcase            // 下划线单词转为小写驼峰单词
	ModeCamelcaseToUnderscore                 // 驼峰单词转为下划线单词
)

接下来对具体的单词子命令进行设置和集成,新增如下代码:

var desc = strings.Join([]string{
	"该子命令支持各种单词格式转换,模式如下:",
	"1:全部单词转为大写",
	"2:全部单词转为小写",
	"3:下划线单词转为大写驼峰单词",
	"4:下划线单词转为小写驼峰单词",
	"5:驼峰单词转为下划线单词",
}, "\n")

var wordCmd = &cobra.Command{
	Use:   "word",
	Short: "单词格式转换",
	Long:  desc,
	Run: func(cmd *cobra.Command, args []string) {
		var content string
		switch mode {
		case ModeUpper:
			content = word.ToUpper(str)
		case ModeLower:
			content = word.ToLower(str)
		case ModeUnderscoreToLowerCamelcase:
			content = word.UnderscoreToLowerCamelCase(str)
		case ModeUnderscoreToUpperCamelcase:
			content = word.UnderscoreToUpperCamelCase(str)
		case ModeCamelcaseToUnderscore:
			content = word.CamelCaseToUnderscore(str)
		default:
			log.Fatalf("暂不支持该转换模式,请执行 help word 查看帮助文档")
		}
		log.Printf("输出结果: %s", content)
	},
}

在上述代码中,其核心在于子命令 word 的 cobra.Command 调用和设置,其一共包含如下是三个常用选项,分别是:

  • User:子命令的命令标识。
  • Short:简短说明,在 help 命令输出的帮助信息中展示。
  • Long:完整说明,在 help 命令输出的帮助信息中展示。

下面我们根据单词转换所需的参数,即单词内容和转换的模式,进行命令参数的设置和初始化,新增如下代码:

var str string
var mode int8

func init() {
	wordCmd.Flags().StringVarP(&str, "str", "s", "", "请输入单词内容")
	wordCmd.Flags().Int8VarP(&mode, "mode", "m", 0, "请输入单词转换的模式")
}

在 VarP 系列的方法中,第一个参数为需要绑定的变量,第二个参数为接收该参数的完整的命令标志,第三个参数为对应的短标识,第四个参数为默认值,第五个参数为使用说明。

验证

在完了单词格式转换功能后,我们已经初步拥有一个工具了,下面验证一下此工具的功能是否正确。一般来说,在拿到一个 CLI 应用程序后,我们会先执行 help 命令查看其帮助信息,具体如下:

$ go run main.go help word
该子命令支持各种单词格式转换,模式如下:
1:全部单词转为大写
2:全部单词转为小写
3:下划线单词转为大写驼峰单词
4:下划线单词转为小写驼峰单词
5:驼峰单词转为下划线单词

Usage:
   word [flags]

Flags:
  -h, --help         help for word
  -m, --mode int8    请输入单词转换的模式
  -s, --str string   请输入单词内容

手动验证四种单词转换模式的功能是否正常,具体如下:

$ go run main.go word -s=eddycjy -m=1
输出结果: EDDYCJY
$ go run main.go word -s=EDDYCJY -m=2
输出结果: eddycjy
$ go run main.go word -s=eddycjy -m=3
输出结果: Eddycjy
$ go run main.go word -s=EDDYCJY -m=4
输出结果: eDDYCJY
$ go run main.go word -s=EddyCjy -m=5
输出结果: eddy_cjy

小结

作为第一个实战项目,我们基于第三方开源库 Cobra 和标准库 strings、unicode 实现了多种模式的单词转换功能,此功能在日常工作中较为实用,因为我们经常需要对输入、输出数据进行各种类型的转换和拼装。

便捷的时间工具

在查看原始数据时,经常需要看格式化后的个性化时间,或是时间戳等。如果不同系统中的时间格式不一样,比较规则不一样,那么每用一次就需要做一轮转换。很多时候,业务接口的入参开始时间和结束时间是一个时间戳的值,这时就需要依靠外部的一些快捷站点,或是内部的 Web 站点来获取、调整时间了。首先要连上网,然后输入站点地址,等等。这显然不符合我们的极客思维,因此本节就做一个时间相关的工具,提高获取时间的效率。

获取时间

在项目的 internal 目录下新建 timer 目录,并新建 time.go 文件,目录结构如下:

|
├─ internal
|  ├─ timer
|  |  └─ time.go

在 time.go 文件中写入如下代码:

func GetNowTime() time.Time {
    return time.Now()
}

在 GetNowTime 方法中对标准库 time 的 Now 方法进行封装,用于返回当前本地时间的 Time 对象。此处的封装主要是为了便于后续对 Time 对象做进一步的统一处理。

推算时间

下面对时间进行推算,在 time.go 文件中新增方法,代码如下:

func GetCalculateTime(currentTime time.Time, d string) (time.Time ,error){
    duration, err := time.ParseDuration(d)
    if err != nil {
        return time.Time(), err
    }
    return currentTime.Add(duration), nil
}

在上述代码中,我们调用了连个方法来处理,分别是 ParseDuration 方法和 Add 方法。ParseDuration 方法用于从字符串中解析出 duration(持续时间),其支持的有效单位有 ns、us(或 μs)、ms、s、m 和 h,例如,“300ms”,"-1.5h" or “2h45m”。而在 Add 方法中,我们可以传入其返回的 duration,这样就可以得到当前 Timer 时间加上 duration 后所得到的最终时间。

为什么要添加一个 ParseDuration 方法,而不直接用 Add 方法来做呢?实际上,在这个时间工具中,我们预先并不知道传入的值是什么,因此最好用 ParseDuration 方法先处理一下。

如果预先知道准确的 duration,且不需要适配,那么即可直接使用 Add 方法进行处理,代码如下:

const (
	Nanosecond  Duration = 1
	Microsecond          = 1000 * Nanosecond
	Millisecond          = 1000 * Microsecond
	Second               = 1000 * Millisecond
	Minute               = 60 * Second
	Hour                 = 60 * Minute
)