我以前构建过一个工具,以让生活更轻松。这个工具被称为: gomodifytags ,它会根据字段名称自动填充结构体的标签字段。示例如下:
(在 vim-go 中使用 gomodifytags 的一个用法示例)
使用这样的工具可以 轻松管理 结构体的多个字段。该工具还可以添加和删除标签,管理标签选项(如omitempty),定义转换规则(snake_case、camelCase 等)等等。但是这个工具是如何工作的? 在后台中它究竟使用了哪些 Go 包? 有很多这样的问题需要回答。
这是一篇非常长的博客文章,解释了如何编写类似这样的工具以及如何构建它的每一个细节。 它包含许多特有的细节、提示和技巧和某些未知的 Go 位。
拿一杯咖啡,开始深入探究吧!
首先,列出这个工具需要完成的功能:
- 它需要读取源文件,理解并能够解析 Go 文件
- 它需要找到相关的结构体
- 找到结构体后,需要获取其字段名称
- 它需要根据字段名更新结构标签(根据转换规则,即:snake_case)
- 它需要能够使用这些改动来更新文件,或者能够以可接受的方式输出改动
我们首先来看看 结构体标签的定义 是什么,之后我们会学习所有的部分,以及它们如何组合在一起,从而构建这个工具。
结构体的标签 值 (其内容,比如`json:"foo"`)并 不是官方标准的一部分 ,不过,存在一个非官方的规范,使用 reflect 包定义了其格式,这种方法也被 stdlib(例如 encoding/ json)包所采用。它是通过 reflect.StructTag 类型定义的:
结构标签的定义比较简洁所以不容易理解。该定义可以分解如下:
- 结构标签是一个字符串(字符串类型)
- 结构标签的 Key 是非引号字符串
- 结构标签的 value 是一个带引号的字符串
- 结构标签的 key 和 value 用冒号(:)分隔。冒号隔开的一个 key 和对应的 value 称为 “key value 对”。
- 一个结构标签可以包含多个 key valued 对(可选)。key-value 对之间用空格隔开。
- 可选设置不属于定义的一部分。类似 encoding/json 包将 value 解析为逗号分开的列表。value 的第一个逗号后面的任何部分都是可选设置的一部分,例如:“ foo, omitempty,string”。其中 value 拥有一个叫 “foo” 的名字和可选设置 [“omitempty”, "string"]
- 由于结构标签是一个字符串,需要双引号或者反引号包含。又因为 value 也需要引号包含,经常用反引号包含结构标签。
以上规则概况如下:
(结构标签的定义有许多隐含细节)
已经了解什么是结构标签,接下来可以根据需要修改结构标签。问题来了,如何才能很容易的对所做的修改进行解析?很幸运,reflect.StructTag 包含一个可以解析结构标签并返回特定 key 的 value 的方法。示例如下:
- package main
-
- import (
- "fmt"
- "reflect"
- )
-
- func main() {
- tag := reflect.StructTag(`species:"gopher" color:"blue"`)
- fmt.Println(tag.Get("color"), tag.Get("species"))
- }
输出:
- blue gopher
如果 key 不存在则返回空串。
这是非常有帮助的, 但是 ,它有一些附加说明,使其不适合我们,因为我们需要更多的灵活性。这些是:
- 它无法检测到标签是否存在 格式错误 (即:键被引用了,值是未引用等)
- 它不知道选项的 语义
- 它没有办法 迭代现有的标签 或返回它们。 我们必须知道我们要修改哪些标签。 如果不知道其名字怎么办?
- 修改现有标签是不可能的。
- 我们不能重新 构建新的struct标签 。
为了改进这一点,我编写了一个自定义的Go包,它修复了上面的所有问题,并提供了一个可以轻松修改struct标签的每个方面的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"
- }
既然我们已经知道如何解析一个struct标签了,以及修改它或创建一个新的,现在是时候来修改一个有效的Go源文件了。在上面的示例中,标签已经存在了,但是如何从现有的Go结构中获取标签呢?
简要回答:通过 AST 。AST( Abstract Syntax Tree ,抽象语法树)允许我们从源代码中检索每个单独的标识符(node)。下图中你可以看到一个结构类型的AST(简化版):
(结构体的基本的Go ast.Node 表示)
在这棵树中,我们可以检索和操纵每个标识符,每个字符串和每个括号等。这些都由 AST 节点表示。例如,我们可以通过替换表示它的节点中的名字将字段名称从“Foo”更改为“Bar”。相同的逻辑也适用于struct标签。
要 得到Go AST ,我们需要解析源文件并将其转换为AST。实际上,这两者都是通过一个步骤处理的。
要做到这一点,我们将使用 go/parser 包来 解析 文件以获取(整个文件的)AST,然后使用 go/ast 包来遍历整棵树(我们也可以手动执行, 但这是另一篇博文的主题)。下面代码你可以看到一个完整的例子:
- package main
-
- import (
- "fmt"
- "go/ast"
- "go/parser"
- "go/token"
- )
-
- func main() {
- src := `package main
- type Example struct {
- Foo string` + " `json:\"foo\"` }"
-
- fset := token.NewFileSet()
- file, err := parser.ParseFile(fset, "demo", src, parser.ParseComments)
- if err != nil {
- panic(err)
- }
-
- ast.Inspect(file, func(x ast.Node) bool {
- s, ok := x.(*ast.StructType)
- if !ok {
- return true
- }
-
- for _, field := range s.Fields.List {
- fmt.Printf("Field: %s\n", field.Names[0].Name)
- fmt.Printf("Tag: %s\n", field.Tag.Value)
- }
- return false
- })
- }
上面代码输出如下:
-
Field: Foo
- Tag: `json:"foo"`
上面代码执行以下操作:
- 我们定义了仅包含一个结构体的有效Go包的实例。
- 我们使用 go/parser 包来解析这个字符串。解析器包也可以从磁盘读取文件(或整个包)。
- 在我们解析之后,我们保存我们的节点(分配给变量文件)并查找由 *ast.StructType 定义的AST节点(参见AST映像作为参考)。遍历树是通过ast.Inspect()函数完成的。它会遍历所有节点,直到它收到false值。这是非常方便的,因为它不需要知道每个节点。
- 我们打印结构体的字段名称和结构标签。
我们现在可以完成 两件重要的事情了 ,首先,我们知道如何 解析一个 Go 源文件 并检索其中结构体的标签(通过go/parser)。其次,我们知道 如何解析 Go 结构体标签 ,并根据需要进行修改(通过 github.com/fatih/structtag )。
既然我们有了这些,我们可以通过使用这两个重要的代码片段开始构建我们的工具(名为 gomodifytags )。该工具应顺序执行以下操作:
- 获取配置,以识别我们要修改哪个结构体
- 根据配置查找和修改结构体
- 输出结果
由于 gomodifytags 将主要由编辑器来执行,我们打算通过 CLI 标志传递配置信息。第二步包含多个步骤,如解析文件、找到正确的结构体,然后修改结构(通过修改 AST 完成)。最后,我们将输出结果,或是按照原始的 Go 源文件或是某种自定义协议(如 JSON,稍后再说)。
以下是 gomodifytags 简化之后的主要功能:
让我们开始详细解释每个步骤。为了保持简单,我将尝试以萃取形式解释重要的部分。尽管一切都是一样的,一旦你读完了这篇博文,你将能够在无需任何指导的情况下通读整个源代码(你将会在本指南的最后找到所有资源)
让我们从第一步开始,了解如何 获取配置 。以下是我们的配置文件,其中包含所有的必要信息
- type config struct {
- // first section - input & output
- file string
- modified io.Reader
- output string
- write bool
-
- // second section - struct selection
- offset int
- structName string
- line string
- start, end int
-
- // third section - struct modification
- remove []string
- add []string
- override bool
- transform string
- sort bool
- clear bool
- addOpts []string
- removeOpts []string
- clearOpt bool
- }
它分为 三个 主要部分:
第一部分包含有关如何和哪个文件要读入的配置。这可以是本地文件系统的文件名,也可以是直接来自stdin的数据(主要用在编辑器中)。它还设置了如何输出结果(Go源文件或JSON形式),以及我们是否应该覆写文件,而不是输出到stdout中。
第二部分定义了如何选择一个结构体及其字段。有多种方法可以做到这一点。我们可以通过它的偏移(光标位置)、结构名称,单行(仅指定字段)或一系列行来定义它。最后,我们总是需要得到起始行号。例如在下面的例子中,你可以看到一个例子,我们用它的名字来选择结构体,然后提取起始行号,以便我们可以选择正确的字段:
而编辑器最好使用 字节偏移量 。例如下面你可以看到我们的光标刚好在“Port”字段名称之后,从那里我们可以很容易地得到起始行号:
config配置中的 第三 部分实际上是一个到我们的 structtagpackage的 一对一的映射。它基本上允许我们在读取字段后将配置传递给structtag包。如你所知,structtag包允许我们解析一个struct标签并在各个部分进行修改。但是,它不会覆写或更新结构体的域值。
我们该如何获得配置呢? 我们只需使用flag包,然后为配置中的每个字段创建一个标志,然后给他们赋值。举个例子:
- flagFile := flag.String("file", "", "Filename to be parsed")
- cfg := &config{
- file: *flagFile,
- }
我们对 配置中的每个字段 执行相同操作。相关完整的列表请查看gomodifytag的当前master分支上的 flag 定义。
一旦我们有了配置,我们就可以做一些基本的验证了:
- func main() {
- cfg := config{ ... }
-
- err := cfg.validate()
-
请发表评论