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

arslan.io/2017/09/14/…node

做者:Fatih Arslangit

译者:oopsguy.comgithub

我以前编写过一个叫 gomodifytags 的工具,它使个人工做变得很轻松。它会根据字段名称自动填充结构体标签字段。让我来展现一下它的功能:golang

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

使用这样的工具能够很容易管理结构体的多个字段。该工具还能够添加和删除标签、管理标签选项(如 omitempty)、定义转换规则(snake_casecamelCase 等)等。但该工具是怎样工做的呢?它内部使用了什么 Go 包?有不少问题须要回答。json

这是一篇很是长的博文,其解释了如何编写这样的工具以及每一个构建细节。它包含许多独特的细节、技巧和未知的 Go 知识。vim

拿起一杯咖啡☕️,让咱们深刻一下吧!数组


首先,让我列出这个工具须要作的事情:bash

  1. 它须要读取源文件、理解并可以解析 Go 文件
  2. 它须要找到相关的结构体
  3. 找到结构体后,它须要获取字段名称
  4. 它须要根据字段名来更新结构体标签(根据转换规则,如 snake_case
  5. 它须要可以把这些更改更新到文件中,或者可以以可消费的方式输出更改后的结果

咱们首先来了解什么是 结构体(struct)标签(tag),从这里咱们能够学习到全部东西以及如何把它们组合在一块儿使用,在此基础上您能够构建出这样的工具。app

结构体的标签值(内容,如 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/struc… 获取。 这个包容许咱们以简洁的方式解析和修改标签。如下是一个完整的示例,您能够复制/粘贴并自行尝试:

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/struc…)。

咱们有了这些,如今能够经过使用这两个知识点开始构建咱们的工具(命名为 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 字段名称后面,从那里咱们能够很容易地获得开始行/结束行:

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

咱们如何得到配置?咱们只需使用 flag 包,而后为配置中的每一个字段建立一个标志,而后分配它们。举个例子:

flagFile := flag.String("file", "", "Filename to be parsed")
cfg := &config{
	file: *flagFile,
}
复制代码

咱们对配置中的每一个字段执行相同操做。有关完整内容,请查看 gomodifytag 当前 master 分支的标志定义

一旦咱们有了配置,就能够作些基本的验证:

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 标志(这是一个布尔标志)。若是用户了传递它,咱们只需将 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 是一个任意的数据流,咱们须要可以根据给定的协议进行解析。在这种状况下,咱们假定存档包含如下内容:

  • 文件名,后接一行新行
  • 文件大小(十进制),后接一行新行
  • 文件的内容

由于咱们知道文件大小,能够无障碍地解析文件内容。任何超出给定文件大小的部分,咱们仅仅中止解析。

方法也被其余几个工具所使用(如 gurugogetdoc 等),对编辑器来讲很是有用。 由于这样可让编辑器传递修改后的文件内容,而不会保存到文件系统中。所以命名为 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 <= c.offset && c.offset <= structEnd {
			encStruct = st.node
			break
		}
	}

	if encStruct == nil {
		return 0, 0, errors.New("offset is not inside a struct")
	}

	// offset mode selects all fields
	start := c.fset.Position(encStruct.Pos()).Line
	end := c.fset.Position(encStruct.End()).Line

	return start, end, nil
}
复制代码

咱们使用 collectStructs() 来收集全部结构体,以后在这里迭代。还得记得咱们存储了用于解析文件的初始 token.FileSet 么?

如今能够用它来获取每一个结构体节点的偏移信息(咱们将其解码为一个 token.Position,它为咱们提供了 .Offset 字段)。 咱们所作的只是一个简单的检查和迭代,直到咱们找到结构体(这里命名为 encStruct):

for _, st := range structs {
	structBegin := c.fset.Position(st.node.Pos()).Offset
	structEnd := c.fset.Position(st.node.End()).Offset

	if structBegin <= c.offset && c.offset <= structEnd {
		encStruct = st.node
		break
	}
}
复制代码

有了这些信息,咱们能够提取找到的结构体的开始位置和结束位置:

start := c.fset.Position(encStruct.Pos()).Line
end := c.fset.Position(encStruct.End()).Line
复制代码

该逻辑一样适用于结构体名称选择。 咱们所作的只是尝试检查结构体名称,直到找到与给定名称一致的结构体,而不是检查偏移量是否在给定的结构体范围内:

func (c *config) structSelection(file ast.Node) (int, int, error) {
	// ...

	for _, st := range structs {
		if st.name == c.structName {
			encStruct = st.node
		}
	}

	// ...
}
复制代码

如今咱们有了开始位置和结束位置,咱们终于能够进行第三步了:修改结构体字段。

main 函数中,咱们将使用从上一步解析的节点来调用 cfg.rewrite() 函数:

func main() {
	// ... find start and end position of the struct to be modified


	rewrittenNode, errs := cfg.rewrite(node, start, end)
	if errs != nil {
		if _, ok := errs.(*rewriteErrors); !ok {
			return errs
		}
	}


	// continue outputting the rewritten node
}
复制代码

这是该工具的核心。在 rewrite 函数中,咱们将重写开始位置和结束位置之间的全部结构体字段。 在深刻了解以前,咱们能够看一下该函数的大概内容:

// rewrite rewrites the node for structs between the start and end
// positions and returns the rewritten node
func (c *config) rewrite(node ast.Node, start, end int) (ast.Node, error) {
	errs := &rewriteErrors{errs: make([]error, 0)}

	rewriteFunc := func(n ast.Node) bool {
		// rewrite the node ...
	}

	if len(errs.errs) == 0 {
		return node, nil
	}

	ast.Inspect(node, rewriteFunc)
	return node, errs
}
复制代码

正如你所看到的,咱们再次使用 ast.Inspect() 来逐步处理给定节点的树。咱们重写 rewriteFunc 函数中的每一个字段的标签(更多内容在后面)。

由于传递给 ast.Inspect() 的函数不会返回错误,所以咱们将建立一个错误映射(使用 errs 变量定义),以后在咱们逐步遍历树并处理每一个单独的字段时收集错误。如今让咱们来谈谈 rewriteFunc 的内部原理:

rewriteFunc := func(n ast.Node) bool {
	x, ok := n.(*ast.StructType)
	if !ok {
		return true
	}

	for _, f := range x.Fields.List {
		line := c.fset.Position(f.Pos()).Line

		if !(start <= line && line <= end) {
			continue
		}

		if f.Tag == nil {
			f.Tag = &ast.BasicLit{}
		}

		fieldName := ""
		if len(f.Names) != 0 {
			fieldName = f.Names[0].Name
		}

		// anonymous field
		if f.Names == nil {
			ident, ok := f.Type.(*ast.Ident)
			if !ok {
				continue
			}

			fieldName = ident.Name
		}

		res, err := c.process(fieldName, f.Tag.Value)
		if err != nil {
			errs.Append(fmt.Errorf("%s:%d:%d:%s",
				c.fset.Position(f.Pos()).Filename,
				c.fset.Position(f.Pos()).Line,
				c.fset.Position(f.Pos()).Column,
				err))
			continue
		}

		f.Tag.Value = res
	}

	return true
}
复制代码

记住,AST 树中的每个节点都会调用这个函数。所以,咱们只寻找类型为 *ast.StructType 的节点。一旦咱们拥有,就能够开始迭代结构体字段。

这里咱们使用 startend 变量。这定义了咱们是否要修改该字段。若是字段位置位于 start-end 之间,咱们将继续,不然咱们将忽略:

if !(start <= line && line <= end) {
	continue // skip processing the field
}
复制代码

接下来,咱们检查是否存在标签。若是标签字段为空(也就是 nil),则初始化标签字段。这在有助于后面的 cfg.process() 函数避免 panic:

if f.Tag == nil {
	f.Tag = &ast.BasicLit{}
}
复制代码

如今让我先解释一下一个有趣的地方,而后再继续。gomodifytags 尝试获取字段的字段名称并处理它。然而,当它是一个匿名字段呢?:

type Bar string

type Foo struct {
	Bar //this is an anonymous field
}
复制代码

在这种状况下,由于没有字段名称,咱们尝试从类型名称中获取字段名称

// if there is a field name use it
fieldName := ""
if len(f.Names) != 0 {
	fieldName = f.Names[0].Name
}

// if there is no field name, get it from type's name
if f.Names == nil {
	ident, ok := f.Type.(*ast.Ident)
	if !ok {
		continue
	}

	fieldName = ident.Name
}
复制代码

一旦咱们得到了字段名称和标签值,就能够开始处理该字段。cfg.process() 函数负责处理有字段名称和标签值(若是有的话)的字段。在它返回处理结果后(在咱们的例子中是 struct tag 格式),咱们使用它来覆盖现有的标签值:

res, err := c.process(fieldName, f.Tag.Value)
if err != nil {
	errs.Append(fmt.Errorf("%s:%d:%d:%s",
		c.fset.Position(f.Pos()).Filename,
		c.fset.Position(f.Pos()).Line,
		c.fset.Position(f.Pos()).Column,
		err))
	continue
}

// rewrite the field with the new result,i.e: json:"foo"
f.Tag.Value = res
复制代码

实际上,若是你记得 structtag,它返回标签实例的 String() 表述。在咱们返回标签的最终表述以前,咱们根据须要使用 structtag 包的各类方法修改结构体。如下是一个简单的说明图示:

用 structtag 包修改每一个字段

例如,咱们要扩展 process() 中的 removeTags() 函数。此功能使用如下配置来建立要删除的标签数组(键名称):

flagRemoveTags = flag.String("remove-tags", "", "Remove tags for the comma separated list of keys")

if *flagRemoveTags != "" {
	cfg.remove = strings.Split(*flagRemoveTags, ",")
}
复制代码

removeTags() 中,咱们检查是否使用了 --remove-tags。若是有,咱们将使用 structtag 的 tags.Delete() 方法来删除标签:

func (c *config) removeTags(tags *structtag.Tags) *structtag.Tags {
	if c.remove == nil || len(c.remove) == 0 {
		return tags
	}

	tags.Delete(c.remove...)
	return tags
}
复制代码

此逻辑一样适用于 cfg.Process() 中的全部函数。


咱们已经有了一个重写的节点,让咱们来讨论最后一个话题。输出和格式化结果:

在 main 函数中,咱们将使用上一步重写的节点来调用 cfg.format() 函数:

func main() {
	// ... rewrite the node

	out, err := cfg.format(rewrittenNode, errs)
	if err != nil {
		return err
	}

	fmt.Println(out)
}
复制代码

您须要注意的一件事是,咱们输出到 stdout。这佯作有许多优势。首先,您只需运行工具就能查看到结果, 它不会改变任何东西,只是为了让工具用户当即看到结果。其次,stdout 是可组合的,能够重定向到任何地方,甚至能够用来覆盖原来的工具。

如今咱们来看看 format() 函数:

func (c *config) format(file ast.Node, rwErrs error) (string, error) {
	switch c.output {
	case "source":
		// return Go source code
	case "json":
		// return a custom JSON output
	default:
		return "", fmt.Errorf("unknown output mode: %s", c.output)
	}
}
复制代码

咱们有两种输出模式

第一个source)以 Go 格式打印 ast.Node。这是默认选项,若是您在命令行使用它或只想看到文件中的更改,那么这很是适合您。

第二个选项(json)更为先进,其专为其余环境而设计(特别是编辑器)。它根据如下结构体对输出进行编码:

type output struct {
	Start  int      `json:"start"`
	End    int      `json:"end"`
	Lines  []string `json:"lines"`
	Errors []string `json:"errors,omitempty"`
}
复制代码

对工具进行输入和最终结果输出(没有任何错误)大概示意图以下:

回到 format() 函数。如以前所述,有两种模式。source 模式使用 go/format 包将 AST 格式化为 Go 源码。该软件包也被许多其余官方工具(如 gofmt)使用。如下是 source 模式的实现方式:

var buf bytes.Buffer
err := format.Node(&buf, c.fset, file)
if err != nil {
	return "", err
}

if c.write {
	err = ioutil.WriteFile(c.file, buf.Bytes(), 0)
	if err != nil {
		return "", err
	}
}

return buf.String(), nil
复制代码

格式包接受 io.Writer 并对其进行格式化。这就是为何咱们建立一个中间缓冲区(var buf bytes.Buffer)的缘由,当用户传入一个 -write 标志时,咱们可使用它来覆盖文件。格式化后,咱们返回缓冲区的字符串表示形式,其中包含格式化后的 Go 源代码。

json 模式更有趣。由于咱们返回的是一段源代码,所以咱们须要准确地呈现它本来的格式,这也意味着要把注释包含进去。问题在于,当使用 format.Node() 打印单个结构体时,若是它们是有损的,则没法打印出 Go 注释。

什么是有损注释(lossy comment)?看看这个例子:

type example struct {
	foo int 

	// this is a lossy comment

	bar int 
}
复制代码

每一个字段都是 *ast.Field 类型。此结构体有一个 *ast.Field.Comment 字段,其包含某字段的注释。

可是,在上面的例子中,它属于谁?属于 foo 仍是 bar

由于不可能肯定,这些注释被称为有损注释。若是如今使用 format.Node() 函数打印上面的结构体,就会出现问题。 当你打印它时,你可能会获得(play.golang.org/p/peHsswF4J…):

type example struct {
	foo int

	bar int
}
复制代码

问题在于有损注释是 *ast.File一部分它与树分开。只有打印整个文件时才能打印出来。 因此解决方法是打印整个文件,而后删除掉咱们要在 JSON 输出中返回的指定行:

var buf bytes.Buffer
err := format.Node(&buf, c.fset, file)
if err != nil {
	return "", err
}

var lines []string
scanner := bufio.NewScanner(bytes.NewBufferString(buf.String()))
for scanner.Scan() {
	lines = append(lines, scanner.Text())
}

if c.start > len(lines) {
	return "", errors.New("line selection is invalid")
}

out := &output{
	Start: c.start,
	End:   c.end,
	Lines: lines[c.start-1 : c.end], // cut out lines
}

o, err := json.MarshalIndent(out, "", " ")
if err != nil {
	return "", err
}

return string(o), nil
复制代码

这样作确保咱们能够打印全部注释。


这就是所有内容!

咱们成功完成了咱们的工具,如下是咱们在整个指南中实施的完整步骤图:

gomodifytags的概述

回顾一下咱们作了什么:

  • 咱们经过 CLI 标志检索配置
  • 咱们经过 go/parser 包解析文件来获取一个 ast.Node
  • 在解析文件以后,咱们搜索 获取相应的结构体来获取开始位置和结束位置,这样咱们能够知道须要修改哪些字段
  • 一旦咱们有了开始位置和结束位置,咱们再次遍历 ast.Node,重写开始位置和结束位置之间的每一个字段(经过使用 structtag 包)
  • 以后,咱们将格式化重写的节点,为编辑器输出 Go 源代码或自定义的 JSON

在建立此工具后,我收到了不少友好的评论,评论者们提到了这个工具如何简化他们的平常工做。正如您所看到,尽管看起来它很容易制做,但在整个指南中,咱们已经针对许多特殊的状况作了特别处理。

gomodifytags 成功应用于如下编辑器和插件已经有几个月了,使得数以千计的开发人员提高了工做效率:

  • vim-go
  • atom
  • vscode
  • acme

若是您对原始源代码感兴趣,能够在这里找到:

我还在 Gophercon 2017 上发表了一个演讲,若是您感兴趣,可点击下面的 youtube 地址观看:

www.youtube.com/embed/T4AIQ…

18.jpg

谢谢您阅读此文。但愿这个指南能启发您从头建立一个新的 Go 工具。

相关文章
相关标签/搜索