[ 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 应用很快就搭建起来了,但是它是如何实现的呢?我们一起来深入看看,做到知其然并知其所以然。整体分析流程图如下所示:
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
)