My External Storage

Oct 18, 2019 - 6 minute read - Comments - Go OSS

[Go] レイヤードアーキテクチャの階層構造を守らないimportを警告するlinterを作った

Goでクリーンアーキテクチャ等のレイヤードアーキテクチャを実装するための静的解析ツールを作った。
webhandlerパッケージからusecaseパッケージを使わずに直接domainパッケージを使わないで!」というような、やってほしくないimportをエラーにできる。

TL;DR

  • クリーンアーキテクチャなどのレイヤードアーキテクチャでは、利用できるパッケージに制限がある
  • Goは循環importができないので、自然に単方向依存は満たしやすい
  • しかし、層を飛び越して、2つ下の層のパッケージを直接使うような実装は言語仕様で防げない
  • 今回作ったツールはそのような誤ったimportを静的解析で警告する
  • パッケージ間の層の関係は実行時にJSON配列で渡すことで利用者独自の階層構造を読み込めるようにした

なぜ作ったのか

クリーンアーキテクチャやオニオンアーキテクチャなど、なんらかのレイヤードアーキテクチャの類を使って実装している人は多いのではないだろうか。 私もその中のひとりだ。

このようなレイヤードアーキテクチャでは、利用できるパッケージに制限がある。まず大前提として単方向の依存関係があるだろう。 また、大体の場合、各層は階層構造を持っており、それぞれのパッケージ(ライブラリ)は同じ層、あるいは1つ下の層のパッケージしか利用してはいけない。

Goは循環importが言語仕様でできない。そのため、Goでこのようなレイヤードアーキテクチャを実装するとき単方向依存は自然に満たしやすい。 しかし、層を飛び越して、2つ下の層のパッケージを直接使うような実装は言語仕様で防げない。今回作った静的解析ではそのような誤ったimportを静的解析で警告する。

パッケージ間の層の関係は実行時にJSON配列で渡すことで利用者独自の階層構造を読み込めるようにした。 例えば、クリーンアーキテクチャの記事に記載されている階層構造は以下のようになる。 単純に同じ層のパッケージ名を羅列し、内包する層がある場合は内部にJSON配列をまた定義すればよい。

[
  "externalinterfaces",
  "web",
  "devices",
  "db",
  "ui",
  [
    "controllers",
    "gateways",
    "presenters",
    [
      "usecases",
      [
        "entity"
      ]
    ]
  ]
]

"のエスケープがめんどくさいが、)Go1.12以上の実行環境ならば、このJSON配列を-jsonlayerフラグで渡すことで実行できる。 例えば以下の実行例は、webパッケージからcontrollersなどの一つ下の層を使わずに直接2つ下のusecasesパッケージを参照していたときに出力されるエラーだ。

$ go vet -vettool=$(which layer) -layer.jsonlayer "[\"externalinterfaces\", \
  \"web\", \"devices\", \"db\", \"ui\", [ \"controllers\", \"gateways\", \"presenters\", \
  [ \"usecases\", [ \"entity\" ] ] ] ]" ./...
# github.com/budougumi0617/clean_architecture/web
web/delete_handler.go:7:2: github.com/budougumi0617/clean_architecture/web must not include "github.com/budougumi0617/clean_architecture/usecases"

repository/usersrepository/companiesusecase/usersusecase/companiesのようなネストしたパッケージ構成を使っていた場合もチェックするように実装してある。

実装的な部分

実用を考えてAnalyzerを実装したのは初めてだったが、以下のところで時間がかかった。

JSON配列

階層構造を定義するための-jsonlayerフラグの引数は、YAMLで表現するとコマンドライン引数として定義を渡しにくいと思い、JSONにしてある。 キーもいらないと思い、JSON配列にしたが、JSON配列をunmarshalすると[]interface{}配列になる。 そのため、独自のUnmarshalJSONを実装して構造体に詰めている。

// Layer expresses Layer architecture.
type Layer struct {
	Packages []string `json:"Packages"`
	Inside   *Layer   `json:"Inside"`
	Raw      []interface{}
}

// UnmarshalJSON unmarshals JSON data by custom logic.
func (l *Layer) UnmarshalJSON(data []byte) error {
	var raw []interface{}
	err := json.Unmarshal(data, &raw)
	if err != nil {
		return err
	}
	fillLayer(raw, l)

	return nil
}

func fillLayer(raw []interface{}, l *Layer) {
	l.Raw = raw
	for _, e := range raw {
		switch e := e.(type) {
		case string:
			l.Packages = append(l.Packages, e)
		case []interface{}:
			i := &Layer{}
			fillLayer(e, i)
			l.Inside = i
		}
	}
}

-vettoolオプション対応

unitcheckerパッケージを使うことで、go vetコマンドの-vettoolオプション経由で利用する実行バイナリになる。

`

go vetコマンドが-vettoolオプションを利用できるのはGo1.12以降のため、mainパッケージの実装をbuild constraintsで2種類用意しておく必要がある。 このあたりは、gostaticanalysis/sqlrowsと同じ構成にさせてもらった。READMEの内容もほぼ踏襲している。

GitHub Actions

いつもはCircleCIなのだが、旬(?)なのでGitHub Actionsを使ってCIを組んでみた。慣れるまで時間がかかりそう。

フラグの渡し方

-vettoolオプション用のツールにしたとき、どうやってフラグの情報を渡していいのか迷った。 順を追って実行すればわかるのだが${Analyzerの名前}.${flagの名前}で該当のAnalyzerに値を渡すことができるらしい。

$ go vet -vettool=$(which layer) -h
usage: go vet [-n] [-x] [-vettool prog] [build flags] [vet flags] [packages]
Run 'go help vet' for details.
Run '/Users/budougumi0617/go/bin/layer -help' for the vet tool's flags.

$ layer -help
layer is a tool for static analysis of Go programs.

Usage of layer:
        layer unit.cfg  # execute analysis specified by config file
        layer help      # general help
        layer help name # help on specific analyzer and its flags

$ layer help layer
layer: layer checks whether there are dependencies that illegal cross-border the layer structure. The layer structure is defined as a JSON array using the -jsonlayer option.

Analyzer flags:

  -layer.jsonlayer string
        jsonlayer defines layer hierarchy by JSON array (default "[ \"external\",\"db\",\"ui\", [ \"controllers\", [ \"usecases\", [ \"entity\" ] ] ] ]")

テストコードは無視する

さすがにテストコードまで厳密にレイヤードアーキテクチャする必要はないと思っている。 なので、_test.goでファイル名が終わる場合は無視する。 実行時唯一の情報であるanalysis.Pass構造体からファイル名を取得する方法は@podhmoさんの記事を参考にした。

終わりに

前々から作りたいなと思っていて、時間ができたので作ってみた。 脳内設計は前からしていたものの、実装自体はだいたい8時間以内で完成していると思う。 一度慣れれば問題ない部分も多いので、次の作るときはもう少しは早く終わりそうだ。 静的解析ツール作成はインフラの準備もいらないし、言語仕様(構造)の勉強にもなるし面白い。

もし使ってくれる方がいらっしゃったらフィードバックをいただけると嬉しい(もし良いと思ったらStarももらえると幸い…!)。

参考

関連記事