Mercari Advent Calendar 2018 の16日目はメルペイ エキスパートチームの@tenntenn お送りします。
この記事では、Goの静的解析の新しいムーブメントであるgolang.org/x/tools/go/analysisを使ったモジュール化について解説したいと思います。
「静的解析は関係ないや」と思って、タイトルを見てブラウザのタブを閉じようと思ったかもしれませんが、ほとんどのGopherには無関係ではないと思いますので、ぐっと我慢してしばらく間お付き合いください。
静的解析のモジュール化とは
Goにはgo vet
やgolint
など静的解析ツールが多数あります。
静的解析ツールを用いることで、プログラムを実行せずにバグになりうる箇所を検出することができます。
実際に、コードレビューを行う際にCIで静的解析ツールを実行している方も多いかと思います。
Goは標準でgoパッケージが提供されているため、簡単に静的解析を行うことができます。
みなさんが開発時によくつかっているgofmt
やgoimports
などを始めとる静的解析ツールもgo
パッケージを使って作られています。
もちろん、go
パッケージを使えば、自分で静的解析ツールを作ることも簡単にできます。
自作の静的解析ツールを作ろうと思った場合、図1のように多くの部分をgo
パッケージがやってくれます。
静的解析ツールの多くは、抽象構文木や型情報をソースコードをもとに取得する部分は共通しています。
それに加え、図2の多くの静的解析で同じライブラリを使うこともあり、複数の静的解析ツールを使うと、実は中で重ための処理を無駄に繰り返している場合があります。
そのような状況から、静的解析をモジュール化することができるgolang.org/x/tools/go/analysisパッケージが登場しました。
analysis
パッケージは、図3のように静的解析の処理をAnalyzer
という単位でモジュール化します。
Analyzer
は依存する他のAnalyzer
の解析結果をもとに、解析を行うことができます。
そのため、golang.org/x/tools/go/analysis/passes以下で提供されいるようなAnalyzer
を用いることで最低限の実装で静的解析ツールを作成することができます。
また、各Analyzer
はゴールーチンで実行されるため、他のAnalyzer
が解析してる間に並行して解析を行うことができます。図3のAnalyzer A
のような複数のAnalyzer
から依存されるものもありますが、複数回実行されることはなく、sync.Once
によって1回だけ実行されるようになっています。
Analyzerの入出力データ
Analyzer
の入力データは抽象構文木や型情報などですが、それに加えて他のAnalyzer
の出力結果も入力として扱えます。
Analyzer
の出力結果は大きく分けて以下の3種類になります。
Result
Diagnostics
Fact
Result
は次のAnalyzer
に渡すための任意の型の値です。
図3の場合では、Analyzer A
がAnalyzer B
やAnalyzer C
に渡すために用いるものです。
Diagnostics
は、ソースコードの任意の位置に紐付いた情報になります。
例えば、fmt.Printf
の使い方を間違えている箇所を指摘する静的解析ツールを作りたい考えます。
次のようなソースコードが与えられた場合に、6行目のfmt.Printf
はフォーマットに%d
を指定しているにも関わらず、引数に整数値を渡していません。
これを指摘するAnalyzer
はDiagnostics
として6行目に使い方が間違っているというメッセージを出力します。
package main import "fmt" func main() { fmt.Printf("%d") // 6行目 }
Fact
は、次のようなソースコードが与えられたときに、「関数f
はfmt.Printf
のラッパーです」のような静的解析している中で見つかる事実を表します。
型情報やパッケージに紐付けることができます。Fact
は、アドレス空間を跨いで扱えるようにencoding/gob
でエンコーディングできる前提になっています。
package main import "fmt" func f(format string, args ...interface{}) { fmt.Printf(format, args...) } func main() { f("%d") }
Result
は、抽象構文木のノードや型情報に結びつかないAnalyzer
の結果になります。
Result
は基本的にはそのまま静的解析ツールのユーザに伝えられるような結果ではなく、そのAnalyzer
に依存している別のAnalyzer
に提供されるものです。
例えば、golang.org/x/tools/go/analysis/passes/inspectパッケージで提供されているAnalyzer
は抽象構文木を簡単に探索するための機能を提供する型の値をResult
として返しています。
Analyzerの実装例
golang.org/x/tools/go/analysis/passes/findcallパッケージは、指定した名前の関数を呼び出している箇所を探す、シンプルな実装のAnalyzer
です。まずはこのfindcall
パッケージを題材に、Analyzerがどう構成されているかを見ていきます。
このパッケージで提供されているfindcall.Analyzer
は次のようなanalysis.Analyzer
型の変数になっています。
var Analyzer = &analysis.Analyzer{ Name: "findcall", Doc: Doc, Run: run, RunDespiteErrors: true, FactTypes: []analysis.Fact{new(foundFact)}, }
Name
フィールドはAnalyzer
の名前、Doc
フィールドは静的解析ツールのユーザのためのAnalyzer
の説明です。
Run
フィールドで設定される関数がAnalyzer
の実際の処理になります。ここではrun
という名前のパッケージ関数が指定されています。
RunDespiteErrors
フィールドは構文解析や型チェックが何かしらの理由でエラーを出していても解析を行うかどうかのフラグです。
FactTypes
フィールドは、このAnalyzer
で出力されるFact
の型を設定されるフィールドです。
Analyzer
へのオプションは、次のように*flag.FlagSet
型のFlags
フィールドを用いてプログラム引数として渡されるようになっています。
ここでは探す関数の名前を指定できるようにしています。
var name string // -name flag func init() { Analyzer.Flags.StringVar(&name, "name", name, "name of the function to find") }
Run
フィールドで指定したrun
関数では、関数呼び出し部分から指定した名前の関数を探す部分と関数定義部分から探す部分の2種類の処理から構成されています。前者の結果はDiagnostics
として出力し、後者の結果はFact
として出力しています。
func run(pass *analysis.Pass) (interface{}, error) { for _, f := range pass.Files { ast.Inspect(f, func(n ast.Node) bool { if call, ok := n.(*ast.CallExpr); ok { var id *ast.Ident switch fun := call.Fun.(type) { case *ast.Ident: id = fun case *ast.SelectorExpr: id = fun.Sel } if id != nil && !pass.TypesInfo.Types[id].IsType() && id.Name == name { pass.Reportf(call.Lparen, "call of %s(...)", id.Name) } } return true }) } // Export a fact for each matching function. // // These facts are produced only to test the testing // infrastructure in the analysistest package. // They are not consumed by the findcall Analyzer // itself, as would happen in a more realistic example. for _, f := range pass.Files { for _, decl := range f.Decls { if decl, ok := decl.(*ast.FuncDecl); ok && decl.Name.Name == name { if obj, ok := pass.TypesInfo.Defs[decl.Name].(*types.Func); ok { pass.ExportObjectFact(obj, new(foundFact)) } } } } return nil, nil }
ここで詳細を説明するとめちゃくちゃ長くなってしまうので、詳細はQiitaで別の記事を書きましたのでそちらをご覧ください。
go vetから実行する
findcall.Analyzer
のように、各Analyzer
はパッケージ変数として公開することがほとんどです。
そのため、main
関数を持つプログラムとしてAnalyzer
を実行する必要があります。
その機能を提供するパッケージとして、次の3つがあります。
- golang.org/x/tools/go/analysis/singlechecker
- golang.org/x/tools/go/analysis/multichecker
- golang.org/x/tools/go/analysis/unitchecker
singlechecker
は単一のAnalyzer
で構成される静的解析ツールを作るためのパッケージです。
次のようにmain
関数を書くことで簡単に静的解析ツールを作ることができます。
package main import ( "golang.org/x/tools/go/analysis/passes/findcall" "golang.org/x/tools/go/analysis/singlechecker" ) func main() { singlechecker.Main(findcall.Analyzer) }
また、multichecker
は複数のAnalyzer
で構成される静的解析ツールを作るためのパッケージです。
次のようにmain
関数を書きます。
package main import ( "github.com/tenntenn/gosa/passes/xxxxx" "github.com/tenntenn/gosa/passes/yyyyy" "golang.org/x/tools/go/analysis/multichecker" ) func main() { multichecker.Main( xxxxx.Analyzer, yyyyy.Analyzer, ) }
unitchecker
は、静的解析を行うファイルの一覧などをunitchecker.Configで設定することで静的解析を行います。
(12/17 追記:singlechecker
やmultichecker
も内部では、unitchecker
に解析対象のGoファイルなどをunitchecker.Config
に設定して静的解析を行っています。singlechecker
やmultichecker
が内部でunitchecker
を呼んでいる部分は引数で設定ファイルを渡された時だけでした。)
次のように、unitchecker.Main
をmain
関数で呼ぶことで、go vet
から呼び出されるためのコマンドを作れます。
package main import ( "github.com/tenntenn/gosa/passes/xxxxx" "github.com/tenntenn/gosa/passes/yyyyy" "golang.org/x/tools/go/analysis/unitchecker" ) func main() { unitchecker.Main( xxxxx.Analyzer, yyyyy.Analyzer, ) }
unitckecker
で作った静的解析ツールは、次のようにgo vet
から呼び出すことができます。
引数の-vettool
でunitchecker
作った静的解析ツールの実行可能ファイルへのパスを渡すことでgo vet
から実行できます。
ここではmychecker
という静的解析ツールにPATH
が通っているという前提で、which
コマンドでそのパスを取得しています。
$ go vet -vettool=which mychecker
go vet
の-vettool
オプションは、執筆時の最新バージョンであるGo 1.11では実行できません。
しかし、執筆時にはすでにmasterブランチにはマージされているため、Go 1.12以降で利用できるようになるでしょう。
試したい場合は、Goの開発バージョンをソースコードからビルドしてください。
なお、Goのmasterブランチでは次のようにgo vet
自体もunitchecker
で作られるようになっています。
func main() {
unitchecker.Main(
asmdecl.Analyzer,
assign.Analyzer,
atomic.Analyzer,
bools.Analyzer,
buildtag.Analyzer,
cgocall.Analyzer,
composite.Analyzer,
copylock.Analyzer,
httpresponse.Analyzer,
loopclosure.Analyzer,
lostcancel.Analyzer,
nilfunc.Analyzer,
printf.Analyzer,
shift.Analyzer,
stdmethods.Analyzer,
structtag.Analyzer,
tests.Analyzer,
unmarshal.Analyzer,
unreachable.Analyzer,
unsafeptr.Analyzer,
unusedresult.Analyzer,
)
}
なお、unitchecker
は各Analyzer
をゴールーチンで実行し、複数のAnalyzer
から依存されるAnalyzer
の実行もsync.Once
で1度だけ実行されるように作られているため、効率よく静的解析を行えるようになっています。
静的解析ツールのテスト
golang.org/x/tools/go/analysis/analysistest
パッケージでは、コメントを書くだけでAnalyzer
がテストできるような仕組みを提供しています。
以下のようなテスト関数を作り、testdata
ディレクトリ以下に解析対象のGoファイルを生成することでテストを行ってくれます。
func init() { findcall.Analyzer.Flags.Set("name", "println") } func TestFromFileSystem(t *testing.T) { testdata := analysistest.TestData() analysistest.Run(t, testdata, findcall.Analyzer, "a") }
この場合は、testdata/src/a
ディレクトリ以下にa.go
ファイルというファイルを作りテストデータとして利用しています。
// testdata/src/a/a.go package main func main() { println("hi") // want "call of println" print("hi") // not a call of println }
ソースコード中にwant
で始まるコメントを書くことで、その行でDiagnostics
として期待した解析結果が出力されているかチェックしてくれます。
ここでは、println("hi")
の部分でDiagnostics
がcall of println
というメッセージで出力されるかどうかをテストしています。
あとはいつもどおりgo test
を実行することで実際のソースコードをテストデータとして使った静的解析ツールのテストが簡単に実行できます。
静的解析ツールを作ろう
Analyzer
を作る場合、テストなどを含めて毎回同じようなコードを書くことが多くなります。
そのため、筆者は雛形を生成するためのskeletonというCLIツールを作りました。
次のようにgo get
でインストールすることができます。
$ go get github.com/tenntenn/gosa/skeleton
GOPATH以下のディレクトリで、次のようにskeleton
コマンドを実行することでAnalyzer
の雛形を生成することができます。
$ skeleton pkgname $ tree pkgname ├── pkgname.go ├── pkgname_test.go └── testdata └── src └── a └── a.go
ぜひ自作のAnalyzer
を作る場合はご活用ください。
本稿では、静的解析のモジュール化について解説しました。
モジュール化を行うことで静的解析ツールの再利用が進み、これまで以上に多くの静的解析ツールが統一化されたエコシステム上で作られていくことになるでしょう。
実際に、世界のGopherが集まるGophers Slackでは、#toolsや#static-analysisというチャンネルがあり、そこではGoの静的解析やそれを使ったツールについて日夜Gopherたちが活発に議論しています。
来年の2月にはGo1.12のリリースされる予定です。
その後に開催される世界各地のGoのカンファレンスでも静的解析のモジュール化のセッションや議論が活発に行われるでしょう。
この波に乗り遅れないように、読者のみなさんもぜひ年末年始でanalysis
パッケージを試してみてください。
明日、17日目の執筆担当は@kojimaです。
ナイスで面白い記事だと思いますので、お楽しみに!!