ぺんぎんらぼ

お笑いとマンガ好きなしょぼしょぼWeb系エンジニアの日記です。たまに絵を描きます。

お笑いとマンガ好きなしょぼしょぼWeb系エンジニアの日記です

Spring Bootで作るWebアプリケーション⑦ - 相関バリデーション

前回では、Spring Bootを使ったWebアプリケーションで入力されたデータをバリデーション(入力チェック)する方法を解説しました。
前回のバリデーションは項目単位のバリデーションで、単項目チェックや単項目バリデーションと呼ばれるものでした。しかし、実際にアプリケーションでは、複数の項目にまたがったチェックロジックを必要とすることが多くあります。
今回は、入力された複数のデータにまたがってチェックをする、相関チェックや相関バリデーションと呼ばれるものの実装方法を解説します。

相関バリデーションとは

画面から入力された複数のデータにまたがって、データの正当性を検証することを相関チェックや相関バリデーションと呼びます。

例えば、郵便番号の主番号は3桁であること、子番号は4桁であること。これは、各項目ごとにバリデーションすればよいので、前回に解説した単項目チェックで検証できます。
しかし、郵便番号の入力自体の省略可能にした場合、主番号と子番号の両方が未入力であることをチェックする必要があります。

今回、解説する相関バリデーションを使用すると、複数の入力データにまたがるチェックだけでなく、さまざまなチェックを実装することができます。

  • 複数の入力データにまたがるチェック
  • 入力データとデータベースなど、サーバ内に保存されたデータの組み合わせチェック
  • 複雑なロジックを必要とするチェック

準備

相関バリデーションの実装前に、これまで作成したアプリケーションを少し修正します。

名前の入力項目を「苗字」と「名前」に分け、画面も別の入力項目を設けます。

ProfileForm .javaを修正して、nameフィールドをfirstName、lastNameフィールドの2つに置き換えます。

package penguin.web.controller.form;

import javax.validation.constraints.Size;

public class ProfileForm {

    @Size(min = 3, max = 15)
    private String firstName;

    @Size(min = 3, max = 15)
    private String lastName;

    private Integer age;

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }
}

入力画面のname.htmlも同様に、名前の入力項目を「苗字」と「名前」の2つの項目に置き換えます。

<!DOCTYPE html>
<html lang="ja" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Name</title>
<style>
.input-error {
    border: 1px solid red;
}
</style>
</head>
<body>
<form th:action="@{/profile/age}" th:object="${profileForm}" method="post">
    <p>
        <label for="last_name">苗字を入力してください</label>
        <input type="text" name="last_name" id="‘last_name" th:field="*{lastName}" th:errorclass="input-error">
        <span style="color: red;" th:if="${#fields.hasErrors('lastName')}" th:errors="*{lastName}"></span>
    </p>
    <p>
        <label for="first_name">名前を入力してください</label>
        <input type="text" name="first_name" id="first_name" th:field="*{firstName}" th:errorclass="input-error">
        <span style="color: red;" th:if="${#fields.hasErrors('firstName')}" th:errors="*{firstName}"></span>
    </p>
    <p>
        <input type="submit" value="次へ">
    </p>
</form>
</body>
</html>

バリデーションの実装

では、相関バリデーションの実装に進みます。
ここでは例として、新しく追加した「苗字」と「名前」の文字数の合計が20文字以内であることを検証するバリデーションを実装します。

相関バリデーションの実装方法はいくつかあります。

方法1 - フォームBeanに検証メソッドを実装

フォームBeanに検証メソッドを追加して、そのメソッド内に検証ロジックを実装します。

追加する検証メソッドは、以下のルールに従って実装します。

  • @AssertTrueアノテーションをメソッドに指定する。
  • メソッドは、存在しないフィールドのgetterメソッドとして実装する。
  • getterメソッドなので、メソッド名は「is」もしくは「get」で始まる必要がある。
  • 復帰値の型はbooleanにする。
  • バリデーションの結果、正当であればtrue、不当であればfalseを復帰値とする。

追加するメソッドは以下のようになります。

    @AssertTrue(message = "苗字と名前は合計20文字以内で入力してください。")public boolean isValidName() {                                             ➋
        return (firstName != null ? firstName.length() : 0)
                + (lastName != null ? lastName.length() : 0) <= 20;   ➌
    }

➊ 検証メソッドに@AssertTrueアノテーションを指定します。message属性にエラーメッセージを指定することができます。
➋ 検証メソッドをgetterメソッドとして追加します。復帰値の型はbooleanです。ここで注目してほしい点は、存在しないvalidNameフィールドのgetterメソッドとして実装していることです。
➌ チェックロジックを実装します。ここでは苗字の文字数と名前の文字数が20文字以下であれば正当としてtrue、20文字を超える場合は不当としてfalseを復帰値とします。

バリデーションの実装は以上です。ここまでの実装で実行するとわかりますが、このバリデーションでエラーになった場合、画面上でもエラーとなりますが、エラーメッセージが表示されません。理由は、この方法で検出されたエラーはフィールドに紐づくエラーになるためです。今回の実装では、存在しないvalidNameフィールドのエラーとなるので、画面上にエラーメッセージが表示されません。

入力画面のname.htmlを以下のように変更することで、存在しないvalidNameフィールドのエラーのメッセージを表示させることができます。

    <span style="color: red;" th:if="${#fields.hasErrors('validName')}" th:errors="*{validName}"></span><p>
        <label for="last_name">苗字を入力してください</label>
        <input type="text" name="last_name" id="‘last_name" th:field="*{lastName}" th:errorclass="input-error">
        <span style="color: red;" th:if="${#fields.hasErrors('lastName')}" th:errors="*{lastName}"></span>
    </p>

➊ 存在しないvalidNameフィールドでエラーが発生した場合、エラーメッセージを表示します。

f:id:penguinlabo:20201123182733p:plain

この方法の特徴

この方法は実装が非常に簡単ですが、コードの見やすさや保守面で不利な面があります。

  • フォームBeanにバリデーションロジックが入り込む。定義と実装はなるべく疎結合にすべきなので、フォームBeanは定義だけを含めるべき。
  • バリデーションの結果が、存在しないフィールドのエラーとして割り当てられる。

特に、前者のフォームBeanにバリデーションロジックが入り込む点は、コードが見にくくなる原因になるので、避けたいところです。

方法2 - バリデーションアノテーションを自作

これまで、バリデーションアノテーションはBean Validationで用意されていたものを使用しました。

penguinlabo.hatenablog.com

バリデーションアノテーションは自作することも可能です。バリデーションアノテーションを自作して、相関バリデーションを実装してみます。

バリデーションアノテーションを自作する場合、アノテーション自体と、アノテーションに紐づくバリデーションを実装することになります。

アノテーションの実装は以下の通りです。

@Target(value = ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NameSizeValidator.class)public @interface NameSize {
    String message() default "苗字と名前は合計{min}文字から{max}文字で入力してください。";  ➌

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

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

    int min() default 0;                            ➍

    int max() default Integer.MAX_VALUE;            ➎
}

➊ 相関バリデーションに使用するアノテーションなので、指定可能な場所はクラスに限定します。
➋ バリデーションの実装クラスを指定します。バリデーションの実装方法は、この後に解説します。
➌ バリデーションのエラーメッセージを定義します。
➍ バリデーションの属性として最小値を定義します。
➎ バリデーションの属性として最大値を定義します。

次に、バリデーションを実装します。

public class NameSizeValidator implements ConstraintValidator<NameSize, ProfileForm> {  ➊
    private int min;
    private int max;

    @Override
    public void initialize(NameSize annotation) {                                       ➋
        min = annotation.min();
        max = annotation.max();
    }

    @Override
    public boolean isValid(ProfileForm value, ConstraintValidatorContext context) {     ➌
        int size = (value.getFirstName() != null ? value.getFirstName().length() : 0)
                + (value.getLastName() != null ? value.getLastName().length() : 0);

        return size >= min && size <= max;                                              ➍
    }
}

➊ ConstraintValidatorを実装したクラスを作成します。総称型の1つ目はアノテーションのクラス、2つ目はバリデーション対象のクラスを指定します。
➋ initializeメソッドをオーバーライドします。引数でアノテーションを受け取るので、アノテーションの属性から最大値、最小値を取得します。
➌ isValidメソッドをオーバーライドします。引数でバリデーション対象のオブジェクトを受け取るので、そのオブジェクトの内容を検証するロジックを実装します。
➍ 苗字と名前の長さの合計が最小値以上、かつ最大値以下であることを検証します。

アノテーションとバリデーションすることで、自作したバリデーションアノテーションを使用する準備ができました。

自作したバリデーションアノテーションをフォームで使用することで、バリデーションが実行されます。

@NameSize(min = 6, max = 20)public class ProfileForm {

    @Size(min = 3, max = 15)
    private String firstName;

    @Size(min = 3, max = 15)
    private String lastName;

➊ 作成したバリデーションアノテーションをクラスに指定します。アノテーションに定義されている属性により、最小値、最大値を指定します。

クラスに指定するバリデーションアノテーションなので、エラーはフィールドには紐づかず、グローバルエラーとなります。

name.htmlを修正して、グローバルエラーのメッセージも表示されるように修正します。

    <p style="color: red;" th:each="error : ${#fields.globalErrors()}" th:text="${error}"></p><p>
        <label for="last_name">苗字を入力してください</label>
        <input type="text" name="last_name" id="‘last_name" th:field="*{lastName}" th:errorclass="input-error">
        <span style="color: red;" th:if="${#fields.hasErrors('lastName')}" th:errors="*{lastName}"></span>
    </p>

➊ グローバルエラーを表示します。グローバルエラーは複数、存在できるものなので、th:each属性でグローバルエラーの数分、エラーを表示させます。

この方法の特徴

実装したコードを見てもわかる通り、バリデーションの実装と、バリデーションを使うときの定義が完全に分離されていることがわかります。
フォームの実装は、次の一文が追加されるだけです。

@NameSize(min = 6, max = 20)

このアノテーションを見れば、名前の最小文字数が6文字、最大文字数が20文字であることの検証がされることがわかります。
フォームに余計な要素が入り込むことがなく、とても分かりやすいコードになります。

ただ、このバリデーションの実装方法も、マッチした使用方法でないと、コードの可読性や保守性の低下につながります。

今回の実装例では、ProfileFormクラスの苗字、名前のフィールドに特化したバリデーションになっており、ほかのクラスでは使用できない汎用性のないバリデーションになっています。
このアノテーションに対象のフィールドを指定する属性を追加することで、バリデーションの汎用性を上げることができます。

@MultipleSize(field = {"firstName", "lastName"}, min = 6, max = 20)

方法3 - Springバリデーションを実装

方法1、方法2は、Bean Validationの仕組みを利用したものでした。Spring Frameworkでは、独自のバリデーションの仕組みが用意されています。
ここでは、このSpringバリデーションの仕組みを利用したバリデーションの実装方法を説明します。

バリデーションとしては、org.springframework.validation.Validatorを実装したクラスに検証ロジックを実装します。

@Componentpublic class ProfileValidator implements Validator {      ➋

    @Override
    public boolean supports(Class<?> clazz) {             ➌
        return ProfileForm.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {  ➍
        ProfileForm form = ProfileForm.class.cast(target);

        String firstName = form.getFirstName();
        String lastName = form.getLastName();
        int size = (firstName != null ? firstName.length() : 0) + (lastName != null ? lastName.length() : 0);

        if (size < 6 || size > 20) {                      ➎
            errors.reject("", "苗字と名前は合計6文字から20文字で入力してください。");  ➏
        }
    }
}

➊ @Componentアノテーションを指定して、DI管理対象とします。
➋ org.springframework.validation.Validatorを実装します。
➌ supportsメソッドをオーバーライドします。引数でクラスが渡されるので、そのクラスがバリデーション対象か否かをbooleanで返却します。
➍ validateメソッドをオーバーライドします。引数でバリデーション対象のオブジェクトを受け取るので、そのオブジェクトの内容を検証するロジックを実装します。
➎ 苗字と名前の長さの合計が最小値以上、かつ最大値以下であることを検証します。 ➏ バリデーションの結果、エラーの場合、引数のErrorsオブジェクトにエラーを設定します。rejectメソッドの第1引数はプロパティファイルに定義されたエラーメッセージのキー名を指定します。ここでは、プロパティファイルからエラーメッセージを取得しないため、空文字を指定しています。第2引数はプロパティファイルからエラーメッセージが取得できなかった時のエラーメッセージとなります。

Errorsオブジェクトのrejectメソッドで設定したエラーは、グローバルエラーとして登録されます。htmlの修正は、方法2で説明したグローバルエラーの表示方法により、エラーメッセージが表示されるようになります。

次に、このバリデーションを有効にします。これまでは、フォームBeanにアノテーションでバリデーションを指定していましたが、Springバリデータの場合、コントローラークラスでバリデーションを指定します。

@Controller
@RequestMapping("profile")
@SessionAttributes("profileForm")
public class ProfileController {

    @Autowired
    private ProfileValidator profileValidator;      ➊

    @InitBinder
    public void initBinder(WebDataBinder binder) {  ➋
        binder.addValidators(profileValidator);     ➌
    }

➊ 作成したバリデータのインスタンスをDIにより取得します。
➋ @initBinderアノテーションを指定したメソッドを定義します。引数にWebDataBinderを指定します。
➌ WebDataBinderのaddValidatorsメソッドの引数に作成したバリデータのインスタンスを指定して実行します。

この方法の特徴

アノテーションによるバリデーションは、一つのチェックにつき一つのアノテーションを指定しました。
Springバリデーションでは、フォーム単位でのチェックになります。例では、org.springframework.validation.Validatorのvalidateメソッドの実装で一つの検証を実装していますが、複数の検証を実装することが可能です。Errorsオブジェクトのrejectメソッドを呼び出した回数分、エラー情報が登録されます。

アノテーションによるバリデーションはフォームBeanの実装を見れば、そのフォームに対するバリデーションを把握することができますが、Springバリデーションを使用すると、バリデーションの内容を把握するために、フォームBeanにバインドされたSpringバリデーションの実装を確認する必要が出ます。

どのように使い分けるか

バリデーションの方法として、3つの方法を紹介しました。

一番汎用性がある方法は、最後に説明したSpringバリデーションになります。が、すべてのバリデーションをSpringバリデーションで実装すると、コード量は増えますし、バリデーションの内容が把握しずらくなります。

それぞれのバリデーションの特徴から、ケースバイケースで使用することで、見通しの良いコードになります。

方法1 - フォームBeanに検証メソッドを実装

この方法の利点は、なんといっても実装がシンプルで実装量も少なめなこと。
ただ、この方法で、一つのフォームに複数のバリデーションを実装すると、フォームBeanのコードがカオスな状態になります。
この方法でバリデーションを実装することは強くお勧めしません

方法2 - バリデーションアノテーションを自作

バリデーションがフォームから隔離され、バリデーションとして部品化されるので、作成するアプリケーション全体で使われるバリデーションを実装するケースに向いています。
複数のフォームで繰り返し定義されるようなフィールドのバリデーションは、この方法で実装すると、アプリの生産性と保守性の向上が望めます。

方法3 - Springバリデーションを実装

フォーム単位で定義するバリデーションなので、フォームごとにバリデーションを実装する必要があるケースに向いています。
既存のBean Validationアノテーションや、自作したバリデーションアノテーションで検証できない、特殊なケースのみ、この方法で実装するようにしましょう。

最後に

「Spring Bootで作るWebアプリケーション」シリーズはこれで終了です。毎回、書きたいことが盛りだくさんで、長文になってしまいました。
これだけでアプリケーションを作れるわけではなく、Webアプリケーションの最低限の実装の開設となっています。
今後の記事でデータベースアクセスや、認証・認可など、アプリケーションで必須となるであろう要素をSpring Frameworkで実装する方法を紹介していこうと思っています。