バリデーション解体新書
バリデーション解体新書
バリデーションとは何か?
広義には、
何らかの処理を実施するにあたって、入力データが想定する条件を満たすかを検証する行為
と言える。
この定義で、アプリケーションのどこでバリデーションをしているのかを考えると、以下のように各層にそれが見られる。
https://gyazo.com/3c025ee46794196475e32a7446157c5a
このように実装される場所が散らばるので、「バリデーション」や「入力チェック」を分類して開発ガイドラインを作ることが多い。
例えば、大規模Java開発向けのTERASOLUNA開発ガイドラインを見てみると、
ユーザーが入力した値が不正かどうかを検証することは必須である。
入力値の検証は大きく分けて、
1. 長さや形式など、文脈によらず入力値だけを見て、それが妥当かどうかを判定できる検証
2. システムの状態によって入力値が妥当かどうかが変わる検証
がある。
1.の例としては必須チェックや、桁数チェックがあり、2.の例としては登録済みのE-mailかどうかのチェックや、注文数が在庫数以内であるかどうかのチェックが挙げられる。
本節では、基本的には前者のことを説明し、このチェックのことを「入力チェック」を呼ぶ。後者のチェックは「業務ロジックチェック」と呼ぶ。業務ロジックチェックについてはドメイン層の実装を参照されたい。
本ガイドラインでは、基本的に入力チェックをアプリケーション層で行い、業務ロジックチェックは、ドメイン層で行うことをポリシーとする。
と定義されており、さらに入力チェックを「単項目チェック」と「相関項目チェック」に分類している。
これは合理的な分類に見えるが、実際には線引きが難しいところがある。以下のようなバリデーションスペクトラムを考えた時にどこに線引きできるだろうか?
1. 桁数
2. プリミティブな型チェック
3. 文字種
4. パターンマッチ
5. 日付が未来日ではない
6. 最大10個まで選択できる
7. 選択したコードがマスタに存在するか?
8. ステータスが○○の時は、××は必須
9. Eメールアドレスは一意でなくてはならない
10. 申込者は18歳以上である
11. 注文数が在庫数を上回ってなならない
同じガイドラインにしたがっても判断に迷うところがあるだろう。例えば「選択したコードがマスタに存在するか?」はデータベースに接続する必要があるので、業務ロジックチェックとするのだろうか? でもEnumで許容される全てのコードを持てば、文脈によらず入力値だけを見て判定できるので入力チェックとするのだろうか?
4と5の間に線を引き、
Syntactic Validation: データ形式の検証
Semantic Validation: データの意味の検証
と分類すれば、曖昧さがなくなる。だが、この分類の通りに実装を分けても旨みがない。
バリデーションを分類してレイヤーごとに実装しわける際の危険性
ここまでの話は、バリデーションがデータの形式からデータの意味まで検証項目が多岐に渡り、アプリケーションの各層で責務に応じたバリデーションをすることを前提にしていた。
例えばTERASOLUNA公式のExapmleコードでは、Controllerの境界で次のようにadultCountが0〜5、childCountが0〜5というバリデーションをBeanValidationのアノテーションを使って実装している。
code:ReserveTourForm.java
@Data
public class ReserveTourForm implements Serializable {
/**
* serialVersion.
*/
private static final long serialVersionUID = -6732565610738816899L;
@NotNull
@Min(0)
@Max(5)
private Integer adultCount;
@NotNull
@Min(0)
@Max(5)
private Integer childCount;
@Size(min = 0, max = 80)
private String remarks;
}
で、このadultCountとchildCountを別のDTOであるReserveTourInputにコピーして業務ロジック層のReserveService#reserveに渡し、業務ロジックチェックやデータベースへの登録を行う。
code:ReserveTourHelper.java
public ReserveTourOutput reserve(ReservationUserDetails userDetails,
String tourCode,
ReserveTourForm tourReserveForm) throws BusinessException {
ReserveTourInput input = beanMapper.map(tourReserveForm,
ReserveTourInput.class);
input.setTourCode(tourCode);
Customer customer = userDetails.getCustomer();
input.setCustomer(customer);
ReserveTourOutput tourReserveOutput = reserveService.reserve(input);
return tourReserveOutput;
}
だが、ReserveServiceImplでは、adultCountやchildCountがnullでないとか0〜5の範囲であること、というチェックは実装されていない。
code:ReserveServiceImpl.java
@Override
public ReserveTourOutput reserve(
ReserveTourInput input) throws BusinessException {
TourInfo tourInfo = tourInfoSharedService.findOneForUpdate(input
.getTourCode());
DateTime today = dateFactory.newDateTime().withTime(0, 0, 0, 0);
// * check date
// error if today is after payment limit
if (tourInfoSharedService.isOverPaymentLimit(tourInfo)) {
ResultMessages message = ResultMessages.error().add(
MessageId.E_TR_0004);
throw new BusinessException(message);
}
// * check vacancy
int reserveMember = input.getAdultCount() + input.getChildCount();
int aveRecMax = tourInfo.getAvaRecMax();
レイヤーをまたいだメソッドの呼び出しにも関わらず、暗黙的に呼び出し側でバリデーションされていることが前提になっている。これはすなわち、ここで業務ロジックとされるReserveService(Impl)は、安全に再利用できるものではないことを意味している。
なお、TERASOLUNAでは通常の業務ロジックは原則的に再利用しない、特定のコントローラ専用のものとせよ、というガイドラインにして対策しているようである。
このようにレイヤーを分けているにも関わらず、上位レイヤーに呼び出すデータは、下位レイヤーの事前条件を満たすようバリデーション済みであることを暗黙的な呼び出し条件にしてしまうことになる。
(TERASOLUNAのガイドのように「再利用しない」というのも一案ではあるが…) 通常の対策としては業務ロジックでも、事前条件を明記しバリデーションを同様に実装することである。
code:ReserveServiceImpl.java
@Override
public ReserveTourOutput reserve(
ReserveTourInput input) throws BusinessException {
if (input.getAdultCount() == null || input.getAdultCount() < 0 || input.getAdultCount() > 5) {
throw new IllegalArgumentException("adultCount must be between 0 and 5");
}
if (input.getChildCount() == null || input.getChildCount() < 0 || input.getChildCount() > 5) {
throw new IllegalArgumentException("childCount must be between 0 and 5");
}
Controller境界と業務ロジック境界で同じ内容のバリデーションを実行しているが、Controller境界では例外ではなくエラーメッセージオブジェクトを生成しているのに対して、業務ロジック境界では例外をスローしている。業務ロジック境界での
これで事前条件を明示できるので、安全に再利用できることになる。
https://enterprisecraftsmanship.com/images/2020/2020-12-20-exceptional-non-exceptional.png
レイヤーを跨いでデータが受け渡される際は、このように事前条件を表明していくことがセオリーではあるが、上記例のように何の加工もしてないデータをパススルーしていると、バリデーションロジックは重複することになる。
これと同じ構造が、クライアントサイドバリデーションとサーバサイドバリデーションでも起こりがちである。したがってこのような話も出てくることになるが、これはレイヤー跨ぎとは問題が異なっていることを理解しておかなければならない。
https://gyazo.com/d0191cacd7607f6b6ad995396107f0c0
https://gyazo.com/3ac824dfbce319dc5e86d141646c913f
Tierはクライアント-サーバのように物理的な位置が異なるところで分かれる。ということはTier間はネットワーク接続されており、データ改竄の可能性を考慮することが求められる。したがってクライアントサイドでバリデーションしていても、サーバサイドで同様のバリデーションが必要とされる。
一方、アプリケーション内のLayerは通常はメソッド呼び出しで外部からの改竄の余地がないので、上位レイヤーのバリデーションを信じて下位レイヤーで同様のバリデーションを省くのも一応戦術としては取りうる(安全性と再利用性を犠牲にして)。
バリデーションの冗長実装を避けつつ同時に安全性・再利用性も確保できないのか?
できる
まず業務ロジックで実装していた事前条件はメソッドで保証するのではなく、渡すデータ側の不変条件として担保するようにする。前述のTERASOLUNAのExampleコードを例にとれば、業務ロジックに渡すのはReserveTourInputというクラスであるが、ただのデータの入れ物になっていて型が一致しさえすれば何でも格納できる。
code:ReserveTourInput.java
@Data
public class ReserveTourInput {
private String tourCode;
private Integer adultCount;
private Integer childCount;
private String remarks;
private Customer customer;
}
だが、adultCountやchildCountには実際に業務上とりうる値の範囲が決まっているため、受け取った側でもバリデーションが要求される。そこで、その保証を次に示すようにReserveTourInput自身に負わせるようにする。こうするとReserveTourInputはバリデーション条件を満たした状態でのみ存在できる。
code:ReserveTourInput.java
public record ReserveTourInput(
String tourCode,
int adultCount,
int childCount,
String remarks,
Customer customer
) {
public ReserveTourInput {
if (adultCount > 5) {
throw new IllegalArgumentException("Adult count must be 5 or less.");
}
if (childCount > 5) {
throw new IllegalArgumentException("Child count must be 5 or less.");
}
}
}
このコードではまだバリデーションはController境界に存在するもの(TERASOLUNAのExampleだとReserveTourFormに実装されたもの)と重複している。そこで同じものにできないか、という思考が生まれるだろう。
Controller境界で受け取った値から、業務ロジックに渡すReserveTourInputを生成するときに、Controller境界でバリデーションエラーになった時と同じような構造でエラーメッセージを返せるようになれば、Controller境界でのバリデーションと業務ロジック境界でのバリデーションは統合できる。(もちろんController境界のみ、業務ロジック境界のみで必要とされるバリデーションもあるので、それはそれで実装する必要はある)。
この統合は、個々のデータが仕様を満たすかどうか判定しメッセージを生成するだけの従来のバリデーションライブラリでは難しく、バリデーションしつつそれを不変条件としてデータが業務上Validであることを保証する型へとマッピングする機能を持つバリデーションライブラリが必要である。この考え方はParse don't Validateと呼ばれる。このコンセプトで作られたバリデーションライブラリの代表的なものに がある。
YAVIを使って、ReserveTourInputを書き直すと、以下のようにできる。
code:ReserveTourInput.java
public record ReserveTourInput(
String tourCode,
int adultCount,
int childCount,
String remarks
) {
static StringValidator<String> tourCodeValidator = StringValidatorBuilder.of("tourCode", c -> c.greaterThanOrEqual(1).lessThanOrEqual(10))
.build();
static Function<String, StringValidator<Integer>> integerStringValidatorBuilder = name -> StringValidatorBuilder.of(name, c -> c.notNull().isInteger())
.build(Integer::parseInt);
static Function<String, IntegerValidator<Integer>> countValidatorBuilder = name -> IntegerValidatorBuilder.of(name, c -> c.greaterThanOrEqual(0).lessThanOrEqual(5))
.build();
static Arguments1Validator<String, Integer> adultCountValidator = (s, locale, context) -> integerStringValidatorBuilder.apply("adultCount")
.validate(s)
.flatMap(i -> countValidatorBuilder.apply("adultCount").validate(i));
static Arguments1Validator<String, Integer> childCountValidator = (s, locale, context) -> integerStringValidatorBuilder.apply("childCount")
.validate(s)
.flatMap(i -> countValidatorBuilder.apply("childCount").validate(i));
static StringValidator<String> remarksValidator = StringValidatorBuilder.of("remarks", c -> c.greaterThanOrEqual(0).lessThanOrEqual(80))
.build();
static Arguments4Validator<String, String, String, String, ReserveTourInput> validator = ArgumentsValidators.split(
tourCodeValidator,
adultCountValidator,
childCountValidator,
remarksValidator
).apply(ReserveTourInput::new);
public static Validated<ReserveTourInput> parse(String tourCode, String adultCount, String childCount, String remarks) {
return validator.validate(tourCode, adultCount, childCount, remarks);
}
}
これを使うControllerのコードは次のように書き換えできる。もはやControllerがHTTPリクエストボディを受け取る型(元はReserveTourForm)は無用の長物になるで、単にMapに置き換えている。
code:ReserveTourController.java
@Controller
@RequestMapping("tours")
public class ReserveTourController {
@Autowired
private ReserveService reserveService;
@PostMapping("{tourCode}/reserve")
public String confirm(@PathVariable("tourCode") String tourCode,
@RequestParam Map<String, String> params,
Model model) {
return ReserveTourInput.parse(tourCode, params.get("adultCount"), params.get("childCount"), params.get("remarks"))
.fold(errors -> {
// バリデーションエラー時の処理
model.addAttribute("errors", errors);
return "reservetour/reserveForm";
},
input -> {
// パースOK時の処理
ReserveTourOutput output = reserveService.reserve(input);
return "reservetour/reserveConfirm";
});
}
}
何のためにレイヤーを分けるのか?
ここまででバリデーションを重複実装することなく、それを型の不変条件で保証しつつ業務ロジックで安全に使えることを示した。が、実際にこのように実装されているアプリケーションを見かけることは少ない。
その理由は歴史的経緯も含めて色々あるだろうが、1つは(境界が曖昧な)レイヤーありきな思考が原因と思われる。
そこで責務という曖昧な基準に基づいたレイヤー定義を一度アンラーンして、以下に示すように業務上Validなデータのみを扱うか否かだけで境界を作ることを考える。
https://gyazo.com/6fbb8ac93820f07e67760dfc4c1ba3e9
ここで、業務上Validが保証されているデータのみを扱うレイヤーをAlways-valid Layerと名付ける。(ほぼ業務ロジック層と呼ばれるものと似たものになるが、業務ロジックというのはかなり曖昧さを含む定義なのでこれもアンラーンしよう。参考 古典ドメインモデリングパターンの解脱 - 大吉祥寺.pm) 業務上Validが保証されているデータのみを扱えるというのは、再利用性や安全性が高くプログラミングできるというここまでの話に加えて、可読性や理解容易性の観点からも大いにメリットがある (参考 Totality) なので、アプリケーションのレイヤーを設計するなら、Always-valid Layerを作ることを初手に選ぶと良い。(その他必要があれば、他のレイヤーを足していく)
「業務上Valid」はどうやって決めるのか?
ただし、この業務上Validというのも客観的な基準を決めれるものでもなく曖昧さを含む。したがって設計者が意志を持って決める必要がある。
https://gyazo.com/f06d85875eb02675f2917c19a472e057
なんならこれを決めていく行為が、ドメインモデリングの中核といえる。
まとめ
人によって「バリデーション」の言葉の使い方が異なるので、まずそこを合わせるところから始めよう。(本質的に「業務上Validなデータ」を定義しない限りは、バリデーションの分類は曖昧である)
TierとLayerの違いを認識し、必要なバリデーション戦略を立てよう。
Tier境界のバリデーション仕様から作っていくのではなく、業務上Validなデータを定義するところから始める方が結果的に簡単で安全。
が、それをサポートするバリデーションライブラリが少ないのが残念…