[譯] 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 工具。

相關文章
相關標籤/搜索