Spring Boot学習中の者です。初めてQiitaに記事を投稿いたします。拙文かとは思いますが、何卒ご了承いただけますと幸いです。
既にQiitaでSpringのバリデーションに関する記事は存在していますが、自身の復習も兼ねて実装&テストまで分かるような記事を作成いたしました。
個人的な備忘録にはなりますが、Spring Boot学習中の方のお役に立てれば幸いです。
参考文献
使用技術・環境
Spring Boot 2.5.6
Thymeleaf
JUnit 5
Mybatis 2.2.0
MySQL
オリジナルのバリデータを作成する方法
最大文字数や現在より前の日付のみ受け付けるなど、javaxパッケージで既に用意されているアノテーションである程度のバリデーションは可能ですが、アプリケーションで独自のバリデーションを実装したい場合は、下記いずれかを実装したクラスでバリデーションを作成します。
・javax.validation.ConstraintValidator
・org.springframework.validation.Validator
この記事では、下記のサンプルクラスをもとに自作バリデーションを作成していきます。
public class RegisterForm implements Serializable{
private static final long serialVersionUID = 1L;
@Size(min=2,max=15,message="ユーザ名は2字以上15字以内で作成してください")
private String userName;
@Email(message="メールアドレスの形式で入力してください")
@NotEmpty(message="メールアドレスは必須項目です")
private String mail;
@Size(min=8,message="パスワードは8文字以上で入力してください")
private String password;
private String confirmPassword;
Validatorを実装した自作バリデーションの場合
バリデータクラスを作成します。
ここでは「入力されたメールアドレスが、データベース上に存在する場合はエラーメッセージを返す」というバリデータを例とします。
バリデータクラス(implements Validator)
@Component
public class UniqueMailValidator implements Validator {
private final FindDataSharedService findDataSharedService;
public UniqueMailValidator(FindDataSharedService findDataSharedService) {
this.findDataSharedService = findDataSharedService;
}
@Override
public boolean supports(Class<?> clazz) {
return RegisterForm.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
RegisterForm form = (RegisterForm)target;
if(form.getMail() == null || form.getMail().equals("")) {
return;
}
String existMail = findDataSharedService.findMail(form.getMail());
if(existMail != null) {
errors.rejectValue("mail",
"RegisterForm.mail",
"入力されたメールアドレスは既に使われています");
}
}
依存クラスのFindDataSharedServiceは、String型の引数を一つ受け取って、一致するメールアドレスを返すメソッドを実装しています。
気になる方は下記のソースコードをご覧ください。
findDataSharedService.java
@Service
public class FindDataSharedService {
private final FindDataMapper findDataMapper;
public FindDataSharedService(FindDataMapper findDataMapper) {
this.findDataMapper = findDataMapper;
}
@Transactional(readOnly = true)
public String findMail(String mail) {
return findDataMapper.findMail(mail);
}
}
findDataMapper.java
@Mapper
public interface FindDataMapper {
String findMail(String mail);
}
findDataMapper.xml
<select id="findMail" resultType="String" parameterType="String">
SELECT
MAIL
FROM
ACCOUNT
WHERE
MAIL = #{mail}
</select>
supportsメソッドでは引数で受け取ったクラスが、メソッド内で記述しているチェック対象(今回の例ではRegisterFormクラス)かどうかを返します。
validateメソッドでは
① 確認したい項目がnull or 空白なら以降のチェックは実施しない
② 値が入力されているならオブジェクトの値をもとに依存クラスのメソッドを呼び出し、結果がnullでない(値が返ってきた)ならバリデーションエラーとなる
という処理を行っています。
ErrorクラスのrejectValueメソッドの引数には("フィールド名","エラーコード","表示するメッセージ")を渡します。
バリデータクラスを適用する
@Controller
public class RegistrationController {
private final UniqueMailValidator uniqueMailValidator;
public RegistrationController(UniqueMailValidator uniqueMailValidator) {
this.uniqueMailValidator = uniqueMailValidator;
}
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.addValidators(this.uniqueMailValidator);
}
//
@PostMapping("/regist")
String regist(@ModelAttribute("registerForm") @Validated RegisterForm form,
BindingResult result, RedirectAttributes model) {
if(result.hasErrors()) {
return "Login/Registration";
}
//DBにレコードをinsertしてログインページを返す
}
入力チェックをしたいフォームクラスを使用するコントローラに、作成したバリデータクラスをインジェクションして、initBinderメソッドでWebDataBinderにバリデーションを適用します。
あとは@Validatedによる入力チェックで、他のアノテーションベースのバリデーションと一緒に、自作バリデータクラスを利用して値を検証してくれます。
Validatorのテスト
単体テスト
@RunWith(SpringRunner.class)
public class UniqueMailValidatorTest {
@Mock
FindDataSharedService findDataSharedService;
@InjectMocks
UniqueMailValidator uniqueMailValidator;
RegisterForm form = new RegisterForm();
BindingResult bindingResult = new BindException(form, "RegisterForm");
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void validateでメールアドレスが重複してエラーが発生する() throws Exception{
form.setMail("example@ezweb.ne.jp");
when(findDataSharedService.findMail("example@ezweb.ne.jp")).thenReturn("example@ezweb.ne.jp");
uniqueMailValidator.validate(form, bindingResult);
assertEquals(1,bindingResult.getFieldErrorCount());
assertTrue(bindingResult.getFieldError("mail")
.toString().contains("入力されたメールアドレスは既に使われています"));
verify(findDataSharedService,times(1)).findMail("example@ezweb.ne.jp");
}
}
単体テストの例です。BindingResultインタフェースの実装クラスであるBindExceptionクラスをnewしてテストを行っています。
BindExceptionのコンストラクタは(バリデートしたいインスタンス,バリデートしたいインスタンスのクラス名)です。
Formクラスとの結合テスト
@DbUnitConfiguration(dataSetLoader = CsvDataSetLoader.class)
@TestExecutionListeners({DependencyInjectionTestExecutionListener.class,
TransactionalTestExecutionListener.class,
DbUnitTestExecutionListener.class})
@SpringBootTest
public class RegisterFormTest {
@Autowired
Validator validator;
@Autowired
UniqueMailValidator uniqueMailValidator;
RegisterForm form = new RegisterForm();
//ターゲット,ターゲットオブジェクトの名前
BindingResult bindingResult = new BindException(form,"RegistrationForm");
@Test
@DatabaseSetup(value = "/form/Register/setup/")
void メールアドレスの重複でフィールドエラー発生() throws Exception{
form.setUserName("加藤健");
form.setPassword("pinballs");
form.setConfirmPassword("pinballs");
form.setMail("example@ezweb.ne.jp");
validator.validate(form, bindingResult);
assertEquals(0,bindingResult.getFieldErrorCount());
uniqueMailValidator.validate(form, bindingResult);
assertEquals(1,bindingResult.getFieldErrorCount());
assertTrue(bindingResult.getFieldError("mail")
.toString().contains("入力されたメールアドレスは既に使われています"));
}
}
テストクラスに付与しているアノテーションをザックリ説明すると、テストデータ用のCSVを用意してテスト時にテーブルとして読み込んでもらうような設定を行っております。こちらの参考文献が大変分かりやすかったです。
ConstraintValidator を実装した自作バリデーションの場合
「バリデータクラス」に加えて「アノテーションクラス」を作成します。
ここでは「フォームクラスで入力されたパスワードの値が同じであること」をチェックするバリデータを例とします。
アノテーションクラス
@Documented
@Constraint(validatedBy = {ConfirmPasswordValidator.class}) //バリデーションの実装クラス
@Target({ElementType.TYPE,ElementType.ANNOTATION_TYPE}) //注釈型が適用可能なプログラム要素
@Retention(RetentionPolicy.RUNTIME) //アノテーションの読み込みタイミング
public @interface ConfirmPassword {
String message() default "パスワードが一致していません";//エラー時に表示されるメッセージ
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
//必要に応じて属性を指定する。
//指定した属性は後のバリデータクラスや、アノテーションを付与するクラスで必要となる。
String password();
String confirmPassword();
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) /注釈型が適用可能なプログラム要素
@Retention(RetentionPolicy.RUNTIME) //アノテーションの読み込みタイミング
@Documented
public @interface List {
ConfirmPassword[] values();
}
}
アノテーションクラス名は、「@interface インタフェース名」と記述します。
付与されている@Target 等のアノテーションの説明については、参考文献の記事で分かりやすく説明されております。
バリデータクラス(implements ConstraintValidator)
public class ConfirmPasswordValidator implements ConstraintValidator<ConfirmPassword, Object> {
private String password;
private String confirmPassword;
private String message;
@Override
//初期化処理
public void initialize(ConfirmPassword annotation) {
this.password = annotation.password();
this.confirmPassword = annotation.confirmPassword();
this.message = annotation.message();
}
@Override
//値の検証処理
public boolean isValid(Object value, ConstraintValidatorContext context) {
BeanWrapper beanWrapper = new BeanWrapperImpl(value);
Object passwordValue = beanWrapper.getPropertyValue(this.password);
Object confirmPasswordValue = beanWrapper.getPropertyValue(this.confirmPassword);
boolean matched = ObjectUtils.nullSafeEquals(passwordValue, confirmPasswordValue);
if (matched) {
return true;
}
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(message)
.addPropertyNode(confirmPassword).addConstraintViolation();
return false;
}
バリデータクラスはConstraintValidatorインタフェースを実装する必要があります。こちらの引数ですが、
<作成したアノテーションクラス,バリデーションでチェックしたい対象クラス>を指定します。
initializeメソッドで初期化処理を、isValidメソッドで値の検証処理を行います。
isValidの検証処理でBeanWrapper経由で値を取得せずに、初期化処理で設定したプロパティを用いて検証するとエラーになります。私はこれが原因でフォームクラスのテストで必要以上に時間を費やしてしまいました。
BeanWrapperの説明については公式ドキュメントをご覧ください。
ConstraintValidatorContext is 何?
iSValidメソッドの引数ですが、、第二引数に見慣れないクラスが指定されております。
isValidの検証結果がtrueでない場合にあれこれメソッドを呼び出していますが、
① デフォルトで作成される制約違反オブジェクトの生成を無効にする
② エラーメッセージを出力したいプロパティ名と出力するメッセージを指定して、新たに制約違反を作成する
ということを行っております。
アノテーションクラスを適用する
@ConfirmPassword(password="password",confirmPassword="confirmPassword")
public class RegisterForm implements Serializable{
//
バリデーションチェックしたいクラスに「@作成したアノテーションクラス名」を付与すれば、バリデーションクラスのルール(isValid)が適用されます。
アノテーションクラスで属性を指定している場合は、(アノテーションクラスの属性名 = "チェックしたいクラスのプロパティ",...)を指定します。
ConstraintValidatorのテスト
RegisterFormに付与した自作アノテーション(@ConfirmPassword)をテストする例です。
@SpringBootTest
@Transactional
public class RegisterFormTest {
@Autowired
Validator validator;
RegisterForm form = new RegisterForm();
//ターゲット,ターゲットオブジェクトの名前
BindingResult bindingResult = new BindException(form,"RegistrationForm");
@Test
void パスワード不一致でフィールドエラー発生() throws Exception{
form.setUserName("加藤健一");
form.setPassword("pinballs");
form.setConfirmPassword("hogehoge");
form.setMail("example@ezweb.ne.jp");
validator.validate(form, bindingResult);
assertEquals(1,bindingResult.getFieldErrorCount());
assertTrue(bindingResult.getFieldError("confirmPassword")
.toString().contains("パスワードが一致していません"));
}
}
どう使い分けたら良いの?
カスタムバリデーションの実装方法を勉強したあとで、「結局どっちを使えばいいの?」という疑問を持ち、それぞれの特徴を自分なりにまとめてみました。
実装方法 | 特徴 | 効果的なシチュエーション |
---|---|---|
Validator | Controllerクラスに依存を注入し、 必要に応じてInitBinderを記述 →ちょっと面倒? |
・特定のフォームでしか使わない時 ・DBを参照したバリデーションを実装したい時 |
ConstraintValidator | ・一度作ってしまえば、 Formクラスにアノテーションを付けるだけ |
複数のフォームでバリデーションを使いまわしたい時 |
ConstraintValidatorから実装した今回の例では、RegisterForm(ユーザ登録時のフォーム)に自作アノテーションを付与していますが、他のFormクラス(ユーザ登録情報変更フォームとか)でパスワードを確認することも可能です。
一方Validatorから実装した今回の例では、supportsメソッドで確認するクラスをRegisterFormとしているので、他のFormクラスに使いまわすことはできません。Formクラスで実装する共通のインタフェースを作って、それをsupportsメソッドで指定すれば使い回せるかも知れませんが、動作確認していないので保証は出来ません。。
経験談
今回の記事では例を書いていませんが、ConstraintValidatorを実装したバリデータクラスに依存クラスを使用し、DBを参照したバリデーションも可能です。しかし、個人的な意見ですがあまりお勧めいたしません。
自身の経験談になりますが学習用のアプリケーションを作成している時、Formクラスを含むコントローラの単体テストで、ConstraintValidatorを実装したバリデータクラスの依存クラスをモック化できず困っていました。
web上でアレコレ調べた結果、モック化できないことは仕様と判明しました。。
(別のお方が他サイトで質問していた内容なので、この記事での引用は控えさせていただきます)
上記の経験を経て、「DBを参照するバリデータを作成したい場合は、後のテストも考慮してValidatorインタフェースを継承させる」という考えになりました。
あくまでも自身が調査したことなので、別の解決策があったり、仕様が変わったということがあるかも知れません。ご意見ご指摘等ありましたら、コメントいただけますと幸いです。