SpringBoot(のSpringMVC)における自動型変換と入力チェック(Validation)を同時に行う 

## はじめに

SpringBoot(に含まれるSpringMVC：以下SpringMVCと略す）で受け取る入力パラメータの変換と設定する方法と、入力チェックの両方を行う方法を紹介します。

## サンプルコードの動作確認バージョン

* SpringBoot 3.0.2
* JDK 17

## 入力項目を受け取るには

SpringMVC にてWebからパラメータを受け取る方法は、以下の手順で行います。

* `@Controller` アノテーションないしは `@RestController`を付与したクラスを用意する
* `@RequestMapping` にURLのパスを設定
* HTTPメソッドに対応した `@GetMapping` や `@PostMapping` を付与したメソッドを作成する
* メソッドの引数にパラメータ名と同じ名前の変数 ないしは パラメータ名と同じ名前の変数を持たせた任意のクラスを配置する

例：リクエストするURLを `/shop` に設定し、受け取るパラメータに 店舗番号 shopNumber と、店舗名 shopName を設定した Controller

```ShopController.java
package com.github.apz.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/shop")
@Slf4j
public class ShopController {
    @GetMapping
    public String display(Model model, Integer shopNumber, String shopName) {
        log.info("shopNumber: {}, shopName {}", shopNumber, shopName);
        return "success";
    }
}
```

このControllerへリクエストします

```
http://localhost:8080/shop?shopNumber=120&shopName=Alpha
```

出力したログ（一部を省略）
```
c.g.a.controller.ShopController   : shopNumber: 120, shopName Alpha
```

SpringMVCを含む JavaのWebアプリケーションは、すべてのリクエストパラメータはStringで受け取ります。SpringMVCではプリミティブ型とそのラッパークラス（Integerなど）、他にもSpringMVCがデフォルトで自動的に型変換の候補にいれているクラスは、SpringMVCが自動的に判断して変数の値を型変換[^1] します。

[^1]: [JSR-354 Money & Currency(https://github.com/JavaMoney/jsr354-api)](https://github.com/JavaMoney/jsr354-api) の通貨関連クラスと, JSR-310 Date-Time|Joda-Time 2.xなど日付時刻関連クラスへの自動変換とフォーマット、などなど。

なお、入力項目をControllerの引数にすべて並べる以外にも、別のクラスに定義する方法も選べます。これによりContollerメソッドの引数を簡素にできます。

```ShopController.java
package com.github.apz.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/shop")
@Slf4j
public class ShopController {

    @GetMapping
    public String display(Model model, ShopForm shopForm) {
        log.info("shopForm: {}", shopForm);
        return "success";
    }
}
```

先程と同じパラメータを受け取る ShopForm クラスを用意し、Controllerの引数に記述します。

```ShopForm.java
package com.github.apz.controller;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@NoArgsConstructor @Getter @Setter @ToString
public class ShopForm {
    private ShopNumber shopNumber;
    private String shopName;
}
```

入力項目は ShopForm へ格納されます。特殊な設定は必要ありません。なお、このControllerでパラメータを受け取るクラスは複数に分割もできます。

## 入力項目を入力チェック（Validation）するには

リクエストを受け取るControllerメソッドの引数に `@Validated` を付与します。入力チェックの結果は、`@Validated` を付与した次の引数に `BindingResult` クラスを配置して、入力チェックの結果を受け取れます。

:warning: Validationのパッケージは、SpringBootのバージョン3.0.2に対応している spring-boot-starter-validation 3.0.2が hibernate-valiator 8.0.0-final を指定しますので、 **jakarta.validation** になっています。

```ShopController.java
package com.github.apz.controller;

import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/shop")
@AllArgsConstructor
@Slf4j
public class ShopController {

    @GetMapping
    public String display(Model model, @Validated ShopForm shopForm, BindingResult bindingResult) {
        log.info("shopForm: {}", shopForm);
        if (bindingResult.hasErrors()) {
            log.info("error: {}" , bindingResult.getFieldError());
            return "field error: " + bindingResult.getFieldError();
        }
        return "success";
    }
}
```

```ShopForm.java
package com.github.apz.controller;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@NoArgsConstructor @Getter @Setter @ToString
public class ShopForm {

    @NotNull
    @Min(1) @Max(200)
    private Integer shopNumber;

    @NotEmpty
    @Size(min = 2, max = 8)
    private String shopName;
}
```


## 入力項目を型変換（Type Convertion）する

Webからのパラメータを受け取る変数にプリミティブ型やそのラッパー型以外のクラス、またはSpringMVCがデフォルトで変換を提供しているクラス以外のものを変換したい場合は、独自で型変換を定義できます。

例えば以下のように、shopNumberの値を扱う変数を Integer ではなく、ShopNumber クラスの子要素であるvalue に格納させたい場合です。

```ShopForm.java
package com.github.apz.controller;

import com.github.apz.model.shop.ShopNumber;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@NoArgsConstructor @Getter @Setter @ToString
public class ShopForm {
    private ShopNumber shopNumber;
    private String shopName;
}
```

```ShopNumber.java
package com.github.apz.model.shop;

import lombok.Value;

@Value(staticConstructor = "of")
public class ShopNumber {
    private Integer value;
}
```

Webからのパラメータ shopNumber と shopName を扱う ShopForm クラスでは、shopNumber はShopNumberクラスに対応させます。shopNumberの値は、ShopNumber内の value 変数に格納させているとします。

この Webからの **shopNumber の値を ShopNumber へ変換してインスタンスを生成する術を、SpringMVCへ設定** します。

設定は以下の2つどちらかで行います。

* 個々のControllerにて、`@InitBinder` を付与したメソッドにて、`PropertyEditor`を実装した変換用のクラスを定義する
* アプリケーション全体の設定である `@Configulation` を付与している `WebMvcConfigurer` を実装するクラスにて、変換用のクラスを定義する

### 個々のControllerに設定する ＠InitBinder を利用した例

手順：
1. `java.beans.PropertyEditor` を実装 ないしは `java.beans.PropertyEditorSupport` を継承した 変換用のクラスを作成する
1. `@InitBinder`を付与したメソッドにて、上記の変換クラスを設定する

SpringMVCは、PropertyEditor(Support)をString（文字列）と変換対象のクラスを相互変換させるために利用しています。

```ShopController.java
package com.github.apz.controller;

import com.github.apz.config.ShopNumberPropertyEditor;
import com.github.apz.model.shop.ShopNumber;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ui.Model;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/shop")
@AllArgsConstructor
@Slf4j
public class ShopController {

    @InitBinder
    void initBinder(WebDataBinder binder) {
        binder.registerCustomEditor(ShopNumber.class, new ShopNumberPropertyEditor());
    }


    @GetMapping
    public String display(Model model, ShopForm shopForm) {
        log.info("shopForm: {}", shopForm);
        return "success";
    }
}
```

```ShopNumberPropertyEditor.java
package com.github.apz.config;

import com.github.apz.model.shop.ShopNumber;

import java.beans.PropertyEditorSupport;
import java.util.Objects;

public class ShopNumberPropertyEditor extends PropertyEditorSupport {
    @Override
    public void setAsText(String text) throws IllegalArgumentException {
        try {
            setValue(ShopNumber.of(Integer.valueOf(text)));
        } catch (NumberFormatException exception) {
            throw new IllegalArgumentException(text);
        }
    }

    public String getAsText() {
        ShopNumber shopNumber = (ShopNumber) getValue();
        return Objects.nonNull(shopNumber) ? shopNumber.toString() : null;
    }
}
```

### アプリケーション全体設定として WebMvcConfigurerの実装クラスで定義する例

設定手順はこちらの方が簡単です。すべてのControllerに適用されます。

```WebMvcConfig.java
package com.github.apz.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new ShopNumberConverter());
    }
}
```

変換クラスはSpringMVCで用意されている `org.springframework.core.convert.converter.Converter` を実装します。

```ShopNumberConverter.java
package com.github.apz.config;

import com.github.apz.model.shop.ShopNumber;
import org.springframework.core.convert.converter.Converter;

public class ShopNumberConverter implements Converter<String, ShopNumber> {
    @Override
    public ShopNumber convert(String source) {
        return ShopNumber.of(Integer.valueOf(source));
    }
}
```

## 型変換させると＠Validatedだけでは空振りしてしまう

さて、型変換を実装できたところで入力チェックも同じように設定します。

```(抜粋)ShopController.java
public class ShopController {

    @GetMapping
    public String display(Model model, @Validated ShopForm shopForm, BindingResult bindingResult) {
        ....
    }
}
```

```ShopForm.java
@NoArgsConstructor @Getter @Setter @ToString
public class ShopForm {
    private ShopNumber shopNumber;

    @NotEmpty
    @Size(min = 2, max = 8)
    private String shopName;
}
```

```ShopNumber.java
@Value(staticConstructor = "of")
public class ShopNumber {

    @NotNull
    @Min(1) @Max(200)
    private Integer value;
}
```

何と、このままではShopNumberの入力チェックは機能しません。それは入力チェックのアノテーション `@Validated` は、付与した変数とその子要素までを対象とするからです。

先程挙げたクラスを例にすると、`@Validated` を付与している ShopForm が対象になるので、このクラスの変数が入力チェック対象になります。だからといって、

```ShopForm.java
@NoArgsConstructor @Getter @Setter @ToString
public class ShopForm {
    @NotNull
    @Min(1) @Max(200)
    private ShopNumber shopNumber;

    @NotEmpty
    @Size(min = 2, max = 8)
    private String shopName;
}
```

このようにShopNumberへ直接に `@NotNull` や `@Min(1)` などの制約をつけても、
「貴殿はShopNumberクラスなのにIntegerクラスの制約をつけているぞ？」
と当然のように弾かれてしまい、これはうまく動いていません。

## 自動型変換しつつ、従来どおり入力チェックを行うには

入力チェックのアノテーション `@Validated` は、付与した変数とその子要素までを対象としますので、さらに入力チェックを掘り下げるよう、ShopNumberクラスそのものもValidationを適用させるため、`@Valid` を ShopNumberへ付与します。

```ShopForm.java
package com.github.apz.controller;

import com.github.apz.model.shop.ShopNumber;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@NoArgsConstructor @Getter @Setter @ToString
public class ShopForm {
    @Valid  // Validation対象を対象のクラス内にも広げる
    private ShopNumber shopNumber;

    @NotEmpty
    @Size(min = 2, max = 8)
    private String shopName;
}
```

```ShopNumber.java
package com.github.apz.model.shop;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Value;

@Value(staticConstructor = "of")
public class ShopNumber {

    @NotNull
    @Min(1) @Max(200)
    private Integer value;
}
```

```ShopController.java
package com.github.apz.controller;

import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/shop")
@AllArgsConstructor
@Slf4j
public class ShopController {

    @GetMapping
    public String display(Model model, @Validated ShopForm shopForm, BindingResult bindingResult) {
        log.info("shopForm: {}", shopForm);
        if (bindingResult.hasErrors()) {
            log.info("error: {}" , bindingResult.getFieldError());
            return "field error: " + bindingResult.getFieldError();
        }
        return "success";
    }
}
```

これで想定どおりの動作をしてくれます。

### 利点

入力項目に対して型変換とチェック方法を統一でき、これがWebアプリケーションの制約としても働くので、複数Controller間で変数の扱いや入力チェックのルールを揃えることが可能です。

## まとめ

* 入力項目を格納するクラスへ自動的に型変換する方法を示しました
* 型変換したクラスにも入力チェックを実行する方法を示しました

## 参考記事

https://hibernate.org/validator/

## サンプルプロジェクト

https://github.com/A-pZ/spring-type-convertion-sample