パッケージの依存関係を検証するコマンドを自作した
概要
本記事は自作したコマンドの紹介です。
パッケージの依存関係を静的解析するコマンドを作りました。このコマンドの利用者は、あるパッケージが別のパッケージをimportすることを禁止することを意味するルールを定義します。コマンドは、モジュールがルールを満たしているかどうかを検証します。
ルール定義はYAML形式で記述します。
- name: domain層にあるソースコードが、usecase,infra層のパッケージをimportすることを禁止する。
srcImportPathPatterns:
# import元のImportPathを正規表現で記載する
- ^github\.com/suzuito/demo001/domain$
- ^github\.com/suzuito/demo001/domain/.+$
forbiddenImportPathPatterns:
# import元が禁止されているImportPathを正規表現で記載する
- ^github\.com/suzuito/demo001/usecase$
- ^github\.com/suzuito/demo001/usecase/.+$
- ^github\.com/suzuito/demo001/infra$
- ^github\.com/suzuito/demo001/infra/.+$
下記のコマンドを実行します。
import-checker -rule-file rules.yaml -mod-dir ./
モジュール配下にあるGo言語のソースコードがルールを違反していた場合、次のように標準出力へ結果が出力され、コマンドが0以外のステータスで終了します。
## github.com/suzuito/demo001/domain/foo
下記ファイルに違反があります。
- ./domain/foo/bar.go
- "import github.com/suzuito/demo001/usecase/hoge"はルール"domain層にあるソースコードが、usecase,infra層のパッケージをimportすることを禁止する。"に違反します。
背景
ある特定のパッケージのimportを禁止したい場合があります。例えば、クリーンアーキテクチャーの「依存性逆転の法則」に従ってソースコードを書く際、ユースケース層からインフラ層へのimportを禁止したい場合などが該当します。
importを制約するための仕組みを実現する場合、モジュールを細かく分割する方法などが考えられますがモジュールの数が増えるほど管理の手間が大きくなり、結構面倒くさいです。
そこで、ちょっと自作してみることにしました。
GoのAST周りの勉強にもなりそうだし、と思っていたのですが、実際にはASTをほとんど扱うことなく実装なくできてしまいました[1]。技術的に話すことはあまりなく・・・ということで、今回は自作したコマンドの紹介をしたという経緯です。
仕組み
処理の流れは下記の通り。
- (処理1)Go言語のソースコードを解析し、パッケージの依存関係(import元とimport先)を抽出する
- (処理2)抽出されたパッケージの依存関係に対してルールを適用し、ルール違反が検知されれば出力する
本記事では、処理1をどう実現したか?の説明にとどめます(go/parser使ってみたので)。処理2を説明してもあまり面白さはないかなと思いますので割愛します(コマンドのソースコードは短いので、気になる方はそちらをお読みいただければと:bow:)。
Go言語のソースコードを解析し、パッケージの依存関係(import元とimport先)を抽出する
Go言語は、ディレクトリがImportPathとなります[2]。あるモジュールに含まれるImportPathを全て列挙するためには、モジュールディレクトリを全て列挙すれば良いです。filepath.Walk関数などを利用すれば、ディレクトリの列挙を実現できます。
列挙されたディレクトリを引数にしてgo/parserのParseDir関数を呼びます。すると、ParseDir関数はディレクトリ直下のGo言語のソースコードを表現するASTを作ってくれます。
package main
import (
"go/parser"
"go/token"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"golang.org/x/mod/modfile"
)
// パッケージ情報
type Package struct {
// パッケージのimport元
SrcImportPath string
// import先のリスト
DstImportPaths []string
}
func extractPackages() []Package {
dirPathGoModule := "./"
// go.modを読み込む
contentGoMod, _ := os.ReadFile(path.Join(dirPathGoModule, "go.mod"))
goModFile, _ := modfile.Parse("go.mod", contentGoMod, nil)
modName := goModFile.Module.Mod.Path
// .goファイルを読み込み、パッケージ情報をリストアップする
// パッケージ情報を下記の構造体で保持します
packages := []Package{}
filepath.WalkDir(dirPathGoModule, func(dirPathCurrent string, info fs.DirEntry, _ error) error {
if !info.IsDir() {
return nil
}
fset := token.NewFileSet()
// ディレクトリ配下にあるPackageをgo/parser.ParseDir関数を利用してパースする。
astPackages, _ := parser.ParseDir(fset, dirPathCurrent, func(_ fs.FileInfo) bool { return true }, 0)
for _, astPackage := range astPackages {
pkg := Package{
// importする側のパッケージのImportPath
SrcImportPath: path.Join(modName, dirPathCurrent),
DstImportPaths: []string{},
}
// import情報を
// go/parser.ParseDir関数から返されるast.Package配下の
// Filesフィールドから抽出します
for _, goFile := range astPackage.Files {
for _, imp := range goFile.Imports {
importPath := strings.Trim(imp.Path.Value, "\"")
pkg.DstImportPaths = append(pkg.DstImportPaths, importPath)
}
}
packages = append(packages, pkg)
}
return nil
})
return packages
}
go/parserパッケージ
go/parserパッケージは、Go言語のソースコードを解析し、抽象構文木(Abstract Syntax Tree)を作成するためのものです。
go/parser.ParseDir関数は、ディレクトリ配下の全てのソースコードを解析し、解析結果をast.Package構造体として返します。go/parser.ParseFile関数は、1つのソースコードを解析し、解析結果をast.File構造体として返します。ast.File構造体のImportsフィールドに、ImportPathが格納されています。
type File struct {
...
Imports []*ImportSpec // imports in this file
...
あと書き
実は、自分は以前からimportを制約する方法を実現するためには、importを制約したいパッケージをモジュールとして切り出す、という方法が考えられます。他に良い方法があるんですかね?あったら知りたい!
ちなみに、「importを制約したいパッケージをモジュールとして切り出す」とは下記のような感じです。
(パッケージのimportを制約する方法1) 複数のGithubレポジトリを作る
import制約をかけたいパッケージを1つのGithubレポジトリに切り出して管理するという方法です。
例えば、クリーンアーキテクチャーの「依存性逆転の法則」を実現するために、下記のように、domain、usecase、infra層毎にレポジトリを作成します。
github.com/suzuito/demo001-domain
github.com/suzuito/demo001-usecase
github.com/suzuito/demo001-infra
...
この時、domain層からusecase層をimportを禁止したいとします。github.com/suzuito/demo001-domain から github.com/suzuito/demo001-usecase のソースコードのimportを直接禁止することはできませんが、go.modファイルを見れば一目でわかりますので、意図しないimportをコードレビューなどを通して簡単に防止することができます。
しかしながら、やってみるとわかりますが、この方法はすぐに破綻します。管理の手間が大きすぎるためです。domain層のレポジトリを更新後、usecase層のgo.modファイル中の github.com/suzuito/demo001-domain のバージョンを更新しなければなりません。とてつもなく面倒臭い。
(パッケージのimportを制約する方法2) 1つのGithubレポジトリ上で複数のGoのモジュールを作る
1つのレポジトリ上に複数のGoのモジュールを作成するという方法です。
例えば、クリーンアーキテクチャーの「依存性逆転の法則」を実現するために、下記のように、domain、usecase、infra層毎にGoモジュールを作成します。
github.com/suzuito/demo001 レポジトリ配下に複数のGoモジュールを作る
|
+- domain
| |
| + go.mod
|
+- usecase
| |
| + go.mod
|
+- infra
|
+ go.mod
それぞれのGoモジュールのgo.modファイル中では、replaceを利用します。
module github.com/suzuito/demo001/infra
go 1.21
...以下省略...
module github.com/suzuito/demo001/infra
go 1.21
replace github.com/suzuito/demo001/domain => ../domain
...以下省略...
module github.com/suzuito/demo001/infra
go 1.21
replace github.com/suzuito/demo001/domain => ../domain
replace github.com/suzuito/demo001/usecase => ../usecase
...以下省略...
この時、domain層からusecase層をimportを禁止したいとします。domain から usecase のソースコードのimportを直接禁止することはできませんが、go.modファイルを見れば一目でわかりますので、意図しないimportをコードレビューなどを通して簡単に防止することができます。
一見良さそうなのですが、この場合でもやはり、管理の手間が大きいです。3つのmodファイルを管理しなければならないためです。
Discussion