我之前构建过一个工具,以让生活更轻松。这个工具被称为: gomodifytags ,它会根据字段名称自动填充结构体的标签字段。示例以下: (在 vim-go 中使用 gomodifytags 的一个用法示例) 使用这样的工具能够 轻松管理 结构体的多个字段。
我之前构建过一个工具,以让生活更轻松。这个工具被称为: gomodifytags ,它会根据字段名称自动填充结构体的标签字段。示例以下:javascript

(在 vim-go 中使用 gomodifytags 的一个用法示例)java
使用这样的工具能够 轻松管理 结构体的多个字段。该工具还能够添加和删除标签,管理标签选项(如omitempty),定义转换规则(snake_case、camelCase 等)等等。可是这个工具是如何工做的? 在后台中它究竟使用了哪些 Go 包? 有不少这样的问题须要回答。node
这是一篇很是长的博客文章,解释了如何编写相似这样的工具以及如何构建它的每个细节。 它包含许多特有的细节、提示和技巧和某些未知的 Go 位。git
拿一杯咖啡,开始深刻探究吧!github
首先,列出这个工具须要完成的功能:json
- 它须要读取源文件,理解并可以解析 Go 文件
- 它须要找到相关的结构体
- 找到结构体后,须要获取其字段名称
- 它须要根据字段名更新结构标签(根据转换规则,即:snake_case)
- 它须要可以使用这些改动来更新文件,或者可以以可接受的方式输出改动
咱们首先来看看 结构体标签的定义 是什么,以后咱们会学习全部的部分,以及它们如何组合在一块儿,从而构建这个工具。vim

结构体的标签 值 (其内容,好比`json:"foo"`)并 不是官方标准的一部分 ,不过,存在一个非官方的规范,使用 reflect 包定义了其格式,这种方法也被 stdlib(例如 encoding/ json)包所采用。它是经过 reflect.StructTag 类型定义的:bash

结构标签的定义比较简洁因此不容易理解。该定义能够分解以下:编辑器
- 结构标签是一个字符串(字符串类型)
- 结构标签的 Key 是非引号字符串
- 结构标签的 value 是一个带引号的字符串
- 结构标签的 key 和 value 用冒号(:)分隔。冒号隔开的一个 key 和对应的 value 称为 “key value 对”。
- 一个结构标签能够包含多个 key valued 对(可选)。key-value 对之间用空格隔开。
- 可选设置不属于定义的一部分。相似 encoding/json 包将 value 解析为逗号分开的列表。value 的第一个逗号后面的任何部分都是可选设置的一部分,例如:“ foo, omitempty,string”。其中 value 拥有一个叫 “foo” 的名字和可选设置 [“omitempty”, "string"]
- 因为结构标签是一个字符串,须要双引号或者反引号包含。又由于 value 也须要引号包含,常常用反引号包含结构标签。
以上规则概况以下:ide

(结构标签的定义有许多隐含细节)
已经了解什么是结构标签,接下来能够根据须要修改结构标签。问题来了,如何才能很容易的对所作的修改进行解析?很幸运,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()
- if err != nil {
- log.Fatalln(err)
- }
-
- // continue parsing
- }
-
- // validate validates whether the config is valid or not
- func (c *config) validate() error {
- if c.file == "" {
- return errors.New("no file is passed")
- }
-
- if c.line == "" && c.offset == 0 && c.structName == "" {
- return errors.New("-line, -offset or -struct is not passed")
- }
-
- if c.line != "" && c.offset != 0 ||
- c.line != "" && c.structName != "" ||
- c.offset != 0 && c.structName != "" {
- return errors.New("-line, -offset or -struct cannot be used together. pick one")
- }
-
- if (c.add == nil || len(c.add) == 0) &&
- (c.addOptions == nil || len(c.addOptions) == 0) &&
- !c.clear &&
- !c.clearOption &&
- (c.removeOptions == nil || len(c.removeOptions) == 0) &&
- (c.remove == nil || len(c.remove) == 0) {
- return errors.New("one of " +
- "[-add-tags, -add-options, -remove-tags, -remove-options, -clear-tags, -clear-options]" +
- " should be defined")
- }
-
- return nil
- }
将验证部分代码放到一个单一的函数中,使得测试测试更简单。既然咱们已经知道如何获取配置并进行验证,咱们继续去解析文件:

咱们在一开始就讨论了如何解析一个文件。这里解析的是config结构体中的方法。实际上,全部的方法都是config结构体的一部分:
复制代码
- func main() {
- cfg := config{}
-
- node, err := cfg.parse()
- if err != nil {
- return err
- }
-
- // continue find struct selection ...
- }
-
- func (c *config) parse() (ast.Node, error) {
- c.fset = token.NewFileSet()
- var contents interface{}
- if c.modified != nil {
- archive, err := buildutil.ParseOverlayArchive(c.modified)
- if err != nil {
- return nil, fmt.Errorf("failed to parse -modified archive: %v", err)
- }
- fc, ok := archive[c.file]
- if !ok {
- return nil, fmt.Errorf("couldn't find %s in archive", c.file)
- }
- contents = fc
- }
-
- return parser.ParseFile(c.fset, c.file, contents, parser.ParseComments)
- }
解析函数只完成了一件事。解析源码并返回一个ast.Node。若是咱们仅传递文件,这是很是简单的,在这种状况下,咱们使用parser.ParseFile()函数。须要注意的是token.NewFileSet(),它建立一个类型为*token.FileSet。咱们将它存储在c.fset中,但也传递给parser.ParseFile()函数。为何呢?
由于 fileset 用于独立地为每一个文件存储每一个节点的位置信息。这将在之后对于得到ast.Node的确切信息很是有帮助(请注意,ast.Node使用一个紧凑的位置信息,称为token.Pos。要获取更多的信息,它须要经过token.FileSet.Position()函数来获取一个token.Position,其中包含更多的信息)
让咱们继续。若是经过 stdin 传递源文件,它会变得更加有趣。config.modified 字段是易于测试的 io.Reader ,但实际上咱们经过 stdin 传递它。咱们如何检测是否须要从 stdin 读取呢?
咱们询问用户是否 想 经过 stdin 传递内容。在这种状况下,本工具的用户须要传递--modified 标志(这是一个 布尔 标志)。若是用户传递了该标志,咱们只需将 stdin 分配给 c.modified 便可:
复制代码
- flagModified = flag.Bool("modified", false,
- "read an archive of modified files from standard input")
-
- if *flagModified {
- cfg.modified = os.Stdin
- }
若是你再次检查上面的 config.parse() 函数,你将看到咱们检查 .modified 字段是否已分配,由于 stdin 是一个任意数据的流,咱们须要可以根据给定的协议对其进行解析。在这种状况下,咱们假定其中包含如下内容:
- 文件名,后跟换行符
- (十进制)文件大小,后跟换行符
- 文件的内容
由于咱们知道文件大小,咱们能够毫无问题地解析此文件的内容。任何大于给定文件大小的部分,咱们仅需中止解析。
这种 方法 也被其余几种工具所使用(如 guru、gogetdoc 等),而且它对编辑器来讲是很是有用的。由于这样可让编辑器传递修改后的文件内容, 而且无需保存到文件系统中 。所以它被命名为“modified”。
既然咱们已经拥有了 Node ,让咱们继续下一步的“查找结构体”:

咱们的主函数中,咱们将使用在上一步中解析的 ast.Node 中调用 findSelection() 函数:
复制代码
- func main() {
- // ... parse file and get ast.Node
- start, end, err := cfg.findSelection(node)
- if err != nil {
- return err
- }
- // continue rewriting the node with the start&end position
- }
cfg.findSelection() 函数会根据配置文件和咱们选定结构体的方式来返回指定结构体的开始和结束位置。它在给定 Node 上进行迭代,而后返回其起始位置(和以上的配置一节中的解释相似):

(检索步骤会迭代全部 node ,直到其找到一个 *ast.StructType ,而后返回它在文件中的起始位置。)
本文做者:佚名
来源:51CTO
原文连接