😀

パッケージの依存関係を検証するコマンドを自作した

2023/11/24に公開

概要

本記事は自作したコマンドの紹介です。

https://github.com/suzuito/import-checker

パッケージの依存関係を静的解析するコマンドを作りました。このコマンドの利用者は、あるパッケージが別のパッケージをimportすることを禁止することを意味するルールを定義します。コマンドは、モジュールがルールを満たしているかどうかを検証します。

ルール定義はYAML形式で記述します。

rules.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)を作成するためのものです。

https://pkg.go.dev/go/parser

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を利用します。

domain/go.mod
module github.com/suzuito/demo001/infra

go 1.21

...以下省略...
usecase/go.mod
module github.com/suzuito/demo001/infra

go 1.21

replace github.com/suzuito/demo001/domain => ../domain

...以下省略...
infra/go.mod
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ファイルを管理しなければならないためです。

注釈

脚注
  1. 本当は、本記事の主題を「AST周りを勉強した」にするはずだった。。。 ↩︎

  2. ImportPathが、Goのソースコードからimport文によりimportされることができます。import文は、ImportPath上にあるGoのパッケージをimportします。ImportPathとパッケージは区別した方が良さそうです。なぜなら、ディレクトリ名とパッケージ名を一致させる必要はないため。参考 ↩︎

Discussion