【Spring Boot】独自アノテーションを作成してバリデーションチェック

以前、標準制約以外のチェックをしたいとき、チェック処理を集約したバリデータを作成する記事を書きました。（[フォームバリデーションのカスタマイズ【Spring Boot】](https://qiita.com/szk25/items/cf48de58ac83c7436216 "タイトル")）

しかし、この方法だと特定のFormオブジェクトにしか適用できないので再現性が低くなってしまいます。
そこで、独自のアノテーションを作成し複数のFormオブジェクトで使えるようにすることで、再現性を改善できたのではないかと思い、記事にまとめました。

以下、複数項目を使用したチェックと単項目チェックの2つの実装についてまとめています。


### 1.複数項目を使用したチェック
**■概要**
・宿泊プラン、チェックイン日付、チェックアウト日付を指定し空室が存在するかチェックする@VacancyRoomExistsを作成する。
・@VacancyRoomExistsをFormクラス単位に付与することで、複数項目を使用したチェックができる。
・@VacancyRoomExistsを宿泊プラン確認フォームと予約フォームの2つで使用することで、両方のフォームで同じチェックを行うことができる。

#### アノテーションを作成
```VacancyRoomExists.java
package com.example.validator;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import javax.validation.Constraint;
import javax.validation.Payload;

@Documented
@Constraint(validatedBy = {VacancyRoomExistsValidator.class})//バリデータクラスを指定
@Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE})//クラスレベルで制約する
@Retention(RetentionPolicy.RUNTIME)
public @interface VacancyRoomExists {
	String message() default "選択されたお部屋をご用意することができません。お手数ですが、再度検索をお願いいたします。";//エラーメッセージ
	
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    
    String planId();//チェックに使う項目
    String startDate();//チェックに使う項目
    String endDate();//チェックに使う項目

    @Target(ElementType.FIELD)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public static @interface List {
    	VacancyRoomExists[] value();
    }
}
```

#### バリデータクラスを作成
`ConstraintValidator`を継承して作成する。引数は **<作成したアノテーションクラス,バリデーションでチェックしたい対象クラス>** にする。
複数のFormクラスで使いたいので、第二引数はObjectにしている。
```VacancyRoomExistsValidator.java
package com.example.validator;

import java.time.LocalDate;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.beans.factory.annotation.Autowired;

import com.example.domain.Plan;
import com.example.service.SearchPlanService;

public class VacancyRoomExistsValidator implements ConstraintValidator<VacancyRoomExists, Object>{
	private String id;//チェックに使う項目
	private String startDate;//チェックに使う項目
	private String endDate;//チェックに使う項目
	
	@Autowired
	SearchPlanService searchPlanService;//空室が存在するかDBに接続してチェックするためServiceクラスをインジェクション
	
	@Override
    public void initialize(VacancyRoomExists annotation) {
		this.planId=annotation.planId();
		this.startDate=annotation.startDate();
		this.endDate=annotation.endDate();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
    	BeanWrapper beanWrapper = new BeanWrapperImpl(value);
    	Integer planIdVal = (Integer) beanWrapper.getPropertyValue(planId);
    	LocalDate startDateVal = (LocalDate) beanWrapper.getPropertyValue(startDate);
    	LocalDate endDateVal = (LocalDate) beanWrapper.getPropertyValue(endDate);
    	//nullチェックはフォームクラスのNotNullでチェックする
    	if(planIdVal==null||startDateVal==null||endDateVal==null) {
    		return true;
    	}else {
	        // 指定された日程に空室があるかチェックする
	        Plan plan = searchPlanService.searchVacancyRoomPlan(planIdVal,startDateVal,endDateVal);
	        if(plan != null) {
	            return true;
	        } else {
	            return false;
	        }
    	}
    }
}
```

#### Formクラスにアノテーション付与
クラス単位に作成したアノテーション付与。
標準のアノテーションやほかに作成した独自アノテーションも付与できる。

**■宿泊プラン確認フォーム**
```ConfirmPlanForm.java
package com.example.form;

import java.time.LocalDate;

import javax.validation.constraints.NotNull;

import org.springframework.format.annotation.DateTimeFormat;

import lombok.Data;

import com.example.validator.DateIntegrety;
import com.example.validator.VacancyRoomExists;

@Data
@VacancyRoomExists(planId="planId", startDate="checkinDate", endDate="checkoutDate")//クラス単位でアノテーションを付ける。アノテーションの項目とFormの項目を結びつける。
@DateIntegrety(startDate="checkinDate", endDate="checkoutDate")//ほかの独自アノテーション
public class ConfirmPlanForm {
	private Integer planId;
	
    private String planName;
	
	@DateTimeFormat(pattern = "yyyy-MM-dd")
	private LocalDate checkinDate;
	
	@DateTimeFormat(pattern = "yyyy-MM-dd")
	@NotNull(message="チェックアウト日を入力してください")
	private LocalDate checkoutDate;
	
	@NotNull(message="宿泊人数を入力してください")
	private Integer guestNumber;
}

```
**■予約フォーム**
```ReservationForm.java
package com.example.form;

import java.time.LocalDate;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

import org.springframework.format.annotation.DateTimeFormat;

import com.example.validator.VacancyRoomExists;

import lombok.Data;

@Data
@VacancyRoomExists(planId="planId", startDate="checkinDate", endDate="checkoutDate")//クラス単位でアノテーション付与
public class ReservationForm {
	private Integer reservationUser;
	
	@NotBlank(message="氏名を入力してください")
	private String name;
	
    @NotBlank(message="ふりがなを入力してください")
	@Pattern(regexp="^[ぁ-んー]*$", message="ふりがなはひらがなで入力してください")
	private String nameKana;
	
    @NotBlank(message="連絡先を入力してください")
	@Pattern(regexp="^[0-9]{9,12}$", message="電話番号は半角数字で入力してください")
	private String telephoneNumber;
	
    @DateTimeFormat(pattern = "yyyy-MM-dd")
	private LocalDate checkinDate;
	
    @DateTimeFormat(pattern = "yyyy-MM-dd")
	private LocalDate checkoutDate;
	
    @NotBlank(message="チェックイン時刻を入力してください")
	private String checkinTime;
	
    private Integer guestNumber;
	
    private Integer planId;
	
    private Integer totalPrice;
	
    private Integer createUser;
	
    private Integer updateUser;
}
```
#### エラーメッセージの表示
`th:errors="${Formクラス名}"`でエラーメッセージを表示する。

**■宿泊プラン確認画面**
```confirm_plan.html
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org"
				xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.4.2/css/all.css" integrity="sha384-/rXc/GQVaYpyDdyxK+ecHPVYJSN9bmVFBvjA/9eOB+pb3F2w2N6fc5qB9Ew5yIns" crossorigin="anonymous">
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link rel="stylesheet" th:href="@{/css/common.css}" />
<title>プランの確認</title>
</head>
<body>
<!-- ヘッダー -->
<div th:insert="common :: fragment-header"></div>

<main>
<form th:action="@{/plan/confirm/first}" method="post" th:object="${confirmPlanForm}">
<div class="mgn">
<div th:errors="${confirmPlanForm}" class="errorMsg"></div><!-- 空室チェックのエラーメッセージ -->
<div th:errors="*{checkoutDate}" class="errorMsg"></div>
<div th:errors="*{guestNumber}" class="errorMsg"></div>
</div>

<!-- 省略 -->
</form>
</main>
</body>
</html>
```

**■予約画面**
```reservation.html
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org"
				xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.4.2/css/all.css" integrity="sha384-/rXc/GQVaYpyDdyxK+ecHPVYJSN9bmVFBvjA/9eOB+pb3F2w2N6fc5qB9Ew5yIns" crossorigin="anonymous">
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link rel="stylesheet" th:href="@{/css/common.css}" />
<title>予約</title>
</head>
<body>
<!-- ヘッダー -->
<div th:insert="common :: fragment-header"></div>

<main>
<form th:action="@{/reservation/form/confirm}" method="post" th:object="${reservationForm}">
<div class="mgn">
<div th:errors="${reservationForm}" class="errorMsg"></div><!-- 空室チェックのエラーメッセージ -->
<div th:errors="*{name}" class="errorMsg"></div>
<div th:errors="*{nameKana}" class="errorMsg"></div>
<div th:errors="*{telephoneNumber}" class="errorMsg"></div>
<div th:errors="*{checkinTime}" class="errorMsg"></div>
</div>

<!-- 省略 -->
</form>
</main>
</body>
</html>
```

#### 画面イメージ
**■宿泊プラン確認画面**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521820/45174087-81db-b07e-21a6-1017d3523d16.png)

**■予約フォーム画面**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521820/848410c7-57f7-61cd-0cf9-cfab03d13a8e.png)

### 2.単項目のチェック
**■概要**
・入力したユーザーIDがユーザーマスタに存在するかチェックする@UserIdExistsを作成する。
・@UserIdExistsをフィールド単位に付与することで、単項目のチェックができる。

#### アノテーションを作成
`@Target(ElementType.FIELD)` と記載することでフィールド単位の制御が可能になる。
```UserIdExists.java
package com.example.validator;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import javax.validation.Constraint;
import javax.validation.Payload;

@Documented
@Constraint(validatedBy = {UserIdExistsValidator.class})
@Target(ElementType.FIELD)//フィールドレベルで制約する
@Retention(RetentionPolicy.RUNTIME)
public @interface UserIdExists {
	 String message() default "そのユーザーIDはすでに使用されています";

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

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

	    @Target(ElementType.FIELD)
	    @Retention(RetentionPolicy.RUNTIME)
	    @Documented
	    public static @interface List {
	    	UserIdExists[] value();
	    }
}
```
#### バリデータクラスを作成
`ConstraintValidator`の引数は **<作成したアノテーションクラス,バリデーションでチェックしたい対象フィールドの型>** を設定する。
```UserIdExistsValidator.java
package com.example.validator;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

import org.springframework.beans.factory.annotation.Autowired;

import com.example.domain.User;
import com.example.service.UserService;

public class UserIdExistsValidator implements ConstraintValidator<UserIdExists, String>{
	@Autowired
    UserService userService;

    @Override
    public void initialize(UserIdExists annotation) {
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if(value == null || value.isEmpty()) {
            return true;
        }
        User user = userService.searchByUserId(value);
        if(user != null) {
            return false;
        } else {
            return true;
        }
    }
}
```
#### Formクラスのフィールドにアノテーション付与
フィールドに作成したアノテーションを付ける。
```CreateAccountForm.java
package com.example.form;

import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

import com.example.validator.UserIdExists;

import lombok.Data;

@Data
public class CreateAccountForm {
	@NotBlank(message="ユーザーIDを入力してください")
	@Pattern(regexp="^([a-zA-Z0-9._-]{3,15})$", message="ユーザーIDは3～15文字以内で入力してください。使用できる文字：　a-zA-Z0-9_-.")
	@UserIdExists//ユーザーIDにアノテーション付与
	private String userId;
	
    @NotBlank(message="氏名を入力してください")
	private String userName;
	
    @NotBlank(message="メールアドレスを入力してください")
	@Email(message="メールアドレスの形式が不正です")
	private String email;
	
    @NotBlank(message="パスワードを入力してください")
	@Pattern(regexp="^(?=.*?[0-9])(?=.*?[a-z])(?=.*?[A-Z])[0-9a-zA-Z]{8,16}$", message="パスワードは半角英数字を混在させて8～16文字以内で入力してください")
	private String password;
	
    @NotBlank(message="確認用パスワードを入力してください")
	private String confirmPassword;
	
    @NotBlank(message="権限を選択してください")
	private String authority;
    
    private Integer createUser;
	
    private Integer updateUser;
	
	@AssertTrue(message = "パスワードと確認用パスワードが一致しません")
    public boolean isPasswordValid() {
        if (password == null || password.isEmpty()) {
            return true;
        }
        return password.equals(confirmPassword);
    } 
}
```
#### エラーメッセージの表示
```create_account.html 
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.4.2/css/all.css" integrity="sha384-/rXc/GQVaYpyDdyxK+ecHPVYJSN9bmVFBvjA/9eOB+pb3F2w2N6fc5qB9Ew5yIns" crossorigin="anonymous">
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link rel="stylesheet" th:href="@{/css/common.css}" />
<title>ログイン</title>
</head>
<body>
<!-- ヘッダー -->
<div th:insert="common :: fragment-header"></div>
<main>
    <form method="post" th:action="@{/account/create}" th:object="${createAccountForm}">
    <div class="mgn">
    <div th:errors="*{userId}" class="errorMsg"></div><!-- userIdの単項目チェックエラーが表示される -->
	<div th:errors="*{userName}" class="errorMsg"></div>
	<div th:errors="*{email}" class="errorMsg"></div>
	<div th:errors="*{password}" class="errorMsg"></div>
	<div th:errors="*{confirmPassword}" class="errorMsg"></div>
    <div th:if="${#fields.hasErrors('passwordValid')}" th:errors="*{passwordValid}" class="errorMsg"></div>
	<div th:errors="*{authority}" class="errorMsg"></div>
	</div>

<!-- 省略 -->
</form>
</main>
</body>
</html>
```
#### 画面イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2521820/48f63bdd-1603-ca7c-9105-fdb9d70bee33.png)

### 参考
* [Spring MVCにおけるフォームバリデーションの適用事例【前編】](https://qiita.com/kenhori/items/4534f548bbee5bfb6064#2-%E7%8B%AC%E8%87%AA%E3%81%AE%E3%83%81%E3%82%A7%E3%83%83%E3%82%AF%E6%A9%9F%E8%83%BD%E3%82%92%E5%AE%9F%E8%A3%85%E3%81%97%E3%81%9F%E5%88%B6%E7%B4%84%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B "タイトル")