Golang の静的解析に入門する

目次

背景

こんにちは、 @okarin です。プロダクトの開発を進めていると、Pull Request のレビューで同じような指摘をしてしまっていたり、コード規約を作ったものの遵守するのが難しかったりした経験はありませんか? こういったことは静的解析によって機械的に防ぐのが最もいい手段なのではないかと思います。自分たちのユースケースに合わせた静的解析を行いたい場合はツールを自作する必要がありますが、Golang の場合は環境が整っており、比較的簡単に自作して導入できたので記事にまとめました。

今回作成したもの

Golang の開発では、 Accept interfaces, return structs とよく言われますが、今回は return structs を徹底するための静的解析ツールを作成しました(GitHub)。コードも簡単で初学者向けかなと思うので、今回はこれを元に説明していきます。

このツールを導入することで、 interface を return しているところがあるとエラーを出してくれます。もちろん例外を設定することもできますし、 error interface などはデフォルトでエラーにしない仕様にしています。

サンプルは以下のようになります。 Hoge という interface が戻り値になっている場合はエラーをレポートします。また error も interface ですが、この抽象化はよくみられるパターンなので許可しています。

type Hoge interface{}

type Foo struct{}

func f() Hoge { // NG: return value is interface
	return nil
}

func g() Foo { // OK: return value is struct
	return Foo{}
}

func x() error { // OK: error is allowed
	return nil
}

流れ

Golang の静的解析ツールを自作してプロダクト開発に導入するには、おおまかに以下のようになります。

  • Analyzer の作成
  • unitchecker に Analyzer を組み込む
  • unitchecker を go vet で実行する

Analyzer の作成

Analyzer は golang.org/x/tools/go/analysis パッケージの構造体で、Golang の静的解析を自作するには Analyzer の Run 関数を実装するようになります。 @tenntenn さんが skeleton というツールを作成しており、これを使うことで簡単に静的解析ツールの作成を始めることができます。

まずは下記コマンドで skeleton をインストールします。

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

skeleton をインストールしたら、下記のようなコマンドで雛形のコードを生成します。

$ skeleton github.com/xxxxxxxxx/awesomestaticanalyzer

awesomestaticanalyzer
├── cmd
│   └── awesomestaticanalyzer
│       └── main.go
├── go.mod
├── awesomestaticanalyzer.go
├── awesomestaticanalyzer_test.go
└── testdata
    └── src
        └── a
            ├── a.go
            └── go.mod

awesomestaticanalyzer.go の中身はざっくり以下のようになります。この run 関数の中身を実装するだけで簡単な静的解析ツールを作成できます。

package awesomestaticanalyzer

import (
	"go/ast"

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

const doc = "awesomestaticanalyzer is ..."

// Analyzer is ...
var Analyzer = &analysis.Analyzer{
	Name: "awesomestaticanalyzer",
	Docdoc,
	Runrun,
	Requires: []*analysis.Analyzer{
		inspect.Analyzer,
	},
}

func run(pass *analysis.Pass) (any, error) {
	// ...
}

今回、interface を return しているとエラーをレポートする静的解析を実装したものが以下になります。ちょっと長いので次節から少しずつ解説していきます。

package notreturninterface

import (
	"go/ast"
	"go/types"
	"slices"
	"strings"

	"github.com/gostaticanalysis/analysisutil"
	"golang.org/x/tools/go/analysis"
	"golang.org/x/tools/go/analysis/passes/inspect"
	"golang.org/x/tools/go/ast/inspector"
)

const doc = "notreturninterface is ..."

// Analyzer is ...
var Analyzer = &analysis.Analyzer{
	Name: "notreturninterface",
	Docdoc,
	Runrun,
	Requires: []*analysis.Analyzer{
		inspect.Analyzer,
	},
}

var ignoreInterfaces string // -ignore flag

var allowList = []string{"error", "context.Context"}

func init() {
	Analyzer.Flags.StringVar(&ignoreInterfaces, "ignore", "", "comma-separated list of interfaces to ignore")
}

func run(pass *analysis.Pass) (any, error) {
	ignoreInterfacesSet := make(map[string]struct{})
	for _, ignoreInterface := range strings.Split(strings.TrimSpace(ignoreInterfaces), ",") {
	if ignoreInterface != "" {
		ignoreInterfacesSet[ignoreInterface] = struct{}{}
	}
}

	inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

	nodeFilter := []ast.Node{
		(*ast.FuncDecl)(nil),
	}
	pass.Report = analysisutil.ReportWithoutIgnore(pass)
	inspect.Preorder(nodeFilter, func(n ast.Node) {
		switch n := n.(type) {
		case *ast.FuncDecl:
			if n.Type.Results == nil || n.Type.Results.List == nil {
				return
			}

			for _, field := range n.Type.Results.List {
				typeExpr := pass.TypesInfo.TypeOf(field.Type)

				if typeExpr == nil {
					continue
				}

				if _, ok := typeExpr.Underlying().(*types.Interface); !ok {
					continue
				}

				if slices.Contains(allowList, typeExpr.String()) {
					continue
				}

				if _, ok := ignoreInterfacesSet[typeExpr.String()]; ok {
					continue
				}

				pass.Reportf(n.Pos(), "function %s must not return interface %s, but struct", n.Name.Name, typeExpr)
			}
		default:
		}
	})

	return nil, nil
}

flag 解析

まずは flag 解析部分です。 ignore というフラグを渡せるようにして、フラグが渡されたら ignoreInterfacesSet というセットに、エラーの対象としない interface を登録していきます。

var ignoreInterfaces string // -ignore flag

func init() {
	Analyzer.Flags.StringVar(&ignoreInterfaces, "ignore", "", "comma-separated list of interfaces to ignore")
}

func run(pass *analysis.Pass) (any, error) {
	ignoreInterfacesSet := make(map[string]struct{})
	for _, ignoreInterface := range strings.Split(strings.TrimSpace(ignoreInterfaces), ",") {
		if ignoreInterface != "" {
			ignoreInterfacesSet[ignoreInterface] = struct{}{}
		}
	}
	// ...
}

AST の利用

次に、 inspect.Analyzer の結果を取得します。これは Analyzer の宣言時に Requires フィールドに inspect.Analyzer を渡しているので、 inspect.Analyzer の run 関数が事前に実行され、その戻り値が pass.ResultOf に格納されています。この戻り値を利用することで、 AST(抽象構文木)を走査して様々なチェックが可能となります。

var Analyzer = &analysis.Analyzer{
	Name: "notreturninterface",
	Docdoc,
	Runrun,
	Requires: []*analysis.Analyzer{
		inspect.Analyzer,
	},
}

func run(pass *analysis.Pass) (any, error) {
	// ...

	inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

	// ...
}

AST の走査

次に、 nodeFilter を定義して、inspect.Preorder に渡します。AST を深さ優先探索の preorder で走査して、 nodeFilter にマッチする Node のみ第二引数で渡した関数を実行します。

また、 pass.Report = analysisutil.ReportWithoutIgnore(pass) の 1 行を書いておくと、コメントで例外を適用することができます。具体的には、 //lint:ignore <Analyzer.Name> reason というフォーマットでコメントを書くと、その次の行は Analyzer のチェックを無視するようになります。

func run(pass *analysis.Pass) (any, error) {
	// ...

	nodeFilter := []ast.Node{
		(*ast.FuncDecl)(nil),
	}
	pass.Report = analysisutil.ReportWithoutIgnore(pass)
	inspect.Preorder(nodeFilter, func(n ast.Node) {
		// ...
	}
}

関数の戻り値の型チェック

あとは比較的シンプルになるかと思います。今回は関数定義のみ調べたいので、 Node の type が *ast.FuncDecl の場合のみ処理を行います。

n.Type.Results.List は関数の戻り値に関する型の情報が入っていますが、最初に nil チェックを入れています。戻り値がない場合などに Results が nil になるのでチェックが必要です。

その後、 field.Type では情報が少ないので pass.TypesInfo.TypeOf で型情報を取得します。

型情報が取得できたら typeExpr.Underlying().(*types.Interface) で interface かどうかをチェックしています。

さらに今回は error や context.Context は戻り値にすることを許可したいので allowList としてチェックを入れています。

最後に、フラグで渡した ignore のリストは許可して、いずれにも当てはまらないものは interface を return しているとして、エラーをレポートするようになります。

var allowList = []string{"error", "context.Context"}

func run(pass *analysis.Pass) (any, error) {
	// ...
	inspect.Preorder(nodeFilter, func(n ast.Node) {
		switch n := n.(type) {
		case *ast.FuncDecl:
			if n.Type.Results == nil || n.Type.Results.List == nil {
				return
			}

			for _, field := range n.Type.Results.List {
typeExpr := pass.TypesInfo.TypeOf(field.Type)
				if typeExpr == nil {
					continue
				}

				if _, ok := typeExpr.Underlying().(*types.Interface); !ok {
					continue
				}

				if slices.Contains(allowList, typeExpr.String()) {
					continue
				}

				if _, ok := ignoreInterfacesSet[typeExpr.String()]; ok {
					continue
				}

				pass.Reportf(n.Pos(), "function %s must not return interface %s, but struct", n.Name.Name, typeExpr)
			}
		default:
		}
	})

	return nil, nil
}

unitchecker に Analyzer を組み込む

Analyzer が作成できたら、プロダクトに組み込むのは簡単です。たとえば、プロダクトのリポジトリ内で、 cmd/staticanalyzer/main.go というファイルを作成し、以下のようなコードを実装します。 unitchecker.Main に任意の数の Analyzer を渡すことで、静的解析を実行することができます。

package main

import (
	"github.com/ksrnnb/notreturninterface"

	"golang.org/x/tools/go/analysis/unitchecker"
)

func main() {
	unitchecker.Main(
		notreturninterface.Analyzer,
		// add analyzers...
	)
}

unitcheker を go vet で実行する

unitchecker の実装が終わったらビルドして、 go vet で実行します。 unitchecker を利用している場合は go vet 経由で実行しないとエラーになります。 go vet を使用せずにさくっと直接実行したい場合は multichecker も利用することができますが、ビルドした時のサイズは unitchecker のほうがやや小さくなります。

また、フラグを渡す時は Analyzer.Name とフラグ名を . で繋いだものを使います。今回の例だと notreturninterface.ignore になります。

これを Docker image のビルド前などに実行することで、機械的にコード規約などを遵守することができるようになります。

$ go build ./cmd/staticanalyzer

$ go vet -vettool="$(pwd)/staticanalyzer" \
    -notreturninterface.ignore=SomeInterface \
    ./...

まとめ

本記事では静的解析を自作して、プロダクト開発に導入するまでを簡単にみてきました。 Golang の静的解析は解説資料が豊富で、ツールも整っているので誰でも簡単に導入できるかと思います。みなさんもぜひ静的解析を始めてみてください。

参考文献