Goで不正なreturn errを検知する静的解析ツールを作りました!
作ったもの
どんなツール?
Goでエラーハンドリングを行う際に、以下のようにerrがnilかどうかをチェックしてreturnするコードがよく書かれます。
if err := doSomething(); err != nil {
return err
}
このように条件でnilチェックをしていれば問題ないのですが、nilチェックせずにreturn errする場合に戻り値がnilとなる可能性があり不具合が発生するコードになってしまいます。(具体的なコードは後述してます)
empty_err_checkerはそのようなコードを検知する静的解析ツールです。
empty_err_checkerを作った動機
以下のようなコードによる不具合が発生しました。(実際のコードではありません)
func UpdateOrders() error {
orders, err := getOrders()
if err != nil {
return err
}
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
var count int
for i := range orders {
preUpdate := func() error {
count++
invalidStatus := checkStatus(orders[i])
if invalidStatus {
return err // return errors.New("invalid status") を返すのが正しい
}
if err := preUpdateOrder(orders[i]); err != nil {
return err
}
return nil
}
if count < 5 {
if err := preUpdate(orders[i]); err != nil {
continue
}
} else {
<-ticker.C
count = 0
if err := preUpdate(orders[i]); err != nil {
continue
}
}
if err := postUpdate(orders[i]); err != nil {
continue
}
}
return nil
}
このコードの問題はpreUpdate
関数の中でinvalidStatus
がtrue
の場合にreturn err
していることです。本来はinvalidStatus
なのでerror
を返さなければいけませんが、nil
のerr
変数を返却しているので、後続のpostUpdate
関数まで実行されてしまいます。
このレベルの不具合であればテストコードやレビューで気付くべきですが、このコードのようにすり抜けてくる可能性もあるので静的解析ツールを作ろうと思いました。
あとは社内でGoの静的解析勉強会も行われていたので、ツールを作ってみたかったというモチベーションもありました。
skeletonについて
skeleton
は静的解析ツールに必要なファイル群を一括で作成してくれる便利なツールです。指定したGoのモジュール名に対してskeletonコマンドを実行すると、必要な実装ファイルやテストコードを生成してくれます。--plugin
オプションをつけると、golangci-lintのプラグインに必要な実装ファイルも自動的に追加してくれます。
$ skeleton github.com/snkrdunk/empty_err_checker
skeletonはテストコードも自動生成してくれます。
解析対象にしたいソースコードをtestdata/src配下に置いてテストを実行します。
テストコードはではerrorを検知したい箇所に// want "error message"
のようなコメントを入れておくだけで良いので、直感的で分かりやすいです。
func invalidErrChecker() error {
var err error
isValid := isValid()
if !isValid {
return err // want "returned error is not checked."
}
return nil
}
実装解説
静的解析の実行にはsinglechecker.Main
を使っています。
singlechecker.Main
は単一のanalysis.Analyzer
を実行する時に使います。
func main() { singlechecker.Main(empty_err_checker.Analyzer) }
ツールの実装に関してはanalysis.AnalyzerのRunフィールドに渡す関数を実装していきます。
// Runのsignature
Run func(*Pass) (interface{}, error)
emtpy_err_checkerのanalysis.Analyzer
analysis.Analyzer
のRequires
フィールドでは依存するanalysis.Analyzer
を指定でき、実装したanalyzerよりも先に実行されます。
empty_err_checker
ではinspect.Analyzer
を指定していて、これはast探索を最適化してくれるanalyzerです。
run関数
run関数の処理はざっくり以下のようなになってます。
-
ast.IfStmt
だけを対象に処理 - stackから親の
ast.IfStmt
を取得してparentIfStmtSlice
に詰める -
current(ast.Node)
とparentIfStmtSlice
を渡してgetEmptyErrReturnStmt
を実行 -
getEmptyErrReturnStmt
のresponseがnilでない場合はレポートする
pass.ResultOf
にはRequires
で指定した事前に実行されるAnalyzerの解析結果が入っています。
なのでpass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
で取り出した、inspector.Inspector
structを使ってast探索することができます。
nodeFilter
は、解析するastの探索対象にしたいast.Node
をフィルタリングするために使います。
inspector.Inspector
inspector.Inspector
でast探索する場合は以下3つのメソッドが使えます。
第一引数のtypesで特定のast.Node
を指定することでフィルタリングできるのと、深さ優先探索でASTを探索するという共通点以外の差分は↓のようになってます。
Preorder
func (in *Inspector) Preorder(types []ast.Node, f func(ast.Node))
Preorder
は事前にトラバースされた順に全てのast.Nodeを探索します。
Nodes
func (in *Inspector) Nodes(types []ast.Node, f func(n ast.Node, push bool) (proceed bool))
Nodes
はPreorder
と基本は同じですが、第二引数で指定している関数のsignatureが違います。
子のast.Node
探索前にf(ast.Node, true)
が呼ばれます。f
がfalse
を返却する場合は、子のast.Node
のサブツリーは探索されないようになっています。
またツリーの探索を抜ける時には、f(ast.Node, false)
が呼ばれます。
この仕組みによって、子のサブツリーまで探索する必要ない場合の制御などが可能になっています。(そのような理解をしています)
WithStack
func (in *Inspector) WithStack(types []ast.Node, f func(n ast.Node, push bool, stack []ast.Node) (proceed bool))
WithStack
は基本的にNodes
と同じですが、第二引数で指定している関数のsignatureが違います。
f
の第三引数で[]ast.Node
型で探索中のast.Node
に対する親ast.Node
のstack情報を受け取れます。
empty_err_checker
では、return err
してるast.Node
を起点にif文の条件式をチェックしており、if文がネストしてる場合などを考慮して事前にstackから親のast.IfStmt
を全て取得してます。
getEmptyErrReturnStmt関数
この関数ではまず引数で受け取った*ast.IfStmt
のBody.List
をループして、*ast.ReturnStmt
を取得してます。
その後は↓のバリデーション関数を通してます。
- isReturnErr
- errという変数名でerror型をreturnしてるか確認
- isCheckedIfErrIsNil
-
return err
してる場合にifの条件でerrのnilチェックをしてるか確認
-
- isAssignedErr
- ifブロックの中でerr変数に代入されているか確認
currentの*ast.IfStmt
で↑の3つを通過した場合は、親の*ast.IfStmt
でもnilチェックされてるかなどをループを回して確認してます。
ast.IfStmt
のstructは以下のようになっていて、Bodyからはifがtrueの場合のコードブロックを解析することができます。
elseのコードブロックはElseフィールドから解析できます。
// An IfStmt node represents an if statement.
IfStmt struct {
If token.Pos // position of "if" keyword
Init Stmt // initialization statement; or nil
Cond Expr // condition
Body *BlockStmt
Else Stmt // else branch; or nil
}
BlockStmt
のstructは以下のようになっていて、ListのStmt
はinterface{}
なので適宜TypeAssertionして使う必要があります。getEmptyErrReturnStmtは*ast.ReturnStmt
にTypeAssertionしてます。
// A BlockStmt node represents a braced statement list.
BlockStmt struct {
Lbrace token.Pos // position of "{"
List []Stmt
Rbrace token.Pos // position of "}", if any (may be absent due to syntax error)
}
isReturnErr関数
この関数は第二引数で受ける*ast.ReturnStmt
からerror型のerrという変数をreturnしているか確認しています。
isCheckedIfErrIsNil関数
これはifの条件式でerr != nil
のチェックがある場合はtrue
を返却する関数です。
条件式はast.IfStmt.Cond
から取得でき、型はExpr
というinterface{}
です。
err != nil
のような条件の場合Condの型は*ast.BinaryExpr
になります。
printすると以下のような構造であることがわかります。
// formatが%+vの出力結果
&{X:err OpPos:3771754 Op:!= Y:nil}
// formatが%#vの出力結果
&ast.BinaryExpr{X:(*ast.Ident)(0x1400336fce0), OpPos:3771754, Op:44, Y:(*ast.Ident)(0x1400336fd00)}
条件式が全てerr != nil
であれば、X, Yをそれぞれ*ast.Indent
にTypeAssertionして、その文字列をチェックすれば良いのですが、err != nil && isValid
のように条件式が複数ケースも考慮する必要があります。
err != nil && isValid
のように条件が複数の場合は、↓のような構造になります
// formatが%+vの出力結果
&{X:0x14002fffbc0 OpPos:3771765 Op:&& Y:isValid}
// formatが%#vの出力結果
&ast.BinaryExpr{X:(*ast.BinaryExpr)(0x14002fffbc0), OpPos:3771765, Op:34, Y:(*ast.Ident)(0x140035e20a0)}
Yは*ast.Ident
なので文字列を取得して条件式をチェックすることができますが、Xはまだ*ast.BinaryExpr
なのでX,Yを取り出して*ast.Indet
にTypeAssertionする必要があります。
このように複数条件式の場合は、*ast.BinaryExpr
のXとYがそれぞれ*ast.Ident
にTypeAssertionできるまで再起的にチェックする必要があるので、isCheckedIfErrIsNilはそのような実装にしました。
第一引数で受け取るcondの型が*ast.BinaryExpr
の場合は、XとYでそれぞれ再起的にisCheckedIfErrIsNil
を実行して、err != nil
の条件が含まれているかどうか確認してます。
isAssignedErr関数
この関数は引数で受け取る*ast.IfStmt
を使って、ifのコードブロックの中でerrという変数に値が代入されているかどうか確認しています。
empty_err_checkerはifの条件でerrのnilチェックをしないでreturn errしてるコードを検知しますが、ifのブロックの中でerrに代入されてる場合もあるので、それを判定するための関数を実装しました。
具体例としてテストコードを貼っておきます。
感想
初めて静的解析ツール作成してみましたが、skeletonやinspectorのおかげでそこまで苦労せずに作れた印象でした。
まだまだastパッケージまわり理解できない箇所あるので引き続きツール作成して勉強していきたいです。
あとツール自体は結構前に作っていて、どんな実装か思い出しながら記事書くのが辛かったので、次回からはすぐ記事くようにしたいと思いました。
株式会社SODAの開発組織がお届けするZenn Publicationです。 是非Entrance Bookもご覧ください! → recruit.soda-inc.jp/engineer
Discussion