[譯] Go 終極指南:編寫一個 Go 工具

StackGC發表於2017-10-27

arslan.io/2017/09/14/…
作者:Fatih Arslan
譯者:oopsguy.com

我之前編寫過一個叫 gomodifytags 的工具,它使我的生活變得很輕鬆。它會根據欄位名稱自動填充結構體標籤欄位。讓我來展示一下它的功能:

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

使用這樣的工具可以很容易管理結構體的多個欄位。該工具還可以新增和刪除標籤、管理標籤選項(如 omitempty)、定義轉換規則(snake_casecamelCase 等)等。但該工具是怎樣工作的呢?它內部使用了什麼 Go 包?有很多問題需要回答。

這是一篇非常長的博文,其解釋瞭如何編寫這樣的工具以及每個構建細節。它包含許多獨特的細節、技巧和未知的 Go 知識。

拿起一杯咖啡☕️,讓我們深入一下吧!


首先,讓我列出這個工具需要做的事情:

  1. 它需要讀取原始檔、理解並能夠解析 Go 檔案
  2. 它需要找到相關的結構體
  3. 找到結構體後,它需要獲取欄位名稱
  4. 它需要根據欄位名來更新結構體標籤(根據轉換規則,如 snake_case
  5. 它需要能夠把這些更改更新到檔案中,或者能夠以可消費的方式輸出更改後的結果

我們首先來了解什麼是 結構體(struct)標籤(tag),從這裡我們可以學習到所有東西以及如何把它們組合在一起使用,在此基礎上您可以構建出這樣的工具。

結構體的標籤值(內容,如 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 表示形式的結構體型別
一個基本的 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,並返回該檔案的開始位置和結束位置
查詢步驟遍歷所有節點,直到找到一個 *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 包修改每個欄位
用 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的概述
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
18.jpg

謝謝您閱讀此文。希望這個指南能啟發您從頭建立一個新的 Go 工具。

相關文章