😽

【Go言語】自作した静的解析ツールをGitHub Actionsで実行する

2021/12/13に公開

本記事では、

  1. Goで静的解析ツールを自作する
  2. 自作した静的解析ツールをGitHub Actionsで実行する

方法についてまとめます。

自作した静的解析ツール

oncenv

https://github.com/ytakaya/oncenv
今回は、テストやinit関数以外でos.Getenv()が実行されていることを指摘するoncenvというツールを作りました。os.Getenv()は環境変数を取得するためのメソッドです。
自分のユースケースではプロセス起動時に一度環境変数を取得できれば十分なため、

func request() {
  apiHost := os.Getenv("API_HOST")
  resp, _ := http.Get(apiHost + "hoge")
  ...
}

と書いてリクエストごとにホストを環境変数から取得するよりも、

var apiHost string

func init() {
  apiHost = os.Getenv("API_HOST")
}

func request() {
  resp, _ := http.Get(apiHost + "hoge")
  ...
}

と書いた方が値を使い回せるようになりますし、os.Getenv()内で取るロックの回数を減らせるので、多少パフォーマンスも向上するでしょう。

実装

Goの静的解析ツールの実装についてはこちらの記事が非常に丁寧に書かれており参考になりました。
上記の記事でも解説されていますが、Goの静的解析ツールはanalysis.Analyzerを実装して作ります。
最終的なディレクトリ構成は以下の様になっています。

.
├── cmd
│   └── oncenv
│       └── main.go
├── go.mod
├── go.sum
├── oncenv.go
├── oncenv_test.go
└── testdata
    └── src
        └── a
            └── a.go

以下で実装について解説していきます。

テストデータの作成

まずは静的解析ツールに期待する挙動をテストにします。Analyzerのテストはgolang.org/x/tools/go/analysis/analysistestパッケージを使うことで簡単に書くことができます。
analysistestRun()RunWithSuggestedFixes()は指定したディレクトリ配下のパッケージに対して、テストを実行します。テストデータのディレクトリパスを返すanalysistest.TestData()がtestdataディレクトリのパスを返すので、testdata配下にテスト用のコードを書いていきます。

testdata/src/a/a.go
package main

import "os"

func init() {
	os.Getenv("A")
}

type a string
func (a a) a() {
	os.Getenv("A") // want "os\\.Getenv\\(\\) is used in inappropriate place"
}

func main() {
	os.Getenv("A") // want "os\\.Getenv\\(\\) is used in inappropriate place"
}

ソースコード内に// want ...形式のコメントを書くことで、Analyzerの診断結果と期待するメッセージが一致するかをテストしてくれます。今回はinit関数外でos.Getenv()を使っている箇所でメッセージを出力することを期待しています。
// wantに続くメッセージは正規表現で書く必要があるため、.(は適切にエスケープします。今回の場合、// want "os.Getenv() is used in inappropriate place"などと書くと期待するテストができません。

Analyzerの実装

テストを書けたらいよいよAnalyzerの実装をしていきます。
NameDocはそれぞれツール名とツールの説明で、ツールの実態をRunに実装していきます。

oncenv.go
var Analyzer = &analysis.Analyzer{
	Name: "oncenv", // ツール名
	Doc:  "oncenv detects os.Getenv in inappropriate place", // ツールの説明
	Run:  run,
}

func run(pass *analysis.Pass) (interface{}, error) {
	for _, f := range pass.Files {
		if strings.Contains(f.Name.Name, "_test") {
			continue
		}
		for _, decl := range f.Decls {
			if decl, ok := decl.(*ast.FuncDecl); ok && decl.Name.Name != "init" { // init()以外のメソッド宣言部を探索
				ast.Inspect(decl, func(n ast.Node) bool {
					if fun, ok := n.(*ast.SelectorExpr); ok { // ast.Inspect()でノードを探索していく
						if x, ok := fun.X.(*ast.Ident); ok {
							if x.Name == "os" && fun.Sel.Name == "Getenv" { // os.Getenv()の呼び出しの場合、メッセージ出力
								pass.Report(analysis.Diagnostic{
									Pos:     fun.Pos(),
									Message: "os.Getenv() is used in inappropriate place",
								})
							}
						}
					}
					return true
				})
			}
		}
	}
	return nil, nil
}

実装は単純で、メソッド宣言部を順番に探索して*ast.SelectorExprが見つかったときに、その呼び出しがos.Getenv()か否かを確認しています。
今回は簡易的にパッケージ名に_testが含まれるか否かでテスト内のos.Getenv()を検知しない様にしています。テストのパッケージ名に必ず_testがついてる訳ではないため、本来はメソッド名などからテスト内の呼び出しであることを判定する必要があると思います。

go vetで実行できるようにする

go vetはGoに標準で組み込まれている静的解析ツールです。自作した静的解析ツールもgo vetコマンドから呼び出すことができます。
unitcheckerを使って、go vetから実行できる静的解析ツール(vettool)を作ります。

package main

import (
	"github.com/ytakaya/oncenv"
	"golang.org/x/tools/go/analysis/unitchecker"
)

func main() { unitchecker.Main(oncenv.Analyzer) }

unitchecker.Main()には複数のAnalyzerを渡すことができ、必要なAnalyzerを列挙することで自分だけのvettoolを作ることができます。
上記のソースコードをビルドして、

$ go vet -vettool={実行ファイル}

とすると、自作のvettoolを実行することができます。
オプションを必要とするAnalyzer(例えばfaindcall)をgo vetから実行する場合は、

$ go vet -vettool={実行ファイル} -findcall.name=println

の形式でプログラム引数を渡します。findcallはオプションで渡された関数の呼び出しを検出する静的解析ツールです。
https://github.com/golang/tools/tree/master/go/analysis/passes/findcall

今回は自作したoncenvだけを実行するvettoolを作りました。
https://github.com/ytakaya/vettool-example

GitHub Actionsで実行できるようにする

テストや静的解析をCIで実行することで、コード品質の向上が期待できます。今回はGitHub Actionsで自作の静的解析ツールを実行する方法をまとめます。
https://github.com/ytakaya/gha-govet-example
こちらがサンプルのリポジトリです、以下でワークフローについて説明します。

go vetを実行する

上記で開発したvettoolをGitHub Actionsで実行します。以下にワークフローの一部を掲載します。

- name: Set up Go
  uses: actions/setup-go@v2
  with:
    go-version: 1.17
        
- name: install vettool
  run : GOBIN=$(pwd) go install github.com/ytakaya/vettool-example@latest
        
- name: run vet
  run: go vet -vettool=$(pwd)/vettool-example

単純に自分が使いたいAnalyzerをまとめたvettoolをインストールしてgo vetコマンドで実行するだけです。
あとはプルリクエストなどをフックに実行して、いずれかのAnalyzerの解析に引っかかればGitHub Actionsも失敗してくれます。

こんな感じで失敗してくれます。

privateリポジトリのvettoolを使う

publicリポジトリのvettoolを導入するのは上記の様に簡単でした。しかし、チームの独自ルールを反映した静的解析ツールを使う場合などは、vettoolをprivateリポジトリに置きたい場合もあります。privateリポジトリのvettoolをGitHub Actionsで実行するのは一手間必要です。本題からは少しそれますが、以下の様なステップを追加することで、privateリポジトリのvettoolを実行できます。

- name: Configure git for private modules
  env:
    TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
  run: git config --global url."https://ytakaya:${TOKEN}@github.com".insteadOf "https://github.com"
  
- name: install vettool
  run : GOBIN=$(pwd) GOPRIVATE="github.com/ytakaya/vettool-example" go install github.com/ytakaya/vettool-example@latest

PERSONAL_ACCESS_TOKENはGitHubのトークンページで、privateリポジトリの読み取り権限をつけたトークンを発行し、リポジトリのシークレットに登録した値です。
アクセストークンやGOPRIVATEに関しては以下に記載があります。
https://github.com/mvdan/github-actions-golang/blob/master/README.md#how-do-i-install-private-modules

まとめ

Goで静的解析ツールを実装する方法と、それをGitHub Actionsで実行する方法を紹介しました。簡単にASTをいじれる方法が提供されているのが良いと思いました。チーム内の独自ルールへの違反やよくあるレビューの指摘などを静的解析で発見できる様にすると、開発効率が上がったりしそうだなと感じました!

Discussion