File NestingでまとめるGoのディレクトリ構成
Goで大規模Webアプリケーションを開発するにあたって悩ましいのはディレクトリ構成です。
世の中にはさまざまなプラクティスに溢れているものの、Goはその言語特性から他言語のプラクティスをそのまま適用しづらい面があります。
本記事では一般的なディレクトリ構成のパターンをまとめつつ、その上でFile Nestingを駆使したGoプロジェクトのディレクトリ構成を提案していきます。
一般的なディレクトリ構成パターン
技術単位
まず一番よくみるパターンとしてあるのが、技術的な分類ごとに配置する考え方です。
たとえばレイヤードアーキテクチャにおいてレイヤーごとにディレクトリを分ける場合、この構成パターンに基づいていると言えます。
├ service/
│ ├ order_service.go
│ └ purchase_service.go
├ repository/
│ ├ order_repository.go
│ ├ invoice_repository.go
│ └ payment_repository.go
├ entitiy/
│ ├ order.go
│ ├ invoice.go
│ └ payment.go
└ value_object/
├ order_id.go
└ invoice_id.go
実際にGoの記事やサンプルリポジトリを見てもこの構成パターンに基づくものが多く見られますし、RailsやLaravelなど多くのフレームワークも初期の構成がこのパターンに紐づいていることが多いです。
一方でこの配置パターンには、一方で関連性の近いファイルが遠くに配置されることでファイル同士の関連性が見えづらくなるという批判もあります。
たとえば書籍『良いコード/悪いコードで学ぶ設計入門 ―保守しやすい 成長し続けるコードの書き方』では、これを技術駆動パッケージングと呼びアンチパターンとしています。
機能単位
そこで出てくるのが機能単位の分類です。
いわゆるコロケーションという考え方で、機能単位で配置することで関連するリソース同士を近くに置くことができ、高い凝集度を保つことができます。
├ order/
│ ├ order_service.go
│ ├ order_repository.go
│ ├ order.go
│ └ order_id.go
└ purchase/
├ purchase_service.go
├ invoice.go
├ invoice_id.go
├ payment.go
├ invoice_repository.go
└ payment_repository.go
複合パターン
もちろんこれらの複合パターンも考えられます。
たとえばfeatures以下に機能別でファイルを配置しつつ、機能共通で使われるモジュールはトップレベルで機能ごとに配置する方法はフロントエンドだと多く採用されるパターンです。
またトップレベルは機能ごとに分けつつサブディレクトリで技術単位で区切るなどの組み合わせも考えられます。
├ components/
│ └ button
├ hooks/
│ └ use-toggle
├ utils/
└ feature/
└ some-feature/
├ hooks/
├ utils/
└ component/
Goにおける分割単位を考える
さて、ではGoのプロジェクトにおいて上記の前提をもとに何も考えずに選べば良いかというと、なかなかそうはいきません。
Goでは、ディレクトリはパッケージに紐づきます。
つまり視認性だけを意識してディレクトリを切ることはできず、Goのパッケージの特性踏まえて適切か意識しつつディレクトリ構成を考える必要が出てくるからです。
機能単位
まず機能単位の構成パターンにおいて特に問題になるのが循環参照の禁止です。
Goでは、パッケージAのいずれかの構造体や関数が他のパッケージBに依存している時点でパッケージAはパッケージBに依存しているとされます。
さらに「A → B → C → A」のようなケースも循環参照とされてしまいます。
機能間の依存関係は必ずしも単方向になるとは限りません。機能Aは機能Bに依存し、機能Bは機能Aに依存することは往々にしてあり得ます。
もちろんinterfaceを介して機能パッケージ間の依存を無くすなど対策は考えられますが、何も対処せずにただディレクトリを分けるだけだと循環参照を回避するために余計にごちゃごちゃした分類になってしまう恐れがあります。
技術単位
レイヤードアーキテクチャはレイヤー間の依存関係を明確にする考え方です。
したがってレイヤー単位でパッケージを分けるのであれば循環参照の禁止は制約にならないどころか、正しいアーキテクチャを保つための助けになります。
そのためGoの循環参照制約を考えると、レイヤーごとにパッケージを分けるのは良い選択に感じられます。
一方でサービス内のあらゆる機能のファイルが1つのパッケージに集まるため、ファイルが膨大になり視認性が下がる問題があります。
1つのディレクトリに何十、何百とファイルが配置してしまうとレイヤー間だけでなく、レイヤー内における関連性もわかりづらくなってしまいます。
サブディレクトリで区切る
そうなったときよくある対処法として、サブディレクトリで整理する方法があります。
トップレベルは技術単位で区切りつつ、サブディレクトリで機能単位でまとめる方法です。
しかしこの場合サブディレクトリごとにパッケージが生まれてしまうため、機能間に依存がある場合再度循環参照の問題に悩まされることとなります。
├ service/
│ ├ order/
│ │ └ order_service.go
│ └ purchase/
│ └ purchase_service.go
└ repository/
├ order/
│ └ order_repository.go
└ purchase/
├ invoice_repository.go
└ payment_repository.go
またパッケージ名も意識しないと同じ命名のものが複数生まれます。
もちろんそれ自体に問題はないですし、package orderrepository
などそれぞれユニークな命名にすることもできます。
しかしパッケージをimportする際に選ぶ必要が出てきたり、複数同名パッケージをimportする際に命名を変更する必要が出てきたり、気軽にディレクトリを切りすぎると開発体験は低下することになってしまいます。
File Nestingを用いたディレクトリ戦略
過度にパッケージを分けすぎず、かつ視認性を落としたくない。
そこでFile Nesting機能の登場です!
File NestingはVS Codeの機能[1]で、関連するファイルを1つにまとめ折りたたみ表示可能にする機能です。
テストコードとテスト元ファイルをまとめたり、go.mod
とgo.sum
をまとめるのがよくある使い方ですね。
今回はそれを用いて関連ファイルをグルーピングすることで、同一パッケージ内で擬似的にディレクトリを切っていきます。
File Nestingの設定はSettingsのFeatures/Explorer
にあります。
そこでItemを*.md
に、Valueを${capture}.go, ${capture}_*.go
で指定します。
そしてまとめたい単位ごとにmdファイルを作成し、そこに対応するGoファイルをそのmdファイルの命名から始まるように命名します。
するとどうでしょう、同一パッケージでありながら擬似的にディレクトリを切ったような挙動にすることができました!
良い点
この方法の良い点として、まず同一パッケージのまま自由にグルーピングを整理できる点にあります。
たとえばuser.md
からnotification.md
にファイルを移動させる場合も既存のコードの書き換えなくファイルの改名だけですみます。
またmdファイルでファイルをまとめているため、そこに説明が書けるのも便利な点です。
課題点
とはいえ課題点もあり、ファイル名のPrefixでグルーピングするためファイルの名前がどうしても長くなってしまいがちです。
ですので場合によってはチームで短縮語を決めてそれで統一するのも良いかもしれません。
多少わかりづらい短縮語でも、mdファイルに説明文が書けば迷うことはないでしょう。
おわりに
というわけでFile Nestingを用いて同一パッケージ内でファイルを整理する方法を提案しました。
File Nestingでまとめれば、そもそもフラットパッケージのまま長く開発を進めていくのもありかもしれません。
その上で具体的にどういうディレクトリ構成にするかは別の記事でまとめていければと思います!
-
GoLandにもありそうですが、詳しくないので言及は避けておきます。 ↩︎
Discussion