Go 终极指南:编写一个 Go 工具

您所在的位置:网站首页 findselection Go 终极指南:编写一个 Go 工具

Go 终极指南:编写一个 Go 工具

2024-07-13 16:18| 来源: 网络整理| 查看: 265

原文:https://arslan.io/2017/09/14/the-ultimate-guide-to-writing-a-go-tool/ 作者:Fatih Arslan 译者:oopsguy.com

我之前编写过一个叫 gomodifytags 的工具,使我的开发工作变得很轻松。它会根据字段名称自动填充结构体标签字段。下图是它的功能展示:

在 vim-go 中使用 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(简化版):

一个基本的 Go ast.Node 表示形式的结构体类型

在这棵树中,我们可以检索和操作每个标识符、每个字符串、每个括号等。这些都通过 AST 节点表示。例如,我们可以通过替换表示它的节点将字段名称从 Foo 更改为 Bar。该逻辑同样适用于结构体标签。

要获得一个 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 包来解析这个字符串。parser 包也可以从磁盘读取文件(或整个包)。 在解析后,我们处理了节点(分配给变量文件)并查找由 ast.StructType 定义的 AST 节点(参考 AST 图)。通过 ast.Inspect() 函数完成树的处理。它会遍历所有节点,直到它收到 false 值。这非常方便,因为它不需要知道每个节点。 我们打印了结构体的字段名称和结构体标签信息。

我们现在可以做两件重要的事,首先,我们知道了如何解析一个 Go 源文件并检索其结构体标签(通过 go/parser);其次,我们知道了如何解析 Go 结构体标签,并根据需要进行修改(通过 github.com/fatih/structtag)。

有了这些,我们现在可以使用这两个知识点来开始构建我们的工具(命名为 gomodifytags)。该工具应按顺序执行以下操作

获取配置,用于描述要修改哪个结构体 根据配置查找和修改结构体 输出结果

由于 gomodifytags 将主要应用于编辑器,我们将通过 CLI 标志(flag)传入配置。第二步包含多个步骤,如解析文件,找到正确的结构体,然后修改结构体(通过修改 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 字段名称后面,从那里我们可以很容易地得到开始行和结束行:

配置中的第三个部分实际上是一个映射到 structtag 包的一对一映射。它基本上允许我们在读取字段后将配置传给 structtag 包。如你所知,structtag 包允许我们解析一个结构体标签并对各个部分进行修改。但它不会覆盖或更新结构体字段。

我们如何获得配置?我们只需使用 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) }

parse 函数只做一件事:解析源代码并返回一个 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 标志(这是一个 bool flag)。如果用户传递了它,我们只需将 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。

现在我们有了自己的节点,让我们继续 查找结构体 这一步:

在 main 函数中,我们将使用从上一步解析得到的 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() 函数根据配置返回结构体的开始位置和结束位置以告知我们如何选择一个结构体。它迭代给定节点,然后返回开始位置和结束位置(如上配置部分中所述):

查找步骤遍历所有节点,直到找到一个 *ast.StructType,并返回该文件的开始位置和结束位置

但怎么做呢?记住有三种模式。分别是行选择、偏移量和结构体名称:

// findSelection returns the start and end position of the fields that are // suspect to change. It depends on the line, struct or offset selection. func (c *config) findSelection(node ast.Node) (int, int, error) { if c.line != "" { return c.lineSelection(node) } else if c.offset != 0 { return c.offsetSelection(node) } else if c.structName != "" { return c.structSelection(node) } else { return 0, 0, errors.New("-line, -offset or -struct is not passed") } }

行选择是最简单的部分。这里我们只返回标志值本身。因此如果用户传入 --line 3,50 标志,函数将返回(3, 50, nil)。 它所做的就是拆分标志值并将其转换为整数(同样执行验证):

func (c *config) lineSelection(file ast.Node) (int, int, error) { var err error splitted := strings.Split(c.line, ",") start, err := strconv.Atoi(splitted[0]) if err != nil { return 0, 0, err } end := start if len(splitted) == 2 { end, err = strconv.Atoi(splitted[1]) if err != nil { return 0, 0, err } } if start > end { return 0, 0, errors.New("wrong range. start line cannot be larger than end line") } return start, end, nil }

当您选中一行或多行并高亮它们时,编辑器将使用此模式。

对于偏移量和结构体名称选择,我们需要做更多的工作。首先需要收集所有给定的结构体,以便可以计算偏移位置或查找结构体名称。为此,我们首先要有一个收集所有结构体的函数:

// collectStructs collects and maps structType nodes to their positions func collectStructs(node ast.Node) map[token.Pos]*structType { structs := make(map[token.Pos]*structType, 0) collectStructs := func(n ast.Node) bool { t, ok := n.(*ast.TypeSpec) if !ok { return true } if t.Type == nil { return true } structName := t.Name.Name x, ok := t.Type.(*ast.StructType) if !ok { return true } structs[x.Pos()] = &structType{ name: structName, node: x, } return true } ast.Inspect(node, collectStructs) return structs }

我们使用 ast.Inspect() 函数逐步遍历 AST 并查找结构体。 我们首先查找 *ast.TypeSpec,以便获得结构体名称。查找 *ast.StructType 时给定的是结构体本身,而不是它的名字,这就是为什么我们有一个自定义的 structType 类型,它保存了名称和结构体节点本身。这样在各个地方都很方便,因为每个结构体的位置都是唯一的,并且在同一位置上不可能存在两个不同的结构体,因此我们使用位置作为 map 的键。

现在我们拥有了所有结构体,在最后可以为偏移量和结构体名称模式返回一个结构体的起始位置和结束位置。对于偏移位置,我们检查偏移是否在给定的结构体之间:

func (c *config) offsetSelection(file ast.Node) (int, int, error) { structs := collectStructs(file) var encStruct *ast.StructType for _, st := range structs { structBegin := c.fset.Position(st.node.Pos()).Offset structEnd := c.fset.Position(st.node.End()).Offset if structBegin


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3