kagamihogeの日記

kagamihogeの日記です。

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