My External Storage

Feb 1, 2019 - 11 minute read - Comments - go

golang.org/x/tools/go/analysisでLinterツールを自作する #gounco #golang

この記事ではGo(Un)Conferenceで発表したGoにおけるLinterツールの作成方法をまとめる。

TL;DR

実際に静的解析コマンドを実装したサンプルリポジトリは以下になる。

golang.org/x/tools/go/analysisとは

golang.org/x/tools/go/analysisパッケージはGoで静的解析ツールを作成するための仕組みを提供するパッケージだ。詳細については@tenntennさんがmercariのブログにまとめてくれている。

このパッケージを使うと静的解析ロジックをモジュール化することができ、自由に組み合わせたりすることができる。 利用例でみると、すでにgo vetコマンドの静的解析ロジックは分割・モジュール化され、analysis/passes以下に各モジュールとして配置されている。 モジュールはanalysis.Analyzer構造体を実装することで構成される。 go vetコマンドのmain関数は以下のようになっており、各モジュールのAnalyzerオブジェクトを読み込んでいるだけなのがわかるはずだ。

// https://github.com/golang/tools/blob/master/go/analysis/cmd/vet/vet.go

func main() {
	multichecker.Main(
		// the traditional vet suite:
		asmdecl.Analyzer,
		assign.Analyzer,
		atomic.Analyzer,
		atomicalign.Analyzer,
		bools.Analyzer,
		buildtag.Analyzer,

		// ...

		nilness.Analyzer,
	)
}

静的解析モジュールを作成するための基本的な知識

MercariのブログGoDocを見てもらえばいいのだが、golang.org/x/tools/go/analysisパッケージで静的解析モジュールを作る際には以下の要点を押さえておけばよい。

Analyzer

各静的解析のモジュールの実態がAnalyzer構造体となる。Analyzer構造体のRunメソッドに静的解析ロジックを実装することで静的解析が実現する。Analyzerでは他のAnalyzerに解析結果を提供する際のResultType、依存する他のAnalyzerを指定するRequiresなどのフィールドが存在する。

type Analyzer struct {
    // ...

    // ロジック本体。戻り値はなくても良いが、Resultとして他のAnalyzerに結果を渡す場合は返す。
    Run func(*Pass) (interface{}, error)

    // ...

    // このAnalyzerが実行時に利用したい他のAnalyzer
    Requires []*Analyzer

    // このAnalyzerが他のAnalyzerに提供する情報。Runメソッドの戻り値
    ResultType reflect.Type

    // 同じAnalyzer内で解析対象のパッケージをまたいで解析情報を利用したい場合はFactという概念を利用してやり取りする
    FactTypes []Fact
}

Pass

静的解析は実行時に受け取った解析対象のパッケージに対してAnalyzerRunメソッドを実行していく。 Runメソッドの引数として解析対象パッケージの情報を持つのがPass構造体だ。

type Pass struct {
    Analyzer *Analyzer // the identity of the current analyzer

    // 解析対象パッケージの静的解析用の情報
    Fset       *token.FileSet // file position information
    Files      []*ast.File    // the abstract syntax tree of each file
    OtherFiles []string       // names of non-Go files of this package
    Pkg        *types.Package // type information about the package
    TypesInfo  *types.Info    // type information about the syntax trees
    TypesSizes types.Sizes    // function for computing sizes of types

    // ...

    // 他のAnalyzerのReslutを利用したい場合はこの中から受け取る
    ResultOf map[*Analyzer]interface{}

    // Factをやりとりするときの関数
    ImportObjectFact func(obj types.Object, fact Fact) bool

    // ...
}

静的解析中に利用するデータ概念

AnalyzerPassの説明中に出てきた3種類のデータの概念が以下だ。

自作のLinterを作ってみる

ここからは実際に自作のLinterを作ってみる。まず最初は@tenntennさんが公開しているskeletenコマンドを使うと簡単にテンプレートが生成される。

mylinterという名前で静的解析モジュールのテンプレートを作成するときは以下のように使う。

$ go get github.com/tenntenn/gosa/skeleton
$ skeleton mylinter
mylinter
├── mylinter.go  # 静的解析ロジックを書く本体
├── mylinter_test.go
└── testdata
    └── src
        └── a # テストを書くときのテストケースパッケージ
            └── a.go

これで静的解析ロジックをmylinter.goの中のAnalyzer.Runに自作の静的解析ロジックを実装していくだけでよい。

package mylinter

import (
        "go/ast"

        "golang.org/x/tools/go/analysis"
        "golang.org/x/tools/go/analysis/passes/inspect"
        "golang.org/x/tools/go/ast/inspector"
)

var Analyzer = &analysis.Analyzer{
        Name: "mylinter",
        Doc:  Doc,
        Run:  run,
        Requires: []*analysis.Analyzer{
                inspect.Analyzer,
        },
}

const Doc = "mylinter is ..."

func run(pass *analysis.Pass) (interface{}, error) {
  // 省略
}

このままAnalyzerをコマンドとして実行したい場合は以下のようなcmd/main.goファイルを用意すればよいだろう。

// cmd/main.go
package main

import (
        "github.com/budougumi0617/mylinter"
        "golang.org/x/tools/go/analysis/multichecker"
        "golang.org/x/tools/go/analysis/passes/inspect"
)

func main() {
        multichecker.Main(
                // skeletonで生成したAnalyzerの初期コードの場合はinspect.Analyzerの結果に依存している
                inspect.Analyzer,
                mylinter.Analyzer,
        )
}

ここではmultichecker.Main関数で定義しているが、Main関数の実装はいくつか提供されている。 一つのAnalyzerだけを使ったLinterなのか?複数のAnalyzerを使ったLinterなのか?などによって使い分ける。

あとは通常どおりgo buidすればLinterコマンドがもう利用できる。(以下はサンプルリポジトリを実行した例)

$ go build -o icheck ./cmd/main.go

# testdataディレクトリ以下のサンプルコードのimport構成が特殊なので
# GOPATHをちょっと変えているが、本来はGOPATHの再定義は必要ない
$ GOPATH=`pwd`/testdata:$GOPATH ./icheck ./testdata/src/...
/Users/shimizubudougumi0617/importcheck/testdata/src/handler/h.go:7:2: \
handler must not include "repository"

ASTの探索について。

準備は簡単なのだが、実際に難しいところは目的の静的解析ロジックを組み立てるところだろう(逆にいうと本質的な部分にフォーカスできるようになっているとも言える)。 抽象構文木(AST)については@tenntennさんのQiitaの記事や@motemenさんのgit bookを見るとわかりやすい。

skeletonで生成したコードにもふくまれているが、inspectパッケージを使うと簡単にASTを走査できるのでswitch文を組み立てるだけでパースを開始できる。

func run(pass *analysis.Pass) (interface{}, error) {
        inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

        // 調べたいNodeの型でフィルタリングできる
        nodeFilter := []ast.Node{
                (*ast.Ident)(nil),
        }

        inspect.Preorder(nodeFilter, func(n ast.Node) {
                switch n := n.(type) {
                case *ast.Ident:
                        _ = n
                }
        })

        return nil, nil
}

あとはシンプルなAnalyzerの実装を見ていくとなんとなくやり方が見えてくると思う(私はまだ見えていない)。

Analyzerのテストを用意する

analysistestパッケージを使うと、簡単にAnalyzerをテストすることができる。 下記のように静的解析でエラーを出したいテスト用のサンプルコードを準備するだけだ。 エラーを出したい行に対して、// want と始めて期待出力文字列をコメントしておくだけでよい。

// testdata/src/handler/h.go
package handler

import (
        "repository" // want "handler must not include \"repository\""
        "service"
)

...

あとは用意したサンプルコードを読み込んで、Analyzerを実行するテストケースを用意する。skeleteonコマンドでテンプレートを生成した場合、xxx_test.goにそのようなテストケースがすでに生成されている。 テストコードには「./testdata/srcディレクトリにある"a"パッケージを一緒に生成したAnalyzerで静的解析する」テストケースがすでにあるので、testdata/src/a/a.goファイルに自分が実装した静的解析ロジックで見つけたいエラーを書いておくだけだ。

package mylinter_test

import (
        "testing"

        "github.com/budougumi0617/mylinter"
        "golang.org/x/tools/go/analysis/analysistest"
)

func Test(t *testing.T) {
        testdata := analysistest.TestData()
        analysistest.Run(t, testdata, mylinter.Analyzer, "a")
}

skeletonで生成したコードでそのままgo testすれば、サンプルコードに書かれた// wantが満たされずFAILするのがわかる。

$ go test
--- FAIL: Test (0.03s)
    analysistest.go:311: a/a.go:4: no diagnostic was reported matching "pattern"
FAIL
exit status 1
FAIL    github.com/budougumi0617/mylinter       0.042s

その他のTips

静的解析を実装するときに参考にしたいコード

そもそも静的解析でどんなことができるのか?あの静的解析はどのようにロジックを組めばできるのだろう?という疑問もあるはずだ。 そういう疑問を持ったときははまずgolang.org/x/tools/go/analysis/passes以下のパッケージを読むといいだろう。go vetの静的解析の実装が全てAnalyzerベースになっている、

ExportObjectFactなど、Factsを使った実装はgolang.org/x/tools/go/analysis/passes/printf/printfパッケージで行われている。

Resultについてはgolang.org/x/tools/go/analysis/passes/inspect/inspectを参考にすれば良い(他のモジュールからの使い方についてはskeletonコマンドで生成したコードを見ればわかるだろう)。

テストケースの書き方について

テスト用のサンプルコードはtestdataディレクトリ以下に作成することになるが、analysistestパッケージでテストを実行するときはtestdataディレクトリがGOPATHになる。なので、たとえばrepositoryパッケージをimportするhandlerパッケージというサンプルコードを作ったとすると、ディレクトリ構成は以下のようになる。

$ tree testdata
testdata
└── src
    ├── domain
    │   └── d.go
    ├── handler
    │   └── h.go
    ├── repository
    │   └── r.go
    └── service
        └── s.go
// testdata/src/handler/h.go

package handler

import (
        "encoding/json"
        "net/http"

        "repository" // want "handler must not include \"repository\""
        "service"
)

// Do anything...

フラグ

CLIツールの場合フラグ処理を入れたくなるのが常だ。analysisパッケージを使った実装した場合、実行時のフラグはAnalyzer.Flagsに入って渡ってくる。具体的な呼び出し方はMercariのブログを見るとよいだろう。

type Analyzer struct {
    // ...

    // Flags defines any flags accepted by the analyzer.
    // The manner in which these flags are exposed to the user
    // depends on the driver which runs the analyzer.
    Flags flag.FlagSet

    // ...
}

既存Linterツールの移行について

すでにgolang.org/x/tools/go/analysisパッケージ以前の仕組みでLinterを作成していた方もいるだろう。 既存Linterツールの移行(マイグレーション)についてはizumin5210さんやdaisuzuさんの記事が参考になる。

終わりに

私は今までLinterを作るのは難しそうだなと思っていたが、golang.org/x/tools/go/analysisで提供されるようになった仕組みを使えば、静的解析ロジック以外の殆どを気にしなくてすむ。また静的解析ロジックそのものもヘルパーモジュールを使うことでだいぶラクに実装できる。 今回サンプルとして実装したLinterツールはハードコーディングだらけで使い物にならないので、設定ファイルから条件を読み込むようにしたりして実際の業務で使えるLinterを作ってみたいと思う。 また、私は英語がちゃんと読めないので、日本語でいろいろな情報を書いてくださっている先輩Gopherのみなさんに感謝しかない(ましてやジェネレータまで提供してくれている!)。

参考

関連記事