用Go語言編寫一門工具的終極指南
我以前構建過一個工具,以讓生活更輕松。這個工具被稱為: gomodifytags ,它會根據字段名稱自動填充結構體的標簽字段。示例如下:
(在 vim-go 中使用 gomodifytags 的一個用法示例)
使用這樣的工具可以 輕松管理 結構體的多個字段。該工具還可以添加和刪除標簽,管理標簽選項(如omitempty),定義轉換規則(snake_case、camelCase 等)等等。但是這個工具是如何工作的? 在后臺中它究竟使用了哪些 Go 包? 有很多這樣的問題需要回答。
這是一篇非常長的博客文章,解釋了如何編寫類似這樣的工具以及如何構建它的每一個細節。 它包含許多特有的細節、提示和技巧和某些未知的 Go 位。
拿一杯咖啡,開始深入探究吧!
首先,列出這個工具需要完成的功能:
- 它需要讀取源文件,理解并能夠解析 Go 文件
- 它需要找到相關的結構體
- 找到結構體后,需要獲取其字段名稱
- 它需要根據字段名更新結構標簽(根據轉換規則,即:snake_case)
- 它需要能夠使用這些改動來更新文件,或者能夠以可接受的方式輸出改動
我們首先來看看 結構體標簽的定義 是什么,之后我們會學習所有的部分,以及它們如何組合在一起,從而構建這個工具。
結構體的標簽 值 (其內容,比如`json:"foo"`)并 不是官方標準的一部分 ,不過,存在一個非官方的規范,使用 reflect 包定義了其格式,這種方法也被 stdlib(例如 encoding/ json)包所采用。它是通過 reflect.StructTag 類型定義的:
結構標簽的定義比較簡潔所以不容易理解。該定義可以分解如下:
- 結構標簽是一個字符串(字符串類型)
- 結構標簽的 Key 是非引號字符串
- 結構標簽的 value 是一個帶引號的字符串
- 結構標簽的 key 和 value 用冒號(:)分隔。冒號隔開的一個 key 和對應的 value 稱為 “key value 對”。
- 一個結構標簽可以包含多個 key valued 對(可選)。key-value 對之間用空格隔開。
- 可選設置不屬于定義的一部分。類似 encoding/json 包將 value 解析為逗號分開的列表。value 的***個逗號后面的任何部分都是可選設置的一部分,例如:“ foo, omitempty,string”。其中 value 擁有一個叫 “foo” 的名字和可選設置 [“omitempty”, "string"]
- 由于結構標簽是一個字符串,需要雙引號或者反引號包含。又因為 value 也需要引號包含,經常用反引號包含結構標簽。
以上規則概況如下:
已經了解什么是結構標簽,接下來可以根據需要修改結構標簽。問題來了,如何才能很容易的對所做的修改進行解析?很幸運,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(簡化版):
在這棵樹中,我們可以檢索和操縱每個標識符,每個字符串和每個括號等。這些都由 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 ,然后返回它在文件中的起始位置。)