株式会社ヘンリーでSREなどをやっている戸田(id:eller)です。
私は先日より複数のブログやプレゼンで「Gradleプロジェクトを分割したいけどできてない」と言っております。 Gradleプロジェクト分割にはコードの置き場所が明確になってプログラマの心理的な負担を下げ、Gradleプロジェクトという単位で依存を管理することで不正な依存が入る余地を減らす利点があります。またKotlinコンパイルひいてはGradleビルドの高速化にも繋がるため、プロダクト品質や開発体験を向上するにあたって重要な施策だと考えています。
そして……とうとうやりましたよ!プロジェクト分割を!!!🎉 ということで今回はGradleプロジェクトを分割する際に配慮したことをまとめておきます。
そもそもGradleプロジェクト分割は何が難しいのか
Gradleに限らずMavenでもAntでも良いのですが、プロジェクトをあとから分割することは至難の業です。それは多くの場合、クラスやパッケージの依存関係が複雑かつ循環していることが多いためです。
Javaのウェブアプリケーションは古くから3層アーキテクチャなどの構造が知られており、構造化が進んでいるはずなのですが、現実は厳しいものです。個人的にも ArchUnit
を使ったりGradleのプロジェクト分割を使ったりして工夫してきましたが、新規プロジェクトはともかく既存プロジェクトは泥団子状態のことが多いです。
逆に言えば依存関係が整理されていれば、IDEやコンパイラを活用することでプロジェクト分割をスムーズに行えます。コイツが最初の山であり、ラスボスなのです。
import文ベースで粗く依存関係を見るところから着手
依存関係を見るのは、先のスライドで紹介した jdeps
による解析が簡単です。これで循環を見つけられればしめたものです。ひとつひとつ見ていき、これをほどいていきましょう。
ほどきかたは本当にケース・バイ・ケースなので、勝ちパターンのようなものはありません。気合と根性!です。ですがリファクタリングの一種ではあるため、広く知られているリファクタリング手法が適用可能です。参考書籍として自分からは、だいぶ古いですが「レガシーソフトウェア改善ガイド」をおすすめしておきます。
とはいえケース・バイ・ケースと言われてもイメージが湧かないかと思いますので、弊社の事例から単純なものを紹介します。
クラスを別パッケージに移動した事例
たとえば gRPC API で使うデータ型のための extension が見た目の循環を生んでいることがありました。次の依存関係はパッケージのレベルは循環していますが、クラスのレベルでは循環していません:
graph LR subgraph service FooService --> BarService BarExtension end subgraph model FooModel --> BarModel end FooService --> FooModel BarService --> BarModel FooModel --> BarExtension
この場合は、 extension の置き場を調整して循環を断ち切れます:
graph LR subgraph service FooService --> BarService end subgraph model FooModel --> BarModel BarExtension end FooService --> FooModel BarService --> BarModel FooModel --> BarExtension
プロジェクトもきちんと切り分けられそうです:
graph LR subgraph foo subgraph foo.service FooService end subgraph foo.model FooModel end end subgraph bar subgraph bar.service BarService end subgraph bar.model BarModel BarExtension end end FooService --> BarService FooModel --> BarModel FooService --> FooModel BarService --> BarModel FooModel --> BarExtension
もちろん移動することに不都合がないかどうかは検討が必要ですが、多くの場合は問題になりにくいのではないかと思われます。またこの過程で internal
修飾子を削除した箇所がそこそこあり、その影響があるかどうかも確認していました。
ドメイン外の概念を見つけてプロジェクトから追い出す
もうひとつ弊社の事例を紹介します。今回は一枚岩のプロジェクトからひとつのドメインに属する概念を取り出すことを目的としていました。理想的には次のようになってほしかったわけです:
graph LR subgraph 元一枚岩 FooService --> FooModel end subgraph 新プロジェクト BarService --> BarModel end FooService --> BarService FooModel --> BarModel
ところが実際には、新プロジェクトのクラスが依存する「新プロジェクトには属するべきではないクラス」が存在します。 これを新プロジェクト内に保持してしまうと、新プロジェクトがまた新たな一枚岩になってしまうリスクがあると感じました:
graph LR classDef hoge fill:orange; subgraph 元一枚岩 FooService --> FooModel end subgraph 新プロジェクト BarService --> BarModel --> HogeEntity:::hoge --> HogeValueObject:::hoge end FooService --> BarService FooModel --> BarModel FooModel --> HogeEntity
よってこの「あるべきではないクラス」を切り出すための別のGradleプロジェクトを作成し、そこに該当するクラスを追い出しました:
graph LR classDef hoge fill:orange; subgraph 元一枚岩 FooService --> FooModel end subgraph 新プロジェクト BarService --> BarModel end subgraph shared HogeEntity:::hoge --> HogeValueObject:::hoge end BarModel --> HogeEntity FooService --> BarService FooModel --> BarModel FooModel --> HogeEntity
この新しく作成した shared
プロジェクトには、今回対象としなかったドメインに属するクラスが入ってきます。プロジェクト間で共有されるためのクラスやユーティリティであれば問題ないのですが、意図せず入ってきてしまったクラスもいくつか見つかりました。今後のリファクタリングで正しい居場所に動かす必要がありそうです。
Gradleプロジェクト分割による効果
当施策の定量的効果として、Gradleビルド高速化効果がどの程度あったのかを見てみました。1週間ほど観察しましたが、残念ながらGitHub Actionsにおけるビルド高速化効果は確認できませんでした。 理屈の上では、sharedプロジェクトないし今回切り出したプロジェクトに変更が入らなければ、それらに属するコードのコンパイルとテストを実施しなくて済むはずなんですけどね。
遠目に見ると20~30秒くらい早くなったかな?という気はしますが明らかな改善ではないですし、試行回数が少ないこともあるためしばらく様子を見たほうが良さそうです。
一方で定性的効果としては、プロジェクト分割による見通しの良さが評価されています。以前から package を使って整理を試みていましたが、package では依存関係の制約が緩く、意図しない依存が混ざることがありました。 プロジェクトを分割することで、こうした依存が明確に洗い出され、今後も意図しない依存の混入が起こりにくくなる効果が期待されています。
まとめ
以上で今回実施したGradleプロジェクト分割の概要を説明しました。依存の整理さえできれば問題ないこと、依存のほどきかたはケース・バイ・ケースだがリファクタリング手法が適用できること、プロジェクト分割によって新たなリファクタリング課題が見つかることについて触れました。
定量的効果であるビルド高速化効果はまだ確認できていませんが、定性的効果としての見通しの良さは package による整理よりも高い成果が得られたと考えています。 製品開発のスケールしやすさを確保するためにも、今後も分割を進めていこうと考えています。
ヘンリーでは各種エンジニア職を積極的に採用しています。医療ドメインに興味がある方も、GradleやSREに興味がある方も、ぜひカジュアル面談でお話させていただければと思います。よろしくお願いいたします!