https://arslan.io/2017/09/14/the-ultimate-guide-to-writing-a-go-tool/
作者:Fatih Arslan
译者:oopsguy.com
我之前编写过一个叫 gomodifytags 的工具,它使我的生活变得很轻松。它会根据字段名称自动填充结构体标签字段。让我来展示一下它的功能:
使用这样的工具可以很容易管理结构体的多个字段。该工具还可以添加和删除标签、管理标签选项(如 omitempty
)、定义转换规则(snake_case
、camelCase
等)等。但该工具是怎样工作的呢?它内部使用了什么 Go 包?有很多问题需要回答。
这是一篇非常长的博文,其解释了如何编写这样的工具以及每个构建细节。它包含许多独特的细节、技巧和未知的 Go 知识。
拿起一杯咖啡??,让我们深入一下吧!
首先,让我列出这个工具需要做的事情:
- 它需要读取源文件、理解并能够解析 Go 文件
- 它需要找到相关的结构体
- 找到结构体后,它需要获取字段名称
- 它需要根据字段名来更新结构体标签(根据转换规则,如
snake_case
)
- 它需要能够把这些更改更新到文件中,或者能够以可消费的方式输出更改后的结果
我们首先来了解什么是 结构体(struct)标签(tag),从这里我们可以学习到所有东西以及如何把它们组合在一起使用,在此基础上您可以构建出这样的工具。
结构体的标签值(内容,如 json: "foo"
)不是官方规范的一部分,但是 reflect
包定义了一个非官方规范的格式标准,这个格式同样被 stdlib
包(如 encoding/json
)所使用。它通过 reflect.StructTag 类型定义:
这个定义有点长,不是很容易让人理解。我们尝试分解一下它:
- 一个结构体标签是一个字符串文字(因为它有字符串类型)
- 键(key)部分是一个无引号的字符串文字
- 值(value)部分是带引号的字符串文字
- 键和值由冒号(:)分隔。键与值且由冒号分隔组成的值称为键值对
- 结构体标签可以包含多个键值对(可选)。键值对由空格分隔。
- 不是定义的部分是选项设置。像
encoding/json
这样的包在读取值时当作一个由逗号分隔列表。 第一个逗号后的内容都是选项部分,比如 foo,omitempty,string
。其有一个名为 foo
的值和 [omitempty
, string
] 选项
- 因为结构体标签是字符串文字,所以需要使用双引号或反引号包围。因为值必须使用引号,因此我们总是使用反引号对整个标签做处理。
总的来说:
我们已经了解了什么是结构体标签,我们可以根据需要轻松地修改它。 现在的问题是,我们如何解析它才能使我们能够轻松进行修改?幸运的是,reflect.StructTag
包含一个方法,它允许我们进行解析并返回指定键的值。以下是一个示例:
package main
import (
"fmt"
"reflect"
)
func main() {
tag := reflect.StructTag(`species:"gopher" color:"blue"`)
fmt.Println(tag.Get("color"), tag.Get("species"))
}
结果:
blue gopher
如果键不存在,则返回一个空字符串。
这是非常有用,但也有一些不足使得它并不适合我们,因为我们需要更多的灵活性:
- 它无法检测到标签是否格式错误(如:键部分用引号包裹,值部分没有使用引号等)。
- 它无法得知选项的语义。
- 它没有办法迭代现有的标签或返回它们。我们必须要知道要修改哪些标签。如果不知道名字怎么办?
- 修改现有标签是不可能的。
- 我们不能从头开始构建新的结构体标签。
为了改进这一点,我写了一个自定义的 Go 包,它解决了上面提到的所有问题,并提供了一个 API,可以轻松地改变结构体标签的各个方面。
该包名为 structtag,可以从 github.com/fatih/structtag 获取。 这个包允许我们以简洁的方式解析和修改标签。以下是一个完整的示例,您可以复制/粘贴并自行尝试:
package main
import (
"fmt"
"github.com/fatih/structtag"
)
func main() {
tag := `json:"foo,omitempty,string" xml:"foo"`
// parse the tag
tags, err := structtag.Parse(string(tag))
if err != nil {
panic(err)
}
// iterate over all tags
for _, t := range tags.Tags() {
fmt.Printf("tag: %+v\n", t)
}
// get a single tag
jsonTag, err := tags.Get("json")
if err != nil {
panic(err)
}
// change existing tag
jsonTag.Name = "foo_bar"
jsonTag.Options = nil
tags.Set(jsonTag)
// add new tag
tags.Set(&structtag.Tag{
Key: "hcl",
Name: "foo",
Options: []string{"squash"},
})
// print the tags
fmt.Println(tags) // Output: json:"foo_bar" xml:"foo" hcl:"foo,squash"
}
现在我们了解了如何解析、修改或创建结构体标签,是时候尝试修改一个 Go 源文件了。在上面的示例中,标签已经存在,但是如何从现有的 Go 结构体中获取标签呢?
答案是通过 AST。AST(Abstract Syntax Tree,抽象语法树)允许我们从源代码中检索每个标识符(节点)。 下面你可以看到一个结构体类型的 AST(简化版):
在这棵树中,我们可以检索和操作每个标识符、每个字符串、每个括号等。这些都由 AST 节点表示。例如,我们可以通过替换表示它的节点将字段名称从 Foo
更改为 Bar
。 该逻辑同样适用于结构体标签。
要获得一个 Go AST,我们需要解析源文件并将其转换成一个 AST。实际上,这两者都是通过同一个步骤来处理的。
要实现这一点,我们将使用 go/parser 包来解析文件以获取 AST(整个文件),然后使用 go/ast 包来处理整个树(我们可以手动做这个工作,但这是另一篇博文的主题)。 您在下面可以看到一个完整的例子:
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/tok