我们过去对Hibernate Validator中包含的约束非常保守,但最近我们改变了这一政策,并希望有更多的内置约束。

当然,我们不会接受一切:提出的约束需要具有通用性和良好定义,但我们的想法是在Hibernate Validator中提供更多功能。

本文将有助于那些拥有一些有趣的定制Bean Validation约束并希望与社区分享的人。

当约束是Hibernate Validator的一部分而不是独立时,有几个好处

  • 所有Hibernate Validator用户都可以从这些约束中受益。它们有良好的文档,易于查找。

  • 这些约束由整个团队维护,并遵循Hibernate Validator的演变。

  • 它们获得Hibernate Validator注解处理器的支持。这个注解处理器可以在编译期间扫描类,并在约束注解使用不当时提供警告/错误。这有助于在编译时发现问题并避免运行时错误。

  • 对于引擎中的约束,有程序性约束定义

  • 最后但同样重要的是,这些约束可以在引擎级别获得额外的必需功能,例如Hibernate Validator的ScriptEvaluator,用于评估@ScriptAssert@ParameterScriptAssert所需的脚本,或者一些额外的配置属性,如新引入的temporalValidationTolerance,它在比较时间实例时用于时间约束。

为了本文的目的,将创建一个新约束注解来检查给定的String是否是有效的ISBN

这个约束已经被包含在Hibernate Validator中。

步骤 1 - 添加约束注解和验证器

第一步是创建约束本身,并带有相应的验证器,然后将它们注册到引擎中。

添加约束注解

为了做到这一点,首先需要创建一个新的约束注解。在我们的例子中,它将被命名为 @ISBN,并有一个自己的属性,type,它将定义被检查的ISBN是ISBN 10还是ISBN 13。

/**
 * Checks that the annotated character sequence is a valid
 * <a href="https://en.wikipedia.org/wiki/International_Standard_Book_Number">ISBN</a>.
 * The length of the number and the check digit are both verified.
 * <p>
 * The supported type is {@code CharSequence}. {@code null} is considered valid.
 * <p>
 * During validation all non ISBN characters are ignored. All digits and 'X' are considered
 * to be valid ISBN characters. This is useful when validating ISBN with dashes separating
 * parts of the number (ex. {@code 978-161-729-045-9}).
 *
 * @author Marko Bekhta
 * @since 6.0.6
 */
@Documented
@Constraint(validatedBy = { })
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
public @interface ISBN {
    String message() default "{org.hibernate.validator.constraints.ISBN.message}";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };

    Type type() default Type.ISBN13;

    /**
     * Defines several {@code @ISBN} annotations on the same element.
     */
    @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
    @Retention(RUNTIME)
    @Documented
    public @interface List {
        ISBN[] value();
    }

    /**
     * Defines the ISBN length. Valid lengths of ISBNs are {@code 10} and {@code 13}
     * which are represented as {@link Type#ISBN10} and {@link Type#ISBN13} correspondingly.
     */
    enum Type {
        ISBN10,
        ISBN13
    }
}

从这段代码片段中,以下几点值得强调

  • @ConstraintvalidatedBy 参数为一个空数组。在这里不需要声明任何验证器,因为它们将直接注册到引擎中。

  • 默认信息的键格式为 {org.hibernate.validator.constraints.${nameOfConstraint}.message},其中 ${nameOfConstraint} 应替换为与约束名称相同的字符串。

  • 如果适用,应定义一个 @Repeatable 注解。

  • 新约束被添加到 org.hibernate.validator.constraints 包或其子包中。

  • 添加一个javadoc描述约束执行的检查类型以及适用类型非常重要。

  • @since 标记应指定引入约束的版本。

添加约束验证器

有了注解之后,现在可以实现相应的验证器。对于每对约束和适用类型,应实现 ConstraintValidator 接口。对于 @ISBN,它只有 CharSequence。由于这将是特定于Hibernate Validator的约束,其验证器应放在 org.hibernate.validator.internal.constraintvalidators.hv 包中。以下是一个 ISBNValidator 的可能实现

public class ISBNValidator implements ConstraintValidator<ISBN, CharSequence> {

    /**
     * Pattern to replace all non ISBN characters. ISBN can have digits or 'X'.
     */
    private static Pattern NON_ISBN_CHARACTERS = Pattern.compile( "[^\\dX]" );

    private int length;

    private Function<String, Boolean> checkChecksumFunction;

    @Override
    public void initialize(ISBN constraintAnnotation) {
        switch ( constraintAnnotation.type() ) {
            case ISBN10:
                length = 10;
                checkChecksumFunction = this::checkChecksumISBN10;
                break;
            case ISBN13:
                length = 13;
                checkChecksumFunction = this::checkChecksumISBN13;
                break;
        }
    }

    @Override
    public boolean isValid(CharSequence isbn, ConstraintValidatorContext context) {
        if ( isbn == null ) {
            return true;
        }

        // Replace all non-digit (or !=X) chars
        String digits = NON_ISBN_CHARACTERS.matcher( isbn ).replaceAll( "" );

        // Check if the length of resulting string matches the expecting one
        if ( digits.length() != length ) {
            return false;
        }

        return checkChecksumFunction.apply( digits );
    }
    // check algorithm details are omitted here.
}

作为引擎一部分实现的约束验证器和外部实现的验证器之间实际上没有区别。有关约束验证器实现的更多详细信息,请参阅文档

注册验证器

现在验证器实现已经到位,它应以某种方式注册。当一个约束是引擎的一部分时,不需要在 validatedBy 属性中声明它,也不需要使用如本帖子中所述的 ServiceLoader 机制。相反,它应该通过添加以下行直接在 ConstraintHelper 构造函数中注册

putConstraint( tmpConstraints, ISBN.class, ISBNValidator.class );

建议将这些可用的验证器声明按约束的字母顺序排列。

ISBN 示例中,只有一个验证器,但也可以为同一约束注册多个验证器,使用 ConstraintHelper#putConstraints() 方法如下

putConstraints( tmpConstraints, ISBN.class, Arrays.asList(
        ISBNValidatorForCharacterSequence.class,
        ISBNValidatorForSomeOtherClass.class,
        ....
        ISBNValidatorForSomeAnotherClass.class
) );

您可以在这里看到它的实际操作。

向资源包添加默认信息

ValidationMessages.properties 中的消息键在组内按字母顺序排列。必须始终添加一个默认信息(英文)。

org.hibernate.validator.constraints.Email.message   ...
org.hibernate.validator.constraints.ISBN.message    = invalid ISBN number
org.hibernate.validator.constraints.Length.message  ...

如果可以提供可靠的其他语言文件翻译,那么添加翻译也是受欢迎的,但这绝对不是约束包含的阻碍。

测试一切

对于新约束,应添加两种类型的测试。首先,应测试所有约束验证器实现,以确保它们中的检查给出预期的结果。这些测试添加到 org.hibernate.validator.test.internal.constraintvalidators.hv 包中。应包含正面和负面场景。

private ISBNValidator validator;

@BeforeMethod
public void setUp() throws Exception {
    validator = new ISBNValidator();
}

@Test
public void validISBN10() throws Exception {
    validator.initialize( initializeAnnotation( ISBN.Type.ISBN10 ) );

    assertValidISBN( null );
    assertValidISBN( "99921-58-10-7" );
    assertValidISBN( "9971-5-0210-0" );
    assertValidISBN( "960-425-059-0" );
    assertValidISBN( "0-9752298-0-X" );
    //... more positive cases
}

@Test
public void invalidISBN10() throws Exception {
    validator.initialize( initializeAnnotation( ISBN.Type.ISBN10 ) );

    // invalid check-digit
    assertInvalidISBN( "99921-58-10-8" );
    assertInvalidISBN( "9971-5-0210-1" );
    assertInvalidISBN( "960-425-059-2" );
    assertInvalidISBN( "80-902734-1-8" );
    // ... more negative cases

    // invalid length
    assertInvalidISBN( "" );
    assertInvalidISBN( "978-0-5" );
    assertInvalidISBN( "978-0-55555555555555" );
    // ... more negative cases
}

private ISBN initializeAnnotation(ISBN.Type type) {
    ConstraintAnnotationDescriptor.Builder<ISBN> descriptorBuilder = new ConstraintAnnotationDescriptor.Builder<>( ISBN.class );
    descriptorBuilder.setAttribute( "type", type );
    return descriptorBuilder.build().getAnnotation();
}

由于@ISBN约束具有一个type属性,需要测试其行为,并且因为ConstraintValidator需要将注解传递给initialize()方法,可以使用ConstraintAnnotationDescriptor.Builder来创建注解代理,如上面示例中的initializeAnnotation()方法所示。

这些测试确保验证器能正确工作。但还需要确保新的约束及其验证器被引擎注册并捕获。这类测试被添加到org.hibernate.validator.test.constraints.annotations.hv包中,并从AbstractConstrainedTest扩展。应该添加一个简单的bean,将新的约束应用于允许的类型和位置(字段/方法返回值等),作为测试的private static类。并且应该以这样的bean的实例作为参数调用Validator#validate()方法,以确保新的约束能正常工作。

对于@ISBN约束的此类测试的例子可以如下

public class ISBNConstrainedTest extends AbstractConstrainedTest {

    @Test
    public void testISBN() {
        Foo foo = new Foo( "978-1-56619-909-4" );
        Set<ConstraintViolation<Foo>> violations = validator.validate( foo );
        assertNoViolations( violations );
    }

    @Test
    public void testISBNInvalid() {
        Foo foo = new Foo( "5412-3456-7890" );
        Set<ConstraintViolation<Foo>> violations = validator.validate( foo );
        assertThat( violations ).containsOnlyViolations(
                violationOf( ISBN.class ).withMessage( "invalid ISBN number" )
        );
    }

    private static class Foo {
        @ISBN
        private final String number;

        public Foo(String number) {
            this.number = number;
        }
    }
}

为了对约束违规进行断言,使用ConstraintViolationAssert断言类。它包括ConstraintViolationAssert#assertNoViolations(),将检查传递的约束违规集合是否为空。

它还有一个ConstraintViolationAssert#assertThat()方法,该方法接收一个违规集合并返回一个ConstraintViolationSetAssert,它提供了一个丰富的API来对违规进行断言。

对于ConstrainedTest的目的,只需检查预期的违规存在并带有预期的消息即可(参见上面的ISBNConstrainedTest#testISBNInvalid())。

最好为针对约束编写的所有测试添加一个@TestForIssue注解。这个注解可以应用于测试方法或测试类。它只有一个参数 - jiraKey,它有助于将测试链接到相应的JIRA工单(例如 @TestForIssue(jiraKey = "HV-{number}"),其中{number}是对应JIRA工单的编号)。

步骤 2 - 添加程序定义

第二步是在org.hibernate.validator.cfg.defs包中添加一个新的约束的程序定义。定义本身应该扩展ConstraintDef并提供允许指定所有约束特定属性的方法。

public class ISBNDef extends ConstraintDef<ISBNDef, ISBN> {

    public ISBNDef() {
        super( ISBN.class );
    }

    public ISBNDef type(ISBN.Type type) {
        addParameter( "type", type );
        return this;
    }
}

YourConstraintDef中的方法名称应与相应注解属性的名称匹配。方法应允许链式调用,因此约束定义可以一次初始化,因此它们应返回this

程序定义也应进行测试。根据其复杂性和所需的测试数量,它们可以包含在验证器测试类中(对于ISBN示例,在ISBNValidatorTest中),或者在同一包中拥有自己的测试类。

一个简单的程序约束测试需要一个bean,可以将新的约束应用于它,并对其应用测试。

@Test
public void testProgrammaticDefinition() throws Exception {
    HibernateValidatorConfiguration config = getConfiguration( HibernateValidator.class );
    ConstraintMapping mapping = config.createConstraintMapping();
    mapping.type( Book.class )
            .property( "isbn", FIELD )
            .constraint( new ISBNDef().type( ISBN.Type.ISBN13 ) );
    config.addMapping( mapping );
    Validator validator = config.buildValidatorFactory().getValidator();

    Set<ConstraintViolation<Book>> constraintViolations = validator.validate( new Book( "978-0-54560-495-6" ) );
    assertNoViolations( constraintViolations );

    constraintViolations = validator.validate( new Book( "978-0-54560-495-7" ) );
    assertThat( constraintViolations ).containsOnlyViolations(
            violationOf( ISBN.class )
    );
}

private static class Book {

    private final String isbn;

    private Book(String isbn) {
        this.isbn = isbn;
    }
}

步骤 3 - 添加注解处理器支持

在第三步中,还应在annotation processor类型中注册一个新的约束。

  • 首先,应将新的约束按字母顺序添加到TypeNames.HibernateValidatorTypes中。

public static final String EMAIL = ....
public static final String ISBN = ORG_HIBERNATE_VALIDATOR_CONSTRAINTS + ".ISBN";
public static final String LENGTH = ....
  • 然后这个约束,以及它可以应用的所有类型,都应该在注解处理器的 ConstraintHelper 构造函数中注册。

registerAllowedTypesForBuiltInConstraint( HibernateValidatorTypes.EMAIL, ....
registerAllowedTypesForBuiltInConstraint( HibernateValidatorTypes.ISBN, CharSequence.class );
registerAllowedTypesForBuiltInConstraint( HibernateValidatorTypes.LENGTH, ....
  • 为了确保约束正确注册,应该在 ConstraintValidationProcessorTest 中添加一个简单的测试。

/**
 * Simple bean that has both correct and incorrect usages of ISBN constraint.
 */
public class ModelWithISBNConstraints {
    @ISBN private String string;
    @ISBN private CharSequence charSequence;
    @ISBN private Integer integer;
}

public class ConstraintValidationProcessorTest extends ConstraintValidationProcessorTestBase {
    // ...

    @Test
    public void isbnConstraints() {
        File[] sourceFiles = new File[] {
                compilerHelper.getSourceFile( ModelWithISBNConstraints.class )
        };

        boolean compilationResult =
                compilerHelper.compile( new ConstraintValidationProcessor(), diagnostics, false, true, sourceFiles );

        assertFalse( compilationResult );
        assertThatDiagnosticsMatch(
                diagnostics,
                new DiagnosticExpectation( Kind.ERROR, 22 )
        );
    }
}

步骤 4 - 添加文档

最后,为了完成向 Hibernate Validator 添加新约束,应该在 参考指南 中添加该约束的文档。

应该在 附加约束 的第二章节中添加一个新的列表项,类似于以下内容

`@ISBN`:: Checks that the annotated character sequence is a valid https://en.wikipedia.org/wiki/International_Standard_Book_Number[ISBN]. `type` determines the type of ISBN. The default is ISBN-13.
    Supported data types::: `CharSequence`
    Hibernate metadata impact::: None

它应该描述约束的目的、可以通过约束属性指定什么,以及它可以应用于哪些类型。附加约束列表按字母顺序排列。

结论

本文中作为示例使用的完整代码可以在 Github 上找到。

本文提供了将新约束添加到 Hibernate Validator 的逐步说明。

  • 步骤 1 - 添加约束注解和验证器 - 提交

  • 步骤 2 - 添加程序定义 - 提交

  • 步骤 3 - 添加注解处理器支持 - 提交

  • 步骤 4 - 添加文档 - 提交

还有一点,本文没有提到。虽然这不是严格必要的,但在实施之前讨论新约束的添加是推荐的。讨论可以在我们的 JIRA(为新约束创建的新票据)或通过 邮件列表 进行。

返回顶部