じゃんけんアドベントカレンダー の 8 日目です。
前回で「3 層 + MVC + トランザクションスクリプト」という構成になりました。
そして、まだまだコードには色々な課題があることを挙げました。
今回はまず、3 層アーキテクチャにおける依存の向きの問題を解決しようと思います。
現状の課題
現状の構成をもう一度見てみると、以下のようになっています。
また、JankenService は以下のようなコードになっています。
public class JankenService {
private JankenCsvDao jankenCsvDao = new JankenCsvDao();
private JankenDetailCsvDao jankenDetailCsvDao = new JankenDetailCsvDao();
:
このように、現状では JankenService が JankenCsvDao と JankenDetailCsvDao に直接依存しています。
本来的には、サービスのコアであるビジネスロジックが、技術的な詳細であるデータアクセスに依存するべきではありません。
そこで、ビジネスロジック層とデータアクセス層の依存の向きを逆転してみます。
まずは ArchUnit で自動テスト
早速コードを直したくはありますが、コードを直す前に自動テストを書きたいところです。
そこで、ArchUnit を使い、パッケージの依存の向きの自動テストを記述しようと思います。
まずは build.gradle に ArchUnit を追加します。
dependencies {
:
testImplementation 'com.tngtech.archunit:archunit-junit5:0.14.1'
}
build.gradle に追加したら、
- プレゼンテーション層が App.java を置いている com.example.janken 以外のどこにも依存されていないこと
- データアクセス層がどこにも依存されていないこと
のテストを記述します。
@AnalyzeClasses(
packages = com.example.janken.ArchitectureTest.ROOT_PACKAGE,
importOptions = {ImportOption.DoNotIncludeTests.class})
public class ArchitectureTest {
// アノテーションから指定可能にするため package private
static final String ROOT_PACKAGE = "com.example.janken";
private static final String PRESENTATION_LAYER = ROOT_PACKAGE + ".presentation..";
private static final String BUSINESS_LOGIC_LAYER = ROOT_PACKAGE + ".businesslogic..";
private static final String DATA_ACCESS_LAYER = ROOT_PACKAGE + ".dataaccess..";
@ArchTest
public static final ArchRule レイヤーの依存の向きが設計通り = Architectures
.layeredArchitecture()
.layer(PRESENTATION_LAYER).definedBy(PRESENTATION_LAYER)
.layer(BUSINESS_LOGIC_LAYER).definedBy(BUSINESS_LOGIC_LAYER)
.layer(DATA_ACCESS_LAYER).definedBy(DATA_ACCESS_LAYER)
.layer(ROOT_PACKAGE).definedBy(ROOT_PACKAGE)
.whereLayer(PRESENTATION_LAYER).mayOnlyBeAccessedByLayers(ROOT_PACKAGE)
.whereLayer(DATA_ACCESS_LAYER).mayNotBeAccessedByAnyLayer();
}
実行すると、現状ではビジネスロジック層やプレゼンテーション層がデータアクセス層に依存しているため、テストが失敗しました。
$ ./gradlew test
:
ArchitectureTest > レイヤーの依存の向きが設計通り FAILED
java.lang.AssertionError at ArchRule.java:94
:
このように、ArchUnit を使えばパッケージの依存の向きを簡単に自動テストできます。
他にも、循環依存がないことをテストしたり、継承関係にあるクラスの命名規則をテストするなど、様々なテストが記述可能です。
レイヤーの依存の向きは、Gradle や Maven のマルチプロジェクト機能を使って保証されることも多いです。
しかし、マルチプロジェクト機能を今回のように途中から導入するのは少し大変なので、そういった場合は ArchUnit がちょうどいいかもしれません。
参考
Model をビジネスロジック層に移動する
さて、これでパッケージ構成を修正する準備が整いました。
まずは Model をビジネスロジック層に移動してみます。
これで、service -> model の線がビジネスロジック層の中に移り、ビジネスロジック層からデータアクセス層に向かう線が 1 つなくなりました。
Service から CsvDao への依存の向きの問題を解決する
さて、service -> model の依存の問題を解決するのは簡単でしたが、service -> csvdao の依存を解決するには一工夫必要です。
具体的な解決策としては、ビジネスロジック層に DAO のインタフェースを設けます。
このようにして依存の向きを逆転する手法は、書籍『Clean Architecture 達人に学ぶソフトウェアの構造と設計』などで解説されています。
これにより、サービスのコードは以下のようになりました。
public class JankenService {
private JankenDao jankenDao = new JankenCsvDao();
private JankenDetailDao jankenDetailDao = new JankenDetailCsvDao();
:
ですが、実はこれでは ArchUnit で書いたテストは通過しません。
これでは結局 new JankenCsvDao()
というコードがサービス内にあるので、依存の向きをちゃんと整理しきれていないのです。
どこで new するのか問題
このように、Controller や Service といったクラスが依存する先をどこで new するかには、主に 2 つの解決策があります。
- Dependency Injection (DI) パターン
- Service Locator パターン
の 2 つです。
この 2 つの比較についてはすでにたくさん記事があるので、ここでは詳しく解説しません。
結論としては、ServiceLocator より DI の方が優れていると言われる場合が多いです。
ですが、DI はライブラリ・フレームワークを使わないとコード量が結構多くなってしまうので、ひとまず ServiceLocator を導入しようと思います。
ゆくゆく Spring Framework を導入予定なので、その際に DI に切り替えようと思います。
ServiceLocator の導入
今回実装する ServiceLocator は、以下のように使えるようにします。
// インタフェースと実装クラスを登録する
ServiceLocator.register(JankenDao.class, JankenCsvDao.class);
// 登録済みのクラスを取り出す
JankenDao jankenDao = ServiceLocator.resolve(JankenDao.class);
実装
上記の呼び出し方を踏まえて、以下のように実装しました。
public class ServiceLocator {
private static Map<Class<?>, ClassInstancePair> registry = new HashMap<>();
/**
* インタフェースと実装クラスを登録します。
* <p>
* 登録時点ではインスタンスは作成しません。
*/
public static <T, U> void register(Class<T> interfaceClass, Class<U> implementationClass) {
val classInstancePair = new ClassInstancePair(implementationClass, null);
registry.put(interfaceClass, classInstancePair);
}
/**
* インタフェースを指定して実装クラスを取得します。
* <p>
* 初めて取得されるクラスの場合、この時点で引数のないコンストラクタによってインスタンスが生成されます。
*/
@SuppressWarnings("unchecked")
public static <T> T resolve(Class<T> interfaceClass) {
val classInstancePair = registry.get(interfaceClass);
// インスタンス未作成の場合、作成して登録する
if (!classInstancePair.existInstance()) {
val newClassInstancePair = classInstancePair.ofInstanceCreatedByNoArgsConstructor();
registry.put(interfaceClass, newClassInstancePair);
}
return (T) registry.get(interfaceClass).getInstance();
}
}
@AllArgsConstructor
class ClassInstancePair {
private Class<?> clazz;
@Getter
private Object instance;
boolean existInstance() {
return instance != null;
}
ClassInstancePair ofInstanceCreatedByNoArgsConstructor() {
try {
val constructor = clazz.getConstructor();
val instance = constructor.newInstance();
return new ClassInstancePair(clazz, instance);
} catch (NoSuchMethodException
| InstantiationException
| IllegalAccessException
| InvocationTargetException e) {
throw new RuntimeException(e);
}
}
}
今回は、resolve でインスタンスを取り出す際に必要があれば new するようにしています。
これは、登録時に new してしまうと登録する順番に気を付けないといけなくなる場合があるためです。
なお、ServiceLocator の実装は以下の記事を参考にしました。
利用側
ServiceLocator を作成したので、利用する側も実装しようと思います。
依存解決の設定は main クラスですることにしました。
public class App {
public static void main(String[] args) {
// 依存解決の設定
ServiceLocator.register(JankenController.class, JankenController.class);
ServiceLocator.register(PlayerService.class, PlayerService.class);
ServiceLocator.register(JankenService.class, JankenService.class);
ServiceLocator.register(PlayerDao.class, PlayerCsvDao.class);
ServiceLocator.register(JankenDao.class, JankenCsvDao.class);
ServiceLocator.register(JankenDetailDao.class, JankenDetailCsvDao.class);
// 実行
ServiceLocator.resolve(JankenController.class).play();
}
}
登録されたクラスのインスタンスを取得する箇所は以下のようになっています。
public class JankenController {
:
private PlayerService playerService = ServiceLocator.resolve(PlayerService.class);
private JankenService jankenService = ServiceLocator.resolve(JankenService.class);
:
public class JankenService {
private JankenDao jankenDao = ServiceLocator.resolve(JankenDao.class);
private JankenDetailDao jankenDetailDao = ServiceLocator.resolve(JankenDetailDao.class);
:
これにより、ArchUnit の自動テストも通るようになりました。
4 層へ
これで、ビジネスロジックがデータアクセスに依存するべきではない、という問題は解決しました。
せっかくなので、このタイミングで最近よく見る 4 層構成にしてみようと思います。
パッケージ名も 4 層でよくある
- presentation
- application
- domain
- infrastructure
という名前に変更しました。
この階層構造は、オニオンアーキテクチャ、ヘキサゴナルアーキテクチャ、クリーンアーキテクチャなどを採用した際に見られるものと近くなっています。
ところで、現状のプログラムでは、ビジネスロジックをデータと処理を分離した「トランザクションスクリプト」パターンで実装しています。
本来 4 層構成にする際は、ビジネスロジックを「トランザクションスクリプト」パターンではなく「ドメインモデル」パターンで実装することが多いです。
ですが、「どのようなレイヤー構成にするか」と「ビジネスロジックをどう実装するか」は独立して考えることもできますし、そのほうが最初は理解しやすいのではないかと思います。
次回のテーマ
今回で依存の向きを整理し、4 層構成に持って行きました。
これで前回挙げていたサービスクラスの課題である
- サービスが CSV に保存するクラスに依存している
- トランザクションが実現されていない
- まだまだサービスがファット
- ID の採番問題
の 1 つ目が解決しました。
次回は 2 つ目の、「トランザクションが実現されていない」を解決しようと思います。
それでは、今回の記事はここまでにします。最後まで読んでくださりありがとうございました。
じゃんけんアドベントカレンダー に興味を持ってくださった方は、是非購読お願いします。
次回の記事
【Day 9】ファイルから RDB に置き換える【じゃんけんアドカレ】
現時点のコード
コードは GitHub の この時点のコミット を参照ください。