目的
-
contextパッケージの公式ドキュメントにContextについて以下のように記載されています
Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx:
ざっくりとした意味としては
Contextは構造体に埋め込むべきではなく、それそれの関数に明示的に渡すべきである。関数の第一引数にすべきで、ctxという名前をつけるのが典型的だ。
- 個人的にはこれが一種のコーディングルールのような感じになっているので、静的解析でチェックできるようにすれば楽そうだと思いました
実装
テストコード
まずはテストコードから書いてみました。
より静的解析っぽくするために context パッケージをあえて ctxPkg として定義してimportしたテストファイルです。
関数名から分かるように OKFunc1 と OKFunc2 は
-
ctxという変数名でcontext.Contextを定義している - 関数の第一引数に渡されている
という点で公式ドキュメントにあるような書き方になっています。しかし、 NGFunc1 と NGFunc2 は変数名が正しくなかったり、引数の位置が正しくない位置にあります。
package example
import (
"context"
ctxPkg "context"
)
func OKFunc1(ctx context.Context, id int, email string) {}
func OKFunc2(ctx ctxPkg.Context, attrs map[string]interface{}) {}
func NGFunc1(id int, email string, ctx context.Context) {}
func NGFunc2(c context.Context) {}
解析コード
大まかなロジックとしては
- ファイル全体を解析
- 解析の対象を関数の宣言だけに絞る
- 変数が
context.Contextのinterfaceを満たしているかチェック - 満たしているなら、変数名と引数の位置をチェック
というような流れになります。できたコードは以下になります。
package main
import (
"fmt"
"go/ast"
"go/importer"
"go/parser"
"go/token"
"go/types"
"log"
)
func main() {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "_example.go", nil, 0)
if err != nil {
log.Fatal("Error: ", err)
}
info := &types.Info{
Defs: map[*ast.Ident]types.Object{},
}
config := &types.Config{
Importer: importer.Default(),
}
_, err = config.Check("main", fset, []*ast.File{file}, info)
if err != nil {
log.Fatal("Error:", err)
}
p, err := config.Importer.Import("context")
if err != nil {
log.Fatal("Error:", err)
}
ctxType := p.Scope().Lookup("Context").Type()
it, ok := ctxType.Underlying().(*types.Interface)
if !ok {
log.Fatal("should be found Context interface")
}
ast.Inspect(file, func(n ast.Node) bool {
f, ok := n.(*ast.FuncDecl)
if !ok {
return true
}
for i, p := range f.Type.Params.List {
ident := p.Names[0]
obj := info.ObjectOf(ident)
if obj == nil {
return true
}
if types.Implements(obj.Type(), it) {
if ident.Name != "ctx" {
fmt.Printf("%s: variable name of context.Context in `%s` is invalid\n", fset.Position(ident.Pos()), f.Name)
}
if i != 0 {
fmt.Printf("%s: position of context.Context in `%s` is invalid\n", fset.Position(ident.Pos()), f.Name)
}
}
}
return true
})
}
解説
下準備
下準備として以下を行なっています
-
token.NewFileSet()でファイル情報を記録する構造体を初期化 -
parser.ParseFileの部分で_example.goをパースします- 最後の引数はパーサーのモードを指定する数字でコメントを含めてパースしたり、importの宣言部分だけをパースしたりすることができます
-
types.Infoは型チェックの結果が保持される構造体です- ここでは関数の宣言以外は不要な情報なので
Defsのみを指定します - そのほかには例えば変数の使用された情報を保持する
Usesなどが指定できます
- ここでは関数の宣言以外は不要な情報なので
-
types.Configで型チェックの設定を指定できます -
config.Checkで型情報のチェックが走ります
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "_example.go", nil, 0)
if err != nil {
log.Fatal("Error: ", err)
}
info := &types.Info{
Defs: map[*ast.Ident]types.Object{},
}
config := &types.Config{
Importer: importer.Default(),
}
_, err = config.Check("main", fset, []*ast.File{file}, info)
if err != nil {
log.Fatal("Error:", err)
}
context.Context のインポート
関数に渡される引数が context.Context なのかは「引数が context.Contextのinterfaceを満たしているか」で判断するので、元となる context.Context interfaceの情報を持ってこなくてはなりません。
参考になる記事がなくて苦労しましたが、他のパッケージから型情報を持ってくるには config.Importer.Import を使えば良いようです。Importer.Import の返り値は *types.Package です。この types.Package 構造体はGoのパッケージの情報を持っています。
さらに types.Package.Scope() の返り値がパッケージレベルで定義されているオブジェクト (関数など) の情報を持っているので、Context というinterfaceを Lookup メソッドを使って検索しています。
Lookup を行なった時点ではそれが何の型なのか分からないため、最後に Context の型情報を持ってきたのちに types.Interface に型アサーションしています。
p, err := config.Importer.Import("context")
if err != nil {
log.Fatal("Error:", err)
}
ctxType := p.Scope().Lookup("Context").Type()
it, ok := ctxType.Underlying().(*types.Interface)
if !ok {
log.Fatal("should be found Context interface")
}
引数のチェック
いよいよ最後の部分です。ast.Inspect を使ってASTを深さ優先で探索していきます。
関数以外のノードは不要なため、最初に ast.FuncDecl に型アサーションして満たさないものは後続の処理を行わないようにしてます。
関数の引数は FuncDecl.Type.Params で取得できます。返り値としては FieldList という構造体のため、さらにそのフィールドの []*Field をfor loopで検査していきます。
info が型情報を持っているため、引数に対応する型情報を info.ObjectOf で取得します。返り値は types.Object となります。実際は types.Object は型情報以外にもパッケージ等の情報を持っています。
型情報だけが欲しいので obj.Type() で型情報を取得した後に types.Implements で types.Type が types.Interface を実装しているかチェックします。
その後は名前が ctx かどうか、引数の位置が最初になっているかどうかをチェックしてます。
ast.Inspect(file, func(n ast.Node) bool {
f, ok := n.(*ast.FuncDecl)
if !ok {
return true
}
for i, p := range f.Type.Params.List {
ident := p.Names[0]
obj := info.ObjectOf(ident)
if obj == nil {
return true
}
if types.Implements(obj.Type(), it) {
if ident.Name != "ctx" {
fmt.Printf("%s: variable name of context.Context in `%s` is invalid\n", fset.Position(ident.Pos()), f.Name)
}
if i != 0 {
fmt.Printf("%s: position of context.Context in `%s` is invalid\n", fset.Position(ident.Pos()), f.Name)
}
}
}
return true
})
コードを実行してみる
❯ go run ./main.go
_example.go:12:36: position of context.Context in `NGFunc1` is invalid
_example.go:14:14: variable name of context.Context in `NGFunc2` is invalid
ちゃんと NGFunc1 と NGFunc2 のみがエラーとして検出されています。
まとめ
- Goの静的解析用のパッケージを使用して、
context.Contextが関数の第一引数にctxとして指定されているかどうかを調べるツールができました - 結構簡単にできるので楽しかったです!!
- 次は
analysis.Analyzerに組み込んでみたいと思います