※この記事は、2022 Speee Advent Calendar5日目の記事です。 昨日の記事はこちら。
はじめに
アドプラットフォーム事業部でバックエンドエンジニアをしている@muroon01です。UZOUという広告配信プラットフォームを開発・運用しています。業務でGoの静的解析を使用してzap用のコードGeneratorを作成したのでそれについて記載したいと思います。
zapとは
- uberがつくったGoのログライブラリ
- jsonなどプログラムで処理しやすい形式でのログ出力が可能。他にもフォーマッターのカスタマイズも可能
- ゼロアロケーションである(ただし使用法による)
- 条件サンプリング機能を有している
- 独自のzapcore.Core機能を実装することにより独自Writerを定義して書き込みをカスタマイズできる
- 例えばこちらを使ってログの書き込みをトリガーにSentryに通知することができる
zapの面倒なところ
パフォーマンスを重視したい状況で、ログのフィールド情報を引数として渡す際にはパラメータの型に応じたfuncを使用してフィールド情報(ログメッセージに付随するパラメータ)を渡さなくてはならないところです。
type ExampleLog struct { URL string Attempt int Backoff time.Duration } e := &ExampleLog { URL : "https://examples", Attempt: 1, Backoff : time.Second, } conf := zap.NewProductionConfig() conf.EncoderConfig.LevelKey = "" conf.EncoderConfig.TimeKey = "" conf.EncoderConfig.MessageKey = "" logger, _ := conf.Build(zap.WithCaller(false)) logger.Info("", zap.String("url" e.URL), // 型に応じた処理 zap.Int("attempt", e.Attempt), // 型に応じた処理 zap.Duration("backoff", e.Backoff), // 型に応じた処理 ) // {"url":"https://examples","attempt":1,"backoff":1}
logger.Info
メソッドの引数のzap.String
からzap.Duration
がまさにその箇所です。
Info
に限らず、出力用のメソッドは第2引数以降の引数はすべてzap.Field型に変換して渡す必要があります。
zap.SugaredLogger
を使用する方法がありますが、こちらは上記の方法よりも実行時のパフォーマンスが落ちるので使用してません。
また、zap.Any
という関数ならすべての型に対してpackage外から使用できますが、こちらは内部でreflection処理が実行されるので使用してません。
UZOUではExampleLog
のようなログ用のstructが十数個存在し、ログ出力時に都度zap.String
, zap.Int
などを指定するのではなく、ログ用のstruct単位でまとめたかったので下記のようなメソッドを定義しました。
func (l *ExampleLog) GetZapFields() []zap.Field { return []zap.Field{ zap.String("url" e.URL), zap.Int("attempt", e.Attempt), zap.Duration("backoff", e.Backoff), } }
ログ出力時はこのようになります。
logger.Info("", e.GetZapFields()...) // {"url":"https://examples","attempt":1,"backoff":1}
まとめてGenerateするツール
数十個あるstructのGetZapFields
メソッドを記載するのは手間ですので、各struct情報からGetZapFields
メソッド定義をgo generate
で生成するツールを作成しました。
- ログstructを静的解析を使ってフィールドの情報を解析する
- 解析した情報をもとにzapのfunc名をマッピングし出力
- go generateを使用して上記の処理を呼び出す
1. ログstructを静的解析を使ってフィールドの情報を解析する
静的解析とは対象のソースコードをプログラムとして実行することなく解析することです。 内容を文字列として検索するのではなく、ソースコード情報として取得し選別・使用することが可能です。 linterやコード補完などに使用されます。
静的解析をつかって各型情報を得る主な流れは
- 対象のソース(goファイル)に対して字句解析を行いトークンを生成
- トークンから構文解析を行い、抽象構文木(AST)を生成
- 抽象構文木を使って各型情報を取得する
使用するパッケージは以下の通りです。
go/token
- トークンに関するる型を提供するパッケージ
go/parser
- 構文解析を行う為のパッケージ
go/ast
- 抽象構文木(AST)を表現する為の型が定義されているパッケージ
構文解析の結果を表示してみる
例えば下記の下記のようなExampleLog structを定義したファイルexample.go
があります。
type ExampleLog struct { URL string Attempt int Backoff time.Duration }
このファイルを下記のようにして構文解析内容を表示してみると
package main import ( "go/ast" "go/parser" "go/token" ) func main() { // 該当sourceコードをASTに変換 fset := token.NewFileSet() f, _ := parser.ParseFile(fset, "example.go", nil, 0) // example.goはExampleLogが定義しているファイル // ASTの中身を表示 ast.Print(fset, f) }
出力結果
0 *ast.File { 1 . Package: example.go:1:1 2 . Name: *ast.Ident { 3 . . NamePos: example.go:1:9 4 . . Name: "main" 5 . } 6 . Decls: []ast.Decl (len = 2) { 7 . . 0: *ast.GenDecl { 8 . . . TokPos: example.go:3:1 9 . . . Tok: import 10 . . . Lparen: - 11 . . . Specs: []ast.Spec (len = 1) { 12 . . . . 0: *ast.ImportSpec { 13 . . . . . Path: *ast.BasicLit { 14 . . . . . . ValuePos: example.go:3:8 15 . . . . . . Kind: STRING 16 . . . . . . Value: "\"time\"" 17 . . . . . } 18 . . . . . EndPos: - 19 . . . . } 20 . . . } 21 . . . Rparen: - 22 . . } 23 . . 1: *ast.GenDecl { 24 . . . TokPos: example.go:5:1 25 . . . Tok: type 26 . . . Lparen: - 27 . . . Specs: []ast.Spec (len = 1) { 28 . . . . 0: *ast.TypeSpec { 29 . . . . . Name: *ast.Ident { 30 . . . . . . NamePos: example.go:5:6 31 . . . . . . Name: "ExampleLog" 32 . . . . . . Obj: *ast.Object { 33 . . . . . . . Kind: type 34 . . . . . . . Name: "ExampleLog" 35 . . . . . . . Decl: *(obj @ 28) 36 . . . . . . } 37 . . . . . } 38 . . . . . Assign: - 39 . . . . . Type: *ast.StructType { 40 . . . . . . Struct: example.go:5:17 41 . . . . . . Fields: *ast.FieldList { 42 . . . . . . . Opening: example.go:5:24 43 . . . . . . . List: []*ast.Field (len = 3) { 44 . . . . . . . . 0: *ast.Field { 45 . . . . . . . . . Names: []*ast.Ident (len = 1) { 46 . . . . . . . . . . 0: *ast.Ident { 47 . . . . . . . . . . . NamePos: example.go:6:2 48 . . . . . . . . . . . Name: "URL" 49 . . . . . . . . . . . Obj: *ast.Object { 50 . . . . . . . . . . . . Kind: var 51 . . . . . . . . . . . . Name: "URL" 52 . . . . . . . . . . . . Decl: *(obj @ 44) 53 . . . . . . . . . . . } 54 . . . . . . . . . . } 55 . . . . . . . . . } 56 . . . . . . . . . Type: *ast.Ident { 57 . . . . . . . . . . NamePos: example.go:6:10 58 . . . . . . . . . . Name: "string" 59 . . . . . . . . . } 60 . . . . . . . . } 61 . . . . . . . . 1: *ast.Field { 62 . . . . . . . . . Names: []*ast.Ident (len = 1) { 63 . . . . . . . . . . 0: *ast.Ident { 64 . . . . . . . . . . . NamePos: example.go:7:2 65 . . . . . . . . . . . Name: "Attempt" 66 . . . . . . . . . . . Obj: *ast.Object { 67 . . . . . . . . . . . . Kind: var 68 . . . . . . . . . . . . Name: "Attempt" 69 . . . . . . . . . . . . Decl: *(obj @ 61) 70 . . . . . . . . . . . } 71 . . . . . . . . . . } 72 . . . . . . . . . } 73 . . . . . . . . . Type: *ast.Ident { 74 . . . . . . . . . . NamePos: example.go:7:10 75 . . . . . . . . . . Name: "int" 76 . . . . . . . . . } 77 . . . . . . . . } 78 . . . . . . . . 2: *ast.Field { 79 . . . . . . . . . Names: []*ast.Ident (len = 1) { 80 . . . . . . . . . . 0: *ast.Ident { 81 . . . . . . . . . . . NamePos: example.go:8:2 82 . . . . . . . . . . . Name: "Backoff" 83 . . . . . . . . . . . Obj: *ast.Object { 84 . . . . . . . . . . . . Kind: var 85 . . . . . . . . . . . . Name: "Backoff" 86 . . . . . . . . . . . . Decl: *(obj @ 78) 87 . . . . . . . . . . . } 88 . . . . . . . . . . } 89 . . . . . . . . . } 90 . . . . . . . . . Type: *ast.SelectorExpr { 91 . . . . . . . . . . X: *ast.Ident { 92 . . . . . . . . . . . NamePos: example.go:8:10 93 . . . . . . . . . . . Name: "time" 94 . . . . . . . . . . } 95 . . . . . . . . . . Sel: *ast.Ident { 96 . . . . . . . . . . . NamePos: example.go:8:15 97 . . . . . . . . . . . Name: "Duration" 98 . . . . . . . . . . } 99 . . . . . . . . . } 100 . . . . . . . . } 101 . . . . . . . } 102 . . . . . . . Closing: example.go:9:1 103 . . . . . . } 104 . . . . . . Incomplete: false 105 . . . . . } 106 . . . . } 107 . . . } 108 . . . Rparen: - 109 . . } 110 . } 111 . Scope: *ast.Scope { 112 . . Objects: map[string]*ast.Object (len = 1) { 113 . . . "ExampleLog": *(obj @ 32) 114 . . } 115 . } 116 . Imports: []*ast.ImportSpec (len = 1) { 117 . . 0: *(obj @ 12) 118 . } 119 . Unresolved: []*ast.Ident (len = 3) { 120 . . 0: *(obj @ 56) 121 . . 1: *(obj @ 73) 122 . . 2: *(obj @ 91) 123 . } 124 }
該当ファイルに対して下記の情報を出していることがわかります。
- package情報 (4行目)
- import package情報 (7〜22行目)
- struct名 (31行目)
- struct内のfield情報 (42〜101行目)
- URL (44〜50行目)
- Attempt (61〜77行目)
- Backoff (78〜100行目)
構文解析結果を利用してfield情報をまとめる
ast.Print
やめてast.Inspect
を使用して構文解析結果を取得し、struct情報とfield情報を抜き出します。
func main() { // 該当sourceコードをASTに変換 fset := token.NewFileSet() f, _ := parser.ParseFile(fset, "example.go", nil, 0) // ASTを使用して各フィールドを解析 ast.Inspect(f, func(n ast.Node) bool { if v, ok := n.(*ast.GenDecl); ok { for _, s := range v.Specs { if t, ok := s.(*ast.TypeSpec); ok { if st, ok := t.Type.(*ast.StructType); ok { fmt.Printf("structName:%s\n", t.Name.String()) if st.Fields != nil { for i, fi := range st.Fields.List { fmt.Printf("### field index:%d ###\n", i) for _, ind := range fi.Names { fmt.Printf("fieldName:%s\n", ind.Name) } // fieldのTypeの解析 if fi.Type != nil { if ty, ok := fi.Type.(*ast.Ident); ok { fmt.Printf("fieldType:%s\n", ty.Name) } // fieldがstruct型 if ty, ok := fi.Type.(*ast.SelectorExpr); ok { structType := ty.Sel.Name // 外部pkgの場合 if x, ok := ty.X.(*ast.Ident); ok { pkgName := x.Name fmt.Printf("fieldType:%s.%s\n", pkgName, structType) } } // fieldがpointer型の場合 if ty, ok := fi.Type.(*ast.StarExpr); ok { if x, ok := ty.X.(*ast.Ident); ok { fmt.Printf("fieldType:%s's pointer\n", x.Name) } // struct型のpointerの場合 if cty, ok := ty.X.(*ast.SelectorExpr); ok { structType := cty.Sel.Name // 外部pkgの場合 if x, ok := cty.X.(*ast.Ident); ok { pkgName := x.Name fmt.Printf("fieldType:%s.%s's pointer\n", pkgName, structType) continue } fmt.Printf("fieldType:%s's pointer\n", structType) } } // fieldがArrayの場合 if ty, ok := fi.Type.(*ast.ArrayType); ok { // 要素の型 if x, ok := ty.Elt.(*ast.Ident); ok { fmt.Printf("fieldType:%s's array\n", x.Name) } // 要素がstructの場合 if cty, ok := ty.Elt.(*ast.SelectorExpr); ok { structType := cty.Sel.Name // 外部pkgの場合 if x, ok := cty.X.(*ast.Ident); ok { pkgName := x.Name fmt.Printf("fieldType:%s.%s's array\n", pkgName, structType) continue } fmt.Printf("fieldType:%s's array\n", structType) } } } } } } } } } return true }) }
出力結果
structName:ExampleLog ### field index:0 ### fieldName:URL fieldType:string ### field index:1 ### fieldName:Attempt fieldType:int ### field index:2 ### fieldName:Backoff fieldType:time.Duration
2. 解析した情報をもとにzapのfunc名をマッピングし出力
field名と型情報がわかれば、その型に応じたzap.Field用のfunc名を返してあげるだけです。 返すzap.Field用funcはzap.Anyの中身でわかります。
こうして今回必要なfuncが下記の通りであることがわかります。
// zap.必要なfunc名(key名, structのfield) zap.String("url" e.URL), zap.Int("attempt", e.Attempt), zap.Duration("backoff", e.Backoff),
※key名は該当field名をsnake caseにしたものを使用してます。
この結果がGetZapFields
の戻り値の配列の中身になりますので、メソッドの中身を記載してファイル出力すればGenaratorの完成です。
3. go generateを使用して上記の処理を呼び出す
Generatorのコマンドをgo generateを使用して該当ファイルの方に定義しておくと
example.go
のような対象ファイルが増えたときも一発で全対象ファイルが更新できるので楽です。
たとえば上記のexample.go
に下記を追加します。
//go:generate {Generatorコマンドのpath} -f $GOFILE
example.go
の存在するディレクトリに移動してgo generate
を実行するとGetZapFields
メソッドが定義されているファイルが生成されます。
さいごに
今回のような静的解析をつかったツールは2
の部分を別のマッピングルールを変えれば、
structのフィールド情報からメソッドを生成するような別のツールにも応用できると思います。
- ログstructを静的解析を使ってフィールドの情報を解析する
- 解析した情報をもとにzapのfunc名をマッピングし出力 (マッピングルールを変える)
- go generateを使用して上記の処理を呼び出す
上記の考え方を応用して、zapのMarshalLogObject
メソッドのGeneratorを作成してみました。
https://github.com/muroon/zmlog
以上になります。
We are hiring!!
Speeeでは一緒にサービス開発を推進してくれる仲間を大募集しています!
こちらのFormよりカジュアル面談も気軽にお申し込みいただけます!
Speeeでは様々なポジションで募集中なので「どんなポジションがあるの?」と気になってくれてた方は、こちらチェックしてみてください!もちろんオープンポジション的に上記に限らず積極採用中です!!!