Annotating errorsのlinter
はじめに
Go言語の話です。
過去の記事でも触れたDave Cheney先生の記事や、各種のerror handling best practiceでも触れらているように、Goのerror
は、そのままreturnせず、errors.Wrap
などでannotateすると良いとされています。
ですが、人間(というより僕)の心は弱いもので、つい手癖でそのままreturn err
してしまいます。これをいちいちソースコードレビューでチェックするのは辛いので、静的に検査できるツールを作りました。
またgoのanalysisに関する記事を読んで、せっかくなのでanaysis
にも対応させました。
今回の記事ではツールの紹介と実装の紹介をします。
作ったツールはこちら
Annotate error
簡単に言うと、下記のような状況のときにはerrors.Wrap
などを用いてどういう状況なのかを付与すると、debugなどで有利だよという方針です。
func OpenFile(fpath string) error { _, err := os.Open(fpath) if err != nil { return err } return nil }
このコードを違うパッケージのコードから呼び出します。
func Caller() { err := somepackage.OpenFile("./notexistsfile.txt") if err != nil { log.Fatal(err) } // 以下略 }
このとき、(もちろんOpenFileに渡される引数のファイルは存在しないものとして)、エラーとして出力される文字列は、下記のようになります。
2018/12/25 18:29:59 open ./notexistsfile.txt: no such file or directory
このerror
の情報は、os.Open
が生成したものであり、このエラーが発生した箇所、OpenFile
の位置情報が抜け落ちてしまっています。どこで関数を読んだかが分からなくなってしまい、デバッグの際に苦労することになります。
そこで、推奨されるのがerrors.Wrapf()
などを用いて、エラー時のコンテキストを付与していく手法です。
OpenFileを修正して、errors.Wrap()
を使います。
func OpenFile(fpath string) error { _, err := os.Open(fpath) if err != nil { return errors.Wrap(err, "os.Open failed at OpenFile") } return nil }
この状態で呼び出すと、メッセージが以下のようになります。
2018/12/25 18:38:43 os.Open failed at OpenFile(): open ./notexistsfile.txt: no such file or directory exit status 1
すると、Wrap
で渡した情報がerrorに付与され、より情報を詳細にすることができました。(また、ここでは触れませんが、log.Fatalf("%+v", err)
で表示されることにより、stacktraceの情報も得ることができます)
どういった情報を付与するかはそれぞれのユースケースによるかとは思いますが、僕の場合は、関数名とその際の簡単な状況を付与しています。
レイヤを複数にまたがるようなerror handlingはWrap
するのが基本的な作法となっているかと思います。
mustwrap
しかし、正しくerrorがWrap
されて返されているかをチェックする方法は、(僕が調べたところ)存在せず、僕たちのプロジェクトでもソースコードレビューで対応するといった状況でした。
これは完全に言い訳なのですが、editorやIDEのsnippetに
if err != nil { return err }
が登録されており、ついついその機能を使ってしまうため、ソースコードレビューで度々指摘され、静的検査でなんとかならないかなあと思っていました。
そこで、ASTをパースして上記の状況を検査するツールを作りました。
使ってみる
$ go get -u github.com/akito0107/errwrp/cmd/mustwrap-standalone
でインストールできます。
上記のファイルを検査にかけます。(--path
オプションは必須です)
$ mustwrap-standalone --path ./
すると、以下の画像のような出力になります。
error
がWrap
されずにreturnされている箇所を指摘し、強調表示してくれます。
該当の箇所をerrors.Wrap()
を用いて修正すると、エラーは出力されなくなります。
ちなみに、fmt.Errorf(), errors.New(), errors.Wrapf()
等の関数を用いても同様にエラーは出力されなくなります。
実装の紹介
実装の紹介をします。
下のコードはこのファイルからの抜粋です。
func Parse(r io.Reader, fname string) ([]*ParsedAST, *token.FileSet, error) { src, err := ioutil.ReadAll(r) // fileから読み込み if err != nil { return nil, nil, errors.Wrap(err, "parser: ioutil/ReadAll") } fset := token.NewFileSet() f, err := parser.ParseFile(fset, "", string(src), parser.ParseComments) // ASTを取得 if err != nil { return nil, nil, errors.Wrap(err, "parser: parser/ParseFile") } decls := parse(fname, f) return decls, fset, nil } func parse(fname string, f *ast.File) []*ParsedAST { var decls []*ParsedAST ast.Inspect(f, func(n ast.Node) bool { switch x := n.(type) { case *ast.FuncDecl: if x.Type.Results == nil { return true } resList := x.Type.Results.List for i := 0; i < len(resList); i++ { // FuncDeclのResultsの型を見て、errorがあれば結果セットに追加 if !containsErrorType(resList[i].Type) { continue } if isNamedReturn(resList[i]) { // NamedReturnについては未対応 logger.Warningf("named return currently not supported: %v", x.Name) continue } p := &ParsedAST{ ErrOrd: i, AST: x, FileName: fname, } decls = append(decls, p) } return true default: return true } return true }) return decls }
最初に、チェック対象のファイルを読み込み、parser.ParseFile
を使ってASTを取得します。
取得したASTをast.Inspect
し、*ast.FuncDecl
(関数定義のAST)を抽出します。ast.FuncDeclのDocumentを見るとわかりやすいのですが、FuncDeclは下記のような定義になってる構造体です。
type FuncDecl struct { Doc *CommentGroup // associated documentation; or nil Recv *FieldList // receiver (methods); or nil (functions) Name *Ident // function/method name Type *FuncType // function signature: parameters, results, and position of "func" keyword Body *BlockStmt // function body; or nil for external (non-Go) function }
error
のreturnがある関数だけを抜き出そうとした場合、FuncDecl.Type
(FuncType)のResults
プロパティをチェックすれば可能です。
ここで抽出したast.FuncDecl
と、後々にline noなどの情報を得るため、token.FileSet
を固めてreturn
します。
集められたast.FuncDecl
は、次のCheck
関数に渡されます。ここからの抜粋です。
type Result struct { Position token.Position Pos token.Pos Fname string } func Check(aset *ParsedAST, fset *token.FileSet) ([]*Result, error) { res := check(aset, fset) return res, nil } func check(aset *ParsedAST, fset *token.FileSet) []*Result { var res []*Result ast.Inspect(aset.AST, func(n ast.Node) bool { switch x := n.(type) { case *ast.ReturnStmt: // may be return directly via function call if len(x.Results) < len(aset.AST.Type.Results.List) { // おそらくreturn func()のパターン、wrapすべき。 res = append(res, &Result{ Fname: aset.FileName, Position: fset.Position(x.Pos()), Pos: x.Pos(), }) return true } expr := x.Results[aset.ErrOrd] if isNilError(expr) { // return nilのパターン、無視する return true } if isUsingPkgErrors(expr) { // return errors.Wrapなどのパターン、無視する return true } if mayUseOriginalError(expr) { // custom type errorのパターン、無視する。 return true } if isUsingFmtError(expr) { // fmt.Errorfなどを使っているパターン、無視する。 return true } // それ以外の場合はreportする res = append(res, &Result{ Fname: aset.FileName, Position: fset.Position(x.Pos()), Pos: x.Pos(), }) default: return true } return true }) return res }
*ast.FuncDecl
をさらにInspect
し、return
文のASTである*ast.ReturnStmt
の中身を読み、err
をそのままreturn
している箇所をResult
の構造体に格納していきます。
この実装だと、
err := func() err = errors.Wrap(err, "....") return err
のように、errors.Wrap
とreturn
が別のstatementで行われた場合チェックできないといったような問題点もありますが、現実ではあまりないユースケースな気もしているので、現在はこういった実装になっています。
Result
構造体のPosition
プロパティは、token.FileSet
のPosition関数にASTのPos
を渡すことによって得られるASTの位置情報です。
token.Positionによって定義され、LineNoや、filenameを得ることができます。
(後述しますが、analysis対応のため、Posの情報もそのままResultに詰めています。)
あとはResultをいい感じに色をつけてprintするだけです。
analysisへの対応
冒頭でも紹介しましたが、この記事を読んで、analysisパッケージの存在を知りました。
せっかくなので、このツールをanalysisのinterfaceに対応させようと思います。
必要な情報はすべてResult
に入っているので、それをpass.Reportf
に渡してあげるだけで完結します。
var Analyzer = &analysis.Analyzer{ Name: "mustwrap", Doc: "check for just returning error without errors.Wrap", RunDespiteErrors: false, Run: run, } func run(pass *analysis.Pass) (interface{}, error) { var decls []*ParsedAST for _, f := range pass.Files { decls = append(decls, parse("", f)...) } var res []*Result for _, d := range decls { res = append(res, check(d, pass.Fset)...) } for _, r := range res { pass.Reportf(r.Pos, "should be use errors.Wrap() or errors.Wrapf()") } return nil, nil }
analysis.Analyzer
構造体を宣言し、Name
, Doc
といった必要なプロパティを設定します。
run
は、pass *analysis.Pass
の構造体を引数に取る関数で、チェックした結果をReport
したい場合は、token.Pos
と、メッセージを渡せばそれだけあとはanalyzerがやってくれます。
mainは下記のように実装します。
package main import ( "github.com/akito0107/errwrp" "golang.org/x/tools/go/analysis/singlechecker" ) func main() { singlechecker.Main(errwrp.Analyzer) }
singlechecker.Main
にわたすだけの非常に簡単なAPIです。
このanalysis対応版は、mustwrap
というコマンドで公開しています。
$ go get -u github.com/akito0107/errwrp/cmd/mustwrap
インストールしたら実行してみます。standalone版はpathをoptionに渡していましたが、analysis版はpackageのpathを引数に渡します。
$ mustwrap github.com/akito0107/path/to/targetpkg /Users/akito/go/src/github.com/akito0107/path/to/targetpkg/openfile.go:11:3: should be use errors.Wrap() or errors.Wrapf()
github.com/akito0107/path/to/targetpkg
というのがチェック対象のpackageだと思ってください。
このようにチェックした結果を出力してくれることが確認できました。
(み、見た目が。。。ここはなんとかしていきたいですね。。。)
まとめ
annotate errorの説明と、その静的解析ツールの紹介をしました。実は今回作ったツールはまだ実プロダクトで使えてはいないので、正直どこまで役に立つかはわかっていません。。。軽く使ってみた感想だと、チェックツールがうるさすぎるので、もうちょっとルールを工夫したいと思ってはいます。なにかいいアイデア等ありましたら共有していただきたいと思っています!
analysis
パッケージについては、情報がまだあまりなく、手探りの状態でしたが、上記のブログや、公式を読むとなんとか実装することができました。
まだAnalyzerの依存関係や、その有用性などの理解が全くできていないので、引き続き学習していきたいと思います。
ありがとうございました!
- この記事は、リクルートエンジニアアドベントカレンダーその223日目の記事です。