Goにおける静的解析のモジュール化について

Mercari Advent Calendar 2018 の16日目はメルペイ エキスパートチームの@tenntenn お送りします。

この記事では、Goの静的解析の新しいムーブメントであるgolang.org/x/tools/go/analysisを使ったモジュール化について解説したいと思います。

「静的解析は関係ないや」と思って、タイトルを見てブラウザのタブを閉じようと思ったかもしれませんが、ほとんどのGopherには無関係ではないと思いますので、ぐっと我慢してしばらく間お付き合いください。

静的解析のモジュール化とは

Goにはgo vetgolintなど静的解析ツールが多数あります。
静的解析ツールを用いることで、プログラムを実行せずにバグになりうる箇所を検出することができます。
実際に、コードレビューを行う際にCIで静的解析ツールを実行している方も多いかと思います。

Goは標準でgoパッケージが提供されているため、簡単に静的解析を行うことができます。
みなさんが開発時によくつかっているgofmtgoimportsなどを始めとる静的解析ツールもgoパッケージを使って作られています。
もちろん、goパッケージを使えば、自分で静的解析ツールを作ることも簡単にできます。
自作の静的解析ツールを作ろうと思った場合、図1のように多くの部分をgoパッケージがやってくれます。

f:id:uedatakuya275:20181214185312p:plain:w400
図1: Goの静的解析

静的解析ツールの多くは、抽象構文木や型情報をソースコードをもとに取得する部分は共通しています。
それに加え、図2の多くの静的解析で同じライブラリを使うこともあり、複数の静的解析ツールを使うと、実は中で重ための処理を無駄に繰り返している場合があります。

f:id:uedatakuya275:20181214190003p:plain:w400
図2: 無駄に同じ処理を実行してしまう

そのような状況から、静的解析をモジュール化することができるgolang.org/x/tools/go/analysisパッケージが登場しました。

analysisパッケージは、図3のように静的解析の処理をAnalyzerという単位でモジュール化します。
Analyzerは依存する他のAnalyzerの解析結果をもとに、解析を行うことができます。
そのため、golang.org/x/tools/go/analysis/passes以下で提供されいるようなAnalyzerを用いることで最低限の実装で静的解析ツールを作成することができます。

f:id:uedatakuya275:20181214190701p:plain:w400
図3: 静的解析のモジュール化

また、各Analyzerはゴールーチンで実行されるため、他のAnalyzerが解析してる間に並行して解析を行うことができます。図3のAnalyzer Aのような複数のAnalyzerから依存されるものもありますが、複数回実行されることはなく、sync.Onceによって1回だけ実行されるようになっています。

Analyzerの入出力データ

Analyzerの入力データは抽象構文木や型情報などですが、それに加えて他のAnalyzerの出力結果も入力として扱えます。
Analyzerの出力結果は大きく分けて以下の3種類になります。

  • Result
  • Diagnostics
  • Fact

Resultは次のAnalyzerに渡すための任意の型の値です。
図3の場合では、Analyzer AAnalyzer BAnalyzer Cに渡すために用いるものです。

Diagnosticsは、ソースコードの任意の位置に紐付いた情報になります。
例えば、fmt.Printfの使い方を間違えている箇所を指摘する静的解析ツールを作りたい考えます。
次のようなソースコードが与えられた場合に、6行目のfmt.Printfはフォーマットに%dを指定しているにも関わらず、引数に整数値を渡していません。
これを指摘するAnalyzerDiagnosticsとして6行目に使い方が間違っているというメッセージを出力します。

package main
import "fmt"
func main() {
fmt.Printf("%d") // 6行目
}

Factは、次のようなソースコードが与えられたときに、「関数ffmt.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つがあります。

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で設定することで静的解析を行います。
singlecheckermulticheckerも内部では、unitcheckerに解析対象のGoファイルなどをunitchecker.Configに設定して静的解析を行っています。(12/17 追記:singlecheckermulticheckerが内部でunitcheckerを呼んでいる部分は引数で設定ファイルを渡された時だけでした。)

次のように、unitchecker.Mainmain関数で呼ぶことで、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から呼び出すことができます。
引数の-vettoolunitchecker作った静的解析ツールの実行可能ファイルへのパスを渡すことで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")の部分でDiagnosticscall 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です。
ナイスで面白い記事だと思いますので、お楽しみに!!

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加