每個 gopher 都需要了解的 Go AST

最近業務遷移,大約 100+ 個接口需要從舊的服務,遷到公司框架。遇到幾個痛點:

  1. 結構體 dto 做 diff, 對比結果

  2. 自定義的結構體與 protobuf 生成的互相轉換,基於 json tag

這類工作要麼手寫 (編譯期), 要麼 reflect 反射實現 (運行時)。其中 #1 考濾到性能問題,手寫最優,但是結構體太大,同時 100+ 個接口遷移,工作量可以想象

google 開源的 go-cmp[1], 輸出美觀,反射性能開銷大了點。當前業務大量使用,堆機器吧又不是不能用

#2 目前不好解決,可以簡單的 json Marshal 再 Unmarshal, 但有些字段類型不一致,同時如何做 json tag 到 pb tag 轉換呢?

我們當前的方案是通過解析 ast, 讀源碼生成結構體樹,然後 BFS 遍歷自動生成轉換代碼

//go:generate ast-tools --action convert --target-pkg aaa/dto/geresponse --src-pkg bbb/dto --source aaaResponse  --target bbbResponse

結合 go generate 自動生成,這是我們的目標

Go AST 基礎

不搞編譯器的大多隻需要懂前端,不涉及 IR 與後端,同時 go 官方還提供了大量開箱即用的庫 go/ast[2]

type Node interface {
 Pos() token.Pos // position of first character belonging to the node
 End() token.Pos // position of first character immediately after the node
}

所有實現 Pos End 的都是 Node

我們來看一個例子吧,goast 可視化界面 [3] 更直觀一些

// Manager ...
type Manager struct {
 Same      string
 All       bool   `json:"all"`
 Version   int    `json:"-"`
 NormalStruct  pkgcmd.RootApp
 PointerStruct *pkgcmd.RootApp
 SlicesField       []int
 MapField           map[string]string
}

我們定義結構體 Manager 來看一下 goast 輸出結果

29  .  1: *ast.GenDecl {
30  .  .  Doc: nil
31  .  .  TokPos: foo:7:1
32  .  .  Tok: type
33  .  .  Lparen: -
34  .  .  Specs: []ast.Spec (len = 1) {
35  .  .  .  0: *ast.TypeSpec {
36  .  .  .  .  Doc: nil
37  .  .  .  .  Name: *ast.Ident {
38  .  .  .  .  .  NamePos: foo:7:6
39  .  .  .  .  .  Name: "Manager"
40  .  .  .  .  .  Obj: *ast.Object {
41  .  .  .  .  .  .  Kind: type
42  .  .  .  .  .  .  Name: "Manager"
43  .  .  .  .  .  .  Decl: *(obj @ 35)
44  .  .  .  .  .  .  Data: nil
45  .  .  .  .  .  .  Type: nil
46  .  .  .  .  .  }
47  .  .  .  .  }

*ast.GenDecl 通用聲明,*ast.TypeSpec 代表是個類型的定義,名稱是 Manager

48    .  Assign: -
49    .  Type: *ast.StructType {
50    .  .  Struct: foo:7:14
51    .  .  Fields: *ast.FieldList {
52    .  .  .  Opening: foo:7:21
53    .  .  .  List: []*ast.Field (len = 7) {
54    .  .  .  .  0: *ast.Field {
55    .  .  .  .  .  Doc: nil
56    .  .  .  .  .  Names: []*ast.Ident (len = 1) {
57    .  .  .  .  .  .  0: *ast.Ident {
58    .  .  .  .  .  .  .  NamePos: foo:8:2
59    .  .  .  .  .  .  .  Name: "Same"
60    .  .  .  .  .  .  .  Obj: *ast.Object {
61    .  .  .  .  .  .  .  .  Kind: var
62    .  .  .  .  .  .  .  .  Name: "Same"
63    .  .  .  .  .  .  .  .  Decl: *(obj @ 54)
64    .  .  .  .  .  .  .  .  Data: nil
65    .  .  .  .  .  .  .  .  Type: nil
66    .  .  .  .  .  .  .  }
67    .  .  .  .  .  .  }
68    .  .  .  .  .  }
69    .  .  .  .  .  Type: *ast.Ident {
70    .  .  .  .  .  .  NamePos: foo:8:12
71    .  .  .  .  .  .  Name: "string"
72    .  .  .  .  .  .  Obj: nil
73    .  .  .  .  .  }
74    .  .  .  .  .  Tag: nil
75    .  .  .  .  .  Comment: nil
76    .  .  .  .  }
77    .  .  .  .  1:

*ast.StructType 代表類型是結構體,*ast.Field 數組保存結構體成員聲明,一共 7 個元素,第 0 個字段名稱 Same, 類型 string

131  .  3: *ast.Field {
132  .  .  Doc: nil
133  .  .  Names: []*ast.Ident (len = 1) {
134  .  .  .  0: *ast.Ident {
135  .  .  .  .  NamePos: foo:11:2
136  .  .  .  .  Name: "NormalStruct"
137  .  .  .  .  Obj: *ast.Object {
138  .  .  .  .  .  Kind: var
139  .  .  .  .  .  Name: "NormalStruct"
140  .  .  .  .  .  Decl: *(obj @ 131)
141  .  .  .  .  .  Data: nil
142  .  .  .  .  .  Type: nil
143  .  .  .  .  }
144  .  .  .  }
145  .  .  }
146  .  .  Type: *ast.SelectorExpr {
147  .  .  .  X: *ast.Ident {
148  .  .  .  .  NamePos: foo:11:16
149  .  .  .  .  Name: "pkgcmd"
150  .  .  .  .  Obj: nil
151  .  .  .  }
152  .  .  .  Sel: *ast.Ident {
153  .  .  .  .  NamePos: foo:11:23
154  .  .  .  .  Name: "RootApp"
155  .  .  .  .  Obj: nil
156  .  .  .  }
157  .  .  }
158  .  .  Tag: nil
159  .  .  Comment: nil
160  .  }

*ast.SelectorExpr 代表該字段類型是 A.B,其中 A 代表 package, 具體 B 是什麼類型不知道,還需要遍歷包 A

221  .  6: *ast.Field {
222  .  .  Doc: nil
223  .  .  Names: []*ast.Ident (len = 1) {
224  .  .  .  0: *ast.Ident {
225  .  .  .  .  NamePos: foo:14:2
226  .  .  .  .  Name: "MapField"
227  .  .  .  .  Obj: *ast.Object {
228  .  .  .  .  .  Kind: var
229  .  .  .  .  .  Name: "MapField"
230  .  .  .  .  .  Decl: *(obj @ 221)
231  .  .  .  .  .  Data: nil
232  .  .  .  .  .  Type: nil
233  .  .  .  .  }
234  .  .  .  }
235  .  .  }
236  .  .  Type: *ast.MapType {
237  .  .  .  Map: foo:14:21
238  .  .  .  Key: *ast.Ident {
239  .  .  .  .  NamePos: foo:14:25
240  .  .  .  .  Name: "string"
241  .  .  .  .  Obj: nil
242  .  .  .  }
243  .  .  .  Value: *ast.Ident {
244  .  .  .  .  NamePos: foo:14:32
245  .  .  .  .  Name: "string"
246  .  .  .  .  Obj: nil
247  .  .  .  }
248  .  .  }
249  .  .  Tag: nil
250  .  .  Comment: nil
251  .  }
252  }

*ast.MapType 代表類型是字段,Key, Value 分別定義鍵值類型

內容有點多,大家感興趣自行實驗

遍歷

看懂了 go ast 相關基礎,我們就可以遍歷獲取結構體樹形結構,廣度 + 深度相結合

func (p *Parser) IterateGenNeighbours(dir string) {
 path, err := filepath.Abs(dir)
 if err != nil {
  return
 }

 p.visitedPkg[dir] = true

 pkgs, err := parser.ParseDir(token.NewFileSet(), path, filter, 0)
 if err != nil {
  return
 }

 todo := map[string]struct{}{}
 for pkgName, pkg := range pkgs {
  nbv := NewNeighbourVisitor(path, p, todo, pkgName)
  for _, astFile := range pkg.Files {
   ast.Walk(nbv, astFile)
  }

  // update import specs per file
  for name := range nbv.locals {
   fmt.Sprintf("IterateGenNeighbours find struct:%s pkg:%s path:%s\n", name, nbv.locals[name].importPkg, nbv.locals[name].importPath)
   nbv.locals[name].importSpecs = nbv.importSpec
  }
 }

 for path := range todo {
  dir := os.Getenv("GOPATH") + "/src/" + strings.Replace(path, "\"""", -1)
  if _, visited := p.visitedPkg[dir]; visited {
   continue
  }
  p.IterateGenNeighbours(dir)
 }
}

這裏的工作量比較大,涉及 import 包,調試了很久,有些 linter 只需讀單一文件即可,工作量沒法比

模板輸出

最後一步就是輸出結果,這裏要 BFS 廣度遍歷結構體樹,然後渲染模板

var convertSlicePointerScalarTemplateString = `
    {if ArrayLength == "" %}
    dst.{{ TargetFieldName }} = make([]{{ TargetType }}, len(src.{{ SrcFieldName }}))
    {% endif %}
    for i := range src.{{ SrcFieldName }} {
     if src.{{ SrcFieldName }}[i] == nil {
      continue
     }

     tmp := *src.{{ SrcFieldName }}[i] 
     dst.{{ TargetFieldName }}[i] = &tmp
    }

上面是轉換 [8]*Scalar 可以是數組或切片,模板使用 pongo2[4] 實現的 jinji2 語法,非常強大

// ConvertDtoInsuranceOptionToCommonInsuranceOptionV2 only convert exported fields
func ConvertDtoInsuranceOptionToCommonInsuranceOptionV2(src *dto.InsuranceOption) *common.InsuranceOptionV2 {
    if src == nil {
        return nil
    }
    dst := &common.InsuranceOptionV2{}
    dst.ID = src.ID
    dst.OptionPremium = src.OptionPremium
    dst.InsuranceSignature = src.InsuranceSignature
    dst.Title = src.Title
    dst.Subtitle = src.Subtitle
    dst.ErrorText = src.ErrorText
    dst.IsIncluded = src.IsIncluded
    starCurrency := ConvertDtoCurrencyDTOToCommonCurrencyDTO(src.Currency)
    if starCurrency != nil {
        dst.Currency = *starCurrency
    }
    return dst
}

上面是輸出結果的樣例,整體來講比手寫靠譜多了,遇到個別 case 還是需要手工 fix

AST 其它應用場景

1. 規則

工作當中用到編譯原理的場景非常多,比如去年高老闆分享的用規則引擎讓你一天上線十個需求

If aa.bb.cc == 1  // 說明是多車型發單
  Unmarshal(bb.cc.ee)
  看type是否爲 4 
else  // 單車型發單
 Unmarshal(bb.cc.ff)
  看type是否爲 4 
(type = 4 的是拼車)

業務需要多種多樣,訂閱 MQ 根據需求做各種各樣的統計,入庫,供業務查詢。如果業務類型少還好,但是 DIDI 業務複雜,如果每次都人工手寫 go 代碼效率太低

最後解決思路是 JPATH + Expression Eval, 需求只需要寫表達式,服務解析表達示即可。Eval 庫也是現成的 govaluate[5]

2. 模板

jinja2 就是這類的代表

原理非常簡單,感興趣的可以看官方實現

3. Inject 代碼

這裏要介紹兩個項目 pingcap failpoint[6] 和 uber-go 的 gopatch

failpoint 實現很簡單,代碼裏寫 Marker 函數,這些空函數在正常編譯時會被編譯器優化去掉,所以正常運行時 zero-cost

var outerVar = "declare in outer scope"
failpoint.Inject("failpoint-name-for-demo", func(val failpoint.Value) {
    fmt.Println("unit-test", val, outerVar)
})

故障注入時通過 failctl 將 Marker 函數轉換爲故障注入函數,這裏就用到了 go-ast 做劫持轉換

uber-go 的 gopatch 也非常強大,假如你的代碼有很多 go func 開啓的 goroutine, 你想批量加入 recover 邏輯,如果數據特別多人工加很麻煩,這時可以用 gopatcher

var patchTemplateString = `@@
@@
+ import "runtime/debug"
+ import "{{ Logger }}"
+ import "{{ Statsd }}"

go func(...) {
+    defer func(){
+        if err := recover(); err != nil {
+            statsd.Count1("{{ StatsdTag }}""{{ FileName }}")
+            logging.Error("{{ LoggerTag }}""{{ FileName }} recover from panic, err=%+v, stack=%v", err, string(debug.Stack()))
+        }
+    }()
   ...
 }()
`

編寫模板,上面的例子自動在 go func(...) { 開頭注入 recover 語句塊,非常方便

這個庫能做的事情特別多,感興趣自行實驗

4. linter

大部分 linter 工具都是用 go ast 實現的,比如對於大寫的 Public 函數,如果沒有註釋報錯

// BuildArgs write a
func BuildArgs() {
    var a int
    a = a + bbb.c
    return a
}

我們看下該代碼的 ast 代碼

29  .  .  1: *ast.FuncDecl {
30  .  .  .  Doc: *ast.CommentGroup {
31  .  .  .  .  List: []*ast.Comment (len = 1) {
32  .  .  .  .  .  0: *ast.Comment {
33  .  .  .  .  .  .  Slash: foo:7:1
34  .  .  .  .  .  .  Text: "// BuildArgs write a"
35  .  .  .  .  .  }
36  .  .  .  .  }
37  .  .  .  }
38  .  .  .  Recv: nil
39  .  .  .  Name: *ast.Ident {
40  .  .  .  .  NamePos: foo:8:6
41  .  .  .  .  Name: "BuildArgs"
42  .  .  .  .  Obj: *ast.Object {
43  .  .  .  .  .  Kind: func
44  .  .  .  .  .  Name: "BuildArgs"
45  .  .  .  .  .  Decl: *(obj @ 29)
46  .  .  .  .  .  Data: nil
47  .  .  .  .  .  Type: nil
48  .  .  .  .  }
49  .  .  .  }

linter 只需要檢查 FuncDecl 的 Name 如果是可導出的,同時 Doc.CommentGroup 不存在,或是註釋不以函數名開頭,報錯即可

另外如果大家對代碼 cycle 有要求,那麼是不是可以 ast 掃一遍來發現呢?如果大家要求函數不能超過 100 行,是不是也可以實現呢?

玩法很多 ^^

小結

編譯原理雖然難,但是搞業務的只需要前端知識即可,不用研究的太深,有需要的場景,知道 AST 如何解決問題就行

關於 Go AST 大家有什麼看法,歡迎留言一起討論,大牛多留言 ^_^

董澤潤的技術筆記 十一年 IT 老兵,運維架構好把式,長期分享乾貨

參考資料

[1]

go-cmp: https://github.com/google/go-cmp,

[2]

go ast: https://pkg.go.dev/go/ast,

[3]

goast-viewer 可視化界面: https://yuroyoro.github.io/goast-viewer/index.html,

[4]

go pongo2 jinja2: github.com/flosch/pongo2,

[5]

govaluate: https://github.com/Knetic/govaluate,

[6]

pingcap failpoint: https://github.com/pingcap/failpoint,

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pCcNtUykXAwb-BN_prPGpA