用Go語言編寫一門工具的終極指南

摘要:
我之前構建過一個工具,以讓生活更輕鬆。這個工具被稱爲: gomodifytags ,它會根據字段名稱自動填充結構體的標籤字段。示例以下: (在 vim-go 中使用 gomodifytags 的一個用法示例) 使用這樣的工具能夠 輕鬆管理 結構體的多個字段。

我之前構建過一個工具,以讓生活更輕鬆。這個工具被稱爲: gomodifytags ,它會根據字段名稱自動填充結構體的標籤字段。示例以下:javascript

用Go語言編寫一門工具的終極指南
(在 vim-go 中使用 gomodifytags 的一個用法示例)java

使用這樣的工具能夠 輕鬆管理 結構體的多個字段。該工具還能夠添加和刪除標籤,管理標籤選項(如omitempty),定義轉換規則(snake_case、camelCase 等)等等。可是這個工具是如何工做的? 在後臺中它究竟使用了哪些 Go 包? 有不少這樣的問題須要回答。node

這是一篇很是長的博客文章,解釋瞭如何編寫相似這樣的工具以及如何構建它的每個細節。 它包含許多特有的細節、提示和技巧和某些未知的 Go 位。git

拿一杯咖啡,開始深刻探究吧!github

首先,列出這個工具須要完成的功能:json

  • 它須要讀取源文件,理解並可以解析 Go 文件
  • 它須要找到相關的結構體
  • 找到結構體後,須要獲取其字段名稱
  • 它須要根據字段名更新結構標籤(根據轉換規則,即:snake_case)
  • 它須要可以使用這些改動來更新文件,或者可以以可接受的方式輸出改動

咱們首先來看看 結構體標籤的定義 是什麼,以後咱們會學習全部的部分,以及它們如何組合在一塊兒,從而構建這個工具。vim

用Go語言編寫一門工具的終極指南

結構體的標籤 值 (其內容,好比`json:"foo"`)並 不是官方標準的一部分 ,不過,存在一個非官方的規範,使用 reflect 包定義了其格式,這種方法也被 stdlib(例如 encoding/ json)包所採用。它是經過 reflect.StructTag 類型定義的:bash

用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 也須要引號包含,常常用反引號包含結構標籤。

以上規則概況以下:ide

用Go語言編寫一門工具的終極指南
(結構標籤的定義有許多隱含細節)

已經瞭解什麼是結構標籤,接下來能夠根據須要修改結構標籤。問題來了,如何才能很容易的對所作的修改進行解析?很幸運,reflect.StructTag 包含一個能夠解析結構標籤並返回特定 key 的 value 的方法。示例以下:

複製代碼
  1. package main
  2. import (
  3. "fmt"
  4. "reflect"
  5. )
  6. func main() {
  7. tag := reflect.StructTag(`species:"gopher" color:"blue"`)
  8. fmt.Println(tag.Get("color"), tag.Get("species"))
  9. }

輸出:

複製代碼
  1. blue gopher

若是 key 不存在則返回空串。

這是很是有幫助的, 可是 ,它有一些附加說明,使其不適合咱們,由於咱們須要更多的靈活性。這些是:

  • 它沒法檢測到標籤是否存在 格式錯誤 (即:鍵被引用了,值是未引用等)
  • 它不知道選項的 語義
  • 它沒有辦法 迭代現有的標籤 或返回它們。 咱們必須知道咱們要修改哪些標籤。 若是不知道其名字怎麼辦?
  • 修改現有標籤是不可能的。
  • 咱們不能從新 構建新的struct標籤 。

爲了改進這一點,我編寫了一個自定義的Go包,它修復了上面的全部問題,並提供了一個能夠輕鬆修改struct標籤的每一個方面的API。

用Go語言編寫一門工具的終極指南

這個包被稱爲 structtag ,而且能夠從 github.com/fatih/structtag 獲取到。這個包容許咱們以一種整潔的方式 解析和修改標籤 。如下是一個完整的可工做的示例,複製/粘貼並自行嘗試下:

複製代碼
  1. package main
  2. import (
  3. "fmt"
  4. "github.com/fatih/structtag"
  5. )
  6. func main() {
  7. tag := `json:"foo,omitempty,string" xml:"foo"`
  8. // parse the tag
  9. tags, err := structtag.Parse(string(tag))
  10. if err != nil {
  11. panic(err)
  12. }
  13. // iterate over all tags
  14. for _, t := range tags.Tags() {
  15. fmt.Printf("tag: %+v\n", t)
  16. }
  17. // get a single tag
  18. jsonTag, err := tags.Get("json")
  19. if err != nil {
  20. panic(err)
  21. }
  22. // change existing tag
  23. jsonTag.Name = "foo_bar"
  24. jsonTag.Options = nil
  25. tags.Set(jsonTag)
  26. // add new tag
  27. tags.Set(&structtag.Tag{
  28. Key: "hcl",
  29. Name: "foo",
  30. Options: []string{"squash"},
  31. })
  32. // print the tags
  33. fmt.Println(tags) // Output: json:"foo_bar" xml:"foo" hcl:"foo,squash"
  34. }

既然咱們已經知道如何解析一個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 包來遍歷整棵樹(咱們也能夠手動執行, 但這是另外一篇博文的主題)。下面代碼你能夠看到一個完整的例子:

複製代碼
  1. package main
  2. import (
  3. "fmt"
  4. "go/ast"
  5. "go/parser"
  6. "go/token"
  7. )
  8. func main() {
  9. src := `package main
  10. type Example struct {
  11. Foo string` + " `json:\"foo\"` }"
  12. fset := token.NewFileSet()
  13. file, err := parser.ParseFile(fset, "demo", src, parser.ParseComments)
  14. if err != nil {
  15. panic(err)
  16. }
  17. ast.Inspect(file, func(x ast.Node) bool {
  18. s, ok := x.(*ast.StructType)
  19. if !ok {
  20. return true
  21. }
  22. for _, field := range s.Fields.List {
  23. fmt.Printf("Field: %s\n", field.Names[0].Name)
  24. fmt.Printf("Tag: %s\n", field.Tag.Value)
  25. }
  26. return false
  27. })
  28. }

上面代碼輸出以下:

複製代碼
  1. Field: Foo
  2. 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 簡化以後的主要功能:

用Go語言編寫一門工具的終極指南

讓咱們開始詳細解釋每一個步驟。爲了保持簡單,我將嘗試以萃取形式解釋重要的部分。儘管一切都是同樣的,一旦你讀完了這篇博文,你將可以在無需任何指導的狀況下通讀整個源代碼(你將會在本指南的最後找到全部資源)

讓咱們從第一步開始,瞭解如何 獲取配置 。如下是咱們的配置文件,其中包含全部的必要信息

複製代碼
  1. type config struct {
  2. // first section - input & output
  3. file string
  4. modified io.Reader
  5. output string
  6. write bool
  7. // second section - struct selection
  8. offset int
  9. structName string
  10. line string
  11. start, end int
  12. // third section - struct modification
  13. remove []string
  14. add []string
  15. override bool
  16. transform string
  17. sort bool
  18. clear bool
  19. addOpts []string
  20. removeOpts []string
  21. clearOpt bool
  22. }

它分爲 三個 主要部分:

第一部分包含有關如何和哪一個文件要讀入的配置。這能夠是本地文件系統的文件名,也能夠是直接來自stdin的數據(主要用在編輯器中)。它還設置瞭如何輸出結果(Go源文件或JSON形式),以及咱們是否應該覆寫文件,而不是輸出到stdout中。

第二部分定義瞭如何選擇一個結構體及其字段。有多種方法能夠作到這一點。咱們能夠經過它的偏移(光標位置)、結構名稱,單行(僅指定字段)或一系列行來定義它。最後,咱們老是須要獲得起始行號。例如在下面的例子中,你能夠看到一個例子,咱們用它的名字來選擇結構體,而後提取起始行號,以便咱們能夠選擇正確的字段:

用Go語言編寫一門工具的終極指南

而編輯器最好使用 字節偏移量 。例以下面你能夠看到咱們的光標恰好在「Port」字段名稱以後,從那裏咱們能夠很容易地獲得起始行號:

用Go語言編寫一門工具的終極指南

config配置中的 第三 部分其實是一個到咱們的 structtagpackage的 一對一的映射。它基本上容許咱們在讀取字段後將配置傳遞給structtag包。如你所知,structtag包容許咱們解析一個struct標籤並在各個部分進行修改。可是,它不會覆寫或更新結構體的域值。

咱們該如何得到配置呢? 咱們只需使用flag包,而後爲配置中的每一個字段建立一個標誌,而後給他們賦值。舉個例子:

複製代碼
  1. flagFile := flag.String("file", "", "Filename to be parsed")
  2. cfg := &config{
  3. file: *flagFile,
  4. }

咱們對 配置中的每一個字段 執行相同操做。相關完整的列表請查看gomodifytag的當前master分支上的 flag 定義。

一旦咱們有了配置,咱們就能夠作一些基本的驗證了:

複製代碼
  1. func main() {
  2. cfg := config{ ... }
  3. err := cfg.validate()
  4. if err != nil {
  5. log.Fatalln(err)
  6. }
  7. // continue parsing
  8. }
  9. // validate validates whether the config is valid or not
  10. func (c *config) validate() error {
  11. if c.file == "" {
  12. return errors.New("no file is passed")
  13. }
  14. if c.line == "" && c.offset == 0 && c.structName == "" {
  15. return errors.New("-line, -offset or -struct is not passed")
  16. }
  17. if c.line != "" && c.offset != 0 ||
  18. c.line != "" && c.structName != "" ||
  19. c.offset != 0 && c.structName != "" {
  20. return errors.New("-line, -offset or -struct cannot be used together. pick one")
  21. }
  22. if (c.add == nil || len(c.add) == 0) &&
  23. (c.addOptions == nil || len(c.addOptions) == 0) &&
  24. !c.clear &&
  25. !c.clearOption &&
  26. (c.removeOptions == nil || len(c.removeOptions) == 0) &&
  27. (c.remove == nil || len(c.remove) == 0) {
  28. return errors.New("one of " +
  29. "[-add-tags, -add-options, -remove-tags, -remove-options, -clear-tags, -clear-options]" +
  30. " should be defined")
  31. }
  32. return nil
  33. }

將驗證部分代碼放到一個單一的函數中,使得測試測試更簡單。既然咱們已經知道如何獲取配置並進行驗證,咱們繼續去解析文件:

用Go語言編寫一門工具的終極指南

咱們在一開始就討論瞭如何解析一個文件。這裏解析的是config結構體中的方法。實際上,全部的方法都是config結構體的一部分:

複製代碼
  1. func main() {
  2. cfg := config{}
  3. node, err := cfg.parse()
  4. if err != nil {
  5. return err
  6. }
  7. // continue find struct selection ...
  8. }
  9. func (c *config) parse() (ast.Node, error) {
  10. c.fset = token.NewFileSet()
  11. var contents interface{}
  12. if c.modified != nil {
  13. archive, err := buildutil.ParseOverlayArchive(c.modified)
  14. if err != nil {
  15. return nil, fmt.Errorf("failed to parse -modified archive: %v", err)
  16. }
  17. fc, ok := archive[c.file]
  18. if !ok {
  19. return nil, fmt.Errorf("couldn't find %s in archive", c.file)
  20. }
  21. contents = fc
  22. }
  23. return parser.ParseFile(c.fset, c.file, contents, parser.ParseComments)
  24. }

解析函數只完成了一件事。解析源碼並返回一個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 便可:

複製代碼
  1. flagModified = flag.Bool("modified", false,
  2. "read an archive of modified files from standard input")
  3. if *flagModified {
  4. cfg.modified = os.Stdin
  5. }

若是你再次檢查上面的 config.parse() 函數,你將看到咱們檢查 .modified 字段是否已分配,由於 stdin 是一個任意數據的流,咱們須要可以根據給定的協議對其進行解析。在這種狀況下,咱們假定其中包含如下內容:

  • 文件名,後跟換行符
  • (十進制)文件大小,後跟換行符
  • 文件的內容

由於咱們知道文件大小,咱們能夠毫無問題地解析此文件的內容。任何大於給定文件大小的部分,咱們僅需中止解析。

這種 方法 也被其餘幾種工具所使用(如 guru、gogetdoc 等),而且它對編輯器來講是很是有用的。由於這樣可讓編輯器傳遞修改後的文件內容, 而且無需保存到文件系統中 。所以它被命名爲「modified」。

既然咱們已經擁有了 Node ,讓咱們繼續下一步的「查找結構體」:

用Go語言編寫一門工具的終極指南

咱們的主函數中,咱們將使用在上一步中解析的 ast.Node 中調用 findSelection() 函數:

複製代碼
  1. func main() {
  2. // ... parse file and get ast.Node
  3. start, end, err := cfg.findSelection(node)
  4. if err != nil {
  5. return err
  6. }
  7. // continue rewriting the node with the start&end position
  8. }

cfg.findSelection() 函數會根據配置文件和咱們選定結構體的方式來返回指定結構體的開始和結束位置。它在給定 Node 上進行迭代,而後返回其起始位置(和以上的配置一節中的解釋相似):

用Go語言編寫一門工具的終極指南

(檢索步驟會迭代全部 node ,直到其找到一個 *ast.StructType ,而後返回它在文件中的起始位置。)


本文做者:佚名

來源:51CTO

原文連接

相關文章
相關標籤/搜索