Taste of Tech Topics

Acroquest Technology株式会社のエンジニアが書く技術ブログ

Spring Modulith でモジュラモノリスなアプリの構造を検証してみた

アクロクエスアドベントカレンダー 12月9日 の記事です。

普段は Java, Python でバックエンドの開発をしている大塚優斗です😃

最近は Spring フレームワークのメジャーアップデートなどで盛り上がっていますね!

10月にこんな記事を見かけて、Spring Modulith がとても気になっていたので、手元で試したことを書いていきます✍️

Spring Modulith とは

Spring Modulith はモジュラモノリスな Spring Boot アプリケーションの開発をサポートするプロジェクトです。

元々は Moduliths というプロジェクトでしたが、現在はプロジェクト名が Spring Modulith に変更され、Spring Boot 3 をベースラインとした実験的なプロジェクトとなっています。

spring.io

モジュラモノリスは、モノリスのように単一でデプロイ可能な形を保ちつつ、マイクロサービスのようにモジュール性を持ったアーキテクチャです。
モジュラモノリスの説明は、日本語でとてもわかりやすい記事が書かれているので、そちらを参照してください。

モジュラモノリスに移行する理由 ─ マイクロサービスの自律性とモノリスの一貫性を両立させるアソビューの取り組み - エンジニアHub|Webエンジニアのキャリアを考える!

モジュラモノリスでは、自己完結したビジネスドメインに合わせた疎結合なモジュールを作ることが重要と言われています。
そうすることによって、モジュールごとに独立して開発することが容易になり、あるビジネスドメインで仕様の変更が起きたとしても、影響範囲を対応するモジュールに留めることができるなどのメリットがあるためです。

しかし、単一のリポジトリで開発するため、やろうと思えば各モジュールを密結合にすることができてしまいます。
例えば、Java では子パッケージのクラスを参照するには、子パッケージのクラスに public 修飾子をつける必要がありますが、これによって他モジュールのクラスからアクセスできるようになってしまいます。 さらに、コンパイラはこれらの意図しない参照に対して、警告してくれません。

Spring Modulith では、各モジュールを疎結合に保つためにモジュールの境界ルールを定め、それをテストとして扱うことで、開発者が良くモジュール化されたコードを実装することをサポートしてくれます。

Spring Modulith でできること

公式ドキュメントによると、主に下記のようなことができるようです。

  1. モジュール構造の検証
  2. モジュールに閉じた結合テスト
  3. イベントによるモジュール同士の連携
  4. モジュールのドキュメント化
  5. モジュール同士の連携のモニタリング

この記事では、5番目以外の項目を試していきたいと思います。

この記事内で扱うサンプルコードでは、Maven 3, Java 17, Spring Boot 3.0.0 を使用しています。

0. Spring Modulith でのパッケージの扱いについて

各機能を見ていく前に Spring Modulith におけるパッケージの扱いについて、説明しておきます。

Spring Modulith では、メインアプリケーションクラスが存在するパッケージ直下のサブパッケージをアプリケーションモジュールと呼び、あるドメインに対応するモジュールとして扱います。
そして、アプリケーションモジュール直下の public なクラスは、そのモジュールの公開インターフェースとして扱われます。
また、アプリケーションモジュール配下のサブパッケージは、他のモジュールからアクセスされない内部的なものとして扱われます。

後述しますが、Spring Modulith ではモジュール構造にいくつかのルールが決められており、モジュール構造の検証テストを実行した際に、決まっているルールに違反した場合、テストが失敗します。

この記事内のサンプルコードでは、公式ドキュメントと同様に Order モジュールと Inventory モジュールを扱います。
ファイル構成は以下の通りです。

src/main/java/com/example
└── samplemodulith
    ├── SampleModulithApplication.java
    ├── inventory  # アプリケーションモジュール①
    │   ├── InventoryService.java  # アプリケーションモジュール直下のファイルは公開インターフェースとして扱われる
    │   └── internal  # 他モジュールからはアクセスされないカプセル化されたパッケージ
    │       └── InternalInventoryComponent.java
    └── order  # アプリケーションモジュール②
        ├── Order.java
        ├── OrderCompleted.java
        └── OrderService.java
src/test/java/com/example
└── samplemodulith
    ├── DocumentationTests.java
    ├── ModularityTests.java
    ├── SampleModulithApplicationTests.java
    ├── inventory
    │   └── InventoryIntegrationTests.java
    └── order
        └── OrderIntegrationTests.java
pom.xml

サンプルコードのパッケージ構造

1. モジュール構造の検証

Spring Modulith によるアプリケーションモジュールの検証では以下の3つのことができ、それぞれどういった場合にテストが失敗するかというルールがあります。

  1. 循環参照の検知
    • アプリケーションモジュール間で循環参照している場合にテストが失敗する
  2. 別モジュールへのアクセス違反の検知
    • あるアプリケーションモジュールが別のアプリケーションモジュールの内部パッケージを参照した場合にテストが失敗する
  3. 依存するアプリケーションモジュールの明示
    • 明示的にモジュールの依存関係を定義したときに他のアプリケーションモジュールへの依存があった場合にテストが失敗する

3 はオプション的な立ち位置なので、基本的な 1 と 2 をそれぞれ試していきます。

循環参照の検知

モジュール間に循環参照があった場合にテストで検知できることを試していきます。

サンプルコードでは、循環参照を作るためにコンストラクタで OrderService クラスと InventoryService クラスが相互依存するように定義しています。

package com.example.samplemodulith.order;

import com.example.samplemodulith.inventory.InventoryService;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final InventoryService inventoryService;

    public OrderService(InventoryService inventoryService) {
        this.inventoryService = inventoryService;
    }
}
package com.example.samplemodulith.inventory;

import com.example.samplemodulith.order.OrderService;
import org.springframework.stereotype.Service;

@Service
public class InventoryService {

    private final OrderService orderService;

    public InventoryService(OrderService orderService) {
        this.orderService = orderService;
    }
}

モジュール間で循環参照がある

モジュール構造の検証は ApplicationModules インスタンスverify メソッドで行えます。

package com.example.samplemodulith;

import org.junit.jupiter.api.Test;
import org.springframework.modulith.model.ApplicationModules;

public class ModularityTests {

    @Test
    void verifyModularity() {
        var modules = ApplicationModules.of(SampleModulithApplication.class);
        modules.forEach(System.out::println);
        modules.verify();
    }
}

テストを実行すると、以下のようにモジュール同士が循環参照していることをわかりやすく表示してくれました。

org.springframework.modulith.model.Violations: - Cycle detected: Slice inventory -> 
                Slice order -> 
                Slice inventory
  1. Dependencies of Slice inventory
    - Constructor <com.example.samplemodulith.inventory.InventoryService.<init>(com.example.samplemodulith.order.OrderService, com.example.samplemodulith.inventory.internal.InternalInventoryComponent)> has parameter of type <com.example.samplemodulith.order.OrderService> in (InventoryService.java:0)
    - Field <com.example.samplemodulith.inventory.InventoryService.orderService> has type <com.example.samplemodulith.order.OrderService> in (InventoryService.java:0)
  2. Dependencies of Slice order
    - Constructor <com.example.samplemodulith.order.OrderService.<init>(com.example.samplemodulith.inventory.InventoryService, org.springframework.context.ApplicationEventPublisher)> has parameter of type <com.example.samplemodulith.inventory.InventoryService> in (OrderService.java:0)
    - Field <com.example.samplemodulith.order.OrderService.inventoryService> has type <com.example.samplemodulith.inventory.InventoryService> in (OrderService.java:0)

別モジュールへのアクセス違反の検知

次は、あるモジュールが他モジュールの内部パッケージにアクセスしている場合に、テストで検知されることを確認してみます。

Order モジュールが Inventory モジュールの内部パッケージにあるクラスに依存するコードを用意します。

package com.example.samplemodulith.order;

import com.example.samplemodulith.inventory.internal.InternalInventoryComponent;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final InternalInventoryComponent internalInventoryComponent;

    public OrderService(InternalInventoryComponent internalInventoryComponent) {
        this.internalInventoryComponent = internalInventoryComponent;
    }

OrderモジュールのクラスがInventoryモジュールの内部パッケージにアクセスしている

先ほどと同じテストを実行すると、以下のようにエラーになりました。

org.springframework.modulith.model.Violations: - Module 'order' depends on non-exposed type com.example.samplemodulith.inventory.internal.InternalInventoryComponent within module 'inventory'!
OrderService declares constructor OrderService(InternalInventoryComponent, ApplicationEventPublisher) in (OrderService.java:0)
- Module 'order' depends on non-exposed type com.example.samplemodulith.inventory.internal.InternalInventoryComponent within module 'inventory'!
Field <com.example.samplemodulith.order.OrderService.internalInventoryComponent> has type <com.example.samplemodulith.inventory.internal.InternalInventoryComponent> in (OrderService.java:0)
- Module 'order' depends on non-exposed type com.example.samplemodulith.inventory.internal.InternalInventoryComponent within module 'inventory'!
Constructor <com.example.samplemodulith.order.OrderService.<init>(com.example.samplemodulith.inventory.internal.InternalInventoryComponent, org.springframework.context.ApplicationEventPublisher)> has parameter of type <com.example.samplemodulith.inventory.internal.InternalInventoryComponent> in (OrderService.java:0)

内部パッケージの中には、同一モジュールの公開インタフェースからの参照があるために public になるクラスもありますが、これらのクラスが別モジュールからアクセスされた場合に、テストで検知できるのはとても助かりますね。

2. モジュールに閉じた結合テスト

この章では、以下の2点を確認していきます。

  1. 単一のアプリケーションモジュールで結合テストができること
  2. Bootstrap モードによって、結合テスト時に他モジュールの Bean 生成ができること

単一のアプリケーションモジュールで結合テストができること

Spring Modulith では、ある単一のモジュールの結合テストを独立して実行できます。
これにより不要な Bean 生成をせずに済みます。

Spring Modulith では、 Spring Boot ではお馴染みの @SpringBootTest アノテーションの代わりに、@ApplicationModuleTest アノテーションを用いて、テストを実行します。

package com.example.samplemodulith.inventory;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.modulith.test.ApplicationModuleTest;

@ApplicationModuleTest
public class InventoryIntegrationTests {

    @Autowired
    private InventoryService inventoryService;

    @Test
    void test() {
    }
}

テスト実行後、以下のようなログが出力されます。 STANDALONE モードで起動され、他モジュールの依存性がないことがわかりますね。
他モジュールへの依存があった場合は、テストが失敗します。

Bootstrapping @ModuleTest for inventory in mode STANDALONE (class com.example.samplemodulith.SampleModulithApplication)========================================================================================================================
## inventory ##
> Logical name: inventory
> Base package: com.example.samplemodulith.inventory
> Direct module dependencies: none
> Spring beans:
  + ….InventoryService
  + ….internal.InternalInventoryComponent

Bootstrap モードによって、結合テスト時に他モジュールの Bean 生成ができること

次に、あるモジュールが他のモジュールの Bean に依存するパターンを見ていきます。

OrderモジュールがInventoryモジュールに依存している

この場合、2つのモジュール内で定義されている Bean を生成する必要がありますが、Spring Modulith には直接依存しているモジュールの Bean も同時に Bootstrap する DIRECT_DEPENDENCIES モードがあります。

@ApplicationModuleTest(mode = ApplicationModuleTest.BootstrapMode.DIRECT_DEPENDENCIES)
class OrderIntegrationTests {

    @Autowired
    private InventoryService inventoryService;

    @Test
    void test() {
    }
}

実行後のログは以下のようになります。

Order モジュールが Inventory モジュールに依存しており、Order モジュールの結合テストでは InventoryService クラスが注入されていることを確認できます。

Bootstrapping @ModuleTest for order in mode DIRECT_DEPENDENCIES (class com.example.samplemodulith.SampleModulithApplication)=============================================================================================================================
## order ##
> Logical name: order
> Base package: com.example.samplemodulith.order
> Direct module dependencies: inventory
> Spring beans:
  + ….OrderService
=============================================================================================================================
Included dependencies:
=============================================================================================================================
## inventory ##
> Logical name: inventory
> Base package: com.example.samplemodulith.inventory
> Direct module dependencies: none
> Spring beans:
  + ….InventoryService
  + ….internal.InternalInventoryComponent
=============================================================================================================================

ただし、依存先のモジュールが利用できない場合でもテストができるように、依存先モジュールの Bean をモックする方がベターです。

従来通り Spring Boot の @MockBean アノテーションを使用します。

@ApplicationModuleTest
class OrderIntegrationTests {

    @MockBean
    private InventoryService inventoryService;

    @Test
    void test() {
    }
}
Bootstrapping @ModuleTest for order in mode STANDALONE (class com.example.samplemodulith.SampleModulithApplication)====================================================================================================================
## order ##
> Logical name: order
> Base package: com.example.samplemodulith.order
> Direct module dependencies: inventory
> Spring beans:
  + ….OrderService

Order モジュールだけでテストが実行できていることが確認できました。

3. イベントによるモジュール同士の連携

よりモジュール同士を疎結合にするために、Spring Modulith ではモジュール間のやりとりに Spring Application Events を使うことを推奨しています。

モジュール間のやり取りにイベントを用いることで、呼び出し元が呼び出し先について知る必要がなくなり、テスト時も呼び出し先の Spring Bean に依存/モックする必要がなくなります。

Kafka などのメッセージングシステムを使用することに似ていますが、Spring Application Events は Spring Framework が提供しているため、追加の依存関係/インフラは必要ありません。

OrderService の実装は以下のようになります。InventoryService に依存しない形になりました。

package com.example.samplemodulith.order;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final ApplicationEventPublisher events;

    public OrderService(ApplicationEventPublisher events) {
        this.events = events;
    }

    public void complete() {
        events.publishEvent(new OrderCompleted());
    }
}
package com.example.samplemodulith.order;

public class OrderCompleted {
}

InventoryServiceはOrderCompletedイベントをlistenする

テストでは、@ApplicationModuleTest アノテーションによって、テストメソッドに PublishEvents インスタンスを注入することができます。

ログからも他のモジュールに依存しない形でテストできていることがわかります。

import org.springframework.modulith.test.PublishedEvents;

import static org.assertj.core.api.Assertions.assertThat;

@ApplicationModuleTest
class OrderIntegrationTests {

    @Autowired
    private OrderService orderService;

    @Test
    void complete_success(PublishedEvents events) {
        orderService.complete();

        assertThat(events.ofType(OrderCompleted.class)).hasSize(1);
    }
}
Bootstrapping @ModuleTest for order in mode STANDALONE (class com.example.samplemodulith.SampleModulithApplication)====================================================================================================================
## order ##
> Logical name: order
> Base package: com.example.samplemodulith.order
> Direct module dependencies: none
> Spring beans:
  + ….OrderService

4. モジュールのドキュメント化

SpringModulith では、モジュール間の関係を表すモジュールコンポーネント図と、モジュールキャンバスと呼ばれるモジュールの概要表を生成できます。

ドキュメントの生成には、ApplicationModules を使用し、以下のテストコードでモジュールコンポーネント図とモジュールキャンバスを作成しています。

package com.example.samplemodulith;

import org.junit.jupiter.api.Test;
import org.springframework.modulith.docs.Documenter;
import org.springframework.modulith.model.ApplicationModules;

public class DocumentationTests {

    ApplicationModules modules = ApplicationModules.of(SampleModulithApplication.class);

    @Test
    void writeDocumentationSnippets() {
        new Documenter(modules)
                .writeModulesAsPlantUml()
                .writeIndividualModulesAsPlantUml()
                .writeModuleCanvases();
    }
}

テスト実行後、target ディレクトリ配下に以下のような形で生成されたドキュメントが置かれます。

target/spring-modulith-docs
├── components.uml
├── module-beanreference.adoc
├── module-beanreference.uml
├── module-catalog.adoc
├── module-catalog.uml
├── module-inventory.adoc
├── module-inventory.uml
├── module-order.adoc
├── module-order.uml
├── module-typereference.adoc
└── module-typereference.uml

モジュールコンポーネント

コンポーネント図のスタイルには C4 か UML を選択できます。
図中でモジュール間の依存関係を表している矢印は以下の3つに分かれます。

  1. depends on
    • 他モジュールの型を参照している
  2. uses
    • 他モジュールのSpring Beanを参照している
  3. listen to
    • 他モジュールのイベントを待ち受けている

モジュールコンポーネント図の例。説明のためにtypereferenceモジュールとbeanreferenceモジュールを追加しています

モジュールキャンバス

Spring Modulith では、モジュールの概要を表形式でまとめたものをモジュールキャンバスと呼んでいます。
モジュールキャンバスは、以下のセクションに分かれており、Asciidoc ファイルで生成されます。

  1. アプリケーションモジュールのベースパッケージ名
  2. モジュールで公開されている Spring Beans
  3. jMolecules によって集約として扱われている集約ルート
  4. モジュールによって publish されるイベント
  5. モジュールによって listen されるイベント
  6. モジュールで公開されている Spring Boot Configuration properties

試しに Inventory モジュールのモジュールキャンバスを見てみると以下のようになっていました。

Inventoryモジュールのキャンバス

より多くのセクションが含まれている表の例が、公式ドキュメントに書かれているので、そちらも参照してください。

まとめ

今回は、Spring Modulith の基本的な使い方を手を動かしながら試してみました。

モジュラモノリスでアプリを作る際に、モジュール構成のテストをすることで、モジュールを疎結合に保ちながら開発できるのは心強いと思いました。

ちなみに、Spring Modulith 開発者の Oliver Drotbohm さんによると、2023年の第二四半期に非実験版のプロジェクトに昇格させる予定のようなので楽しみですね!

また、この記事で紹介できていない機能もあるので、興味がある方はぜひご自身で調べてみてください。

参考

Acroquest Technologyでは、キャリア採用を行っています。
  • ディープラーニング等を使った自然言語/画像/音声/動画解析の研究開発
  • Elasticsearch等を使ったデータ収集/分析/可視化
  • マイクロサービス、DevOps、最新のOSSを利用する開発プロジェクト
  • 書籍・雑誌等の執筆や、社内外での技術の発信・共有によるエンジニアとしての成長
  少しでも上記に興味を持たれた方は、是非以下のページをご覧ください。 www.wantedly.com