Vavr Validation
ライブラリ
Vavr Validation
概要
Vavr ValidationはJava向けの関数型バリデーションライブラリで、エラーを累積できるアプリカティブファンクターを提供します。従来の例外ベースやBean Validationとは異なり、関数型プログラミングの原則に基づいた型安全なバリデーションを実現します。複数のバリデーションエラーを収集し、すべてのエラーを一度に報告できるため、フォームバリデーションやAPI入力検証において特に有効です。Vavrの豊富な関数型データ構造と組み合わせることで、表現力豊かで保守性の高いバリデーションロジックを構築できます。
詳細
Vavr Validationは、ScalaのScalazやCatsライブラリにインスパイアされたJava向けの関数型バリデーション実装です。Validation<E, T>型は2つの状態を持ちます:Valid<E, T>(成功)とInvalid<E, T>(失敗)。重要な特徴として、Validationはモナドではなくアプリカティブファンクターとしてモデル化されており、これによりエラーの累積が可能になります。combine()メソッドを使用して最大8つのバリデーションを組み合わせ、すべてのエラーをSeq<E>として収集できます。また、Option、Try、Eitherといった他のVavr型との相互変換もサポートしており、関数型プログラミングのエコシステム内でシームレスに動作します。
主な特徴
- エラー累積: 複数のバリデーションエラーを収集し一度に報告
- 型安全: コンパイル時に型チェックされるバリデーション
- アプリカティブファンクター: 独立したバリデーションの並列実行と結果の組み合わせ
- 関数型統合: Option、Try、Either等のVavr型との相互運用
- 宣言的記述: 関数合成による読みやすいバリデーションロジック
- 不変性: すべてのバリデーション結果は不変オブジェクト
メリット・デメリット
メリット
- すべてのバリデーションエラーを一度に収集できる
- 型安全でコンパイル時にエラーを検出
- 関数型プログラミングパラダイムとの高い親和性
- チェーン可能で合成可能なバリデーション
- null安全で例外を投げない設計
- テストしやすい純粋関数として実装可能
デメリット
- 関数型プログラミングの知識が必要
- 従来のJavaスタイルと異なるため学習曲線が急
- Bean Validationのようなアノテーションベースではない
- Spring等のフレームワークとの統合は手動実装が必要
- パフォーマンスは命令型実装よりわずかに劣る場合がある
- Vavrライブラリ全体への依存が発生
参考ページ
書き方の例
インストールと基本セットアップ
<!-- Maven dependency -->
<dependency>
<groupId>io.vavr</groupId>
<artifactId>vavr</artifactId>
<version>0.10.4</version>
</dependency>
// Gradle dependency
dependencies {
implementation 'io.vavr:vavr:0.10.4'
}
基本的なバリデーション
import io.vavr.control.Validation;
import io.vavr.collection.List;
import io.vavr.collection.Seq;
public class BasicValidationExample {
// 単純なバリデーション関数
public static Validation<String, Integer> validateAge(int age) {
return age >= 0 && age <= 120
? Validation.valid(age)
: Validation.invalid("年齢は0〜120の範囲で入力してください");
}
public static Validation<String, String> validateName(String name) {
return name != null && !name.trim().isEmpty()
? Validation.valid(name)
: Validation.invalid("名前は必須です");
}
public static Validation<String, String> validateEmail(String email) {
String emailRegex = "^[A-Za-z0-9+_.-]+@(.+)$";
return email != null && email.matches(emailRegex)
? Validation.valid(email)
: Validation.invalid("有効なメールアドレスを入力してください");
}
public static void main(String[] args) {
// 成功の場合
Validation<String, Integer> validAge = validateAge(25);
System.out.println(validAge.isValid()); // true
System.out.println(validAge.get()); // 25
// 失敗の場合
Validation<String, Integer> invalidAge = validateAge(150);
System.out.println(invalidAge.isInvalid()); // true
System.out.println(invalidAge.getError()); // 年齢は0〜120の範囲で入力してください
// fold を使った処理
String result = validateEmail("[email protected]").fold(
error -> "エラー: " + error,
email -> "メール: " + email
);
System.out.println(result); // メール: [email protected]
}
}
エラー累積とバリデーションの組み合わせ
import io.vavr.control.Validation;
import io.vavr.collection.List;
import io.vavr.collection.Seq;
public class Person {
private final String name;
private final int age;
private final String email;
public Person(String name, int age, String email) {
this.name = name;
this.age = age;
this.email = email;
}
// ゲッター
public String getName() { return name; }
public int getAge() { return age; }
public String getEmail() { return email; }
@Override
public String toString() {
return String.format("Person(name=%s, age=%d, email=%s)", name, age, email);
}
}
public class ValidationCombineExample {
// エラーはSeqで累積される
public static Validation<Seq<String>, Person> validatePerson(
String name, int age, String email) {
return Validation
.combine(
validateName(name),
validateAge(age),
validateEmail(email)
)
.ap(Person::new);
}
// 個別のバリデーション関数(エラーをSeqで返す)
private static Validation<Seq<String>, String> validateName(String name) {
return name == null || name.trim().isEmpty()
? Validation.invalid(List.of("名前は必須です"))
: name.length() < 2
? Validation.invalid(List.of("名前は2文字以上必要です"))
: Validation.valid(name);
}
private static Validation<Seq<String>, Integer> validateAge(int age) {
List<String> errors = List.empty();
if (age < 0) {
errors = errors.append("年齢は0以上である必要があります");
}
if (age > 120) {
errors = errors.append("年齢は120以下である必要があります");
}
return errors.isEmpty()
? Validation.valid(age)
: Validation.invalid(errors);
}
private static Validation<Seq<String>, String> validateEmail(String email) {
if (email == null) {
return Validation.invalid(List.of("メールアドレスは必須です"));
}
String emailRegex = "^[A-Za-z0-9+_.-]+@(.+)$";
return email.matches(emailRegex)
? Validation.valid(email)
: Validation.invalid(List.of("有効なメールアドレス形式ではありません"));
}
public static void main(String[] args) {
// すべてのバリデーションが成功する場合
Validation<Seq<String>, Person> validPerson =
validatePerson("田中太郎", 30, "[email protected]");
if (validPerson.isValid()) {
System.out.println("バリデーション成功: " + validPerson.get());
}
// 複数のエラーが発生する場合
Validation<Seq<String>, Person> invalidPerson =
validatePerson("", -5, "invalid-email");
if (invalidPerson.isInvalid()) {
System.out.println("バリデーションエラー:");
invalidPerson.getError().forEach(error ->
System.out.println(" - " + error)
);
}
// fold でエラーと成功の両方を処理
String result = validatePerson("山田", 25, "[email protected]").fold(
errors -> "エラー: " + errors.mkString(", "),
person -> "成功: " + person.toString()
);
System.out.println(result);
}
}
高度なバリデーション技術
import io.vavr.control.Validation;
import io.vavr.control.Option;
import io.vavr.control.Try;
import io.vavr.control.Either;
import io.vavr.collection.List;
import io.vavr.collection.Seq;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class AdvancedValidationExample {
// カスタムエラー型
sealed interface ValidationError {
record FieldError(String field, String message) implements ValidationError {}
record BusinessError(String code, String message) implements ValidationError {}
}
// 複雑なドメインオブジェクト
public static class User {
private final String id;
private final String username;
private final String email;
private final LocalDate birthDate;
private final Option<String> phoneNumber;
public User(String id, String username, String email,
LocalDate birthDate, Option<String> phoneNumber) {
this.id = id;
this.username = username;
this.email = email;
this.birthDate = birthDate;
this.phoneNumber = phoneNumber;
}
// ゲッター省略
}
// ビジネスルールのバリデーション
public static Validation<Seq<ValidationError>, User> validateUser(
String id, String username, String email,
String birthDateStr, String phoneNumber) {
return Validation
.combine(
validateId(id),
validateUsername(username),
validateEmail(email),
validateBirthDate(birthDateStr),
validatePhoneNumber(phoneNumber)
)
.ap(User::new)
.flatMap(user -> validateBusinessRules(user));
}
private static Validation<Seq<ValidationError>, String> validateId(String id) {
if (id == null || id.isEmpty()) {
return Validation.invalid(List.of(
new ValidationError.FieldError("id", "IDは必須です")
));
}
if (!id.matches("^[A-Z0-9]{8}$")) {
return Validation.invalid(List.of(
new ValidationError.FieldError("id", "IDは8文字の英数字(大文字)である必要があります")
));
}
return Validation.valid(id);
}
private static Validation<Seq<ValidationError>, String> validateUsername(String username) {
List<ValidationError> errors = List.empty();
if (username == null || username.isEmpty()) {
errors = errors.append(new ValidationError.FieldError("username", "ユーザー名は必須です"));
} else {
if (username.length() < 3) {
errors = errors.append(new ValidationError.FieldError("username", "ユーザー名は3文字以上必要です"));
}
if (username.length() > 20) {
errors = errors.append(new ValidationError.FieldError("username", "ユーザー名は20文字以下である必要があります"));
}
if (!username.matches("^[a-zA-Z0-9_]+$")) {
errors = errors.append(new ValidationError.FieldError("username", "ユーザー名は英数字とアンダースコアのみ使用できます"));
}
}
return errors.isEmpty() ? Validation.valid(username) : Validation.invalid(errors);
}
private static Validation<Seq<ValidationError>, String> validateEmail(String email) {
return Option.of(email)
.filter(e -> e.matches("^[A-Za-z0-9+_.-]+@(.+)$"))
.toValidation(List.of(new ValidationError.FieldError("email", "有効なメールアドレスを入力してください")));
}
private static Validation<Seq<ValidationError>, LocalDate> validateBirthDate(String dateStr) {
return Try.of(() -> LocalDate.parse(dateStr, DateTimeFormatter.ISO_DATE))
.toValidation()
.mapError(throwable -> List.of(
new ValidationError.FieldError("birthDate", "日付形式が無効です: " + throwable.getMessage())
))
.flatMap(date -> {
LocalDate now = LocalDate.now();
LocalDate minDate = now.minusYears(120);
if (date.isAfter(now)) {
return Validation.invalid(List.of(
new ValidationError.FieldError("birthDate", "生年月日は未来の日付にできません")
));
}
if (date.isBefore(minDate)) {
return Validation.invalid(List.of(
new ValidationError.FieldError("birthDate", "生年月日が古すぎます")
));
}
return Validation.valid(date);
});
}
private static Validation<Seq<ValidationError>, Option<String>> validatePhoneNumber(String phone) {
if (phone == null || phone.isEmpty()) {
return Validation.valid(Option.none());
}
String phoneRegex = "^\\d{2,4}-\\d{2,4}-\\d{4}$";
return phone.matches(phoneRegex)
? Validation.valid(Option.some(phone))
: Validation.invalid(List.of(
new ValidationError.FieldError("phoneNumber", "電話番号の形式が無効です(例:03-1234-5678)")
));
}
// ビジネスルールのバリデーション(全フィールドを使った検証)
private static Validation<Seq<ValidationError>, User> validateBusinessRules(User user) {
List<ValidationError> errors = List.empty();
// 未成年の場合は電話番号必須
LocalDate eighteenYearsAgo = LocalDate.now().minusYears(18);
if (user.birthDate.isAfter(eighteenYearsAgo) && user.phoneNumber.isEmpty()) {
errors = errors.append(new ValidationError.BusinessError(
"MINOR_PHONE_REQUIRED",
"未成年の場合は電話番号の登録が必須です"
));
}
// その他のビジネスルール...
return errors.isEmpty() ? Validation.valid(user) : Validation.invalid(errors);
}
// Either や Try との相互変換
public static void conversionExample() {
// Either から Validation へ
Either<String, Integer> either = Either.right(42);
Validation<String, Integer> fromEither = Validation.fromEither(either);
// Try から Validation へ
Try<String> tryValue = Try.of(() -> "success");
Validation<Throwable, String> fromTry = Validation.fromTry(tryValue);
// Validation から Either へ
Validation<String, Integer> validation = Validation.valid(100);
Either<String, Integer> toEither = validation.toEither();
// Option から Validation へ
Option<String> option = Option.of("value");
Validation<String, String> fromOption = option.toValidation("値が存在しません");
}
// シーケンスとトラバース
public static void sequenceExample() {
// 複数のバリデーションをシーケンス化
List<Validation<Seq<String>, Integer>> validations = List.of(
Validation.valid(1),
Validation.valid(2),
Validation.valid(3)
);
Validation<Seq<String>, Seq<Integer>> sequenced =
Validation.sequence(validations);
System.out.println(sequenced); // Valid(List(1, 2, 3))
// エラーがある場合
List<Validation<Seq<String>, Integer>> withErrors = List.of(
Validation.valid(1),
Validation.invalid(List.of("エラー1")),
Validation.invalid(List.of("エラー2"))
);
Validation<Seq<String>, Seq<Integer>> sequencedWithErrors =
Validation.sequence(withErrors);
System.out.println(sequencedWithErrors); // Invalid(List(エラー1, エラー2))
// トラバース(マップしてからシーケンス)
List<String> values = List.of("1", "2", "3");
Validation<Seq<String>, Seq<Integer>> traversed =
Validation.traverse(values, str ->
Try.of(() -> Integer.parseInt(str))
.toValidation()
.mapError(t -> List.of("パースエラー: " + str))
);
}
}
実践的な使用例:APIリクエストバリデーション
import io.vavr.control.Validation;
import io.vavr.collection.List;
import io.vavr.collection.Seq;
import io.vavr.collection.HashMap;
import io.vavr.collection.Map;
import io.vavr.control.Option;
public class ApiValidationExample {
// APIリクエストDTO
public static class CreateUserRequest {
private final Map<String, Object> data;
public CreateUserRequest(Map<String, Object> data) {
this.data = data;
}
public Option<String> getString(String key) {
return data.get(key).flatMap(v ->
v instanceof String ? Option.of((String) v) : Option.none()
);
}
public Option<Integer> getInt(String key) {
return data.get(key).flatMap(v ->
v instanceof Integer ? Option.of((Integer) v) : Option.none()
);
}
}
// APIレスポンス
public sealed interface ApiResponse<T> {
record Success<T>(T data) implements ApiResponse<T> {}
record Error<T>(int code, Seq<String> messages) implements ApiResponse<T> {}
}
// バリデーション付きAPIハンドラー
public static class UserApiHandler {
public ApiResponse<User> createUser(CreateUserRequest request) {
return validateCreateUserRequest(request).fold(
errors -> new ApiResponse.Error<>(400, errors),
user -> new ApiResponse.Success<>(user)
);
}
private Validation<Seq<String>, User> validateCreateUserRequest(CreateUserRequest request) {
return Validation
.combine(
request.getString("username")
.toValidation(List.of("usernameは必須です"))
.flatMap(this::validateUsername),
request.getString("email")
.toValidation(List.of("emailは必須です"))
.flatMap(this::validateEmail),
request.getInt("age")
.toValidation(List.of("ageは必須です"))
.flatMap(this::validateAge)
)
.ap((username, email, age) ->
new User(generateId(), username, email, age)
);
}
private Validation<Seq<String>, String> validateUsername(String username) {
if (username.length() < 3) {
return Validation.invalid(List.of("ユーザー名は3文字以上必要です"));
}
if (username.length() > 20) {
return Validation.invalid(List.of("ユーザー名は20文字以下にしてください"));
}
if (!username.matches("^[a-zA-Z0-9]+$")) {
return Validation.invalid(List.of("ユーザー名は英数字のみ使用できます"));
}
return Validation.valid(username);
}
private Validation<Seq<String>, String> validateEmail(String email) {
return email.matches("^[A-Za-z0-9+_.-]+@(.+)$")
? Validation.valid(email)
: Validation.invalid(List.of("有効なメールアドレスを入力してください"));
}
private Validation<Seq<String>, Integer> validateAge(Integer age) {
if (age < 0) {
return Validation.invalid(List.of("年齢は0以上である必要があります"));
}
if (age > 120) {
return Validation.invalid(List.of("年齢は120以下である必要があります"));
}
return Validation.valid(age);
}
private String generateId() {
return java.util.UUID.randomUUID().toString();
}
}
// 使用例
public static void main(String[] args) {
UserApiHandler handler = new UserApiHandler();
// 正常なリクエスト
Map<String, Object> validData = HashMap.of(
"username", "testuser",
"email", "[email protected]",
"age", 25
);
ApiResponse<User> successResponse = handler.createUser(
new CreateUserRequest(validData)
);
switch (successResponse) {
case ApiResponse.Success<User> success ->
System.out.println("ユーザー作成成功: " + success.data());
case ApiResponse.Error<User> error ->
System.out.println("エラー: " + error.messages().mkString(", "));
}
// エラーのあるリクエスト
Map<String, Object> invalidData = HashMap.of(
"username", "ab", // 短すぎる
"email", "invalid", // 無効な形式
"age", -5 // 負の値
);
ApiResponse<User> errorResponse = handler.createUser(
new CreateUserRequest(invalidData)
);
switch (errorResponse) {
case ApiResponse.Success<User> success ->
System.out.println("成功: " + success.data());
case ApiResponse.Error<User> error -> {
System.out.println("バリデーションエラー:");
error.messages().forEach(msg ->
System.out.println(" - " + msg)
);
}
}
}
static class User {
private final String id;
private final String username;
private final String email;
private final int age;
public User(String id, String username, String email, int age) {
this.id = id;
this.username = username;
this.email = email;
this.age = age;
}
@Override
public String toString() {
return String.format("User(id=%s, username=%s, email=%s, age=%d)",
id, username, email, age);
}
}
}
テスト駆動開発での活用
import io.vavr.control.Validation;
import io.vavr.collection.List;
import io.vavr.collection.Seq;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ValidationTest {
@Test
void testValidInput() {
// Given
String validEmail = "[email protected]";
// When
Validation<Seq<String>, String> result = validateEmail(validEmail);
// Then
assertTrue(result.isValid());
assertEquals(validEmail, result.get());
}
@Test
void testInvalidInput() {
// Given
String invalidEmail = "not-an-email";
// When
Validation<Seq<String>, String> result = validateEmail(invalidEmail);
// Then
assertTrue(result.isInvalid());
assertTrue(result.getError().contains("有効なメールアドレスを入力してください"));
}
@Test
void testMultipleErrors() {
// Given
String name = "";
int age = -5;
String email = "invalid";
// When
Validation<Seq<String>, Person> result = validatePerson(name, age, email);
// Then
assertTrue(result.isInvalid());
Seq<String> errors = result.getError();
assertEquals(3, errors.size());
assertTrue(errors.contains("名前は必須です"));
assertTrue(errors.contains("年齢は0以上である必要があります"));
assertTrue(errors.contains("有効なメールアドレス形式ではありません"));
}
@Test
void testValidationComposition() {
// Given
Validation<String, Integer> valid1 = Validation.valid(10);
Validation<String, Integer> valid2 = Validation.valid(20);
// When
Validation<List<String>, Integer> combined = valid1
.combine(valid2)
.ap((a, b) -> a + b);
// Then
assertTrue(combined.isValid());
assertEquals(30, combined.get());
}
private Validation<Seq<String>, String> validateEmail(String email) {
return email != null && email.matches("^[A-Za-z0-9+_.-]+@(.+)$")
? Validation.valid(email)
: Validation.invalid(List.of("有効なメールアドレスを入力してください"));
}
private Validation<Seq<String>, Person> validatePerson(String name, int age, String email) {
return Validation
.combine(
name == null || name.isEmpty()
? Validation.invalid(List.of("名前は必須です"))
: Validation.valid(name),
age < 0
? Validation.invalid(List.of("年齢は0以上である必要があります"))
: Validation.valid(age),
validateEmail(email)
)
.ap(Person::new);
}
record Person(String name, int age, String email) {}
}