Featured image of post Spring Boot 2 + Gradleでマルチプロジェクト開発 レイヤー構造化編

Spring Boot 2 + Gradleでマルチプロジェクト開発 レイヤー構造化編

Spring Boot 2 + Gradleのマルチプロジェクトをapi・datasource・entity・valueのレイヤー構造に整理する手順。各プロジェクトの役割とディレクトリ構成、ビルド関連ファイル、各レイヤーを通してレスポンスを返すまで。DDD入門♪

3090文字

土台は整ったのでAPIモジュールとして動かそう

前回の記事では、APIとして最低限動くところまでご紹介しました。

関連記事 【マイクロサービス】流行りのSpring Boot 2 + Gradle + Java8でマルチプロジェクトな個人開発をしていこう API化編【DDD(ドメイン駆動設計)】 2019/04/06 1254文字 約3分で読めます IT Mac Windows アプリケーション カスタマイズ

今回はレイヤー構造を整えていこうと思います。

手順

プロジェクトの構成

まずは各プロジェクトの構成を整理していきましょう。

今回は以下のような構造にしてみようと思います。

プロジェクト説明
apiプロジェクトRESTfulなユーザーインターフェースを管理
servicemodelAPIやWEB、BATCH単位で異なるのでこの中にmodel/serviceパッケージを切っていきます
datasourceプロジェクトAPIアクセスやDBアクセスなどを管理
entityプロジェクトDBテーブルなどのEntityを管理
valueプロジェクト項目単位のValueオブジェクトを管理

果たしてこれが正解なのかは分かりませんが、一旦はこちらで進めてみようと思います。

考え方によっては、ServiceとModelも別プロジェクトにして参照する場合もありますが、API用のモデルやサービスWEBやBatchで使うことはなく、必要のない依存の結果無駄に容量が大きくなってしまうので、上記のようなプロジェクト構成にしています。

ただ、今回はレイヤー毎にプロジェクトを分けないだけで設計的にはMVC構造を考慮して行く想定でいます!

valueプロジェクト

まずは一番コアとなるvalueプロジェクトです。

[adsense-responsive]

このプロジェクトでは名前やメールアドレス、住所などの一項目レベルを管理していきます。

モデル毎にフィールドを用意するとバリデーションや記述がばらけてしまうため、一元管理がしにくくなってしまいます。

そこで、valueプロジェクトで管理することでそのサービスに登場する項目やバリデーションにブレがなくなるので、バグや仕様のねじれなども抑えることが出来るかな?と言う考えです。

entityプロジェクト

次はentityプロジェクトです。

こちらはデータ構造に対するentityモデルを管理するプロジェクトです。

例えば、DBのテーブルモデル等はAPIやWEB、Batchから見て必ず同一である必要があるため、このentityプロジェクトで管理して行く想定です。

datasourceプロジェクト

次はdatasourceプロジェクトです。

こちらは外部APIやDatabaseアクセスなどを行うクラスを管理するプロジェクトです。

こちらも同じく、外部APIやデータベースは、APIやWeb、バッチで共通なのでプロジェクトとして切り出しています。

apiプロジェクト

最後にapiプロジェクトです。

こちらはこれまでの手順で作ってありますが、この中にサブパッケージとして、controller/model/serviceパッケージを構築して行く想定です。

modelパッケージ

entityモデルと違い、I/Oモデルは必ずしもentityと一致するわけではないので、そのクッションをこのmodelで吸収して、共通のEntityモデルへと変換する想定です。

serviceパッケージ

こちらはAPI単位の業務処理を管理して行きます。

同じ会員登録でも、APIからの処理とバッチからの処理で業務内容が異なる可能性があるので、serviceも別プロジェクトにせずにAPIプロジェクトの一部として行きます。

controllerパッケージ

こちらは言わずもがなですが、RESTfulなAPIを構築するために必要不可欠なパッケージです。

infrastructureパッケージ

最後にinfrastructureパッケージです。

[adsense-responsive]

こちらはAPIモジュールに関する基盤周りの設定や共通定数等を管理していきます。

各プロジェクトの作成

では、実際に各プロジェクトを追加していきましょう。

ディレクトリ構成

ディレクトリ構成は以下のような形になりました。

tree -L 2
.
├── README.md
├── api
│   ├── build.gradle
│   └── src
├── datasource
│   ├── build.gradle
│   └── src
├── devtools
│   ├── api
│   ├── build.gradle
│   ├── gradle
│   ├── gradlew
│   ├── gradlew.bat
│   ├── property.gradle
│   └── settings.gradle
├── entity
│   ├── build.gradle
│   └── src
└── value
    ├── build.gradle
    └── src

各プロジェクトのビルド関連ファイル

次に、各プロジェクトのビルド関連ファイルの設定を見ていきましょう。
今回はdevtoolsプロジェクトのbuild.grdleは特に変更ないので割愛します

devtoolsプロジェクト

devtools/settings.gradle
pluginManagement {
	repositories {
		gradlePluginPortal()
	}
}
rootProject.name = 'devtools'

includeFlat 'value'
includeFlat 'entity'
includeFlat 'datasource'
includeFlat 'api'

devtools/property.gradle
ext {
    accountService = [:]
    accountService.springBootVersion = '2.1.3.RELEASE'
    accountService.sourceCompatibility = 1.8
    accountService.group = 'tech.blogenist.service'
    accountService.version = '0.0.1-SNAPSHOT'

    accountService.lombokVersion = '1.18.6'
}

apiプロジェクト

api/build.gradle
springBoot {
	mainClassName = 'tech.blogenist.service.account.AccountApplication'
}

repositories {
}

dependencies {
	implementation project(":value")
	implementation project(":entity")
    implementation project(":datasource")
	implementation "org.springframework.boot:spring-boot-starter-web:${accountService.springBootVersion}"
	implementation "org.springframework.boot:spring-boot-devtools:${accountService.springBootVersion}"
    compileOnly "org.projectlombok:lombok:${accountService.lombokVersion}"
    annotationProcessor "org.projectlombok:lombok:${accountService.lombokVersion}"
}

datasourceプロジェクト

datasource/build.gradle
repositories {
}

dependencies {
	implementation project(":value")
	implementation project(":entity")
}

entityプロジェクト

entity/build.gradle
repositories {
}

dependencies {
	implementation project(":value")
	compileOnly "org.projectlombok:lombok:${accountService.lombokVersion}"
	annotationProcessor "org.projectlombok:lombok:${accountService.lombokVersion}"
}

valueプロジェクト

value/build.gradle
repositories {
}

dependencies {
  compileOnly "org.projectlombok:lombok:${accountService.lombokVersion}"
  annotationProcessor "org.projectlombok:lombok:${accountService.lombokVersion}"
}

ポイント

[adsense-responsive]

ポイントは依存関係の優先度を考えて、dependencies句にてプロジェクトを参照する必要があります。
また、今回はlombokも使いたいので事前に依存関係に追加しています。

APIをそれぞれのレイヤーのクラスを使うように修正

では、プロジェクト構成が整ったところで前回作成したAPIを改修していきましょう。

今回の修正で以下のようになります。

valueプロジェクト

AccountIdValueクラス

tech.blogenist.service.account.value.account.AccountIdValue
package tech.blogenist.service.account.value.account;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@AllArgsConstructor( staticName = "of" )
@NoArgsConstructor( staticName = "empty" )
@Data
public class AccountIdValue
{
    private Integer value;
}

AccountNameValueクラス

tech.blogenist.service.account.value.account.AccountNameValue
package tech.blogenist.service.account.value.account;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@AllArgsConstructor( staticName = "of" )
@NoArgsConstructor( staticName = "empty" )
@Data
public class AccountNameValue
{
    private String value;
}

AgeValueクラス

tech.blogenist.service.account.value.account.AgeValue
package tech.blogenist.service.account.value.account;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@AllArgsConstructor( staticName = "of" )
@NoArgsConstructor( staticName = "empty" )
@Data
public class AgeValue
{
    private Integer value;
}

SexValueクラス

tech.blogenist.service.account.value.account.SexValue
package tech.blogenist.service.account.value.account;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor(staticName = "of" )
@NoArgsConstructor(staticName = "empty" )
@Data
public class SexValue
{
    private Type               value;

    @AllArgsConstructor
    public enum Type
    {
        NONE( "未設定" ),
        MALE( "男性" ),
        FEMALE( "女性" );

        @Getter
        private String lavel;
    }
}

entityプロジェクト

AccountTableEntityクラス

tech.blogenist.service.account.entity.db.account.AccountTableEntity
package tech.blogenist.service.account.entity.db.account;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import tech.blogenist.service.account.value.account.AccountIdValue;
import tech.blogenist.service.account.value.account.AccountNameValue;
import tech.blogenist.service.account.value.account.AgeValue;
import tech.blogenist.service.account.value.account.SexValue;

@Builder
@AllArgsConstructor( staticName = "of" )
@NoArgsConstructor( staticName = "empty" )
@Data
public class AccountTableEntity
{
    private AccountIdValue   id;
    private AccountNameValue name;
    private AgeValue         age;
    private SexValue          sex;
}

datasourceプロジェクト

AccountDataSourceクラス

tech.blogenist.service.account.datasource.db.account.AccountDataSource
package tech.blogenist.service.account.datasource.db.account;

import java.util.Arrays;
import java.util.List;

import org.springframework.stereotype.Repository;

import tech.blogenist.service.account.entity.db.account.AccountTableEntity;
import tech.blogenist.service.account.value.account.AccountIdValue;
import tech.blogenist.service.account.value.account.AccountNameValue;
import tech.blogenist.service.account.value.account.AgeValue;
import tech.blogenist.service.account.value.account.SexValue;

@Repository
public class AccountDataSource
{
    public List< AccountTableEntity > findBy()
    {
        // 一旦Stub処理
        return Arrays.asList(
                AccountTableEntity.builder()
                        .id( AccountIdValue.of( 10001 ) )
                        .name( AccountNameValue.of( "山田 太郎" ) )
                        .age( AgeValue.of( 20 ) )
                        .sex( SexValue.of( SexValue.Type.MALE ) )
                        .build(),

                AccountTableEntity.builder()
                        .id( AccountIdValue.of( 10002 ) )
                        .name( AccountNameValue.of( "山田 花子" ) )
                        .age( AgeValue.of( 18 ) )
                        .sex( SexValue.of( SexValue.Type.FEMALE ) )
                        .build() );
    }
}

apiプロジェクト

AccountListingElementModelクラス

tech.blogenist.service.account.api.model.account.listing.response.AccountListingElementModel
package tech.blogenist.service.account.api.model.account.listing.response;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import tech.blogenist.service.account.value.account.AccountIdValue;
import tech.blogenist.service.account.value.account.AccountNameValue;
import tech.blogenist.service.account.value.account.AgeValue;
import tech.blogenist.service.account.value.account.SexValue;

@Builder
@AllArgsConstructor( staticName = "of" )
@NoArgsConstructor( staticName = "empty" )
@Data
public class AccountListingElementModel
{
    private AccountIdValue   id;
    private AccountNameValue name;
    private AgeValue         age;
    private SexValue          sex;
}

AccountListingResponseModelクラス

tech.blogenist.service.account.api.model.account.listing.response.AccountListingResponseModel
package tech.blogenist.service.account.api.model.account.listing.response;

import java.util.List;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Builder
@AllArgsConstructor( staticName = "of" )
@NoArgsConstructor( staticName = "empty" )
@Data
public class AccountListingResponseModel
{
    private List< AccountListingElementModel > accounts;
}

AccountListingServiceクラス
[adsense-responsive]

tech.blogenist.service.account.apiservice.account.listing.AccountListingService
package tech.blogenist.service.account.apiservice.account.listing;

import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import tech.blogenist.service.account.api.model.account.listing.response.AccountListingElementModel;
import tech.blogenist.service.account.api.model.account.listing.response.AccountListingResponseModel;
import tech.blogenist.service.account.datasource.db.account.AccountDataSource;
import tech.blogenist.service.account.entity.db.account.AccountTableEntity;

@Service
public class AccountListingService
{
    @Autowired
    private AccountDataSource accountDataSource;

    public AccountListingResponseModel findBy()
    {
        List< AccountTableEntity > entities = accountDataSource.findBy();
        List< AccountListingElementModel > elements = entities.stream()
                .map( entity ->
                {
                    AccountListingElementModelelement = AccountListingElementModel.builder()
                            .id( entity.getId() )
                            .name( entity.getName() )
                            .age( entity.getAge() )
                            .sex( entity.getSex() )
                            .build();
                    return element;
                } )
                .collect( Collectors.toList() );

        return AccountListingResponseModel.of( elements );
    }
}

RestPathConstクラス

tech.blogenist.service.account.api.infrastructure.rest.path.RestPathConst
package tech.blogenist.service.account.api.infrastructure.rest.path;

public class RestPathConst
{
    public class VersionPath
    {
        public static final String V1 = "v1/";
    }

    public class CategoryPath
    {
        public class AccountPath
        {
            public static final String BASE_PATH     = "accounts/";
        }
    }
}

AccountListingControllerクラス

tech.blogenist.service.account.api.controller.account.listing.AccountListingController
package tech.blogenist.service.account.api.controller.account.listing;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import tech.blogenist.service.account.api.infrastructure.rest.path.RestPathConst;
import tech.blogenist.service.account.api.model.account.listing.response.AccountListingResponseModel;
import tech.blogenist.service.account.apiservice.account.listing.AccountListingService;

@RestController
@RequestMapping( RestPathConst.VersionPath.V1 + RestPathConst.CategoryPath.AccountPath.BASE_PATH )
public class AccountListingController
{
    @Autowired
    private AccountListingService service;

    @GetMapping
    public AccountListingResponseModel listing()
    {
        AccountListingResponseModel response = service.findBy();
        return response;
    }
}

動作確認

では、この状態でサーバーを起動して動作確認をしてみましょう。

[adsense-responsive]

GET: http://localhost:8080/v1/accounts/
{
    "accounts": [
        {
            "id": {
                "value": 10001
            },
            "name": {
                "value": "山田 太郎"
            },
            "age": {
                "value": 20
            },
            "sex": {
                "value": "MALE"
            }
        },
        {
            "id": {
                "value": 10002
            },
            "name": {
                "value": "山田 花子"
            },
            "age": {
                "value": 18
            },
            "sex": {
                "value": "FEMALE"
            }
        }
    ]
}

正しく各レイヤーを通じてレスポンスが返ってくるようになりましたね♪

終わりに

これで各レイヤーを通るベースとなる処理が整いました。

次回は実際にDBへアクセスする部分の準備としてFlywayの導入をしていこうと思います。