每個 gopher 都需要了解的 Go AST
最近業務遷移,大約 100+ 個接口需要從舊的服務,遷到公司框架。遇到幾個痛點:
-
結構體 dto 做 diff, 對比結果
-
自定義的結構體與 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
-
Comments
註釋, //-style 或是 /*-style -
Declarations
聲明,GenDecl
(generic declaration node) 代表 import, constant, type 或 variable declaration.BadDecl
代表有語法錯誤的 node -
Statements
常見的語句表達式,return, case, if 等等 -
File
代表一個 go 源碼文件 -
Package
代表一組源代碼文件 -
Expr
表達式 ArrayExpr, StructExpr, SliceExpr 等等
我們來看一個例子吧,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