godepgraphを駆使して、Goプロジェクトの循環参照を突破せよ

GMOメイクショップでエンジニアをしている黒木です。

22年に新卒で入社し、Goでバックエンドの開発をしています。

入社後にGoを学び始めて、学びたての頃は、よく循環参照のエラーを起こしていました。

この経験を踏まえて、Goを学びたての頃に、これを知っていれば、循環参照は怖くないという思いを元に、循環参照が起きた場合、どこが原因で循環参照が起きているかの特定方法と、解決法をご紹介いたします。

ゴール

Goを使っている方なら一度は、import cycle not allowed というエラーに出会ったことはないでしょうか。

このエラーは、パッケージが互いに参照し合っているため発生します。

とは言っても、Goを学びたての頃は、どこが原因なの? ということが多々あると思います。今回は、パッケージ間の依存関係をグラフ化することで、どこが原因かを特定して、循環参照を解決してみたいと思います。

どうやるか

godepgraphというツールを使って、プロジェクト内の依存関係をグラフ化します。その後、グラフから循環参照の原因を特定し、循環参照を解消するところまでやってみます。

github.com

まず始めに

循環参照とは、複数のパッケージが互いに参照し合うことです。パッケージ間の依存関係を有向グラフで表した際に、始点と終点が同じになる経路、いわゆる閉路が存在すると、循環参照が発生します。

循環参照が起こる例を挙げると、以下のような状態になります。packageA を始点として、packageB を経由し、packageA が終点となります。始点と終点が同じ状態です。

このような状態でも、循環参照が発生します。packageA を始点として、 packageB、packageC を経由し、packageA が終点となります。

反対に、循環参照が起こらない例は、以下のような状態になります。packageAを始点、packageBが終点となっているような、始点と終点が異なる状態です。

循環参照が起こるコードを書いてみる

今回は、packageA・packageB・packageCを定義し、packageA → packageB → packageC → packageAのような依存関係があるコードを実装します。

import-cycle-exampleというモジュールを作り、進めていきます。

$ go mod init import-cycle-example

まずは、packageA/packageA.goを定義します。

package packageA

import (
    "import-cycle-example/packageB" // この後packageBを定義します
)

func Called() {
    packageB.Called()
}

続いてpackageB/packageB.gopackageC/packageC.goを定義します。

package packageB

import (
    "import-cycle-example/packageB"
)

func Called() {
    packageC.Called()
}
package packageC

import (
    "import-cycle-example/packageA"
)

func Called() {
    packageA.Called()
}

そして最後にmain.goを定義し、まずはpackageAのCalled()をコールします。

package main

import (
    "import-cycle-example/packageA"
)

func main() {
    packageA.Called()
}

プログラムを実行すると、循環参照が起こりました。

$ go run main.go                                              
package command-line-arguments
        imports import-cycle-example/packageA
        imports import-cycle-example/packageB
        imports import-cycle-example/packageC
        imports import-cycle-example/packageA: import cycle not allowed        imports import-cycle-example/packageA: import cycle not allowed

依存関係をグラフ化して解決する

小規模なプロジェクトであれば、依存関係をグラフ化して、解消するのがおすすめです。 まずは、依存関係のグラフ化のためにgodepgraphをインストールします。

$ go install github.com/kisielk/godepgraph@latest

インストールが完了したら、プロジェクト配下で依存関係をグラフ化します。godepgraph モジュール名 | dot -Tpng -o グラフを出力するファイル名に沿って、コマンドを実行します。

$ godepgraph import-cycle-example | dot -Tpng -o import-cycle-graph.png

出力されたグラフは以下のようになります。

このグラフから、packageA → packageB → packageC → packageAのような依存関係になっていることが分かります。

packageAが始点となり、packageB、packageCを経由して、packageAが終点になる経路になっていることから、循環参照していることがわかります。

今回は、以下のようにpackageCからpackageAの参照を削除して、閉路にならないようにします。

packageC/packageC.goを以下のように修正します。

package packageC

func Called() {
    //packageA.Called()
}

プログラムを実行すると、エラーが消えて、循環参照が解消されました。

$ go run main.go
$

再度依存関係を、グラフ化してみましょう。

$ godepgraph import-cycle-example | dot -Tpng -o import-cycle-fixed-graph.png

packageAが始点となり、packageCが終点になっているので、循環参照が解消されたことがわかります。

一方で、大規模なプロジェクトでこの方法を使用すると、どうなるでしょうか。弊社のmakeshopというプロダクトで、依存関係をグラフ化してみます。

パッケージ数が多いため、グラフから依存関係を把握するのは、得策ではないように感じます。

循環参照が起こないようにするには

ルールに沿ってコーディングすれば、循環参照が起きることはなくなります。その一例として参考までに、クリーンアーキテクチャという設計思想があります。

弊社のmakeshopというサービスでは、クリーンアーキテクチャの思想に習って、ディレクトリが組まれています。

クリーンアーキテクチャは、プロダクトをいくつかのレイヤーに分類します。外側のレイヤーから内側のレイヤーへの依存だけが許可されているので、双方向に依存し合うことはありません。

出典:https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

したがって、このルールにしたがってコーディングできれば、大規模なプロジェクトでも循環参照が起きることはなくなります。

まとめ

循環参照は、Goを学びたての頃にはよくつまづくところですが、原因がわかれば怖くありません。

今回紹介したこの方法以外にも、循環参照を解決する方法はたくさんありますが、少しでもご参考になれば幸いです。