asterisc

技術ネタ中心にその他雑多なこと

Annotating errorsのlinter

はじめに

Go言語の話です。 過去の記事でも触れたDave Cheney先生の記事や、各種のerror handling best practiceでも触れらているように、Goのerrorは、そのままreturnせず、errors.Wrapなどでannotateすると良いとされています。 ですが、人間(というより僕)の心は弱いもので、つい手癖でそのままreturn errしてしまいます。これをいちいちソースコードレビューでチェックするのは辛いので、静的に検査できるツールを作りました。 またgoのanalysisに関する記事を読んで、せっかくなのでanaysisにも対応させました。 今回の記事ではツールの紹介と実装の紹介をします。

作ったツールはこちら

github.com

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 ./

すると、以下の画像のような出力になります。

errorWrapされずに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.Wrapreturnが別のstatementで行われた場合チェックできないといったような問題点もありますが、現実ではあまりないユースケースな気もしているので、現在はこういった実装になっています。

Result構造体のPositionプロパティは、token.FileSetPosition関数に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の依存関係や、その有用性などの理解が全くできていないので、引き続き学習していきたいと思います。

ありがとうございました!