仕事の方でGoの静的解析ツールを作る機会があったので、その技術を使用してmemoir用に静的解析ツールを作ってみたので紹介
静的解析ツールのコード
memoir-backendではエラーのスタックトレースを表示させたい関係で、errorは基本全て特定のメソッドでラップするようにしているが、手動で実装しているので抜けが発生する可能性がある。
なので、ラップしていない箇所を検出する静的解析ツールを作成してみた。
静的解析のチェック後のPR
実装
静的解析ツールの作成にはskeletonを使用。
静的解析ツールを作成するcliツールで以下のコマンドで簡単に静的解析ツールを作成できる。
$ go install github.com/gostaticanalysis/skeleton/v2@latest $ skeleton example.com/mylinter
上記のコマンドでコード生成が行われ、テストファイルなども自動で作成される。(詳しくはREADMEを参照)
こちらを使用して以下の検出できる静的解析を作成。
- メソッドのreturnでerrorを設定されている場合に、CustomError or CustomErrorWrapでラップされていないコードを検出
- 大本のCustomErrorではerrorをラップしないで返すので、ここに関してはコメントで検出を除外することができる
- memoir-backendで固有使用する静的解析ツールなので汎用性は無しでOK
静的解析自体の説明は、この記事では記載しないので、以下を参照でお願いします 🙇♂️
単純に抽象構文木 (AST) を確認した場合は、以下を確認するのもオススメ。
静的解析ツールのコードが、こちらになるので、コードを解説していく。
まず、テスト用のファイルを以下の通りに作成。
テストファイルの作成
■ checkcustomerror/testdata/src/a/a.go
package a import ( "errors" "fmt" ) func f() error { var gopher int if gopher == 0 { err := fmt.Errorf("Error: %s", gopher) return err // want "require customError wrap" } if gopher == 1 { err := fmt.Errorf("Error: %s", gopher) return CustomError(err) // OK } return nil // OK } func CustomError(err error) error { return err // nocheck:checkcustomerror }
以下のコードは、そのままエラーの変数をreturnしているので検知したい。
if gopher == 0 { err := fmt.Errorf("Error: %s", gopher) return err // want "require customError wrap" }
以下のコードは、そのままエラーの変数をCustomErrorのメソッドでラップしているので検知しなくてOKにしたいという感じ静的解析のテスト用のコードを作成する。
if gopher == 1 { err := fmt.Errorf("Error: %s", gopher) return CustomError(err) // OK }
以下のコードは、returnのtypeがerrorでもnilの場合はラップしなくてOKなので検知しなくてもOK。
return nil // OK
以下はラップしているCustomErrorのメソッドなので、errorをそのままreturnしても検知からは除外したいので、コメントでnocheck:checkcustomerror
と記載している。
func CustomError(err error) error { return err // nocheck:checkcustomerror }
と上記が期待通りに検知できるようにコードを作成していく。
各ファイルのコメントを取得
上記のテストファイルにも記載の通りにnocheck:checkcustomerror
のコメントが存在する行の場合は検知から除外したいので以下のコードで各行のコメントを取得する。
■ checkcustomerror/checkcustomerror.go
func getCommentMap(pass *analysis.Pass) map[string]string { var cmap = make(map[string]string) for _, file := range pass.Files { for _, cg := range file.Comments { for _, c := range cg.List { pos := pass.Fset.Position(c.Pos()) cmap[pos.Filename+"_"+strconv.Itoa(pos.Line)] = c.Text } } } return cmap }
これで行毎のコメントのデータが取得できたので、検知した行のコメントを取得して、対象の文字列を含んでいる場合は判定から除外するように処理をすればOK。
各メソッドのreturnを取得
Goではコーディングルールで戻り値の最後をerrorにするというのがあり、memoirでも、それに沿ってコーディングしている。 なので、今回の静的解析では以下のコードでreturnの最後の抽象構文木 (AST) を取得してtypeがerrorの場合は検知する処理を作成。
■ checkcustomerror/checkcustomerror.go
var errType = types.Universe.Lookup("error").Type() // 略 inspect.Preorder(nodeFilter, func(n ast.Node) { switch n := n.(type) { case *ast.ReturnStmt: pos := pass.Fset.Position(n.Pos()) if len(n.Results) == 0 { return } last := n.Results[len(n.Results)-1] if last == nil { return } lastTyp := pass.TypesInfo.TypeOf(last) if lastTyp != errType { return }
ast.ReturnStmtがreturnの構文のASTなので、そこを対象にreturnが存在しない、1番最後の戻り値を取得、戻り値のtypeがerrorを判定させている。
errorが CustomError OR CustomErrorWrapの名前のメソッドでラップされているか判定
以下のコードで、errorが CustomError OR CustomErrorWrapの名前のメソッドでラップされているか判定。
■ checkcustomerror/checkcustomerror.go
func check(pass *analysis.Pass, item ast.Expr) bool { ident, _ := item.(*ast.Ident) if ident != nil { return true } call := item.(*ast.CallExpr) if call == nil { return false } obj := getFun(pass, call.Fun) if obj == nil { return false } return obj.Name() != "CustomError" && obj.Name() != "CustomErrorWrap" } func getFun(pass *analysis.Pass, fun ast.Expr) *types.Func { switch fun := fun.(type) { case *ast.Ident: obj, _ := pass.TypesInfo.ObjectOf(fun).(*types.Func) return obj case *ast.SelectorExpr: obj, _ := pass.TypesInfo.ObjectOf(fun.Sel).(*types.Func) return obj } return nil }
始めに以下でASTがIdent の場合は、ただのerrorを返しているのみになるのでtrueを返して検知させる(実際は手前でラップしている可能性もあるが、今回はそのパターンはスルーしている)
ident, _ := item.(*ast.Ident) if ident != nil { return true }
次に以下でASTがCallExpr の場合は、実行しているメソッドの定義を取得して、メソッド名がCustomError OR CustomErrorWrapかチェック。
obj := getFun(pass, call.Fun) if obj == nil { return false } return obj.Name() != "CustomError" && obj.Name() != "CustomErrorWrap"
メソッド名がCustomError OR CustomErrorWrapで無い場合は、ラップされていない判定でtrueを返して検知させる
検知した箇所を出力させる
以下のコードで 検知した箇所を出力させる
■ checkcustomerror/checkcustomerror.go
pass.Reportf(n.Pos(), "require customError wrap")
これで静的解析ツールのコーディングは完了。
実行方法
skeletonで作成したツールはgo vetから実行可能なので、以下の手順で実行。
まず、go installできるようにするため、Githubのrepositoryにtagを作成してpush。 すると以下の画面のようにmoduleのバージョンが作成される。
その後、以下のコマンドでインストール & 実行。
$ go install github.com/wheatandcat/memoir-static-analytics/checkcustomerror/cmd/checkcustomerror@v0.0.7 # memoir-backendのフォルダで実行 $ go vet -vettool=$(which checkcustomerror) ./...
以下が出力された結果。
# github.com/wheatandcat/memoir-backend/usecase/custom_error usecase/custom_error/error.go:53:3: require customError wrap usecase/custom_error/error.go:59:2: require customError wrap usecase/custom_error/error.go:66:3: require customError wrap usecase/custom_error/error.go:72:2: require customError wrap # github.com/wheatandcat/memoir-backend/usecase/app_trace usecase/app_trace/middleware.go:126:2: require customError wrap # github.com/wheatandcat/memoir-backend/repository repository/item.go:88:3: require customError wrap repository/user.go:74:3: require customError wrap repository/user.go:83:3: require customError wrap # github.com/wheatandcat/memoir-backend/usecase/auth usecase/auth/create.go:26:3: require customError wrap usecase/auth/create.go:30:3: require customError wrap usecase/auth/create.go:34:3: require customError wrap usecase/auth/create.go:96:2: require customError wrap
出力された箇所を確認するとラップされていない、またはCustomError のメソッドが検知されているので以下のPRの通りに修正。
これでやりたかった内容まで完成した。
まとめ
Goの静的解析ツールを自作する場合は、汎用的なツールの作成よりもプロジェクト固有の問題にフォーカスして作成する方が、より実際には役に立つなと思ったので作ってみた。チーム開発だとレビュー毎に確認するのが辛い部分は静的解析ツールを自作して自動化しても良さ気だった。