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

原文:https://arslan.io/2017/09/14/the-ultimate-guide-to-writing-a-go-tool/
做者:Fatih Arslan
譯者:oopsguy.comnode

我以前編寫過一個叫 gomodifytags 的工具,使個人開發工做變得很輕鬆。它會根據字段名稱自動填充結構體標籤字段。下圖是它的功能展現:git

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

使用這樣的工具能夠很容易管理結構體的多個字段。該工具還能夠添加和刪除標籤、管理標籤選項(如 omitempty)、定義轉換規則(snake_casecamelCase 等)等。但這樣的工具是怎樣工做的呢?它內部使用了什麼 Go 包?有不少問題須要回答。github

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

拿起一杯咖啡☕️,讓咱們一塊兒深刻探索!json


首先,讓我列出這個工具須要作的事情:vim

  1. 讀取源文件、理解並可以解析 Go 文件
  2. 找到相關的結構體
  3. 找到結構體後,獲取字段名稱
  4. 根據字段名來更新結構體標籤(根據轉換規則,如 snake_case
  5. 可以把變動後的內容更新到文件中,或者可以以可消費的方式輸出結果

咱們首先來了解什麼是 結構體(struct)標籤(tag),從這裏咱們能夠學習到全部東西以及如何把它們組合在一塊兒使用,在此基礎上您也能夠構建出這樣的工具。數組

結構體的標籤值(內容,如 json: "foo"不是官方規範的一部分,可是 reflect 包定義了一個非官方規範的格式標準,這個格式一樣被 stdlib 包(如 encoding/json)所使用。它經過 reflect.StructTag 類型定義:app

這個定義有點長,不是很容易理解。咱們嘗試分解一下:編輯器

  • 一個結構體標籤是一個字符串(由於它有字符串類型)
  • 鍵(key)部分是一個無引號的字符串
  • 值(value)部分是帶引號的字符串
  • 鍵和值由冒號(:)分隔。鍵與值且由冒號分隔組成的值稱爲鍵值對
  • 結構體標籤能夠包含多個鍵值對(可選)。鍵值對由空格分隔
  • 非定義部分爲選項設置。像 encoding/json 這樣的包在讀取值時把它看成一個由逗號分隔列表。第一個逗號後的內容都是選項部分,好比 foo,omitempty,string。其有一個名爲 foo 的值和 [omitempty, string] 選項
  • 由於結構體標籤是字符串文字,因此須要使用雙引號或反引號包裹。又由於值必須使用引號,所以咱們老是使用反引號對整個標籤作處理。

總的來講:ide

結構體標籤訂義有許多隱藏的細節

咱們已經瞭解了什麼是結構體標籤,咱們能夠根據須要輕鬆地修改它。如今的問題是,咱們如何解析它纔可以輕鬆進行修改?幸運的是,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

謝謝您閱讀此文。但願這篇指南能給您啓發。

相關文章
相關標籤/搜索