JHipster によるエンタープライズアプリケーションの構築(5) Spring Modulithでモジュラーモノリス化する

カバー

[!] この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

JHipsterはマイクロサービスアーキテクチャにも対応していますが、モノリシックな構成(モノリス)での「自動生成→起動→動く」というスピード感は素晴らしいと思います。

この記事ではアーキテクチャ選択肢の1つとしてJHipsterでモジュラーモノリスを実現する方法について紹介します。

モジュラーモノリスとは?

モジュラーモノリスはDeveloper to Architect Lesson 159 - Modular Monolith Architecture (posted April 24, 2023)にて

The Modular Monolith Architecture is a very popular monolithic architecture style that is popularized by the well known architect Simon Brown.

と紹介されており、動画中でSimon Brown氏により「Single deployment unit with functionality grouped by domain area. (意訳:ドメインによって機能分割された1つのデプロイメントユニット)」と説明されています。

Simon Brown氏の講演1でも触れられていますが、モジュラーモノリスの考え方が登場した背景にはマイクロサービスの存在があります。

ここではモジュラーモノリスを端的に説明している記事として以下を紹介します。

Decomposing a Monolith Does Not Require Microservices - Sam Newman at QCon London

一部引用します(日本語は意訳です)。

Decomposing a Monolith Does Not Require Microservicesモノリスを分解するのにマイクロサービスは必須ではない
do not start by thinking the monolith is the enemy.モノリスを敵視して考え始めるべきではない
"monolith" has become a replacement for the name "legacy,"
and believes this is deeply inappropriate.
モノリスがレガシーの代名詞になってしまっているが、それは間違っている
the worst of the monoliths, the distributed monolith.最悪のモノリスは分散モノリスである

単一サービスでのデプロイメント要否については別として、

  • 疎結合にするだけであれば必ずしもマイクロサービスにする必要はない
  • マイクロサービスアーキテクチャのシステム構築に使われるソリューションを利用しただけで疎結合にできるわけではない

と言えます。
マイクロサービスのデプロイメントの複雑さをモノリスの構成で回避しつつ、サービスの疎結合を保つアーキテクチャとして、モジュラーモノリスという言葉が登場しました。

Spring Modulithとは?

Spring ModulithはSpring Bootアプリケーションをモジュラーモノリスで構築するためのツールとしてModulithsというプロジェクトでスタートしました。

その後SpringのExperimentalプロジェクトとなり、バージョン0.6.0まで開発されました。

2023/08にバージョン1.0.0(GA)がリリースされ、ExperimentalプロジェクトからTop-levelプロジェクトになりました。
2023/09にはバージョン1.0.1がリリースされ、この記事を執筆している2023/10現在でも、バージョン1.1.0.M1が開発されており、今後の発展に期待できるプロジェクトです。

主な機能

Spring Modulithにはいろいろな機能がありますが、この記事では抜粋して紹介します。
この記事で紹介しきれない機能や、さらに詳細な機能については公式ドキュメントをご参照ください。

モジュールモデル

Spring Modulithでは規約ベースでモジュールを定義します。
モジュールの定義をコーディングする必要はありません。

この記事ではSpring Bootアプリケーションクラスがあるパッケージを「ルートパッケージ」と呼ぶことにします。
Spring Modulithはルートパッケージ直下にあるサブパッケージ(およびそれ以下全て)を1つのモジュールとして認識します。
モジュールとして認識されるパッケージを「モジュールパッケージ」と呼ぶことにします。

モジュールパッケージ直下のクラスは公開APIとして他のモジュールから参照が可能です。
モジュールパッケージ以下のサブパッケージは内部パッケージの扱いになり、デフォルトでは他のモジュールから参照することができません。

例を示します。
以下において、パッケージ名やクラス名は全て例であり、Java言語で許容される名称であればなんでも構いません。

◆ com.example              パッケージ
  ├ MyApplication           クラス
  ├ MyUtil                  クラス
  ├ module1                 パッケージ
  │ └ Module1Component      クラス
  ├ module2                 パッケージ
  │ └ internal2             パッケージ
  │   └ Internal2Component  クラス
  ├ module3                 パッケージ
  ・・・
com.exampleSpring Bootアプリケーションを配置するパッケージ(ルートパッケージ)
com.example.MyApplicationSpring Bootアプリケーションクラス。
このクラスが配置されているパッケージがSpring Modulithのルートパッケージになる。
com.example.MyUtilルートパッケージに配置されているSpring Bootアプリケーション以外のクラスはモジュールとは関係ないただのクラス。
com.example.module1
com.example.module2
com.example.module3
モジュールとして認識されるパッケージ(モジュールパッケージ)。
この例では3つのモジュールが定義されている。
com.example.module1.Module1Componentmodule1の公開APIとして機能するクラス。
モジュールパッケージ直下のクラスは他のモジュールから参照できる。
com.example.module2.internal2module2の内部パッケージとなる。
Spring Modulithでは他のモジュールからこのパッケージ以下のクラスへの参照は許容されない。
com.example.module2.internal2.Internal2Componentmodule2の内部クラスとなる。
Spring Modulithでは他のモジュールからこのクラスへの参照は許容されない。

上記はデフォルトの動作であり、特殊な依存関係や公開APIを作成するためのアノテーションが準備されています。

詳細は公式ドキュメントの1.1. Application modulesを参照ください。

依存関係の検証

Spring Modulithでは依存関係を検証するためにテストケースを作成します。
具体的にはArchUnitのルールで定義されています。
つまり、モジュールの依存関係が検証されるのはテストが動いた時です。

詳細は公式ドキュメントの2. Verifying Application Module Structureを参照ください。

モジュールのテスト

Spring Modulithには@ApplicationModuleTestというアノテーションが準備されています。
これは@SpringBootTestのモジュール版のようなもので、具体的にはテスト実行時のComponent Scanの対象を「指定したモジュール」のパッケージ以下に限定するものです2

これによって以下のようなメリットが得られます。

  • Componentを限定することで軽量化(テストの実行時間を短縮)できる
  • 他モジュールの不具合や動作環境(データ準備等)に依存せずに実行できる

Spring Modulithでは他モジュールの公開APIをインジェクションしている場合には@MockBeanアノテーションを使用してモック化することを推奨しています。
モックが多くなりすぎてテストケースの作成が困難な場合は依存関係が蜜になっていると判断し、後述するイベントでの連携に置き換えるべきという方針です。
そのため、イベント送受信に関するテストを実行しやすくするためのユーティリティが準備されています。

詳細は公式ドキュメントの3. Integration Testing Application Modulesを参照ください。

モジュール間の連携

Spring Modulithではモジュール間の連携を疎結合にするためにSpringのイベント送受信機能を使うことを推奨しています。
Spring Modulithには@ApplicationModuleListenerというアノテーションが定義されています。
このアノテーションはSpringで定義されている@Async、@Transactional、@TransactionalEventListenerの3つを統合したものです。

@ApplicationModuleListenerには変数がないため、以下の設定値は固定になります。

  • @TransactionalEventListener.phase=AFTER_COMMIT
  • @Transactional.propagation=Propagation.REQUIRES_NEW

これを見ると、Spring Modulithでのモジュール間連携では「非同期」で「イベント発行元のトランザクション終了後に新しいトランザクションでイベントハンドラを実行する」ことが推奨されていると考えられます。

もちろん、用途に応じて同期型や非トランザクションのイベントハンドラを使用することは問題ないと思います。

詳細は公式ドキュメントの4. Working with Application Eventsを参照ください。

ドキュメント化

Spring Modulithは規約ベースのモジュール化を行うため、モジュールの参照関係等が明確に定義された場所がありません。
そのためかはわかりませんが、モジュールの情報や依存関係を確認するためのドキュメントを出力する機能があります。
ドキュメントの出力はテストケースに記載して実行します。

以下のようなドキュメントを出力することができます。

  • 全モジュールの依存関係図(PlantUML)
  • 個々のモジュールを起点とした依存関係図(PlantUML)
  • モジュール情報の一覧表(AsciiDoc)

モジュール情報の一覧表に出力される項目は公式ドキュメントの6.2. Generating Application Module Canvasesを参照ください。

また、ドキュメント化全体についての詳細は6. Documenting Application Modulesを参照ください。

アクチュエータとトレーシング

Spring ModulithはSpring Boot Actuatorと連動して以下の機能を提供しています。

  • モジュールの情報を取得するSpring Boot Actuatorのエンドポイント
  • Micrometer Tracingによるモジュール間のイベント伝搬のトレース

Actuatorのエンドポイントで取得できる情報は前述のモジュール情報の一覧表と同程度のものです。

詳細は公式ドキュメントの8. Production-ready Featuresを参照ください。


ここまででモジュラーモノリスとSpring Modulithの紹介を終わります。
ここからはJHipsterでモジュラーモノリスを実現するために、Spring Modulithを組み込む方法について紹介します。

JHipsterでの構成

モジュラーモノリスの考え方ではモジュールは業務ドメイン(≒マイクロサービスの1つのサービス)で分割します。
JHipsterではJHipster Domain Language(JDL)に定義されるデータモデルからソースコードを生成しますので、データモデルの中心となるEntityをモジュールに分類することができればモジュールを定義できます3

JDLにはEntityに任意のアノテーションを付ける機能がありますので、各Entityにアノテーションでモジュール名を付与するようにします。
アノテーションが無いEntityはモジュールには属さないものとして、デフォルトのパッケージに配置します。

@module(モジュール名)
entity エンティティ名 {
    ・・・
}

JHipsterのモノリシックな構成で作成されるJavaのパッケージは以下のようになっています。
以下で「com.example」はJHipsterの自動生成時に指定するパッケージであり、名称は任意です。
この例ではSpring Bootアプリケーションは「com.example」パッケージの直下に生成されます。

以降、パッケージの具体的な階層構造は以下のように省略して説明します。

  • "*":単一階層
  • "**":複数階層
◆ com.example
  ├ aop.*
  ├ config
  ├ domain
  ├ management
  ├ repository
  ├ security
  ├ service.**
  └ web.**

この中でJDLから自動生成されるソースコードが含まれるパッケージは以下の「✓」が付くパッケージになります。
(全く関係ないパッケージは省略)

◆ com.example              
  ├ domain         ✓
  │ └ enumeration  ✓
  ├ repository     ✓
  ├ service        ✓
  │ └ criteria     ✓
  └ web
    └ rest         ✓

これらのパッケージにはJDLからの自動生成とは関係ないクラスも含まれているため、それはモジュールにせずに残すことにします。
モジュールにするパッケージはモジュールであることがわかりやすくなるように「com.example.modules」というパッケージを作り、その下に配置することにします。
結果、以下のようなパッケージ構成になります。

◆ com.example
  ├ aop.*
  ├ config
  ├ domain
  ├ management
  ├ repository
  ├ security
  ├ service.**
  ├ web.**
  └ modules
    ├【モジュール名】
    │ ├ domain         ✓
    │ │ └ enumeration  ✓
    │ ├ repository     ✓
    │ ├ service        ✓
    │ │ └ criteria     ✓
    │ └ web
    │   └ rest         ✓
    ├【モジュール名】
      ・・・

この構成はSpring Modulithの「Spring Bootアプリケーションクラスがあるパッケージの直下のサブパッケージをモジュールと認識する」に反しています。
そのため、モジュールの検出をカスタマイズする必要があります。
Spring ModulithではApplicationModuleDetectionStrategyというインターフェースを実装することによってモジュールの検出をカスタマイズできます。
ApplicationModuleDetectionStrategyのカスタム実装を適用する場合はspring.factoriesに定義が必要になりますので「/resources/META-INF/spring.factories」に「org.springframework.modulith.core.ApplicationModuleDetectionStrategy」の項目を追加し、作成したクラスをフルパッケージ名で指定します。

モジュール検出のカスタマイズ方法は公式ドキュメントの1.1.6. Customizing Module Detection に記載があります。

ソースコード以外では、LiquibaseのChangelogファイルとサンプルデータもモジュールごとにディレクトリを分けて出力することにします。

Changelogファイル/src/main/resources/config/liquibase/changelog/modules/[モジュール名]/*.xml
サンプルデータ/src/main/resources/config/liquibase/fake-data/modules/[モジュール名]/*.csv

JHipsterのLiquibase関連機能にいては別の記事をご参照ください。

Blueprint作成

Entityやモジュールを追加するたびに手作業でパッケージやファイルの移動を行うのは非効率なため、Blueprintを作成しました4

Blueprintについては別の記事で紹介していますので、そちらをご参照ください。

今回作成したBlueprintについて軽く紹介します。

サブジェネレータSide-by-Side概要
liquibaseYESSpring Modulithが使用するEVENT_PUBLICATIONテーブル5を作成するChangelogを生成するように機能追加しました。
Spring Modulithにもこのテーブルを生成する機能はありますが、テーブルの管理をLiquibaseに統一することにしました。
liquibase-changelogsNOEntityがモジュールに属する場合、Changelogファイルとサンプルデータファイルの配置場所を変更するように機能追加しました。
serverNO以下の機能を追加しました。
  • Spring Modulith用のGradle設定
  • モジュール検出をカスタマイズするクラスとspring.factoriesの設定
  • Entityから自動生成されるJavaソースのパッケージをモジュールのパッケージに変更
spring-data-relationalYESSpring Data JPAのリポジトリとエンティティを検出する対象にモジュールパッケージを追加するように機能追加しました。

Blueprint作成の要点

コード全体を掲載すると長くなってしまうため、モジュール名でパッケージやファイルの出力先を変更する部分に絞って紹介します。
JHipsterで生成したプロジェクトにJDLを適用すると、

${プロジェクトのルートディレクトリ}/.jhipster/${エンティティ名}.json

というファイルが作成され、このファイルの情報がBlueprintから参照できるようなっています。
Entityに付与されたカスタム・アノテーションの情報はjsonファイルに以下のような形で保存されます。

{
(略)
    "アノテーションの名前": "アノテーションの値",
(略)
}

今回はアノテーションの名前を「module」としていますので、以下のように保存されます。

{
(略)
    "module": "モジュール名",
(略)
}

Blueprintではこの情報を使用してファイルの出力先やパッケージ名の変更等を行います。

Javaソースコード

serverサブジェネレータで定義している変数でファイルの出力先やパッケージ名を決定しています。
PREPARING_EACH_ENTITYのgetterでこの値を変更することで対応できます。

get [ServerGenerator.PREPARING_EACH_ENTITY]() {
  return {
    ...super.preparingEachEntity,
    async preparingEachEntityTemplateTask({application, entity}) {
      if (entity.module) {
        entity.moduleName = entity.module;
        entity.lowerCaseModuleName = _.toLower(entity.moduleName);
        entity.capitalizedModuleName = _.capitalize(entity.moduleName);
        entity.entityAbsolutePackage = entity.entityAbsolutePackage ? `${entity.entityAbsolutePackage}.modules.${entity.lowerCaseModuleName}` : entity.entityAbsolutePackage;
        entity.entityJavaPackageFolder = entity.entityJavaPackageFolder ? `${entity.entityJavaPackageFolder}/modules/${entity.lowerCaseModuleName}/` : entity.entityJavaPackageFolder;
        entity.entityAbsoluteFolder = entity.entityAbsoluteFolder ? `${entity.entityAbsoluteFolder}/modules/${entity.lowerCaseModuleName}/` : entity.entityAbsoluteFolder;
        entity.entityAbsoluteClass = `${entity.packageName}.${entity.entityClass}`;
      }
    },
  };
}

「entity.module」にJDLのアノテーションで設定したモジュール名が設定されています。

Liquibase関連ファイル

Liquibaseではliquibase-changelogsサブジェネレータでChangelogやサンプルデータを出力していますが、新規のEntityであるかどうかの判定をliquibaseサブジェネレータで行っているため、2つのサブジェネレータで対応する必要があります。

liquibaseサブジェネレータではChangelogファイルの有無で新規かどうかを判定しています。
ですが、モジュール化した場合はファイルのパスが変わっており、このパスに割り込む方法がなかったので処理前後にシンボリックリンクを作成、削除することで対応しています。
ファイルの中を見る処理は無さそうでしたので、シンボリックリンクではなくダミーファイルの作成、削除でも動作すると思います。
LOADING_ENTITIESのgetterでシンボリックリンクを作成する関数を定義し、POST_WRITING_ENTITIESのgetterで削除する関数を定義しています。

get [LiquibaseGenerator.LOADING_ENTITIES]() {
  return {
    async loadingEntitiesTemplateTask() {
      this.getExistingEntityNames().map(entityName => {
        const newConfig = this.sharedData.getEntity(entityName);
        if (newConfig.module) {
          let filePrefix = `modules/${_.toLower(newConfig.module)}/${newConfig.changelogDate}`;
          if (
            fs.existsSync(
              this.destinationPath(`src/main/resources/config/liquibase/changelog/${filePrefix}_added_entity_${entityName}.xml`)
            )
            &&
            !fs.existsSync(
              this.destinationPath(`src/main/resources/config/liquibase/changelog/${newConfig.changelogDate}_added_entity_${entityName}.xml`)
            )) {
              fs.symlinkSync(
                this.destinationPath(`src/main/resources/config/liquibase/changelog/${filePrefix}_added_entity_${entityName}.xml`),
                this.destinationPath(`src/main/resources/config/liquibase/changelog/${newConfig.changelogDate}_added_entity_${entityName}.xml`)
              );
          }
        }
      });
    },
  };
}
get [LiquibaseGenerator.POST_WRITING_ENTITIES]() {
  return {
    async postWritingEntitiesTemplateTask() {
      this.getExistingEntityNames().map(entityName => {
        const newConfig = this.sharedData.getEntity(entityName);
        if (newConfig.module) {
          if (
            fs.existsSync(
              this.destinationPath(`src/main/resources/config/liquibase/changelog/${newConfig.changelogDate}_added_entity_${entityName}.xml`)
            )) {
            fs.unlinkSync(
              this.destinationPath(`src/main/resources/config/liquibase/changelog/${newConfig.changelogDate}_added_entity_${entityName}.xml`)
            );
          }
        }
      });
    },
  };
}

liquibase-changelogsサブジェネレータではChangelogファイルの出力先を変更する必要があります。
ですが、丁度よく変数化されているものがありませんでした。
今回はchangelogDateというファイル名のプレフィックスにディレクトリパスを含めることで対応しています。
PREPARINGのgetterでこの値を書き換えます。

get [LiquibaseChangelogsGenerator.PREPARING]() {
  return {
    ...super.preparing,
    async preparingTemplateTask() {
      if (this.entity.module && !this.databaseChangelog.changelogDate.startsWith('modules/')) {
        this.entity.lowerCaseModuleName = _.toLower(this.entity.moduleName);
        this.databaseChangelog.changelogDate = `modules/${this.entity.lowerCaseModuleName}/${this.databaseChangelog.changelogDate}`;
      }
    },
  };
}

サンプルデータファイルはインクリメンタルChangelogではない場合はファイル名にプレフィックスが付きませんので、出力後に移動することで対応しています。
また、サンプルデータファイルにはblob用に出力されたファイルのパスが入りますので、そこも書き換えます。
POST_WRITING_ENTITIESのgetterでファイルの書き換えと移動を行っています。

get [LiquibaseChangelogsGenerator.POST_WRITING_ENTITIES]() {
  return {
    ...super.postWritingEntities,
    async postWritingEntitiesTemplateTask({ application }) {
      if (this.entity.module && !this.entityChanges.skipFakeData) {
        if (this.databaseChangelog.type === 'entity-new' || this.entityChanges.addedFields.length > 0 || this.entityChanges.shouldWriteAnyRelationship) {
          if (!application.incrementalChangelog || this.options.recreateInitialChangelog) {
            this.editFile(
              `${SERVER_MAIN_RES_DIR}config/liquibase/changelog/${this.databaseChangelog.changelogDate}_added_entity_${this.entity.entityClass}.xml`,
              content => content.replaceAll(
                `file="config/liquibase/fake-data/${this.entity.entityTableName}.csv"`,
                `file="config/liquibase/fake-data/modules/${this.entity.lowerCaseModuleName}/${this.entity.entityTableName}.csv"`,
              )
            );
            this.editFile(
              `${SERVER_MAIN_RES_DIR}config/liquibase/fake-data/${this.entity.entityTableName}.csv`,
              content => content.replaceAll(
                ';../fake-data/blob/',
                ';../../../fake-data/blob/'
              )
            );
            this.moveDestination(
              `${SERVER_MAIN_RES_DIR}config/liquibase/fake-data/${this.entity.entityTableName}.csv`,
              `${SERVER_MAIN_RES_DIR}config/liquibase/fake-data/modules/${this.entity.lowerCaseModuleName}/${this.entity.entityTableName}.csv`);
          } else {
            this.editFile(
              `${SERVER_MAIN_RES_DIR}config/liquibase/fake-data/${this.databaseChangelog.changelogDate}_entity_${this.entity.entityTableName}.csv`,
              content => content.replaceAll(
                ';../fake-data/blob/',
                ';../../../fake-data/blob/'
              )
            );
          }
        }
      }
    },
  };
}

最後に

Javaでモジュラーモノリスを実現する方法としてはGradle(またはMaven)のマルチモジュールプロジェクトとJava Platform Module System(JSR 376)を組み合わせて使う方法もあります。
Javaの標準技術を用いて堅く作るのであればその方法も良いのですが、複数のbuild.gradleや module-info.javaの作成が必要になります。
対してSpring Modulithは規約ベースであるためシンプルで柔軟性があります。
また、純粋なモジュール分割の機能以外にも各種ユーティリティが準備されています。
Spring ModulithがSpringのTop-levelプロジェクトとなったことでコミュニティも活性化しているようです。今後より発展し、使いやすくなっていくことを期待しています。

JHipster関連記事

Footnotes

  1. Simon Brown氏の講演(動画資料

  2. @ApplicationModuleTestには動作モードが3つ用意されており、完全にモジュール内に閉じたComponentのみを生成するのがデフォルトの動作モードです。他の動作モードについては公式ドキュメント3.1. Bootstrap Modesを参照ください。

  3. JHipsterでのSpring Modulithの構成を検討するにあたり、generator-jhipster-modular-monolithを参考にさせて頂きました。
    こちらはSpring Modulithではなく、更新は止まってしまっているようなのですがJDLのアノテーションにモジュール名を付ける方式や、パッケージ構成などを参考にさせて頂いています。

  4. Blueprintは作成時の最新バージョン(v8.0.0-beta.2)で作成しました。

  5. Appendix C: Event publication registry schemas


TOP
アルファロゴ 株式会社アルファシステムズは、ITサービス事業を展開しています。このブログでは、技術的な取り組みを紹介しています。X(旧Twitter)で更新通知をしています。