NHN Cloud Meetup 編集部
バリデーションどこまでやりました?
2020.04.15
19,387
はじめに
アプリケーションを開発するとき、データのバリデーションチェックは一般的にアプリケーション全体で発生します。TOASTのメッセージングプラットフォーム商品であるNotificationは、メッセージ形式、メールアドレス形式、受信・発信者番号など、クライアントの入力値に対して多くの検証を行います。そして、入力値の検証に失敗した際には、エラーに対する原因を把握して理解しやすいようにAPIで適切にレスポンスする必要があります。このような目標を達成するため、Javaにおけるデータのバリデーションチェック標準技術であるBean Validationを採用しました。
この記事では、NHN Forward 2019で発表された、PaaS&API Developer Experience(https://youtu.be/zvuhOz8VhhI)で扱われたBean Validationの内容を文章におこして、もう少し詳しく説明します。Bean Validation 2.0、Hibernate Validator 6.0、Spring Boot 2.0は、Java 8以上の環境を基準とします。
問題
一般的なアプリケーションにおいて、データの検証ロジックは次のような問題を持っています。
- アプリケーション全体に分散している
- コードの重複が激しい
- ビジネスロジックに混ざっているため、検証ロジックの追跡が難しく、アプリケーションが複雑になる
これらの問題から、データの検証ロジックに機能を追加したり修正するのは難しく、エラーが発生する可能性も高い状況です。
解決方法
Javaでは2009年からBean Validationというデータ検証のフレームワークを提供しています。Bean Validationは上述した問題を解決するため、さまざまな制約(Contraint)をドメインモデル(Domain Model)にアノテーション(Annotation)として定義できるようにします。この制約を、検証が必要なオブジェクトに直接定義することで、既存の検証ロジックの問題を解決します。
開始する
1.インストール
Spring Boot Validation Starterを追加します。(Bean Validation実装体としてHibernate Validatorを使用します)
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
2.制約の設定とテスト
以下は、Bean Validationで提供する「@Length」「@NotBlank」「@NotNull」制約を、CreateContact(連絡先作成機能のドメインモデル)に設定したサンプルです。
public class CreateContact { @Length(max = 64) // 最大64 @NotBlank // 空の文字列は不可 private String uid; @NotNull // nullは不可 private ContactType contactType; @Length(max = 1_600) // 最長1600 private String contact; }
次は、CreateContactに値を入力して、バリデーションを検証するコードです。
@BeforeClass public static void beforeClass() { Locale.setDefault(Locale.US); // localeの設定によってエラーメッセージが異なる。 } @Test public void test_validate() { // Given final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); final CreateContact createContact = CreateContact .builder() .uid(null) // @NotBlankが定義されているためnullでなければならない。 .contact("000") .contactType(ContactType.PHONE_NUMBER) .build(); // When final Collection<ConstraintViolation<CreateContact>> constraintViolations = validator.validate(createContact); // Then assertEquals(1, constraintViolations.size()); // ConstraintViolationエラーに対する情報が記される。 assertEquals("must not be blank", constraintViolations.iterator().next().getMessage()); }
機能紹介
1. Springで使用する
さまざまな機能を調べる前に、SpringでBean Validationをどのように使用するか見てみましょう。依存性に「spring-boot-starter-validation」を追加するとすぐに使用できます。ServiceやBeanで使用するには「@Validated」と「@Valid」を追加する必要があります。
@Validated // ここに追加 @Service public class ContactService { public void createContact(@Valid CreateContact createContact) { // '@Valid'が設定されたメソッドを呼び出すときにバリデーションチェックを進める。 // Do Something } }
Controllerには「@Validated」は必要ありません。チェックを行うところに「@Valid」を追加します。
@PostMapping("/contacts") public Response createContact(@Valid CreateContact createContact) { // メソッドを呼び出すときにバリデーションチェックを進める。 return Response .builder() .header(Header .builder() .isSuccessful(true) .resultCode(0) .resultMessage("success") .build()) .build(); }
ここで注意すべき点は、データのバリデーションチェックを行う際に検証が重複して実行されないようにすることです。同じデータのバリデーションチェックが複数実行されると、アプリケーションのパフォーマンスに影響を及ぼす可能性があることを覚えておきましょう。
2.コンテナ(コレクション、マップ、…)
コンテナ(Container)の要素(Element)にもバリデーションチェックが必要な場合があります。Bean Validation 2.0以降、Containerの要素も検証が可能になりました。以下のように制約を定義できます。
public class DeleteContacts { @Min(1) private Collection<@Length(max = 64) @NotBlank String> uids; }
3.カスタム制約(Custom Constraint)
CreateContactのuid属性に絵文字を使用できないように制約を追加するとしましょう。Bean Validationが提供している制約には、このような制約はありません。その代わりに、Bean Validationでは必要な制約を直接定義して使用することができます。次のように、任意の制約(Constraint)と検証者(Validator)を実装することができます。
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE}) @Retention(RUNTIME) @Constraint(validatedBy = NoEmojiValidator.class) @Documented public @interface NoEmoji{ String message() default "Emoji is not allowed"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER}) @Retention(RUNTIME) @Documented @interface List{ NoEmoji[] value(); } } public class NoEmojiValidator implements ConstraintValidator<NoEmoji, String> { @Override public boolean isValid(String value, ConstraintValidatorContext context) { if (StringUtils.isEmpty(value) == true) { return true; } return EmojiParser.parseToAliases(value).equals(value); } }
他の制約と同じように検証する属性に設定します。
public class CreateContact { @NoEmoji @Length(max = 64) @NotBlank private String uid; @NotNull private ContactType contactType; @Length(max = 1_600) private String contact; }
4.制約グループ(Grouping)
以下のように、メッセージ送信に使われるドメインモデルのメッセージがあります。メッセージには一般的なものと広告メッセージがあり、広告メッセージの場合は、連絡先(contact)、広告除去ガイド(removeGuide)属性に値を設定する必要があると仮定しましょう。1つのドメインモデルに2つ存在するケースでは(一般、広告)データのバリデーションチェックが必要です。このようなときには制約を結ぶグループ(Grouping)機能を使用することができます。
public class Message { @Length(max = 128) @NotEmpty private String title; @Length(max = 1024) @NotEmpty private String body; @Length(max = 32, groups = Ad.class) @NotEmpty(groups = Ad.class) // グループを指定できる。(基本値:javax.validation.groups.Default) private String contact; @Length(max = 64, groups = Ad.class) @NotEmpty(groups = Ad.class) private String removeGuide; }
「Ad.class」は、単にグループを指定するためのマーカーインターフェース(Marker Interface)です。
public interface Ad { }
グループが指定されたメッセージのバリデーションチェックを行うメソッドは、次のように指定できます。メソッドに検証するグループを指定した「@Validate(Ad.class)」を追加します。
@Validated @Service public class MessageService { @Validated(Ad.class) // メソッド呼び出したときにはAdグループが指定された制約だけを検証する。 public void sendAdMessage(@Valid Message message) { // Do Something } public void sendNormalMessage(@Valid Message message) { // Do Something } /** * 注: このように呼び出すと、Spring AOP Proxyの構造上、@Validを設定したメソッドが呼び出されてもバリデーションチェックが動作しない。 * SpringのAOP Proxy構造についての説明は、次のリンク先を参考にしてください。 * - https://docs.spring.io/spring/docs/5.2.3.RELEASE/spring-framework-reference/core.html#aop-understanding-aop-proxies */ public void sendMessage(Message message, boolean isAd) { if (isAd) { sendAdMessage(message); } else { sendNormalMessage(message); } }
5.クラス単位の制約(Class Level Constraint)と条件付き検証(Conditional Validation)
ドメインモデルの属性値によって、データのバリデーションチェックを別の方法で行う場合もあります。つまり、実行時に属性値によってデータのバリデーションチェックの方法が決定されるケースがそれに該当します。たとえば、以下のように広告メッセージの有無を表示する属性(isAd)がメッセージに追加され、この属性値によって連絡先(contact)、広告除去ガイド(removeGuide)属性を検証する必要があるとしましょう。この場合には、新しいクラス単位の制約を実装することで解決できます。
@AdMessageConstraint // カスタム制約を実現する。 public class Message { @Length(max = 128) @NotEmpty private String title; @Length(max = 1024) @NotEmpty private String body; @Length(max = 32, groups = Ad.class) @NotEmpty(groups = Ad.class) private String contact; @Length(max = 64, groups = Ad.class) @NotEmpty(groups = Ad.class) private String removeGuide; private boolean isAd; // 広告メッセージの有無を設定できる属性 }
次のように新しい制約を実装します。
@Target({TYPE}) @Retention(RUNTIME) @Constraint(validatedBy = AdMessageConstraintValidator.class) @Documented public @interface AdMessageConstraint { String message() default ""; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
以下のAdMessageConstraintValidatorの実装部分が重要な部分です。isAd属性が真の場合、GroupがAdに設定した属性を検証する必要があるため、コンストラクタからValidatorを受け取ります。そして、isValidメソッドからValidatorにバリデーションチェックを行い、結果を再びConstraintValidatorContextに追加します。
public class AdMessageConstraintValidator implements ConstraintValidator<AdMessageConstraint, Message> { private Validator validator; public AdMessageConstraintValidator(Validator validator) { this.validator = validator; } @Override public boolean isValid(Message value, ConstraintValidatorContext context) { if (value.isAd()) { final Set<ConstraintViolation<Object>> constraintViolations = validator.validate(value, Ad.class); if (CollectionUtils.isNotEmpty(constraintViolations)) { context.disableDefaultConstraintViolation(); constraintViolations .stream() .forEach(constraintViolation -> { context.buildConstraintViolationWithTemplate(constraintViolation.getMessageTemplate()) .addPropertyNode(constraintViolation.getPropertyPath().toString()) .addConstraintViolation(); }); return false; } } return true; } }
このように、クラスレベルのカスタム制約から属性の状態によってデータのバリデーションチェックを指定することができます。
@Validated @Service public class MessageService { /** * message.isAdがtrueならcontcat、removeGuide属性まで検証する。 */ public void sendMessage(@Valid Message message) { // Do Something }
6.エラー処理(Error Handling)
データのバリデーションチェックでエラーが生じた場合は、ConstraintViolationExceptionを発生させます。ConstraintViolationExceptionはエラー情報が含まれているConstraintViolationオブジェクトを持っており、ConstraintViolationExceptionからConstraintViolationを参照して、適切なエラー応答を作成するように実装できます。
@ControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler(value = ConstraintViolationException.class) // バリデーションチェック失敗時に発生する例外を処理 @ResponseBody protected Response handleException(ConstraintViolationException exception) { return Response .builder() .header(Header .builder() .isSuccessful(false) .resultCode(-400) .resultMessage(getResultMessage(exception.getConstraintViolations().iterator())) // エラー応答を作成 .build()) .build(); } protected String getResultMessage(final Iterator<ConstraintViolation<?>> violationIterator) { final StringBuilder resultMessageBuilder = new StringBuilder(); while (violationIterator.hasNext() == true) { final ConstraintViolation<?> constraintViolation = violationIterator.next(); resultMessageBuilder .append("['") .append(getPopertyName(constraintViolation.getPropertyPath().toString())) // バリデーションチェックが失敗した属性 .append("' is '") .append(constraintViolation.getInvalidValue()) // 有効ではない値 .append("'. ") .append(constraintViolation.getMessage()) // バリデーションチェック失敗時のメッセージ .append("]"); if (violationIterator.hasNext() == true) { resultMessageBuilder.append(", "); } } return resultMessageBuilder.toString(); } protected String getPopertyName(String propertyPath) { return propertyPath.substring(propertyPath.lastIndexOf('.') + 1); // 全体属性パスで属性の名前のみを提供 } }
7.動的メッセージの作成(Message Interpolation)
最後にメッセージの作成について説明します。Bean Validationは、デフォルトで制約(Constaint)のmessage属性にエラーに対するメッセージを定義できますが、動的メッセージの作成機能を用いてより詳しく動的なメッセージを作成することができます。デフォルトでは次のように通常のmessage属性に定義されたメッセージが存在します。
... public @interface NoEmoji{ String message() default "Emoji is not allowed"; ...
メッセージ作成時に、次のような機能を使用できます。
パラメーター(Parameter)
メッセージに制約のパラメーター(Parameter)を使用できます。メッセージにパラメーターを使用する方法は次のとおりです。
- 「{}」または「${}」で囲む
- {, }, \, \$は文字として扱う
- 「{」はパラメーターの開始、「}」はパラメーターの最後、\は拡張文字(Escaping Character)、「$」は式の開始に用いる
メッセージ定義 | メッセージの作成結果 | |
---|---|---|
チェックした値 | “Emoji[${validatedValue}] is not allowed” | “Emoji[] is not allowed” |
制約の属性 | “Value must be between {min} and {max}” | “Value must be between 0 and 64” |
表現式(Expression)
メッセージ定義時に「${}」を用いて表現式を使用することができます。下記は、Bean Validationで基本提供している制約「@DecimalMax」です。
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE }) @Retention(RUNTIME) @Repeatable(List.class) @Documented @Constraint(validatedBy = {}) public @interface DecimalMax { ... boolean inclusive() default true; }
以下のように「${}」の表現式を用いてinclusive属性値によってメッセージを作成できます。
メッセージ定義 | メッセージの作成結果 (inclusive = true) | メッセージの作成結果 (inclusive = false) |
---|---|---|
Must be greater than ${inclusive == true ? ‘or equal to ‘ : ”}{value} | Must be greater than or equal to 10 | Must be greater than 10 |
国際化(i18n)
Bean Validationでは、エラーメッセージをさまざまな言語で作成できる機能も提供しています。利用するには制約に定義されたメッセージをメッセージファイル(ValidationMessage.properties)に移す必要があります。Bean Validationでは、Class Pathに追加されたメッセージファイルを自動的に呼び出し、メッセージ作成時に使用します。メッセージファイル名のパターンは、「ValidationMessage_言語コード_国コード.properties」です。韓国語の場合なら、「ValidationMessage_ko_KR.properties」となります。
NoEmoji制約を例にあげると、英語は「ValidationMessage.properties」ファイルに次のように定義できます。
com.toast.notification.beanvalidationexample.validation.NoEmoji.message=Emoji[${validatedValue}] is not allowed
韓国語は「ValidationMessage_ko_KR.properties」ファイルに、英語と同じキー(Key)で値を韓国語で登録します。
com.toast.notification.beanvalidationexample.validation.NoEmoji.message=絵文字[${validatedValue}]は使用できません。
NoEmoji制約のmessage属性には、キーを作成します。
public @interface NoEmoji { String message() default "{com.toast.notification.beanvalidationexample.validation.NoEmoji.message}"
次のように、設定されたロケールに合ったメッセージが作成されることを確認できます。
@Test public void test_default() { // Given Locale.setDefault(Locale.US); final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); final Message message = Message .builder() .title(":+1:") .body("body") .build(); // When final Collection<ConstraintViolation<Message>> constraintViolations = validator.validate(message); // Then Assert.assertEquals("Emoji[:+1:] is not allowed.", constraintViolations.iterator().next().getMessage()); } @Test public void test_ko() { // Given Locale.setDefault(Locale.KOREAN); final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); final Message message = Message .builder() .title(":+1:") .body("body") .build(); // When final Collection<ConstraintViolation<Message>> constraintViolations = validator.validate(message); // Then Assert.assertEquals("絵文字[:+1:]は使用できません。", constraintViolations.iterator().next().getMessage()); }
さいごに
ここまで、Bean Validationの紹介と、TOAST NotificationでBean Validationをどのように使用しているかについて説明しました。前述したように、Bean Validationはデータのバリデーションチェックの大部分を自動化し、制約をドメインモデルに定義して一目で制約が確認できるようにサポートしてくれます。そして、ビジネスロジックとデータのバリデーションチェックのロジックを分離し、アプリケーションを整然と開発できるように手助けしてくれます。
基本的な機能だけでなく、グループ機能やカスタム制約をうまく活用すれば、ほとんどのデータのバリデーションチェックを予想していたよりも簡単に解決できると思います。開発時のデータのバリデーションチェックやエラーメッセージ作成、国際化について検討している開発者の方に役立つ情報となればうれしいです。
Bean Validationについて、もっと詳しく知りたい方は、下記のリンクをご参照ください。