kagamihogeの日記

kagamihogeの日記です。

spring-bootの複数ControllerAdvice

要旨

  • 一つのアプリケーションに一つのglobalなadviceにすべき
    • 画面とweb-apiとをそれぞれ個別のアプリケーションに分離するとか、出来なければcontrollerにlocalのExceptionHandlerで完結させてglobalのadiviceは一つにするとか、adviceのスコープを可能な限り限定するのが望ましい
  • 複数のadviceが避けられない場合はその仕組みを十分に理解してselectorを注意深く使うべき
    • 詳細は以下に述べるが、springの知識を基にすれば挙動は納得できるものの、意外とややこしい挙動をする。長期運用を想定するならこういうのは避けるべきだろう。

ソースコードと解説

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.1.0'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()
}

複数adviceの回避案

まずは複数adviceの回避あるいはトラブルが発生しにくい設定方法について考える。

各controllerにlocalのExceptionHandler

グローバルな例外処理とは別に一部のcontrollerにだけ共通の例外処理を設定したい場合はそのcontrollerにlocalの@ExceptionHandlerを設ける。これは@ControllerAdviceよりも前に適用される。

Global @ExceptionHandler methods, from an @ControllerAdvice, are applied after local ones, from the @Controller.

https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-advice.html

@RestController
public class ApiController {
  @ExceptionHandler(RuntimeException.class)
  public ResponseEntity<ProblemDetail> exception() {

    return ResponseEntity
        .status(HttpStatus.INTERNAL_SERVER_ERROR)
        .body(ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR));

例えば、大半が@Controllerの画面だが補助的にajaxとして使用する@RestControllerが一部存在する、等の場合に有効と考えられる。ただ、この例だとlocalの@ExceptionHandlerで捕捉出来ないと@Controller用のadviceに飛んでweb-apiなのにtext/htmlを返す、という事態になりかねない点には注意が必要である。

adviceのselector

@ControllerAdvice, @RestControllerAdviceにはその適用範囲を限定する属性がある。@ControllerAdviceのjavadocではselectorと呼称している。

Use selectors such as annotations(), basePackageClasses(), and basePackages() (or its alias value()) to define a more narrow subset of targeted controllers.

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/ControllerAdvice.html

basePackageClassesやbasePackagesのpackage

そのadviceが適用されるpackageを指定する。

例えば、@Contoller@RestControllerがそれぞれ異なるpackageに配置してあり、それぞれに異なるadviceを指定したい場合の例は以下となる。複数のadviceが相互排他になるよう指定したい場合にはこれが有効と思われる。

@ControllerAdvice(basePackageClasses = WebController.class)
public class SampleControllerAdvice {
@RestControllerAdvice(basePackageClasses = ApiController.class)
public class SampleRestControllerAdvice {

annotations

https://docs.spring.io/spring-framework/docs/7.0.8/javadoc-api/org/springframework/web/bind/annotation/ControllerAdvice.html#annotations()

例えば、下記のような複合アノテーションと@ControllerAdviceannotations属性の使い方が考えられる。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RestController
public @interface MyApi2 {}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
public @interface MyWeb2 {}
@ControllerAdvice(annotations = MyWeb2.class)
public class SampleControllerAdvice {
@RestControllerAdvice(annotations = MyApi2.class)
public class SampleRestControllerAdvice {

うまくいかないケース

ここからは複数adviceが一見意図した挙動になりそうだけど意図しない挙動になるパターンについて。

annotationsだけではControllerとRestControllerの異なるadviceは上手くいかない

以下のように複数adviceそれぞれのannotations@Controller@RestControllerを指定すればそれぞれwebとapi専用のadviceを設定可能に見えるがこれは上手くいかない事がある。自分の環境だと両方ともSampleControllerAdviceに行ってしまう。つまり、web-apiなのにtext/htmlを返してしまう。

@ControllerAdvice(annotations = Controller.class)
public class SampleControllerAdvice {
@RestControllerAdvice(annotations = RestController.class)
public class SampleRestControllerAdvice {

そこで、以下のように@OrderSampleRestControllerAdvice -> SampleControllerAdvice の順にすると意図通りの挙動になる。

@ControllerAdvice(annotations = Controller.class)
@Order(20)
public class SampleControllerAdvice {
@RestControllerAdvice(annotations = RestController.class)
@Order(10)
public class SampleRestControllerAdvice {

このように、複数adviceはmanged-beanの順序によっては偶然意図通りの動作に見えてしまう事がありうる。

原因の解説

一言で言えば、@RestControllerAdvice@ControllerAdviceであり、内部的にはadvice単位で管理され、複数存在する場合は@Order等で評価の順序が決まるため。

内部的にはどっちもControllerAdvice

まず、以下の通り@RestControllerAdvice@ControllerAdvice + @ResponseBodyの複合アノテーションである。

// (一部省略)
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {

このため、@ControllerAdvice@RestControllerAdviceはどちらも内部的には@ControllerAdviceとして扱われる。なので、@RestControllerで発生した例外が意図に反して@ControllerAdviceに行ってしまった、というのは仕様通りの設定ミスな事が多い、と思われる。ただ、一見は上手くいくように見えるても次に述べる要因により偶然意図通り動作してしまう場合があるので注意が必要。

複数のControllerAdviceの順序は実装依存

次に、advice内部をものすごく簡略化すると Map<@ControllerAdvice, @ExceptionHandlerのメソッド> で保持しており、複数adviceが存在する場合は@Order等で評価の順序が決まるのは他のmanaged-beanと同様。

なので、複数の@ControllerAdviceが存在し、かつ、@Order等の明示的な順序が無い場合はadviceの評価順序は実装依存となる。なので、運が良ければ意図通りの順序のadviceになるが、何らかの事情で順序が変わると突然挙動が変わりうる。まあ実際には早々内部実装が変わる事は無いので発覚しない事も多そうではある。

selectorのannotationsは再帰的

annotationsのselectorは内部的にAnnotationUtils.findAnnotationを使用して再帰的に探索するので記述の以下の例は上手くいかない事がある。

@ControllerAdvice(annotations = Controller.class)
public class SampleControllerAdvice {
@RestControllerAdvice(annotations = RestController.class)
public class SampleRestControllerAdvice {

annotations = Controller.class@Controllerにも@RestControllerにもマッチするのに対してannotations = RestController.class@RestControllerにだけマッチする。前述の通り@Orderを付与すれば意図通りの振る舞いになる事はなる。まぁでも直感的にはなんで??? って感じなので自分ならやらない。

感想

可能な限り複数adviceは避けるべき。

昔のmariadb-java-clientはfetch across commitが出来ない

ぐぐってみると、mariadb-java-clientは2014年頃の1.1.7ではfetch-sizeの挙動が怪しくデータ量など運が悪いとOOMになるケースも多かったようだが、少なくとも3.5.7ではその挙動は無くなっている。

検証内容

docker run --name some-mariadb -e MARIADB_ROOT_PASSWORD=pass -p 3306:3306 mariadb:latest

簡単にメモリを溢れさせるために適当な長さの文字列カラムを持つテーブルを作成。適当な件数をあらかじめ追加しておく。

create table sample (
 id SERIAL NOT NULL PRIMARY KEY,
 name varchar(10000)
);

以下の定義だとorg.mariadb.jdbc:mariadb-java-client3.5.7となる。

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.0.1'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    implementation 'org.apache.commons:commons-lang3'

    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' //3.5.7
}

tasks.named('test') {
    useJUnitPlatform()
}

簡単にOOM起こさせるために最大メモリを極端に小さくする。

-Xmx10m

1.まずは以下のように全件読み込もうとしてOOMの発生を確認する。

    JdbcClient c = JdbcClient.create(ds);
    List<DataLoaddd> list = c.sql("select id, name from sample").query(DataLoaddd.class).list();
    System.out.println(list.size());

2.次に、fetch-sizeを指定してstreamではOOMが発生しない事を確認する。

    JdbcClient c = JdbcClient.create(ds);
    c.sql("select id, name from sample")
        .withFetchSize(1)
        .query(DataRecored.class)
        .stream()
        .forEach(d -> {
          System.out.println(d);
        });

3.加えて、読み込んだ行をupdateするトランザクション処理を追加してもOOMが発生しない事を確認する。

    JdbcClient c = JdbcClient.create(ds);
    c.sql("select id, name from sample")
        .withFetchSize(1)
        .query(DataRecored.class)
        .stream()
        .forEach(d -> {
          System.out.println(d);

          TransactionDefinition t = new DefaultTransactionDefinition();
          TransactionStatus transaction = m.getTransaction(t);
          int update = c.sql(
              "update sample set name = '" + RandomStringUtils.insecure()
                  .nextAlphanumeric(10000) + "' where id = " + d.id()).update();
          m.commit(transaction);

        });

ここでmariadb-java-clientを超古い1.1.7に変更すると、1・2・3いずれもOOMになる。

    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client:1.1.7'

というわけで、2014年頃とかの1.1.7はまともにfetch-sizeが機能していなかったが現代の3.5.7とかはちゃんと機能していそうである。少なくとも、良くあるfetch across commitは書けそうではある。

余談

はじめてMariaDBとそのJDBCを使う事になったが「mariadb bulk fetch」とかでぐぐると上手くいかないとか何とかのブログが何件かヒットして不安になった。ただ、いずれも2015年前後のかなり古いもの。それで、とりあえず単純な動作だけでも確認しとくか、となった。こんくらいなら問題無さそうなのでとりあえず安心している。

spring-bootのrecordへのデシリアライズとバリデーション

record導入後はspring-bootのhttp request/responseのマッピング先はこれになる……かどうかは分からない。とはいえ一通りの挙動確認をする。また、recordが普及すればいわゆるvalue objectへ直接マッピングも増えると思われるのでそれを想定した確認をする。

環境

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.0.5'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2'

    testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()
}

デシリアライズ

厳密にはjacksonの使い方ではあろうが、spring-bootを特に何も考えなければこれを使うであろう。

以降では省略するが、下記のようなcontrollerメソッド引数でreqeustのjsonを親となるrecordへデシリアライズ、を考える。

  @PostMapping("/a")
  public String a(@RequestBody SampleRequest request) {

単一引数

{
  "id": "asdf"
}
public record SampleRequest(SampleId id) {}
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonCreator.Mode;

public record SampleId(String value) {
  @JsonCreator(mode = Mode.DELEGATING)
  public SampleId {}
}

親recordのコンストラクタ引数名をキー名とし、@JsonCreator(mode = Mode.DELEGATING)のコンストラクタ委譲によりvalue objectのコンストラクタへ値を渡す。@JsonCreatorとかDELEGATINGとかややこしいがjacksonでは仕方ないかな……と思う。ややこしい事の詳細は下記を参照。

kagamihoge.hatenablog.com

単一引数(List)

{
  "list": ["string", "sadf"]
}
public record SampleRequestList(SampleList list) {}
public record SampleList(List<String> value) {
  @JsonCreator(mode = Mode.DELEGATING)
  public SampleList {}
}

単一引数(List要素がvalue object)

配列要素をvalue objectにマッピングする。

{
  "list": ["a", "b"]
}
public record SampleRequestListValue(SampleListObject list) {}
public record SampleListObject(List<SampleId> value) {
  @JsonCreator(mode = Mode.DELEGATING)
  public SampleListObject { }
}
SampleRequestListValue[list=SampleListObject[value=[SampleId[value=a], SampleId[value=b]]]]

出来るのか……という奇妙な感動をする。

複数引数

{
  "start": 10,
  "end": 20
}
import com.fasterxml.jackson.annotation.JsonUnwrapped;

public record SampleRequestRange(
    @JsonUnwrapped
    SampleRange range) {}
public record SampleRange(int start, int end) {}

Java側の子オブジェクトのプロパティを親のものとして扱う。

qiita.com

なお、下記のように単に子オブジェクトとして扱うなら@JsonUnwrappedは不要。

{
  "range": {
    "start":10,
    "end": 20
  }
}

ミックス

上記までのを一緒に使用する。

{
  "id": "asdf",
  "list": ["string", "sadf"],
  "start": 0,
  "end": 0
}
public record SampleRequestMix(
    SampleId id,
    SampleList list,
    @JsonUnwrapped
    SampleRange range) {}

バリデーション

セキュア・バイ・デザイン方式

recordのコンストラクタに条件をべた書きする。

public record SampleRequest(SampleId id) {

  public SampleRequest {
    if (id == null) {
      throw new MyDomainViolationException();
    }
  }
}
public record SampleId(String value) {

  @JsonCreator(mode = Mode.DELEGATING)
  public SampleId {
    if (value == null || value.isEmpty()) {
      throw new MyDomainViolationException();
    }

    if (2 < value.length()) {
      throw new MyDomainViolationException();
    }
  }
}

大仰な名前だが、セキュア・バイ・デザイン: 安全なソフトウェア設計 が紹介している方式だからここではその名前をお借りした。詳細は書籍参照で、ざっくり言えば、ドメインルールの順守がセキュアにも繋がる、という主張。書籍は説明のため特定のフレームワーク・ライブラリへの依存を避けてるだけとは思うが、Javaならコンストラクタにベタ書きと実行時例外の組み合わせは、見た目はモッサリするが十分現実的かな、と思う。

なお、jacksonの変換ルールには注意が必要。たとえば、{"id": null}とか{}だとvalue-objectのコンストラクタまで処理が来ない(=DELEGATINGが発生しない)ため、意図通りの挙動にならない。なので親record側でnullチェックが必要になる。ここはドメインではなくcontrollerの世界だから@NotNullとミックスしても良いか、という割切もアリ……かもしれない。

アノテーション

@RestController
public class SampleController {

  @PostMapping("/a")
  public String a(@RequestBody @Validated SampleRequest request) {
public record SampleRequest(
    @Valid
    SampleId id) {}
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;

public record SampleId(
    @NotEmpty
    @Size(max = 2)
    String value) {

  @JsonCreator(mode = Mode.DELEGATING)
  public SampleId {
  }
}

まず、従来通りcontrollerメソッドにorg.springframework.validation.annotation.Validated(またはjakarta.validation.Valid)を付与する。また、これも従来通りネストしたオブジェクトを検証対象にする場合はそれに@Validを付与する。最後に、recordのコンストラクタ引数にアノテーションを付与する。

@JsonUnwrapped も同様。

public record SampleRequestRange(
    @JsonUnwrapped
    @Valid
    SampleRange range) {}
public record SampleRange(@Max(5) int start, int end) {}

感想とか

デシリアライズだけならrecordでシンプルに収められそうな感触がある。

入力値検証は……正直よくわからない。spring bootだと伝統的なbean validationが実績もあり情報も豊富だが、ややこしい事をしようとするとすごいややこしい事になるのが個人的にはあまり好きではない。セキュア・バイ・デザイン方式は考え方はシンプルでややこしい検証ルールも素直に実装してしまえば良いが、コードがモッサリしてアノテーションの簡潔さを捨ててしまうのも勿体なく感じる。昨今はドバッと大量生成もなんとなく許される風潮もあるが、そのトレンドも何時まで続くのかはよくわからない。

他言語や他ライブラリだと全く別アプローチをしておりなるほどなぁと関心するが、とはいえ既存コードとあまりにも隔絶した方針は取れないしなぁ、などと悩む。ずーっと悩んでるで一生悩み続ける気がしないでもない。

参考URL