wheatandcatの開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

skeletonを使用してGoの静的解析ツールを作ってみた

仕事の方でGoの静的解析ツールを作る機会があったので、その技術を使用してmemoir用に静的解析ツールを作ってみたので紹介

静的解析ツールのコード

github.com

memoir-backendではエラーのスタックトレースを表示させたい関係で、errorは基本全て特定のメソッドでラップするようにしているが、手動で実装しているので抜けが発生する可能性がある。

なので、ラップしていない箇所を検出する静的解析ツールを作成してみた。

静的解析のチェック後のPR

github.com

実装

静的解析ツールの作成にはskeletonを使用。

github.com

静的解析ツールを作成するcliツールで以下のコマンドで簡単に静的解析ツールを作成できる。

$ go install github.com/gostaticanalysis/skeleton/v2@latest
$ skeleton example.com/mylinter

上記のコマンドでコード生成が行われ、テストファイルなども自動で作成される。(詳しくはREADMEを参照)

こちらを使用して以下の検出できる静的解析を作成。

  • メソッドのreturnでerrorを設定されている場合に、CustomError or CustomErrorWrapでラップされていないコードを検出
  • 大本のCustomErrorではerrorをラップしないで返すので、ここに関してはコメントで検出を除外することができる
  • memoir-backendで固有使用する静的解析ツールなので汎用性は無しでOK

静的解析自体の説明は、この記事では記載しないので、以下を参照でお願いします 🙇‍♂️

zenn.dev

単純に抽象構文木 (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のバージョンが作成される。

pkg.go.dev

その後、以下のコマンドでインストール & 実行。

$ 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の通りに修正。

github.com

これでやりたかった内容まで完成した。

まとめ

Goの静的解析ツールを自作する場合は、汎用的なツールの作成よりもプロジェクト固有の問題にフォーカスして作成する方が、より実際には役に立つなと思ったので作ってみた。チーム開発だとレビュー毎に確認するのが辛い部分は静的解析ツールを自作して自動化しても良さ気だった。