呓语 | 杨英明的个人博客

专注于c++、Python,欢迎交流

By

200 行代码实现一个简单的命令行解析库

前言

本文介绍一个简单的、只有 200 行代码的命令行解析库的实现原理,库暂命名为 ArgParse,它可以提供以下功能:

  • 支持 int、bool、string 三种类型的参数,比如 -p 10 或者 -f xxx.txt
  • 支持同时识别多个参数,比如 -nfp 10
  • 支持同时定义长/短参数,比如 -h 或者 --help,你需要给它们定义相同的 usage,比如 "帮助信息"
    • "-" 后面的是短参数,"--" 后面的是长参数,错误的使用会报错,比如 -help 是错误的
  • 输出帮助信息(--help)时
    • 长/短参数会自动合并在一起,像这样:-h,--help,它们必须有相同的 usage
    • 根据参数名进行排序

命令行解析库一般在写命令行 CLI 工具的时候会使用到,它的主要作用是方便的配置和解析命令行参数,Go 语言标准库中其实就提供了一个命令行解析库 flag, 但是在使用过程中发现 flag 不支持同时识别多个参数,比如 -nfp 10 这样的参数集合。于是我就撸了个简单的命令行解析库,其中也借鉴了 flag 的思路。

名字嘛,因为之前使用过 python 中的 ArgParse,所以这个简单的 Go 语言命令行解析库也叫做 ArgParse 好了。

开源地址:elliotxx/argparse

使用

利用 ArgParse 写一个 cat 命令来举例。

我们希望 cat 命令行工具有以下输出效果和功能:

>> cat --help
Usage of cat:
    -b  bool    按照二进制模式显示文本
    -f  bool    倒序输出文本
    -h,--help   bool    帮助信息
    -n  bool    输出带行号的文本
    -p  int     输出 n 

那么我们可以利用 ArgParse 这样配置这些参数:

// cat 的参数配置
isf := argparse.Bool("f", false, "倒序输出文本")
isn := argparse.Bool("n", false, "输出带行号的文本")
isb := argparse.Bool("b", false, "按照二进制模式显示文本")
p   := argparse.Int("p", -1, "输出 n 行")
ish := argparse.Bool("h", false, "帮助信息")
ish2 := argparse.Bool("help", false, "帮助信息")

其中,argparse.Bool("f", false, "倒序输出文本") 的作用是告诉解析库我要定义一个布尔型的命令行参数,参数名为 "f",参数默认值为布尔类型的 "false",参数的作用(usage)为 "倒序输出文本"。

这样解析库就知道 cat 命令行工具后面有一个名字为 "f" 的参数需要解析,当遇到 "-f" 的时候,它就会被解析出来,同时该参数的值就会变成 true,表示需要 "倒序输出文本"。

需要注意的是,上述配置中 "h" 和 "help" 参数的作用(usage)都是 "帮助信息",那么参数库认为它们的作用是相同的,在输出时会将它们合并,像这样:-h,--help

在配置好需要解析的参数后,需要调用 Parse() 开始进行解析:

err := argparse.Parse()

正确解析之后,ArgParse 会从命令行中获取到值并覆盖默认值,同时配置代码中返回的变量会获取到该值,因为它们是指针类型。

接下来那就可以用解析后的值做一些操作啦,比如判断是否需要输出帮助信息:

if *ish || *ish2 {
    argparse.Help()
    return
}

或者判断是否倒序输出:

// 从文件中读取全部行
lines, _ := readLines(fp)
if *isf {
    // 对所有行进行倒序
    lines = reverse(lines)
}

实现

其实实现一个乞丐版的命令行解析库比较简单,大概 100 行左右就能完成的,但是我又想加入一些方便的特性,比如:参数排序、参数组、长/短参数、同 usage 参数自动合并等,就需要多写一些代码了,最后 200 行代码差不多达到了我的要求。

下面介绍基本架构和一些特性的实现原理。

基本数据结构

首先需要一个结构体记录 每个命令行参数的配置,这里我们简单一些,只需要记录 命令行的类型、参数的值(包括默认值和解析后的值)以及作用 就好了,这些都是基本的,对吧~

type ArgItem struct {
    Type    string      // 类型,比如 "bool"、"int"、"string" 等
    Value   Value       // 参数值
    Usage   string      // 参数用法,比如 "倒序输出文本"
}

上述代码你可能会疑惑 Value 类型是个什么鬼?为什么要自定义一个类型?

答:因为我们要 用一个结构体存储不同的类型值 啊,如果我这里写死了 Value 的类型为 int 或者 bool,那么它只能记录这一种类型的值,毕竟 Go 语言是一种强类型的静态语言。

所以我们定义了一个 Value 接口:

// 该接口类型的变量可调用 Set 方法进行赋值
type Value interface {
    Set(v string) error         // 设置值
}

同时又定义了三种实现了 Set() 方法,即实现了 Value 接口的类型,来代表我们的解析库支持的三种参数类型:bool、int、string。

// 自定义类型,方便添加 Set 方法,以实现 Value 接口
type boolVal    bool
type intVal     int
type stringVal  string

// 可以解析三类参数:bool、int、string
func (x *boolVal) Set(v string) error {
    r, err := strconv.ParseBool(v)
    if err != nil {
        return err
    }
    *x = boolVal(r)
    return nil
}

func (x *intVal) Set(v string) error {
    ...
}

func (x *stringVal) Set(v string) error {
    ...
}

其中,上述代码介绍了 bool 类型如何实现 Set() 方法,简单来说就是实现了 string => bool 的类型转换,来对 bool 型的参数变量进行赋值。

实现了 Value 接口之后,ArgItem 结构体的 Value 成员就可以记录以上三种类型(boolVal、intVal、stringVal)的变量值,而不是只能记录一种类型,并且可以通过调用 Set() 方法对值进行修改。

有了每个参数的结构体定义,我们还需要一个结构去存储所有的参数配置,这里我们用一个 map 进行存储,key 是参数名称,value 是该参数对应的配置,即 string => ArgItem。

var argMap = make(map[string]ArgItem) // 参数配置

另外还需要一个结构存储没有被解析到的参数,它是个列表就可以了:

var otherArg []string                 // 没解析到的其它参数(不包含在参数配置中的参数)

添加参数配置

那么基本数据结构介绍完了,我们看看添加一个参数配置的过程(比如调用 argparse.Bool)都发生了什么?

// 添加参数初始配置到 argMap
func Bool(name string, defaultValue bool, usage string) (*bool) {
    x := new(bool)
    *x = defaultValue
    v := (*boolVal)(x)
    arg := ArgItem{
        Type:   "bool",
        Value:  v,
        Usage:  usage,
    }
    argMap[name] = arg
    return x
}

首先因为要返回一个 bool 型的指针,我们在函数中先 new 了一个 bool 型变量,它返回一个 bool 型的指针,并将传进来的默认值赋值给了它。

然后我们将 bool 变量转换为了 *boolVal 变量,赋值给 ArgItem 结构体中的 Value 成员,很显然,boolVal 类型实现了 Value 接口,所以这样赋值是没问题的。

最后将记录了参数配置的 ArgItem 变量赋值给了全局变量 argMap,这样解析库就知道了我们添加了一个参数。

至于为什么不是返回 boolVal 型指针,我们不是都定义了嘛?

答:boolVal 是在解析库内部使用的,对于库的调用者来说,我只需要获得解析后的参数值,显然用 Go 默认类型比较方便。

需要注意的是,Bool() 函数中 new 出来的空间是全局存在的,并且通过指针返回了这块空间的地址,也就是说外部可以通过这个指针随时获取这块地址的当前值,即使它可能已经在解析过程中调用 Set() 方法进行了修改。

参数解析

参数解析过程是命令行解析库中最重要的一部分,它定义了参数解析的过程,我们的库中支持 - 和 -- 两种参数的解析,其中 - 代表短参数,-- 代表长参数,错误的使用将报错,比如 -help。

func Parse() error {
    // args parse
    length := len(os.Args)
    for i:=1; i<length; i++ {
        // 依次读取命令行参数
        v := []byte(os.Args[i])
        if len(v) > 1 && v[0] == '-' {
            // 解析每个参数
            argGroup := []string{}
            if len(v) > 2 && v[1] == '-' {
                // 解析 --name 这类参数
                argGroup = append(argGroup, string(v[2:]))
            } else {
                // 解析 -afn 这类参数组
                for _, arg := range v[1:] {
                    argGroup = append(argGroup, string(arg))
                }
            }
            // 处理解析到的每个参数
            readNext := false       // 是否读取了下一个值
            for _, arg := range argGroup {
                if ai, ok := argMap[arg]; ok { // 判断是否是需要解析的参数
                    if ai.Type == "bool" {      // 如果该参数是布尔型
                        ai.Value.Set("true")    // 那么值应该是 true
                    } else {                    // 如果该参数不是布尔型
                        if readNext {           // 已经读取过下一个值,报错
                            return fmt.Errorf("参数解析错误,有多个需要后续值的参数")
                        } else {                // 没读取过,将下一个参数作为该参数的值,比如 -p 10,把 10 作为 p 参数的值
                            readNext = true
                            i += 1
                            if i >= length {
                                return fmt.Errorf("参数 -%s 需要值", arg)
                            }
                            if err := ai.Value.Set(os.Args[i]); err != nil {
                                return fmt.Errorf("%s 无法作为参数 -%s 的值", os.Args[i], arg)
                            }
                        }
                    }
                } else {
                    return fmt.Errorf("参数 -%s 无法识别", arg)
                }
            }
        } else {
            otherArg = append(otherArg, string(v))
        }
    }
    return nil
}

需要注意的是,解析到参数值之后,比如 bool 型的参数,调用 ai.Value.Set("true") 进行了赋值。

我在写这部分代码的时候,惊奇的发现和 flag 库的思路十分相似,所以这个步骤其实没啥需要说明的,看代码就好。

参数排序

因为所有参数配置都是用 map 存储的,而 Go 语言中提取 map 的 key 是随机的过程,这样每次 --help 输出的顺序都不一样,这太丑了,我决定多写一些代码固定住参数输出的顺序。

原理也很简单,用一个 slice 记录所有的参数名,并进行排序,最后按排序后的 key 的顺序进行输出就好了。

keyIndex := []string{}
// 排序
sort.Strings(keyIndex)
for _, k := range keyIndex {
    fmt.Printf("    %s\t%s\t%s\n", k, argMap[k].Type,  argMap[k].Usage)
}

大致思路是这样的,实际写起来还有些差别。

同 usage 参数自动合并等

为了添加这个特性,我把本来不到 10 行的 Help() 扩充到了 40 行,gg

主要是添加了一个 usageMap := map[string][]string{} 变量,用于存储每个 usage 对应的所有参数名,比如 "帮助信息" => ["h", "help"]。

首先遍历一遍所有参数配置,填充 usageMap。

然后在输出时,将某个 usage 对应的全部参数名都输出,比如遇到 argMap["h"] 时,它的 usage 是 "帮助信息",而 "帮助信息" 对应的所有参数名是 ["h", "help"],所以这时实际输出的是 "-h,--help"

Help() 代码:

func Help() {
    // 输出帮助信息
    // 排序并合并同 usage 的参数,比如 -h --help 合并为 -h,--help
    keyIndex := []string{}
    usageMap := map[string][]string{}
    // 合并同 usage 的参数
    for k, v := range argMap {
        // 合并所有同 usage 的参数,usageMap 记录 usage => 该 usage 对应的参数列表
        // 比如 "帮助信息" => [h,help]
        if keys, ok := usageMap[v.Usage]; ok {
            // 遇到同 usage 参数,累加到列表后面
            keys = append(keys, k)
            usageMap[v.Usage] = keys
        } else {
            usageMap[v.Usage] = []string{k}
        }
        // 提取所有参数名,以便之后排序
        keyIndex = append(keyIndex, k)
    }
    // 排序
    sort.Strings(keyIndex)
    // 按顺序输出,并合并同 usage 参数
    fmt.Printf("Usage of %s\n", os.Args[0])
    for _, oldKey := range keyIndex {
        usage := argMap[oldKey].Usage
        argType := argMap[oldKey].Type
        if newKeys, ok := usageMap[usage]; ok {
            sort.Strings(newKeys)
            if len(newKeys) <= 1 {
                fmt.Printf("    %s\t%s\t%s\n", fullArg(oldKey), argType, usage)
            } else {
                // 遇到了同 usage 参数,合并输出,并删除对应 key
                fullKeys := []string{}
                for _, k := range newKeys {
                    fullKeys = append(fullKeys, fullArg(k))
                }
                fmt.Printf("    %s\t%s\t%s\n", strings.Join(fullKeys, ","), argType, usage)
                delete(usageMap, usage)
            }
        }
    }
}

总结

好了,一个简单的命令行解析库就介绍到这里,总的来说,这类解析库比较成熟了,我只是实现了一个比较简单、能用的库,一方面是学习,另一方面为了自己使用~ 代码难免有考虑不周全的地方,欢迎提出建议~

以后还会发布一些 Go 语言库的解析和实现相关的博文,欢迎关注~

原创声明

转载请注明:呓语 » 200 行代码实现一个简单的命令行解析库