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

https://arslan.io/2017/09/14/the-ultimate-guide-to-writing-a-go-tool/
作者:Fatih Arslan
譯者:oopsguy.com

我之前編寫過一個叫 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/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"
}

此時我們已經了解了如何解析、修改和創建結構體標簽,是時候嘗試修改一個 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/structtag)。

有了這些,我們現在可以使用這兩個知識點來開始構建我們的工具(命名為 gomodifytags)。該工具應按順序執行以下操作

  • 獲取配置,用于描述要修改哪個結構體
  • 根據配置查找和修改結構體
  • 輸出結果

由于 gomodifytags 將主要應用于編輯器,我們將通過 CLI 標志(flag)傳入配置。第二步包含多個步驟,如解析文件,找到正確的結構體,然后修改結構體(通過修改 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 分支的 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)
}

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 標志(這是一個 bool flag)。如果用戶傳遞了它,我們只需將 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 變量。這定義了是否要修改該字段。如果字段位置位于 startend 之間,我們將繼續,否則忽略:

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

format 包接受 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() 函數打印上面的結構體,就會出現問題。當你打印它時,你可能會得到(https://play.golang.org/p/peHsswF4JQ):

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 flag 獲取配置
  • 我們通過 go/parser 包解析文件來獲取一個 ast.Node
  • 在解析文件之后,我們搜索 獲取相應的結構體來獲取開始位置和結束位置,這樣我們就可以知道需要修改哪些字段
  • 一旦有了開始位置和結束位置,我們再次遍歷 ast.Node,重寫開始位置和結束位置之間的每個字段(通過使用 structtag 包)
  • 之后,我們將格式化重寫的節點,為編輯器輸出 Go 源代碼或自定義的 JSON

在創建此工具后,我收到了很多好評,評論者們提到了這個工具如何簡化他們的日常工作。正如您所看到,盡管看起來它很容易編寫,但在整篇指南中,我們已經針對許多特殊的情況做了特別處理。

gomodifytags 在以下編輯器和插件中已經成功應用有幾個月了,使得數以千計的開發人員提升了工作效率:

  • vim-go
  • atom
  • vscode
  • acme

如果您對該項目的源代碼感興趣,可以訪問以下倉庫鏈接:

此外,我還在 Gophercon 2017 上發表了一個演講,如果您感興趣,可點擊下面的 youtube 鏈接觀看:

https://www.youtube.com/embed/T4AIQ4RHp-c?version=3&rel=1&fs=1&autohide=2&showsearch=0&showinfo=1&iv_load_policy=1&wmode=transparent

視頻截圖

謝謝您閱讀此文。希望這篇指南能給您啟發。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,002評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,400評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,136評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,714評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,452評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,818評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,812評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,997評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,552評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,292評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,510評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,035評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,721評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,121評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,429評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,235評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,480評論 2 379

推薦閱讀更多精彩內容