BLOG

ブログ

【Spring Boot】ディレクトリ構成の実例を紹介します

こちらは、Mavs Advent Calendar 2024 15日目の記事です🐺!

🌲🌲🌲

こんにちは、ショーです!

皆さんは開発する際に「ディレクトリ構成をどうしようかな〜」とかで悩んだ経験はありますか?
自分はあります。今でも何度も何度も悩んでます。

すでにあるシステムの保守とかですと、すでに出来上がっているのであまり無い経験かもしれないですが、
それでも「なんでこういう構成にしているんだろう」と思ったことはあるのかなと思います。

そこで! 実例を交えてディレクトリを紹介していきたいと思います!
今回はSpring Bootのディレクトリです!

動作環境

JavaAmazon Corretto 17
Spring Boot3.1.1
MySQL8.0

構成考案時に意識していたこと

以下を意識しながら構成を考えて構築するようにしました。

  • 出来る限り疎結合にし、責務分けをはっきりさせること
  • 共通処理で色々行うようにし、各実装者は自身の担当の機能に集中出来るようにすること
    • エラーハンドリングやロギング等
  • Springや付随のライブラリの機能を使い、独自の実装は極力しない・させないようにすること
  • クラス名やメソッド名等の命名をきちんと決めて、各実装者が迷わないようにすること

大まかな全体のディレクトリ構成

一部割愛していますが、大体以下のような構成にしています。

.
└── mavs-api-server
    └── src
        └── main
            └── java
                └── com
                    ├── Application.java           // アプリケーション起動クラス
                    ├── application                // アプリケーション層
                    │   └── sample
                    │       ├── common
                    │       │   ├── enums
                    │       │   ├── filter
                    │       │   ├── interceptor
                    │       │   ├── session
                    │       │   └── util
                    │       ├── controller
                    │       │   ├── form
                    │       │   │   ├── request
                    │       │   │   └── response
                    │       │   └── validation
                    │       └── handler
                    ├── config                     // 設定関連
                    │   ├── common
                    │   ├── domain
                    │   └── sample
                    ├── domain                     // ドメイン層
                    │   ├── aspect
                    │   ├── entity
                    │   ├── exception
                    │   ├── repository
                    │   └── service
                    ├── infrastructure             // インフラストラクチャ層
                    └── initializer                // Spring起動時の初期化処理

この中でコメントを記載している箇所について説明していきます。説明順は以下で進めます。
初期設定やWebサーバーに近いレイヤーから順に説明していきます。

  1. Spring起動時の初期化処理
  2. 設定関連
  3. アプリケーション起動クラス
  4. アプリケーション層
  5. ドメイン層
  6. インフラストラクチャ層

大体のイメージ図

(TERASOLUNAのガイドラインから画像を拝借させていただきました。いつもお世話になっております。ありがとうございます)

① Spring起動時の初期化処理

「Application.java」より前に実行されるクラスなのですが、必要な場合のみ展開してご参考にしてください。

ApplicationContextInitializerというインターフェースを実装したクラスを作成するのですが、後述で説明する「Application.java」より前に実行されるクラスになります。
使用している用途としては、担当案件でAWSのSecrets Managerという機密情報を扱うサービスを利用しており、各実装者は以下のようにアノテーション(@)で簡単に呼び出すようにしたく、その準備処理を行っています。

@Value("${DB_PASSWORD}")
private String dbPassword;

以下、一部割愛していますが実装内容です。
AWSから提供されているSDKを使用してシークレットを取得して、最終的にSpringのEnvironment(@Valueで扱える形式)に追加する処理です。

public class AwsSecretsManagerInitializer
        implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    /** ロガー */
    Logger logger = LoggerFactory.getLogger(AwsSecretsManagerInitializer.class);

    /**
     * 初期化処理
     */
    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
        // 環境変数取得
        ConfigurableEnvironment environment = applicationContext.getEnvironment();
        // シークレット名
        String secretName = environment.getProperty("SECRET_NAME");
        // エンドポイント
        String endpoints = environment.getProperty("SECRET_ENDPOINT");
        // リージョン
        String awsRegion = environment.getProperty("SECRET_REGION");
        try {
            // Secrets Manager のクライアント設定(AWSSecretsManager)
            AwsClientBuilder.EndpointConfiguration config =
                    new AwsClientBuilder.EndpointConfiguration(endpoints, awsRegion);
            AWSSecretsManagerClientBuilder clientBuilder =
                    AWSSecretsManagerClientBuilder.standard();
            clientBuilder.setEndpointConfiguration(config);
            AWSSecretsManager client = clientBuilder.build();

            // Secrets Manager のシークレット取得のリクエスト情報を設定
            GetSecretValueRequest getSecretValueRequest =
                    new GetSecretValueRequest().withSecretId(secretName);

            // Secrets Manager のシークレット取得
            GetSecretValueResult getSecretValueResponse =
                    client.getSecretValue(getSecretValueRequest);
            if (getSecretValueResponse == null) {
                logger.error("The Secret Value returned is null");
                throw new IOException("The Secret Value returned is null");
            }

            // シークレット値をJSON形式で取得
            String secret = getSecretValueResponse.getSecretString();
            ObjectMapper objectMapper = new ObjectMapper();
            JsonNode secretsJson = null;
            if (secret == null) {
                logger.error("The Secret String returned is null");
                throw new IOException("The Secret String returned is null");
            }
            secretsJson = objectMapper.readTree(secret);

            // JSONから動的に取得
            Map<String, String> secretsMap = convertJsonNodeToMap(secretsJson);

            // 取得した環境変数をSpringのEnvironmentに追加
            ((ConfigurableEnvironment) environment).getPropertySources()
                    .addFirst(new PropertySource<Object>("awsSecretsManagerProperties") {
                        @Override
                        public Object getProperty(String name) {
                            return secretsMap.get(name);
                        }
                    });
        } catch (ResourceNotFoundException e) {
            e.printStackTrace();
            logger.error("The requested secret " + secretName + " was not found");
        } catch (InvalidRequestException e) {
            e.printStackTrace();
            logger.error("The request was invalid due to: " + e.getMessage());
        } catch (InvalidParameterException e) {
            e.printStackTrace();
            logger.error("The request had invalid params: " + e.getMessage());
        } catch (IOException e) {
            e.printStackTrace();
            logger.error("Exception while retrieving secret values: " + e.getMessage());
        }
    }

    /**
     * シークレット情報をJSONからMapに変換
     * 
     * @param secretsJson シークレット(JSON形式)
     * @return Mapに変換したシークレット情報
     */
    private Map<String, String> convertJsonNodeToMap(JsonNode secretsJson) {
        Map<String, String> secretsMap = new HashMap<>();

        Iterator<Map.Entry<String, JsonNode>> fields = secretsJson.fields();
        while (fields.hasNext()) {
            Map.Entry<String, JsonNode> entry = fields.next();
            String key = entry.getKey();
            String value = entry.getValue().asText();
            secretsMap.put(key, value);
        }

        return secretsMap;
    }
}

(担当案件ではIAMロール認証周りの実装もありますが、割愛します)

上記を使用する場合、以下のファイルを作成して読み込ませるための設定が必要です。

org.springframework.context.ApplicationContextInitializer=\
com.initializer.AwsSecretsManagerInitializer

② 設定関連

「@Configuration」というプログラム全体の設定に関する役割を担当するクラスを管理するのがここになります。

.
└── mavs-api-server
    └── src
        └── main
            └── java
                └── com
                    └── config
                        ├── common
                        ├── domain
                        └── sample
                            ├── ApplicationConfig.java
                            └── WebMvcConfig.java

クラスの実装方法は何の設定をするのかによってバラバラなので、今回は2つ例を記載します。
まずは、後述で説明する「リクエストボディをキャッシュした後にリクエストサイズ上限チェック」を呼び出すための設定クラスです。

/**
 * アプリケーション設定用クラス
 */
@Configuration
public class ApplicationConfig {
    /** payloadのデフォルトサイズ(1MB) */
    private static final int DEFAULT_LIMIT = 1 * 1024 * 1024;

    /**
     * RequestFilterの登録
     * 
     * @return registrationBean
     */
    @Bean
    FilterRegistrationBean<RequestFilter> requestFilter(
            @Value("${PAYLOAD_LIMIT:" + DEFAULT_LIMIT + "}") Integer limitBytes) {
        RequestFilter requestFilter = new RequestFilter(limitBytes);
        FilterRegistrationBean<RequestFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(requestFilter);
        registrationBean.addUrlPatterns("/api/v1/*");
        registrationBean.setOrder(1);

        return registrationBean;
    }
}

続いて、こちらも後述で説明するInterceptorを設定するためのクラスの例です。
Interceptorは複数登録が可能で、特定のAPIパスを除外することも可能です。

/**
 * WebMVC設定用クラス Interceptor(./interceptor)を設定する
 */
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    /** 共通ロギング用のInterceptor */
    @Autowired
    LoggingInterceptor loggingInterceptor;

    /** サンプル用のInterceptor */
    @Autowired
    SampleInterceptor sampleInterceptor;

    /**
     * Interceptorの登録
     * 
     * @param InterceptorRegistry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // サンプル用のInterceptor対象外パスリスト
        List<String> sampleInterceptorExcludePathPatterns = new ArrayList<>();
        sampleInterceptorExcludePathPatterns.add("/api/v1/sample");
        sampleInterceptorExcludePathPatterns.add("/api/v1/hoge");
        sampleInterceptorExcludePathPatterns.add("/api/v1/fuga");

        // 共通ロギング用のInterceptor
        registry.addInterceptor(loggingInterceptor).addPathPatterns("/api/v1/**").order(1);
        // サンプル用のInterceptor
        registry.addInterceptor(sampleInterceptor).addPathPatterns("/api/v1/**")
                .excludePathPatterns(sampleInterceptorExcludePathPatterns).order(2);
    }
}

③ アプリケーション起動クラス

これはSpring起動時に必須なため、必ず存在します。

クラス名は任意ですが「Application.java」とかシンプルな命名にしています。
ファイルの中身は以下のような感じで、Springの起動とタイムゾーン(JST)の設定を行うようにしています。
この中で「@SpringBootApplication」が重要で、各所で出てくるアノテーションを1つの簡潔なアノテーションにまとめて、Springアプリケーションのプロセスを起動するイメージのようです。
こちらの記事が分かりやすかったです。

/**
 * アプリケーション起動用クラス
 */
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    /**
     * 初期設定
     */
    @PostConstruct
    public void init() {
        // タイムゾーンをJSTに設定する
        TimeZone.setDefault(TimeZone.getTimeZone("JST"));
    }
}

④ アプリケーション層

Webサーバーからのリクエストのデータを受け取り、後続のドメイン層のビジネスロジックを呼び出して加工を行ったデータをWebサーバーにレスポンスを返す役割となります。

.
└── mavs-api-server
    └── src
        └── main
            └── java
                └── com
                    └── application
                        └── sample
                            ├── common
                            │   ├── enums
                            │   │   └── SampleKind.java
                            │   ├── filter
                            │   │   └── RequestFilter.java
                            │   ├── interceptor
                            │   │   └── LoggingInterceptor.java
                            │   ├── session
                            │   │   └── SessionBean.java
                            │   └── util
                            │       └── ConvertUtils.java
                            ├── controller
                            │   ├── ApiSampleController.java
                            │   ├── form
                            │   │   ├── request
                            │   │   │    └── ApiSamplePostSampleRequestData.java
                            │   │   └── response
                            │   │        └── ApiSamplePostSampleResponseData.java
                            │   └── validation
                            │       └── daterange
                            │            ├── DateRange.java
                            │            └── DateRangeValidator.java
                            └── handler
                                └── ApiRequestBodyHandler.java

共通部分(common)

定数(common/enums)

Javaで定数を管理する場合はEnumを使用して管理します。
Javascriptでいうところの「namespace」や「const」に該当すると思っています。

以下、サンプル実装です。
呼び元では、例えば「SampleKind.HOGE.getId()」みたいにすると「1」が取得できます。
「idが2の値(=ふが)を取りたい」という場合は、以下の「getValue」のようなメソッドを用意して、呼び元で「SampleKind.getValue(2)」みたいに使用すると「ふが」という値が取得できます。

@AllArgsConstructor
@Getter
public enum SampleKind {
    HOGE(1, "ほげ"),

    FUGA(2, "ふが"),

    PIYO(3, "ぴよ"),

    private final int id;
    private final String value;

    /**
     * idからValue値を取得する
     * 
     * @param id ID値
     * @return Value値
     */
    public static String getValue(int id) {
        SampleKind[] array = values();
        String value = "";
        for (SampleKind item : array) {
            if (id == item.getId()) {
                value = item.getValue();
                break;
            }
        }
        return value;
    }
}

API実行前後共通処理(common/filter、common/interceptor)

APIの共通的な処理(DB接続やロギング等…)をしたいシチュエーションがあると思いますが、その際にFilterやInterceptorを使用して実現します。

この2点に関して何が違うかと、主に実行タイミングですが説明が難しいのと本題と脱線してしまうので、こちらを参照ください…。
担当案件ではFilterは「リクエストボディをキャッシュした後にリクエストサイズ上限チェック」を行うFilterのみがあり、それ以外のDB接続やロギングはInterceptorで実施しています。

ではまずはFilterの実装例です。
「リクエストボディをキャッシュした後にリクエストサイズ上限チェック」を行っています。
(そのまんまですね。それ以外の説明が特に無く…)

public class RequestFilter extends OncePerRequestFilter {
    /** ロガー */
    Logger logger = LoggerFactory.getLogger(RequestFilter.class);

    /** バイト数の上限値 */
    private final Integer limitBytes;

    public RequestFilter(Integer limitBytes) {
        this.limitBytes = limitBytes;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        // 後続処理でリクエストボティを取得するためキャッシュする
        ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(request);
        // リクエストのサイズ取得
        long contentLengthLong = request.getContentLengthLong();
        if (contentLengthLong > limitBytes) {
            // リクエストサイズ上限エラー
            logger.info("Received " + contentLengthLong + " limit is " + limitBytes);
            HttpServletResponse httpServletResponse = (HttpServletResponse) response;
            httpServletResponse.setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
            return;
        }
        filterChain.doFilter(wrapper, response);
    }
}

続いては、API実行時に共通的に行うログ出力処理のInterceptorです。
各メソッドの動作タイミングは以下の通りです。

  • preHandle:コントローラー実行前
  • postHandle:コントローラー実行後(成功時のみ)
  • afterCompletion:コントローラー実行後(成功/失敗関わらず)
/**
 * 共通ロギング用Interceptor
 */
@Component
@RequestScope
public class LoggingInterceptor implements HandlerInterceptor {
    /** ロガー */
    Logger logger = LoggerFactory.getLogger(LoggingInterceptor.class);

    // 開始時刻
    private long start = 0;
    // リクエスト毎のUUID
    private UUID requestIdentifier = null;

    /**
     * コントローラー前処理
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
            Object handler) throws Exception {
        // 静的ページの場合は処理を中断
        if (isStatic(request.getRequestURI())) {
            return true;
        }

        // 開始時刻
        this.start = System.currentTimeMillis();
        // リクエスト毎のUUID
        this.requestIdentifier = UUID.randomUUID();

        // 開始ログ
        logger.info(String.format("[%s] start", this.requestIdentifier));
        // URI
        logger.info(String.format("[%s] URI: %s", this.requestIdentifier, request.getRequestURI()));
        // メソッド(POST,GET等)
        logger.info(String.format("[%s] METHOD: %s", this.requestIdentifier, request.getMethod()));

        // 全リクエストヘッダ名を取得
        String header = this.getRequestHeader(request);
        // リクエストヘッダー
        logger.info(String.format("[%s] REQUEST HEADERS: %s", this.requestIdentifier, header));

        return true;
    }

    /**
     * コントローラー後処理
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            @Nullable ModelAndView modelAndView) throws Exception {
        // 終了ログ
        logger.info(String.format("[%s] end in %d millisec. STATUS %d", this.requestIdentifier,
                System.currentTimeMillis() - this.start, response.getStatus()));
    }

    /**
     * 静的ページ(JavaScript、CSS等)かどうか
     * 
     * @param uri 対象URI
     * @return 静的ページの場合true
     */
    private boolean isStatic(String uri) {
        return uri.contains("/js/") || uri.contains("/css/") || uri.contains("/fonts/");
    }

    /**
     * 全リクエストヘッダ名を取得
     * 
     * @param request リクエストデータ
     * @return リクエストヘッダ名と値
     */
    private String getRequestHeader(HttpServletRequest request) {
        StringJoiner sjHeader = new StringJoiner(", ");
        Enumeration<String> headerNames = request.getHeaderNames();
        while (headerNames.hasMoreElements()) {
            // ヘッダ名と値を取得
            String headerName = headerNames.nextElement();
            String headerValue = request.getHeader(headerName);

            sjHeader.add(headerName + "=" + headerValue);
        }
        return sjHeader.toString();
    }
}

個人的にはInterceptorの方が使いやすくて好きです。

セッション管理している項目の型定義(common/session)

セッションで管理している項目群をクラス化して管理しています。必要な場合のみ展開してご参考にしてください。

担当案件ではSpringSessionというライブラリを使用してセッション管理をしているのですが、
そのセッションで管理している項目群をクラス定義しています。
セッション管理をしていない場合は、こちらは不要です。

以下、クラスのサンプルです。この項目郡のセッションデータをDBのテーブルで管理しています。

/**
 * セッションデータ定義クラス
 */
@Data
@Component
@SessionScope
public class SessionBean implements Serializable {

    private static final long serialVersionUID = 1L;

    /** ID */
    private Integer id;

    /** 名前 */
    private String name;
}

共通関数クラス郡(common/util)

複数箇所で使いたい便利関数が必要になるシチュエーションがあると思います。
それがここのディレクトリです。

以下、変換処理をする共通クラスのコード例です。
@UtilityClass」というのがインスタンス化が不可なユーティリティクラスを生成してくれるアノテーションです。内部的に「static final」なメンバが作成されます。

/**
 * 変換処理の共通クラス
 */
@UtilityClass
public class ConvertUtils {
    /** ロガー */
    Logger logger = LoggerFactory.getLogger(ConvertUtils.class);

    /**
     * オブジェクトからJSONに変換
     * 
     * @param value 変換元オブジェクト
     * @return JSON変換後文字列。変換失敗時は変換元オブジェクト.toString()を返却
     */
    public String convertObjectToJson(Object value) {
        // インスタンス生成時に SerializationFeature.INDENT_OUTPUT を設定することで整形される
        ObjectMapper objectMapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
        try {
            return objectMapper.writeValueAsString(value);
        } catch (JsonProcessingException e) {
            return value.toString();
        }
    }
}

コントローラー(controller)

コントローラークラス(controller/ApiSampleController.java)

アプリケーション層で各実装者がメインでコードを作成する箇所となります。
主に以下を実装していきます。

  • どんなAPIのパスなのか
  • APIのメソッド(GET/POST/PATCH/DELETE…)は何なのか
  • どんなリクエストデータ/レスポンスデータを管理しているコントローラーのメソッドなのか
  • ビジネスロジッククラス/メソッドは何を呼び出すのか

以下、サンプルのコントローラークラスです。
これで、GETとPOSTの「/api/v1/sample」のAPIを呼び出すことが出来るようになったところです。
(該当のサービスクラスの枠が無いとビルド時にエラーになりますが…)

/**
 * Rest API定義クラス
 */
@RestController
@CrossOrigin
@RequestMapping("/api/v1")
public class ApiSampleController {
    /** ロガー */
    Logger logger = LoggerFactory.getLogger(ApiSampleController.class);

    /** ビジネスロジック用サービス */
    @Autowired
    private ApiSampleService apiSampleService;

    /**
     * サンプルデータを取得する
     * 
     * @return サンプルデータ
     */
    @GetMapping("/sample")
    public ResponseEntity<ApiSampleGetSampleResponseData> getSample(
            ApiSampleGetSampleRequestData request) throws Exception {
        // サンプルデータ取得処理
        ApiSampleGetSampleResponseData response =
                apiSampleService.getSample(request.getId());

        // レスポンス返却
        HttpHeaders headers = new HttpHeaders();
        return ResponseEntity.ok().headers(headers).body(response);
    }

    /**
     * サンプルデータを登録
     * 
     * @param requestBody リクエストボディから抽出されたサンプルデータ
     * @return 登録されたサンプルのレスポンスデータ
     * @throws Exception
     */
    @PostMapping("/sample")
    public ResponseEntity<ApiSamplePostSampleResponseData> postSample(
            @RequestBody ApiSamplePostSampleRequestData requestBody,
            BindingResult result) throws Exception {
        // サンプルデータ登録処理
        ApiSamplePostSampleResponseData response =
                apiSampleService.postSample(requestBody.getName(), requestBody.getAge());

        // レスポンスを返す
        HttpHeaders headers = new HttpHeaders();
        return ResponseEntity.ok().headers(headers).body(response);
    }
}

命名ルールとしては以下にしています。

  • クラス名(ApiSampleController):「Api + 画面物理名 + Controller
  • メソッド名(postSample):「APIのメソッド(GET,POST等) + APIパスの末尾部分(今回でいうと「/sample」のスラッシュ以外)をキャメルケースにした文字列

リクエストデータ/レスポンスデータクラス(controller/form/request、controller/form/response)

Webサーバーから受けるリクエストパラメータや、返却するレスポンスデータの定義を管理するディレクトリです。
ここでリクエストパラメータのバリデーションも行うようにしています。

以下、サンプルのリクエストパラメータクラスです。
バリデーション以外はレスポンスデータも似たように定義します。

/**
 * Rest APIのリクエスト定義クラス
 */
@Data
@Schema(description = "サンプル")
public class ApiSamplePostSampleRequestData {
    /** 名前 */
    @NotBlank()
    @Schema(type = "string", required = true, description = "名前")
    private String name;

    /** 年齢 */
    @NotNull()
    @Schema(type = "int", required = true, description = "年齢")
    private Integer age;
}

バリデーションといっているのは「@NotBlank()」「@NotNull()」です。
これを書くだけで必須チェックを行うことが可能です。他にも色々アノテーションが用意されており、こちらが分かりやすいので参照ください。

クラス名の命名は「Api + 画面物理名 + APIのメソッド(GET,POST等) + RequestData or ResponseData」としています。
また、オブジェクトの配列等で子階層を管理したい場合は、「dto」フォルダを用意してその中に定義していきます。

(余談)
「@Schema」は「springdoc-openapi-starter-webmvc-ui」ライブラリを導入すると使えるようになります。
これを付けていくだけで、ブラウザ(SwaggerUI)でAPIの定義情報が見れるようになります。
この辺りを見ながら過去に導入しました。(活用されているかどうかはさておき)

カスタムバリデーション(controller/validation)

用意されているバリデーションでは実現できない場合に、カスタムバリデーションを用意します。必要な場合のみ展開してご参考にしてください。

今回は開始日/終了日の相関チェックを行うバリデーションを行いたいために、自前で実装しました。

以下が実装例です。まずは呼ばれる側です。

/**
 * 日付相関チェック用バリデータインターフェース
 */
@Documented
@Constraint(validatedBy = DateRangeValidator.class)
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface DateRange {
    /** カスタムバリデータのメッセージ */
    String message() default "{custom.validation.dateRange.invalid}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    /** 開始日のフィールド名 */
    String startDateField();

    /** 終了日のフィールド名 */
    String endDateField();
}
/**
 * 日付相関チェック用バリデータ実装クラス
 */
public class DateRangeValidator implements ConstraintValidator<DateRange, Object> {
    /** 日付フォーマット */
    private static final DateTimeFormatter DATE_FORMATTER =
            DateTimeFormatter.ofPattern("yyyy-MM-dd");

    /** 開始日のフィールド名 */
    private String startDateFieldName;

    /** 終了日のフィールド名 */
    private String endDateFieldName;

    /**
     * 初期化処理
     * 
     * カスタムバリデータで指定された開始日、終了日のフィールド名を設定
     */
    @Override
    public void initialize(DateRange constraintAnnotation) {
        startDateFieldName = constraintAnnotation.startDateField();
        endDateFieldName = constraintAnnotation.endDateField();
    }

    /**
     * 検証処理
     * 
     * 検証が成功した場合はtrue、検証が失敗した場合はfalseを返却
     */
    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        if (value == null) {
            return true;
        }
        try {
            // 開始日と終了日を取得
            LocalDate startDate = parseDate(getFieldValue(value, startDateFieldName));
            LocalDate endDate = parseDate(getFieldValue(value, endDateFieldName));

            // 開始日または終了日がnullの場合、検証をスキップ
            if (startDate == null || endDate == null) {
                return true;
            }

            // 終了日が開始日より前の場合はfalse
            if (endDate.isBefore(startDate)) {
                // エラーを追加(フィールド名は実際のフィールド名)
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(
                        "{custom.validation.dateRange.invalid}").addPropertyNode(endDateFieldName)
                        .addConstraintViolation();

                return false;
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 日付フォーマット処理
     * 
     * @param date 日付
     * @return フォーマット後の日付
     */
    private LocalDate parseDate(Object date) {
        if (date == null) {
            return null;
        }
        return LocalDate.parse(date.toString(), DATE_FORMATTER);
    }

    /**
     * フィールド値を取得
     * 
     * @param object 対象DTOクラス
     * @param fieldName フィールド名
     * @return フィールド値
     * @throws NoSuchFieldException
     * @throws IllegalAccessException
     */
    private Object getFieldValue(Object object, String fieldName)
            throws NoSuchFieldException, IllegalAccessException {
        Class<?> clazz = object.getClass();
        while (clazz != null) {
            try {
                java.lang.reflect.Field field = clazz.getDeclaredField(fieldName);
                field.setAccessible(true);
                return field.get(object);
            } catch (NoSuchFieldException e) {
                clazz = clazz.getSuperclass();
            }
        }
        throw new NoSuchFieldException(fieldName);
    }
}

続いて呼ぶ側の実装例です。
以下のように「@DateRange」をクラスの上に定義することでバリデーションを実行できます。
メンバ変数に開始日と終了日に相当する項目は必須です。(変数名は何でも大丈夫です)

/**
 * Rest APIのリクエスト定義クラス
 */
@Data
@DateRange(startDateField = "startDate", endDateField = "endDate")
@Schema(description = "サンプル")
public class ApiSamplePostSampleRequestData {
〜 略 〜

ハンドラー(handler)

コントローラーの共通処理として、エラー処理等をまとめて管理しています。
@RestControllerAdvice」を使用しており、こちらが分かりやすいかと思います。

担当案件では、エラー処理とリクエストボディのログ出力で使っていますが、
例としてエラー処理の実装を以下に記載します。
例外(Exception)をキャッチして、「ErrorResponseData」クラスにステータスコードやメッセージを詰めながら500エラー(INTERNAL_SERVER_ERROR)としてWebサーバーへ返す例となります。

/**
 * 共通エラーハンドラクラス
 */
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
    /** ロガー */
    Logger logger = LoggerFactory.getLogger(ApiExceptionHandler.class);

    /**
     * すべての例外をキャッチする
     * 
     * @param e アプリケーション例外
     * @param request クライアントリクエスト
     * @return レスポンスエンティティ
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Object> handleAllException(Exception e, WebRequest request) {
        logger.error(e.getMessage(), e);
        ErrorResponseData errorResponseData = new ErrorResponseData();
        errorResponseData.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        errorResponseData.setMessage(e.getMessage());
        return super.handleExceptionInternal(e, errorResponseData, new HttpHeaders(),
                HttpStatus.valueOf(HttpStatus.INTERNAL_SERVER_ERROR.value()), request);
    }
}

「ErrorResponseData」クラスは以下のような実装です。

/**
 * 共通エラーハンドラ用DTO
 */
@Data
public class ErrorResponseData {
    /** HTTPステータスコード */
    @NotBlank(message = "The status is required.")
    private int status;

    /** エラーメッセージ */
    @NotBlank(message = "The message is required.")
    private String message;

    /** エラー詳細 */
    private List<String> invalidParams;
}

⑤ ドメイン層

ビジネスロジック、DBのCRUD操作周り、外部APIへのアクセス等を管理するコアな部分です。
(書いてて思いましたが、DBについては業務よりな操作もあったりで微妙なラインですが、インフラ層の方が適しているかもしれないです…)

.
└── mavs-api-server
    └── src
        └── main
            └── java
                └── com
                    └── domain
                        ├── aspect
                        │   └── ExceptionAspect.java
                        ├── entity
                        │   ├── BaseColumn.java
                        │   └── base
                        │       └── User.java
                        ├── exception
                        │   └── SampleException.java
                        ├── repository
                        │   ├── aws
                        │   │   └── lambda
                        │   │       └── AwsLambdaRepository.java
                        │   └── db
                        │       └── base
                        │           ├── specification
                        │           └── UserRepository.java
                        └── service
                            ├── api
                            │   └── ApiSampleService.java
                            └── shared
                                ├── aws
                                │   └── lambda
                                │       └── AwsLambdaService.java
                                └── db
                                    └── base
                                        └── UserService.java

特定の横断的な関心事を実装(aspect)

ドメイン層の中で横断的に処理したい内容があるかと思います。例えばエラー処理とかですね。
それをまとめておくディレクトリとなります。

以下は、DBの楽観的ロックが発生した場合にハンドリングするための実装例です。
HTTPステータスを423(LOCKED)として扱いつつ、自前の例外クラスに詰めてthrowしている例です。

/**
 * 共通的な例外ハンドリング処理
 */
@Aspect
@Component
@Order(1)
public class ExceptionAspect {
    /** ロガー */
    Logger logger = LoggerFactory.getLogger(ExceptionAspect.class);

    /**
     * OptimisticLockException用ハンドリング処理
     * 
     * @param joinPoint 処理を挿入する場所
     * @param ex 楽観ロック例外
     * @throws SampleException
     */
    @AfterThrowing(value = "execution(* com.domain.service.api..*(..))", throwing = "ex")
    public void handleOptimisticLockException(JoinPoint joinPoint, OptimisticLockException ex)
            throws SampleException {
        logger.error(ex.getMessage(), ex);

        // エラー詳細
        List<String> invalidParams = new ArrayList<String>();
        invalidParams.add("楽観的ロックが発生しました。");

        // HTTPステータス423でサンプル例外をthrow
        throw new SampleException(invalidParams.get(0), HttpStatus.LOCKED.value(),
                invalidParams);
    }

    /**
     * ObjectOptimisticLockingFailureException用ハンドリング処理
     * 
     * @param joinPoint 処理を挿入する場所
     * @param ex 楽観ロック例外
     * @throws SampleException
     */
    @AfterThrowing(value = "execution(* com.domain.service.api..*(..))", throwing = "ex")
    public void handleObjectOptimisticLockingFailureException(JoinPoint joinPoint,
            ObjectOptimisticLockingFailureException ex) throws SampleException {
        logger.error(ex.getMessage(), ex);

        // エラー詳細
        List<String> invalidParams = new ArrayList<String>();
        invalidParams.add("楽観的ロックが発生しました。");

        // HTTPステータス423でサンプル例外をthrow
        throw new SampleException(invalidParams.get(0), HttpStatus.LOCKED.value(),
                invalidParams);
    }
}
2つ例外をハンドリングしている理由は、楽観的ロックの調査中にこちらの仕様が判明したためです。
■「OptimisticLockException」はJPAレベルで発生する例外
 →○○Repository クラス内のカスタムメソッド内で発生する。
■「ObjectOptimisticLockingFailureException」はSpring Framework レベル(レイヤー)で発生する例外
 →JpaRepositoryインターフェースで用意されている標準メソッド内で発生した「OptimisticLockException」を「ObjectOptimisticLockingFailureException」に変換する。

カスタム例外(exception)

Java標準的に様々な例外が用意されていたりはしますが、アプリケーションとして例外を用意しておきたいといったシチュエーションの時にここで管理します。

以下は、自前で用意した例外の実装例です。
ステータスコードやエラーの詳細情報を詰めておくことが出来る例外クラスです。

/**
 * サンプル例外クラス
 */
@Getter
@Setter
public class SampleException extends Exception {
    private static final long serialVersionUID = 1L;

    /** HTTPステータスコード */
    protected int status;

    /** エラー詳細 */
    private List<String> invalidParams = new ArrayList<>();

    /**
     * コンストラクタ
     * 
     * @param message エラーメッセージ
     * @param argStatus HTTPステータスコード
     */
    public SampleException(String message, int argStatus) {
        super(message);
        setStatus(argStatus);
    }

    /**
     * コンストラクタ
     * 
     * @param message エラーメッセージ
     * @param argStatus HTTPステータスコード
     * @param argInvalidParams エラー詳細
     */
    public SampleException(String message, int argStatus, List<String> argInvalidParams) {
        super(message);
        setStatus(argStatus);
        setInvalidParams(argInvalidParams);
    }
}

テーブルの定義をコード化したリソース(entity)

タイトルの通りです。
そのため、テーブルとエンティティは1対1になります。

サンプルコードを2つ記載します。まずは各テーブルの共通カラムをまとめたクラスです。
作成日、更新日、バージョンが共通項目です。
また、DB登録時(@PrePersist)は作成日と更新日、DB更新時(@PreUpdate)は更新日を共通的に設定しています。
「@MappedSuperclass」は共通項目をマッピングしておくことができます。こちらの説明が分かりやすいので参照ください。

@Data
@MappedSuperclass
public class BaseColumn {
    @Column(name = "created_at", updatable = false)
    private Timestamp createdAt;

    @Column(name = "updated_at")
    private Timestamp updatedAt;

    @Version
    @Column(name = "version")
    private Integer version;

    @PrePersist
    public void onPrePersist() {
        Timestamp now = new Timestamp(System.currentTimeMillis());
        setCreatedAt(now);
        setUpdatedAt(now);
    }

    @PreUpdate
    public void onPreUpdate() {
        setUpdatedAt(new Timestamp(System.currentTimeMillis()));
    }
}

続いて、個別のテーブル用エンティティです。
IDカラムは主キー値を生成します。PostgreSQLはSERIAL、MySQLはAUTO_INCREMENTをDB定義に付与する必要があります。

@Data
@EqualsAndHashCode(callSuper = false)
@Entity
@Table(name = "user")
public class User extends BaseColumn {
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(name = "name")
    private String name;

    @Column(name = "age")
    private Integer age;
}

エンティティの操作をするリソース(repository)

サービスクラスから指示を受けてDB(エンティティ)のCRUD操作を行います。
担当案件では、後述する共通サービスクラス(service/shared)からしか呼べない規約にしていて、
複雑なロジックやデータ加工等は、サービスクラス側が担います
そのため、リポジトリはシンプルなロジックのみ記載します。

以下が例です。
インターフェースなので、実装内容は記載不要です。これだけで後はJPA側で勝手にSQLを生成してくれます。便利ですね。

/**
 * ユーザーデータ用のリポジトリクラス
 */
public interface UserRepository
        extends JpaRepository<User, Integer>, JpaSpecificationExecutor<User> {
    /**
     * 名前、年齢をキーにユーザーデータのデータを取得
     * 
     * @param name 名前
     * @param age 年齢
     * @return ユーザーデータのデータリスト
     */
    List<User> findByNameAndAge(String name, Integer age);

    /**
     * 名前に紐づくユーザーデータのデータを削除
     * 
     * @param name 名前
     */
    void deleteByName(String name);

    /**
     * 名前に紐づくレコード件数を取得
     * 
     * @param name 名前
     * @return レコード件数
     */
    int countByName(String name);
}

(余談)
シンプルなCRUD操作なら上記で良いのですが、複雑な検索条件だと上記ではキツくなってきます。
そんな時に便利なのがSpecificationです!
ただ、紹介したいのは山々なのですが、かなり脱線してしまうのでまた別の機会に…

そして、DB操作以外で、もう1つ実装例を紹介させてください。
AWSのLambdaを操作するためのリポジトリです。

/**
 * Lambda(AWS)用のリポジトリインターフェース
 */
public interface AwsLambdaRepository {
    /**
     * Lambda関数を実行
     * 
     * @param invokeRequest Lambda関数に渡すリクエストパラメータ
     * @return Lambda関数実行結果データ
     */
    public InvokeResult invoke(InvokeRequest invokeRequest);
}

上記もインターフェースなので実装は別でします。それは後述するインフラ層で行うので、実装内容が見たい場合は、そちらを参照ください。

ビジネスロジックの実装クラス(service)

コントローラーから指示を受けるビジネスロジッククラス(service/api)

ドメイン層で各実装者がメインでコードを作成する箇所となります。
API実装の主軸となる部分です。

/**
 * サンプルAPI用サービス
 */
@Service
public class ApiSampleService {
    /** ロガー */
    Logger logger = LoggerFactory.getLogger(ApiSampleService.class);

    /**
     * サンプルデータを登録
     * 
     * @param name 名前
     * @param age 年齢
     * @return 登録されたサンプルデータの整形後レスポンスデータ
     * @throws Exception 登録時の例外
     */
    public ApiSamplePostSampleResponseData postSample(String name, Integer age)
            throws Exception {
        ApiSamplePostSampleResponseData response = new ApiSamplePostSampleResponseData();

        // TODO: データ登録などのビジネスロジックを実装

        // レスポンスデータ作成
        response.setId(id);

        return response;
    }
}

命名ルールとしては以下にしています。またコントローラーとAPI用のサービスは1対1です。
publicメソッドも1対1です。

  • クラス名(ApiSampleService):「Api + 画面物理名 + Service
  • メソッド名(postSample):「APIのメソッド(GET,POST等) + APIパスの末尾部分(今回でいうと「/sample」のスラッシュ以外)をキャメルケースにした文字列

各ビジネスロジックから呼ばれる共通サービスクラス(service/shared)

主にDBのCRUD操作やAWSなどの外部サービス操作系等で、
各ビジネスロジックから呼ばれること前提としたリソースをここで管理しています。
ルールとしては、先述で記載の通り、各ビジネスロジックから直接リポジトリを呼んだりするのではなく、sharedで定義しているサービスを呼ぶようにしています。

例えば、関連テーブルも含めたデータ登録を行う場合に、親と関連テーブルの全ての登録処理が正常に処理したらOKとするような場合に、トランザクション処理(@Transactional)を行いやすくなると思います。
また、その処理を別のところでも使い回すとかがあっても、sharedで定義しているメソッドを呼ぶだけでいいので、実装も改修も楽です。

DB操作とAWSのLambda操作の実装例を記載します。
まずはDB操作から!

/**
 * ユーザーマスタ用サービス
 */
@Service
public class UserService {

    /** ユーザーマスタ用リポジトリ */
    @Autowired
    private UserRepository userRepository;

    /**
     * ユーザー情報を取得
     * 
     * @param userId ユーザーID
     * @return
     */
    @Transactional(transactionManager = "baseTransactionManager")
    public User getById(Integer userId) {
        Optional<User> users = userRepository.findById(userId);
        if (users.isPresent()) {
            return users.get();
        }
        return null;
    }

    /**
     * ユーザー情報をデータベースに登録または更新するメソッド
     * 
     * @param userEntity 登録・更新対象のUserエンティティ
     * @return 登録・更新後のUserエンティティ
     * @throws Exception 例外が発生した場合
     */
    @Transactional(transactionManager = "baseTransactionManager")
    public User saveUser(User userEntity) throws Exception {
        // ユーザーデータに登録
        return userRepository.save(userEntity);
    }
}

これだけだと簡素なのですが、上記の「saveUser」を関連テーブルの登録も行うとかする時に呼び元は「saveUser」を呼べばいいだけになるので、便利です。

AWSのLambda操作の実装例です! 対象のLambda関数を実行してレスポンスを返すシンプルな処理です。

/**
 * AWSLambda実行用のサービス
 */
@Service
public class AwsLambdaService {
    /** AWSLambda実行用のリポジトリ */
    @Autowired
    private AwsLambdaRepository awsLambdaRepository;

    /** プロファイル */
    @Value("${SPRING_PROFILES_ACTIVE}")
    private String springProfilesActive;

    /** サンプル用Lambda関数名 */
    private static final String EXEC_SAMPLE_FUNCTION_NAME = "sample";

    /**
     * サンプル処理を実行
     * 
     * @param params パラメータ
     * @return サンプルデータ
     * @throws JsonMappingException
     * @throws JsonProcessingException
     */
    public String execSample(String params)
            throws JsonMappingException, JsonProcessingException {
        // Lambda用リクエストパラメータ
        InvokeRequest invokeRequest = new InvokeRequest()
                .withFunctionName(springProfilesActive + "-" + EXEC_SAMPLE_FUNCTION_NAME)
                .withPayload(params);

        // サンプルLambdaを実行
        InvokeResult invokeResult = awsLambdaRepository.invoke(invokeRequest);

        // Lambdaレスポンスデータ「body」を返却
        String ans = new String(invokeResult.getPayload().array(), StandardCharsets.UTF_8);
        ObjectMapper responseMapper = new ObjectMapper();
        JsonNode node = responseMapper.readTree(ans);
        return node.get("body").textValue();
    }
}

⑥ インフラストラクチャ層

データの永続化や外部サービス操作周りの実装を行う部分です。

.
└── mavs-api-server
    └── src
        └── main
            └── java
                └── com
                    └── infrastructure
                        └── aws
                            └── lambda
                                └── AwsLambdaRepositoryImpl.java

ここではドメイン層のところで記載した「AWSのLambdaを操作するためのリポジトリ」の実装クラスの例を記載します。

/**
 * Lambda(AWS)用のリポジトリ実装クラス
 */
@Repository
public class AwsLambdaRepositoryImpl implements AwsLambdaRepository {
    /** Lambdaエンドポイント */
    @Value("${LAMBDA_ENDPOINT}")
    private String lambdaEndpoint;

    /**
     * CredentialsProviderを取得する
     * 
     * @return CredentialsProvider
     */
    private AWSCredentialsProvider getCredentialsProvider() {
        // CredentialsProviderを取得
        AWSCredentialsProvider credentialsProvider = new ProfileCredentialsProvider();
        return credentialsProvider;
    }

    /**
     * Lambda関数を実行
     * 
     * @param invokeRequest Lambda関数に渡すリクエストパラメータ
     * @return Lambda関数実行結果データ
     */
    public InvokeResult invoke(InvokeRequest invokeRequest) {
        AWSLambda awsLambda = AWSLambdaClientBuilder.standard()
                .withCredentials(getCredentialsProvider())
                .withEndpointConfiguration(
                        new EndpointConfiguration(lambdaEndpoint, Regions.AP_NORTHEAST_1.getName()))
                .build();
        return awsLambda.invoke(invokeRequest);
    }
}

(担当案件ではIAMロール認証周りの実装もありますが、割愛します)

まとめ

ここまで読んでいただき、ありがとうございました!

自分自身も、ディレクトリ構成をどうしようか悩んだ時に色んな記事を参考に模索して、ここまで来たのですが、ブログにしてみると結構なボリュームになってしまいました…。(これでも色々端折ってます)

他にも似たような悩みの方がいらっしゃると思いますし、弊社メンバーも今後困ったりとかあることも考えて、実践でも(ある程度は)使えるくらいには記載したと思いますので、どなたかの参考になったら嬉しいです!

書いているうちに、色々ブログのネタが思い浮かんだりしましたので、それは別で書こうと思います!

それではまた〜!

  • この記事を書いた人
  • 最新の記事

Nakahori Shota

2020年8月にマブスに転職。経験的にはバックエンドが長いが、フロントエンドも魅力を感じて勉強中。 入社初日に激辛麻辣カリー湯麺の辛さ3倍を食べ、マブスの激辛王の称号を得る。(食べきりはしたが、翌日お腹がヤバかった)