用 Go 语言编写一门工具的终极指南

greentiger 发布于8月前 阅读121次
0 条评论

我以前构建过一个工具,以让生活更轻松。这个工具被称为: gomodifytags ,它会根据字段名称自动填充结构体的标签字段。示例如下:

用 Go 语言编写一门工具的终极指南

(在 vim-go 中使用 gomodifytags 的一个用法示例)

使用这样的工具可以 轻松管理 结构体的多个字段。该工具还可以添加和删除标签,管理标签选项(如omitempty),定义转换规则(snake_case、camelCase 等)等等。但是这个工具是如何工作的? 在后台中它究竟使用了哪些 Go 包? 有很多这样的问题需要回答。

这是一篇非常长的博客文章,解释了如何编写类似这样的工具以及如何构建它的每一个细节。 它包含许多特有的细节、提示和技巧和某些未知的 Go 位。

拿一杯咖啡,开始深入探究吧!

首先,列出这个工具需要完成的功能:

  1. 它需要读取源文件,理解并能够解析 Go 文件

  2. 它需要找到相关的结构体

  3. 找到结构体后,需要获取其字段名称

  4. 它需要根据字段名更新结构标签(根据转换规则,即:snake_case)

  5. 它需要能够使用这些改动来更新文件,或者能够以可接受的方式输出改动

我们首先来看看 结构体标签的定义 是什么,之后我们会学习所有的部分,以及它们如何组合在一起,从而构建这个工具。

用 Go 语言编写一门工具的终极指南

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

用 Go 语言编写一门工具的终极指南

结构标签的定义比较简洁所以不容易理解。该定义可以分解如下:

  • 结构标签是一个字符串(字符串类型)

  • 结构标签的 Key 是非引号字符串

  • 结构标签的 value 是一个带引号的字符串

  • 结构标签的 key 和 value 用冒号(:)分隔。冒号隔开的一个 key 和对应的 value 称为 “key value 对”。

  • 一个结构标签可以包含多个 key valued 对(可选)。key-value 对之间用空格隔开。

  • 可选设置不属于定义的一部分。类似 encoding/json  包将 value 解析为逗号分开的列表。value 的第一个逗号后面的任何部分都是可选设置的一部分,例如:“ foo, omitempty,string”。其中 value 拥有一个叫 “foo” 的名字和可选设置 [“omitempty”, "string"] 

  • 由于结构标签是一个字符串,需要双引号或者反引号包含。又因为 value 也需要引号包含,经常用反引号包含结构标签。

以上规则概况如下:

用 Go 语言编写一门工具的终极指南

(结构标签的定义有许多隐含细节)

已经了解什么是结构标签,接下来可以根据需要修改结构标签。问题来了,如何才能很容易的对所做的修改进行解析?很幸运,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。

用 Go 语言编写一门工具的终极指南

这个包被称为 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 语言编写一门工具的终极指南

(结构体的基本的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 )。该工具应顺序执行以下操作:

  1. 获取配置,以识别我们要修改哪个结构体

  2. 根据配置查找和修改结构体

  3. 输出结果

由于 gomodifytags  将主要由编辑器来执行,我们打算通过 CLI 标志传递配置信息。第二步包含多个步骤,如解析文件、找到正确的结构体,然后修改结构(通过修改 AST 完成)。最后,我们将输出结果,或是按照原始的 Go 源文件或是某种自定义协议(如 JSON,稍后再说)。

以下是 gomodifytags 简化之后的主要功能:

用 Go 语言编写一门工具的终极指南

让我们开始详细解释每个步骤。为了保持简单,我将尝试以萃取形式解释重要的部分。尽管一切都是一样的,一旦你读完了这篇博文,你将能够在无需任何指导的情况下通读整个源代码(你将会在本指南的最后找到所有资源)

让我们从第一步开始,了解如何 获取配置 。以下是我们的配置文件,其中包含所有的必要信息

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中。

第二部分定义了如何选择一个结构体及其字段。有多种方法可以做到这一点。我们可以通过它的偏移(光标位置)、结构名称,单行(仅指定字段)或一系列行来定义它。最后,我们总是需要得到起始行号。例如在下面的例子中,你可以看到一个例子,我们用它的名字来选择结构体,然后提取起始行号,以便我们可以选择正确的字段:

用 Go 语言编写一门工具的终极指南

而编辑器最好使用 字节偏移量 。例如下面你可以看到我们的光标刚好在“Port”字段名称之后,从那里我们可以很容易地得到起始行号:

用 Go 语言编写一门工具的终极指南

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
}

将验证部分代码放到一个单一的函数中,使得测试测试更简单。既然我们已经知道如何获取配置并进行验证,我们继续去解析文件:

用 Go 语言编写一门工具的终极指南

我们在一开始就讨论了如何解析一个文件。这里解析的是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 ,让我们继续下一步的“查找结构体”:

用 Go 语言编写一门工具的终极指南

我们的主函数中,我们将使用在上一步中解析的 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 上进行迭代,然后返回其起始位置(和以上的配置一节中的解释类似):

用 Go 语言编写一门工具的终极指南

(检索步骤会迭代所有 node ,直到其找到一个  *ast.StructType  ,然后返回它在文件中的起始位置。)

查看原文: 用 Go 语言编写一门工具的终极指南

  • crazylion
  • lazylion
  • browngorilla
  • brownfish
  • beautifulsnake
  • bluebutterfly
  • lazybear
需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。