使用 Yavi 进行验证

Yavi是一个 Java 验证库,它允许我们轻松、干净地确保我们的对象处于有效状态。

Yavi 是 Java 应用程序中对象验证的绝佳轻量级选择。它不依赖于反射或向被验证的对象添加额外的注释,因此它可以完全独立于我们希望验证的类使用。它还强调类型安全的 API,确保我们不会意外定义不可能的验证规则。此外,它完全支持我们可以在应用程序中定义的任何类型,并且具有大量可依赖的预定义约束,同时仍允许我们在必要时轻松定义自己的约束。

在本教程中,我们将了解 Yavi。我们将了解它是什么、我们可以用它做什么以及如何使用它。

依赖项
在使用 Yavi 之前,我们需要在我们的构建中包含最新版本,在撰写本文时版本为 0.14.1。

如果我们使用 Maven,我们可以在pom.xml文件中包含这个依赖项:

<dependency>
    <groupId>am.ik.yavi</groupId>
    <artifactId>yavi</artifactId>
    <version>0.14.1</version>
</dependency>

此时,我们已准备好开始在我们的应用程序中使用它。

简单验证
一旦我们的项目有了 Yavi,我们就可以开始使用它来验证我们的对象了。

我们可以构建的最简单的验证器适用于简单的值类型,例如String或Integer。每种支持的类型都是使用am.ik.yavi.builder包中的构建器类构建的:

StringValidator<String> validator = StringValidatorBuilder.of("name", c -> c.notBlank())
  .build();

这将构造一个验证器,我们可以使用它来验证String实例以确保它们不为空。构建器的第一个参数是我们要验证的值的名称,第二个参数是定义要应用的验证规则的lambda 。

但更多时候,我们希望验证整个 bean,而不仅仅是单个值。这些验证器是使用 ValidatorBuilder 构建的,我们可以在其中为不同的字段添加多个规则:

public record Person(String name, int age) {}
Validator<Person> validator = ValidatorBuilder.of(Person.class)
  .constraint(Person::name, "name", c -> c.notBlank())
  .constraint(Person::age,
"age", c -> c.positiveOrZero().lessThan(150))
  .build();

每次调用constraint()都会为我们的验证器添加一个针对不同字段的约束。它的第一个参数是该字段的getter,它必须是我们正在验证的类型上的方法。第三个参数是用于定义约束的lambda,与之前相同。Yavi确保这适合我们的getter方法的返回类型。例如,我们可以在String字段上使用notBlank(),但不能在整数字段上使用。

一旦我们获得了验证器,我们就可以使用它来验证适当的对象:

ConstraintViolations result = validator.validate(new Person("", 42));
assertFalse(result.isValid());

返回的ConstraintViolations对象告诉我们提供的对象是否有效,如果无效,我们可以看到实际的违规行为是什么:

assertEquals(1, result.size());
assertEquals("name", result.get(0).name());
assertEquals(
"charSequence.notBlank", result.get(0).messageKey());

这里我们可以看到名称字段无效,并且违规是因为它不应该为空。

验证嵌套对象
通常,我们想要验证的 bean 中还有其他 bean,我们希望确保这些 bean 也是有效的。我们可以使用构建器的nest()方法而不是约束()调用来实现这一点:

public record Name(String firstName, String surname) {}
public record Person(Name name, int age) {}
Validator<Name> nameValidator = ValidatorBuilder.of(Name.class)
  .constraint(Name::firstName, "firstName", c -> c.notBlank())
  .constraint(Name::surname,
"surname", c -> c.notBlank())
  .build();
Validator<Person> personValidator = ValidatorBuilder.of(Person.class)
  .nest(Person::name,
"name", nameValidator)
  .constraint(Person::age,
"age", c -> c.positiveOrZero().lessThan(150))
  .build();

一旦定义,我们就可以像以前一样使用它。不过现在,Yavi 将使用点符号自动组成任何违规的名称,以便我们能够准确地看到发生了什么:

assertEquals(2, result.size());
assertEquals("name.firstName", result.get(0).name());
assertEquals(
"name.surname", result.get(1).name());

这里我们得到了两个预期的违规行为——一个是name.firstName,另一个是name.surname。这些告诉我们有问题的字段嵌套在外部对象的name字段中。

跨字段验证
在某些情况下,我们无法单独验证单个字段。验证规则可能取决于同一对象中其他字段的值。我们可以使用constraintOnTarget()方法实现这一点,该方法验证提供的对象而不是其中的单个字段:

record Range(int start, int end) {}
Validator<Range> validator = ValidatorBuilder.of(Range.class)
  .constraintOnTarget(range -> range.end > range.start, "end", "range.endGreaterThanStart",
   
"\"end\" must be greater than \"start\"")
  .build();

在本例中,我们要确保范围的结束值大于起始值。在执行此操作时,我们需要提供一些额外的值,因为我们实际上是在创建自定义约束。

不出所料,使用此验证器与以前相同。但是,由于我们自己定义了约束,因此我们将在违规中获取自定义值:

assertEquals(1, result.size());
assertEquals("end", result.get(0).name());
assertEquals(
"range.endGreaterThanStart", result.get(0).messageKey());

自定义约束
大多数情况下,Yavi 会为我们提供验证对象所需的所有约束。但是,在某些情况下,我们可能需要标准集未涵盖的内容。

我们之前看到了一个通过提供 lambda 来内联编写自定义约束的示例。我们可以在约束构建器中执行类似操作,为任何字段定义自定义约束:

Validator<Data> validator = ValidatorBuilder.of(Data.class)
  .constraint(Data::palindrome, "palindrome",
    c -> c.predicate(s -> validatePalindrome(s),
"palindrome.valid", "\"{0}\" must be a palindrome"))
  .build();

这里我们使用了predicate()方法来提供 lambda,并给它一个消息键和默认消息。这个 lambda 可以做任何我们想做的事情,只要它符合java.util.function.Predicate的定义。在本例中,我们使用一个函数来检查一个字符串是否是回文。

有时,我们可能希望以更可重用的方式编写自定义约束,我们可以通过创建实现CustomConstraint接口 的类来实现这一点:

class PalindromeConstraint implements CustomConstraint<String> {
    @Override
    public boolean test(String input) {
        String reversed = new StringBuilder()
          .append(input)
          .reverse()
          .toString();
        return input.equals(reversed);
    }
    @Override
    public String messageKey() {
        return "palindrome.valid";
    }
    @Override
    public String defaultMessageFormat() {
        return
"\"{0}\" must be a palindrome";
    }
}

从功能上讲,这与我们的 lambda 相同,只是作为一个类,我们可以更轻松地在验证器之间重用它。在这种情况下,我们只需要将此实例传递给我们的predicate()调用,其他一切都已为我们配置:

Validator<Data> validator = ValidatorBuilder.of(Data.class)
  .constraint(Data::palindrome, "palindrome", c -> c.predicate(new PalindromeConstraint()))
  .build();

无论我们使用哪种方法,我们都可以按照预期使用生成的验证器:

ConstraintViolations result = validator.validate(new Data("other"));
assertFalse(result.isValid());
assertEquals(1, result.size());
assertEquals(
"palindrome", result.get(0).name());
assertEquals(
"palindrome.valid", result.get(0).messageKey());

在这里我们可以看到我们的字段无效,并且结果包括我们定义的消息键,以准确地指出它出了什么问题。

条件约束
并非所有约束都适用于所有情况。Yavi 为我们提供了一些工具来配置某些约束,使其仅在某些情况下起作用。

我们有一个选项,就是为验证器提供上下文。我们可以将其定义为任何我们想要的类型,只要它实现了ConstraintGroup接口,不过枚举是一个非常方便的选项:

enum Action implements ConstraintGroup {
    CREATE,
    UPDATE,
    DELETE
}

然后,我们可以使用constrainOnCondition()包装器定义一个约束,以定义仅在特定上下文下适用的约束:

 

Validator<Person> validator = ValidatorBuilder.of(Person.class)
  .constraint(Person::name, "name", c -> c.notBlank())
  .constraintOnCondition(Action.UPDATE.toCondition(),
    b -> b.constraint(Person::id,
"id", c -> c.notBlank()))
  .build();

这将始终验证名称字段不为空,但仅当我们提供UPDATE上下文时才会验证id字段不为空。

当使用此功能时,我们需要通过提供上下文以及我们正在验证的值来进行稍微不同的验证:

ConstraintViolations result = validator.validate(new Person(null, "Baeldung"), Action.UPDATE);
assertFalse(result.isValid());

如果我们想要拥有更多的控制权,constraintOnCondition()方法可以采用 lambda 表达式,该表达式接受要验证的值和上下文,并指示是否应应用约束。这允许我们定义我们想要的任何条件:

Validator<Person> validator = ValidatorBuilder.of(Person.class)
  .constraintOnCondition((person, ctx) -> person.id() != null,
    b -> b.constraint(Person::name, "name", c -> c.notBlank()))
  .build();

在这种情况下,仅当id字段具有值时,才会验证name字段:

ConstraintViolations result = validator.validate(new Person(null, null));
assertTrue(result.isValid());


论证验证
Yavi 的独特之处之一是它能够将方法调用包装在验证中,确保在调用方法之前参数有效。

参数验证器全部使用ArgumentsValidatorBuilder构建器类构建。为了确保类型安全,此构建器类会构建 16 种可能的类型之一,支持 1 到 16 个参数。

这对于包装对构造函数的调用特别有用。这使我们能够在调用构造函数之前保证参数有效,而不是构造一个可能无效的对象并在之后验证它:

Arguments2Validator<String, Integer, Person> validator = ArgumentsValidatorBuilder.of(Person::new)
  .builder(b -> b
    ._string(Arguments1::arg1, "name", c -> c.notBlank())
    ._integer(Arguments2::arg2,
"age", c -> c.positiveOrZero())
  )
  .build();

_string()和_integer()的语法稍微不寻常,因此编译器知道每个参数要使用的类型。

一旦我们构建了验证器,我们就可以调用它并传入所有适当的参数:

Validated result = validator.validate("", -1);

这个结果告诉我们参数是否有效,如果无效则返回验证错误:

assertFalse(result.isValid());
assertEquals(2, result.errors().size());
assertEquals("name", result.errors().get(0).name());
assertEquals(
"charSequence.notBlank", result.errors().get(0).messageKey());
assertEquals(
"age", result.errors().get(1).name());
assertEquals(
"numeric.positiveOrZero", result.errors().get(1).messageKey());

如果所有参数都有效,那么我们就可以取回方法的结果 - 在本例中是构造的对象:

assertTrue(result.isValid());
Person person = result.value();

我们也可以使用同样的技术来包装对象上的方法:

record Person(String name, int age) {
    boolean isOlderThan(int check) {
        return this.age > check;
    }
}
Arguments2Validator<Person, Integer, Boolean> validator = ArgumentsValidatorBuilder.of(Person::isOlderThan)
  .builder(b -> b
    ._integer(Arguments2::arg2, "age", c -> c.positiveOrZero())
  )
  .build();

这将验证方法调用中的参数,并且只有当所有参数都有效时才调用该方法。在本例中,我们将调用该方法的实例作为第一个参数传递,然后传递所有其他参数:

Person person = new Person("Baeldung", 42);
Validated<Boolean> result = validator.validate(person, -1);


与之前一样,如果参数通过验证,Yavi 就会调用该方法,然后我们就可以访问返回值。如果参数验证失败,它就不会调用包装的方法,而是返回验证错误。

注释处理
到目前为止,Yavi 在几个地方都相当重复。例如,我们需要指定字段名称和方法引用来获取值,而这些值通常具有相同的名称。Yavi附带一个Java 注释处理器,可以在这里提供帮助。

注释字段
我们可以使用@ConstraintTarget注释来注释对象上的字段,以自动生成一些元类:

record Person(@ConstraintTarget String name, @ConstraintTarget int age) {}

这些注释可以放在构造函数参数、getter 或字段上,并且其作用相同。

然后,我们可以在构建验证器时使用这些生成的类:

Validator<Person> validator = ValidatorBuilder.of(Person.class)
  .constraint(_PersonMeta.NAME, c -> c.notBlank())
  .constraint(_PersonMeta.AGE, c -> c.positiveOrZero().lessThan(150))
  .build();

我们不再需要同时指定字段名称和相应的 getter。此外,如果我们尝试使用不存在的字段,那么这将不再编译。

一旦构建完成,该验证器就与之前的验证器相同,并且可以像之前一样使用。

注释参数
当使用包装方法调用支持时,我们也可以对方法参数使用相同的技术。在这种情况下,我们使用@ConstraintArguments注释,注释我们计划验证的方法或构造函数:

record Person(String name, int age) {
    @ConstraintArguments
    Person {
    }
    @ConstraintArguments
    boolean isOlderThan(int check) {
        return this.age() > check;
    }
}

Yavi 为每种方法生成一个元类,我们可以像以前一样使用它来生成验证器。

Arguments2Validator<String, Integer, Person> validator = ArgumentsValidatorBuilder.of(Person::new)
  .builder(b -> b
    .constraint(_PersonArgumentsMeta.NAME, c -> c.notBlank())
    .constraint(_PersonArgumentsMeta.AGE, c -> c.positiveOrZero())
  )
  .build();

和以前一样,我们不再需要手动指定参数名称或位置。我们也不再需要指定正确的约束类型——我们的元类已经为我们定义了所有这些。