🛫

goのパッケージ間の依存を定量化するツールを作った話

2023/03/22に公開

こんにちは、DMM.com プラットフォーム事業本部 マイクロサービスアーキテクトグループの n9te9 です。普段は、DMMの認証認可に関する仕事をしています。

Goのパッケージ間の依存関係を定量的に評価するツールを作りました。
今回の記事は、このツールを使ってどのようなことができるのかと、このツールを実装するにあたってGoのoverlayオプションやgolang.orgが提供しているanalysisパッケージについて理解が深まったのでそれらをまとめていきます。

作成したツール(jackall)について

https://github.com/lkeix/jackall

jackallは、Goのプロジェクトの中で実装したパッケージ同士がどの程度依存し合っているのかを定量的に評価してくれるツールになります。
下記のようにGitHub Actionsでtestdataを対象にしたパッケージ間の依存度を評価した結果があります。

https://github.com/lkeix/jackall/actions/runs/4304895237/jobs/7506568258

calculateのタブを展開することで、各パッケージの依存度が0から1の範囲で出力されていることがわかります。0に近いほどパッケージは安定しており、1に近いほどパッケージが不安定であるという評価の仕方になります。
このツールを作るにあたって、クリーンアーキテクチャ本のパッケージ間の依存を定量化して依存が正しいかどうかを判断するという内容を参考にして実装していきました。クリーンアーキテクチャ本にも同様な記載がありますが、全てのパッケージの依存度が0に近いほどソフトウェアの品質が高く、1に近いほどソフトウェアの品質が低いということではありません。パッケージ毎に適切な依存度を持っていることが重要なので、パッケージ間の依存が良い悪いの判断は開発者に委ねられることになります。ただ、コードベースが大きくなるとパッケージ間の依存が把握しづらくなるので、そのような場合にjackallが役立ってくると思います。

ツールの使い方

GOPATHが設定されておりGOBINへのパスが通っていることが前提となります。
下記のコマンドのようにリポジトリをcloneして、make buildすることで使うことができます。

$ git clone https://github.com/lkeix/jackall
$ cd jackall
$ make build

このツールの使い方は、下記のようにプロジェクトのルートディレクトリに移動し実行することで使うことができます。

$ cd /path/to/project
$ jackall ./...

go installで導入ができなかったのかについては、後述する実装の中で解説していきます。

実装

import文を解析しパッケージ間の依存のベクトルを抽出して、そのベクトルをもとに0から1に正規化するという処理になっています。

この依存度を計測は、以下のような手順で行っています。

  1. singlechecker.Mainを置き換えるスクリプトを生成する
  2. 依存度を計算する際に、1で生成したsinglechecker.Mainをoverlayで置き換える

2段階で行うのは、下記のような背景がありました。

singlecheckerの中でos.Exitが実行されている

analysisパッケージのsinglechecker.Mainは、解析終了時にos.Exitを呼んでいます。os.Exitが呼ばれている理由としては、singlechecker.Mainはコマンドラインツール全体のジョブをここで完結させたいというものがあります。
確かに、パッケージ内のコードのASTを静的解析して特定のコードのパターンを検知するといった場合はos.Exitしても処理の対象がパッケージ内でスコープが閉じられるので問題ないのですし、この解析をコマンドラインツールのジョブとして考えれば理にかなっている設計です。

ただ、今回のようにパッケージ全体のgoファイルから値を抽出した後、抽出した値を元に正規化するといった場合は、singlechecker.Mainの中で実行しているos.Exitが実装を難しくしています。singlechecker.Mainでは各ファイルやパッケージ間の依存のベクトルを抽出し、そのベクトルを元に各パッケージの依存度を0~1に正規化します。そのため、抽出終了段階でos.Exitが呼ばれると後続の正規化の処理の実行ができなくなってしまいます。

singlechecker.Mainを上書きする

singlechecker.Mainのos.Exitを実行しない方向で対応できないか考えました。goのoverlayオプションでsinglechecker.Mainを上書きすると言う方法で実現できそうだったので、この方法で対応しました。overlayオプションで既存のコードを上書きする方法は、tenntennさんのtesttimeを参考にしました。

singlechecker.Mainを上書きするコードを生成するスクリプトは、replace_singlechecker/replace_singlechecker.goに実装しています。置き換えるコードは、_partials/replace_singlechecker.goに記述しており、ここからgoembedで文字列として取得するようにしています。astやtokenなどのパッケージを利用してsinglechecker.Mainの中身を埋め込まれた文字列に置き換えています。

replace_singlecheckerの中では、既存実装のMainの関数名を_Mainに変更し、埋め込まれた文字列に含まれるMainに置き換えています。overlayオプションではjson形式でパッケージ名と置き換えるコードを指定するので、replace_singlecheckerの最終的な出力は、json形式でパッケージ名と置き換える文字列を出力するようにしています。jackallのビルド時にreplace_singlecheckerを実行しoverlayに渡すことで、os.Exitを実行しないsinglechecker.Mainを含んだバイナリを吐き出します。結果として、依存の抽出が終わった後に終了せず、次の処理である依存度の計測を実行することができるようになります。

このように jackall のバイナリの作成時にoverlayを適用する必要があるため、go installではインストールできないようになっています。

依存度の計測

依存度の計測は下記手順で行っています。

  1. import文から依存しているパッケージを抽出する
  2. 1を元に依存度の計測を行う

パッケージ間の依存のベクトルの抽出処理は、各パッケージに含まれるgoファイルのimport文を走査し、依存のベクトルを表現する構造体に格納しています。ベクトルを表現する構造体はシンプルで、依存元のfromと依存先のtoを持ちます。依存のベクトルを抽出を実行するRun関数をanalysis.AnalyzerのRunに設定します。ここに設定されたRun関数は、singlechecher.Mainの内部で実行されます。実行後に依存のベクトルのスライスを取得したいので、Run関数をwrapしてwrap時に渡す依存のベクトルのスライスに反映させるようにします。値をwrapRunの引数に副作用が伴うのであまり好ましくないですが、今回はこれで実装しています。

func wrapRun(vec *dependenceVecs) func(pass *analysis.Pass) (interface{}, error) {
	return func(pass *analysis.Pass) (interface{}, error) {
		// fset := pass.Fset

		for _, f := range pass.Files {
			for _, imprt := range f.Imports {
				name := extractImportPackageName(imprt.Path.Value)
				*vec = append(*vec, &dependenceVec{
					from:  f.Name.Name,
					to:    name,
					toRaw: strings.ReplaceAll(imprt.Path.Value, "\"", ""),
				})
			}
		}

		return nil, nil
	}
}

このvecには依存元のパッケージ from と依存先のパッケージ to の情報を持った構造体のスライスが格納されるため、このデータを元にパッケージ間の依存関係を数値として扱いやすいように変換する処理を実装しています。

func (d dependenceVecs) extractVecEachPackage() map[string]*dependencePair {
	mp := make(map[string]*dependencePair)
	for _, vec := range d {
		p, ok := mp[vec.from]
		if !ok {
			p = &dependencePair{
				in:  0,
				out: 0,
			}
		}

		p.out++
		mp[vec.from] = p

		p, ok = mp[vec.to]
		if !ok {
			p = &dependencePair{
				in:  0,
				out: 0,
			}
		}

		p.in++
		mp[vec.to] = p
	}

	return mp
}

イメージとしては、下記のようになります。
toとfromを持った構造体のスライスを元に各パッケージがどのパッケージから依存しているのか、依存されているのかを数えています。

ここまでできたら、パッケージ毎に依存しているパッケージ数、依存されているパッケージ数が簡単に扱えるので、{依存しているパッケージ数} / ({依存されているパッケージ数} + {依存しているパッケージ数}) の数式で依存度を計算します。

for name, r := range vec.extractVecEachPackage() {
	fmt.Printf("degree of dependency in %s package: %.4f\n", name, float64(r.out)/float64(r.in+r.out))
}
fmt.Printf("the closer degree of dependency is 1, the less stable(unstable) package is\n")
fmt.Printf("the closer degree of dependency is 0, the more stable package is\n")

これを実行すると下記のような出力が得られます。

degree of dependency in main package: 1.0000
degree of dependency in fmt package: 0.0000
degree of dependency in echo package: 0.0000
degree of dependency in entity package: 0.0000
degree of dependency in usecase package: 0.5000
the closer degree of dependency is 0, the more stable package is
the closer degree of dependency is 1, the less stable(unstable) package is

パッケージの依存度を計測することができたのですが、fmtやechoといったパッケージは基本的に依存度は0として扱われるので、依存度を計測する際には除外した方が良いです。
これらの標準パッケージやgo getしたパッケージを除外する処理を実装します。

標準パッケージを除外する実装は以下のようになります。
GOROOT直下のsrcディレクトリにあるパッケージをimportできるかどうかで標準パッケージかどうかを判定しています。srcディレクトリにあるパッケージは標準パッケージとして扱われるので、これを利用して標準パッケージを除外しています。

func removeStdPackages(vecs dependenceVecs) (dependenceVecs, error) {
	res := make(dependenceVecs, 0)
	srcDir := filepath.Join(runtime.GOROOT(), "src")

	for _, vec := range vecs {
		if _, err := build.Default.Import(vec.to, srcDir, 0); err != nil {
			res = append(res, vec)
		}
	}
	return res, nil
}

次に、go getで取得するパッケージを除外する処理は、以下のような実装になります。標準パッケージを除外する処理とは異なり、go listで取得したパッケージの一覧を元に除外するようにしています。

func removeThirdPartyPackages(vecs dependenceVecs) (dependenceVecs, error) {
	cmd := exec.Command("go", "list", "-m", "all")
	out, err := cmd.Output()
	if err != nil {
		return nil, err
	}

	rows := strings.Split(string(out), "\n")
	pkgs := make([]string, 0)
	for _, row := range rows {
		col := strings.Split(row, " ")
		if len(col) == 2 {
			pkgs = append(pkgs, col[0])
		}
	}

	return vecs.filter(pkgs), nil
}

これらを依存度を計測する前に実行するようにして、標準パッケージやgo getで取得したパッケージを除外した場合、以下のような出力になります。echoやfmtパッケージが出力されていないため、アプリケーション自体のパッケージの依存度だけを計測することができるようになりました。

degree of dependency in main package: 1.0000
degree of dependency in entity package: 0.0000
degree of dependency in usecase package: 0.5000
the closer degree of dependency is 1, the less stable(unstable) package is
the closer degree of dependency is 0, the more stable package is

今後について

今回は依存度を計測するためのツールを作成しました。
これによってチーム開発する際や、個人開発においてパッケージ間の依存がどの程度あるのかを把握しやすくなったと思います。また、このツールによって開発しているアプリケーションの設計を見直すきっかけや、チームで開発する際にどのような設計にすれば良いのかを考えるきっかけになれば良いかなと思います。

定量的に依存度を測ることはできるようになりましたが、パッケージの中のファイルの粒度で依存しているのかを可視化することはできていません。DDDなどを採用している場合、このあたりにおいてファイルの粒度までみれた方が集約が適切であるかどうかといった部分の見直しの参考になると思うので、この辺りも実装していきたいです。
現状は、定量化するだけで不安定なパッケージに依存する安定したパッケージの検知などはできていないため、この辺りも実装していこうと思います。また、内部の実装でパッケージ周りのパスなどについてマジックナンバーで指定している箇所などがあったりするのでその辺りも今後修正していきたいと思います。

まとめ

Goのlinterのツールは多く存在しますが、自分が調べた限りでは依存度を計測するツールは存在しなかったので、自分で作成しました。
開発の最初は、簡単に実装できるかなと思っていました。ただ、開発を進めていくうちに、singlecheckerの内部でos.Exitを呼び出していたり、パッケージの依存関係を取得するためにgo listを呼び出していたりと、意外と一筋縄で実装できないなっていう印象を受けました。このあたりのパッケージやツールに関してまだ知識が足りていないからかもしれませんが、もっと良いツールなどあれば簡単に実装できるのかなと思いました。

ただ、このツールを作るにあたって、singlecheckerの理解やoverlayを適用する際はどのように実装すれば良いかなどの知識が深まったため良かったです。

Discussion