本文专门讨论各种数据验证方法:在Java应用程序中验证数据时,可能会碰到项目的陷阱以及应遵循的方法和技术。

我经常看到那些创建者不费吹灰之力选择数据验证方法的项目。 团队在截止日期和含糊要求等形式的巨大压力下进行了项目工作,结果,他们根本没有时间进行准确,一致的验证。 因此,它们的验证代码分散在各处:Javascript代码段,屏幕控制器,业务逻辑箱,域实体,触发器和数据库约束。 这段代码中充满了if-else语句,引发了一系列异常,并试图找出那里的特定数据在哪里进行了验证...结果,随着项目的发展,遵守要求变得困难且昂贵(通常非常令人困惑),并且数据验证方法的一致性。
那么,有没有简单而优雅的方法来验证数据? 有没有一种方法可以保护我们免受无法阅读的罪恶的侵扰,将所有验证逻辑放在一起,并且已经由流行的Java框架的开发人员为我们创建了?
是的,有这种方法。
对于我们CUBA平台的开发人员而言,使用最佳实践非常重要。 我们认为验证代码应:
- 可重复使用并遵循DRY原则;
- 自然且易于理解;
- 放置在开发人员希望看到的地方;
- 能够验证来自不同来源的数据:用户界面,SOAP调用,REST等。
- 在多线程环境中工作不会出现问题;
- 自动在应用程序内部调用,无需手动运行检查;
- 在简洁的对话框中为用户提供清晰,本地化的消息;
- 遵守标准。
让我们看看如何使用使用CUBA Platform框架编写的示例应用程序来实现这一点。 但是,由于CUBA基于Spring和EclipseLink,因此此处使用的大多数技术都可以在支持JPA和Bean验证规范的任何其他Java平台上运行。
使用数据库约束进行验证
验证数据的最常见,最明显的方法可能是在数据库级别使用限制,例如,必需标志(对于值不能为空的字段),字符串长度,唯一索引等。 此方法最适合企业应用程序,因为此类软件通常严格专注于数据处理。 但是,即使在这里,开发人员也经常通过为应用程序的每个级别分别设置限制来犯错。 通常,原因在于开发人员之间的责任分配。
考虑一个我们大多数人都知道的例子,甚至有一些我们自己的经验……如果规范说护照号码字段中应该有10个字符,那么很有可能每个人都会对此进行检查:DDL中的DB架构师,相应Entity中的后端开发人员以及REST服务,最后,UI开发人员直接在客户端。 然后此要求更改,并且字段增加到15个字符。 Devops会更改数据库中的约束值,但对用户而言则没有任何变化,因为在客户端,约束是相同的...
任何开发人员都知道如何避免此问题-验证必须集中! 在CUBA中,可以在JPA实体批注中找到这种验证。 基于此元信息,CUBA Studio将生成正确的DDL脚本并应用适当的客户端验证器。

如果注释发生更改,CUBA将更新DDL脚本并生成迁移脚本,因此,下次您部署项目时,基于JPA的新限制将同时在界面和应用程序数据库中生效。
尽管在数据库级别上的简单性和实现方式使该方法具有绝对的可靠性,但JPA注释的范围仅限于可以在DDL标准中表示的最简单的情况,并且不包括数据库触发器或存储过程。 因此,基于JPA的约束可以使实体字段唯一或必填,或设置最大列长。 您甚至可以使用@UniqueConstraint
批注对列的组合设置唯一的限制。 但这可能就是全部。
如此一来,在需要更复杂的验证逻辑(例如,检查字段的最小值/最大值,使用正则表达式进行验证或仅针对您的应用程序执行自定义检查)的情况下,将应用称为“ Bean验证”的方法。
Bean验证
大家都知道,遵循具有较长生命周期的标准是一种很好的做法,其有效性已在数千个项目中得到证明。 Java Bean验证是JSR 380、349 和303及其应用程序( Hibernate Validator和Apache BVal)中记录的一种方法 。
尽管许多开发人员都熟悉这种方法,但是它经常被低估。 这是一种即使在旧项目中也可以嵌入数据验证的简便方法,它使您可以清晰,简单,可靠且尽可能接近业务逻辑来构建验证。
使用Bean验证为该项目提供了许多优势:
- 验证逻辑位于主题区域旁边:对bin的字段和方法的限制的定义以自然且真正面向对象的方式发生。
- Bean验证标准为我们提供了许多验证注释 ,例如:
@Size
, @Min
@Pattern
, @Email
, @Past
@Pattern
, @Email
, @Past
@Pattern
, @Email
, @Past
,不太标准的@URL
, @Length
,功能最强大的@ScriptAssert
等等。 - 该标准并不仅限于现成的注释,还允许我们创建自己的注释。 我们还可以通过组合多个其他注解来创建新的注解,或使用单独的Java类作为验证器对其进行定义。
例如,在上面的示例中,我们可以设置@ValidPassportNumber
类的级别批注,以根据country
字段的值来验证护照号码是否与格式匹配。 - 约束不仅可以在字段或类上设置,还可以在方法及其参数上设置。 这种方法称为“合同确认” ,将在稍后讨论。
当用户提交输入的信息时, CUBA平台 (与其他框架一样)会自动启动Bean验证,因此如果验证失败,它会立即显示错误消息,并且我们不需要手动运行bin验证器。
让我们返回带有护照号码的示例,但是这次我们将对Person实体的一些限制进行补充:
name
字段必须是2个或更多字符,并且必须有效。 (如您所见,正则表达式并不简单,但是“ Charles Ogier de Batz de Castelmore Comte d'Artagnan”将通过测试,而“ R2D2”则不会通过);height
(高度)应在以下间隔内: 0 < height <= 300
cm;email
字段必须包含与正确电子邮件格式匹配的字符串。
经过所有这些检查,Person类将如下所示:
@Listeners("passportnumber_PersonEntityListener") @NamePattern("%s|name") @Table(name = "PASSPORTNUMBER_PERSON") @Entity(name = "passportnumber$Person") @ValidPassportNumber(groups = {Default.class, UiCrossFieldChecks.class}) @FraudDetectionFlag public class Person extends StandardEntity { private static final long serialVersionUID = -9150857881422152651L; @Pattern(message = "Bad formed person name: ${validatedValue}", regexp = "^[AZ][az]*(\\s(([az]{1,3})|(([az]+\\')?[AZ][az]*)))*$") @Length(min = 2) @NotNull @Column(name = "NAME", nullable = false) protected String name; @Email(message = "Email address has invalid format: ${validatedValue}", regexp = "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$") @Column(name = "EMAIL", length = 120) protected String email; @DecimalMax(message = "Person height can not exceed 300 centimeters", value = "300") @DecimalMin(message = "Person height should be positive", value = "0", inclusive = false) @Column(name = "HEIGHT") protected BigDecimal height; @NotNull @Column(name = "COUNTRY", nullable = false) protected Integer country; @NotNull @Column(name = "PASSPORT_NUMBER", nullable = false, length = 15) protected String passportNumber; ... }
人.java
我相信对@DecimalMin
@Length
, @Pattern
@DecimalMin
, @Length
, @Pattern
之类的注释的使用非常明显,不需要注释。 让我们仔细看看@ValidPassportNumber
批注的实现。
我们全新的@ValidPassportNumber
检查Person#passportNumber
@ValidPassportNumber
是否与Person#country
字段指定的每个国家的正则表达式模式匹配。
首先,让我们看一下文档( CUBA或Hibernate手册很好),根据它,我们需要用这个新的注释标记我们的类,并将groups
参数传递给它,其中UiCrossFieldChecks.class
意味着该验证应在交叉处运行。验证-检查所有单个字段后, Default.class
将限制保存在默认验证组中。
批注描述如下所示:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = ValidPassportNumberValidator.class) public @interface ValidPassportNumber { String message() default "Passport number is not valid"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
ValidPassportNumber.java
在这里, @Target(ElementType.TYPE)
表示此运行时批注的目的是该类,而@Constraint(validatedBy = … )
确定验证是由实现ConstraintValidator<...>
接口的ValidPassportNumberValidator
类执行的。 验证代码本身在isValid(...)
方法中,该方法以相当简单的方式执行实际验证:
public class ValidPassportNumberValidator implements ConstraintValidator<ValidPassportNumber, Person> { public void initialize(ValidPassportNumber constraint) { } public boolean isValid(Person person, ConstraintValidatorContext context) { if (person == null) return false; if (person.country == null || person.passportNumber == null) return false; return doPassportNumberFormatCheck(person.getCountry(), person.getPassportNumber()); } private boolean doPassportNumberFormatCheck(CountryCode country, String passportNumber) { ... } }
ValidPassportNumberValidator.java
仅此而已。 使用CUBA平台,我们无需编写任何代码,只需编写一行代码即可使我们的自定义验证工作并向用户提供错误消息。
没什么复杂的,对吧?
现在,让我们看看它是如何工作的。 在这里,CUBA还有其他功能:它不仅向用户显示错误消息,而且还在未通过bean验证的红色字段中突出显示:

这不是一个优雅的解决方案吗? 通过仅向主题区域的实体添加几个Java批注,可以在UI中充分显示验证错误。
总结本节,让我们再次简要列出Bean验证对实体的优势:
- 它是可以理解和可读的;
- 允许您直接在实体类中定义值约束;
- 可以定制和补充;
- 集成到流行的ORM中,并在更改保存到数据库之前自动运行检查;
- 当用户向UI发送数据时,某些框架还会自动运行Bean验证(如果没有,则很容易手动调用
Validator
接口); - Bean验证是公认的标准,并且在Internet上有完整的文档。
但是,如果您需要对方法,构造函数或REST地址设置限制以验证来自外部系统的数据怎么办? 或者,如果您需要声明性地检查方法参数的值,而无需在每个要测试的方法中编写带有许多if-else条件的无聊代码?
答案很简单:Bean验证也适用于方法!
合同确认
有时有必要超越数据模型状态的验证。 许多方法可能会受益于参数和返回值的自动验证。 这可能不仅是必要的,以检查去往REST或SOAP地址的数据,而且在我们要记下方法调用的前提条件和后置条件以确保输入的数据在执行方法主体之前已验证或返回值的情况下,这是必要的。是在预期范围内,或者例如,我们只需要声明性地描述输入参数的值范围即可提高代码的可读性。
使用bean验证,可以将限制应用于方法和构造函数的输入参数以及返回值,以检查任何Java类中其调用的先决条件和后置条件。 与传统的检查参数和返回值有效性的方法相比,此路径具有多个优点:
- 不必以命令式方式手动执行检查(例如,通过抛出
IllegalArgumentException
等)。 您可以声明性地定义约束,并使代码更易于理解和表达。 - 可以配置,重用和配置约束:您无需为每个检查编写验证逻辑。 更少的代码意味着更少的错误。
- 如果类,方法的返回值或其参数用
@Validated
批注标记,则每次调用该方法时,平台都会自动执行检查。 - 如果该可执行文件带有
@Documented
批注,则其前提条件将包含在生成的JavaDoc中。
使用“合同验证”,我们可以获得清晰,紧凑且易于维护的代码。
例如,让我们看一下CUBA应用程序的REST控制器的接口。 PersonApiService
接口允许您使用getPersons()
方法从数据库中获取人员列表,并使用addNewPerson(...)
调用添加新人员。
并且不要忘记bean验证是继承的! 换句话说,如果我们注释某个类,字段或方法,则所有继承该类或实现此接口的类都将经受相同的验证注释。
@Validated public interface PersonApiService { String NAME = "passportnumber_PersonApiService"; @NotNull @Valid @RequiredView("_local") List<Person> getPersons(); void addNewPerson( @NotNull @Length(min = 2, max = 255) @Pattern(message = "Bad formed person name: ${validatedValue}", regexp = "^[AZ][az]*(\\s(([az]{1,3})|(([az]+\\')?[AZ][az]*)))*$") String name, @DecimalMax(message = "Person height can not exceed 300 cm", value = "300") @DecimalMin(message = "Person height should be positive", value = "0", inclusive = false) BigDecimal height, @NotNull CountryCode country, @NotNull String passportNumber ); }
PersonApiService.java
这段代码是否足够清晰?
_(除了@RequiredView(“_local”)
注释@RequiredView(“_local”)
(特定于CUBA平台,并验证返回的Person
对象包含PASSPORTNUMBER_PERSON
表中的所有字段)。
@Valid
定义了getPersons()
方法返回的每个集合对象还必须根据Person
类的限制进行验证。
在CUBA应用程序中,以下地址提供了这些方法:
- / app / rest / v2 / services / passportnumber_PersonApiService / getPersons
- / app / rest / v2 / services / passportnumber_PersonApiService / addNewPerson
让我们打开Postman应用程序,并确保验证可以正常进行:

您可能已经注意到,在上面的示例中未验证护照号码。 这是因为此字段需要对addNewPerson
方法的参数进行交叉检查,因为用于验证addNewPerson
的正则表达式模板的选择取决于country
字段的值。 这种交叉验证完全类似于类级别的实体限制!
JSR 349和380支持参数的交叉验证。您可以阅读hibernate文档,以了解如何实现自己的类/接口方法的交叉验证。
外部bean验证
世界上没有完美的事物,因此bean验证有其缺点和局限性:
- 有时我们只需要在将更改保存到数据库之前检查对象的复杂图的状态即可。 例如,您需要确保将客户订单的所有元素都放在一个包装中。 这是一个相当困难的操作,每当用户向订单中添加新项目时执行该操作并不是一个好主意。 因此,这种检查可能只需要一次:在数据库中保存
Order
对象及其OrderItem
子对象之前。 - 在事务内部需要进行一些检查。 例如,电子商店系统应该在将商品提交到数据库之前检查是否有足够的商品副本以履行订单。 这样的检查只能在交易内部执行,因为 该系统是多线程的,库存商品的数量可能随时更改。
CUBA平台提供了两种预提交的数据验证机制,称为实体侦听器和事务侦听器 。 让我们更详细地考虑它们。
实体名单
CUBA中的实体侦听 PreUpdateEvent
与 JPA提供给开发人员的PreInsertEvent
, PreUpdateEvent
和PredDeleteEvent
侦听 器非常相似。 两种机制都允许您在实体对象存储在数据库中之前和之后检查实体对象。
在CUBA中,很容易创建和连接实体侦听器,为此您需要做两件事:
- 创建一个实现实体侦听器接口之一的托管Bean。 3个接口对于验证很重要:
BeforeDeleteEntityListener<T>
,
BeforeInsertEntityListener<T>
,
BeforeUpdateEntityListener<T>
- 将
@Listeners
批注添加到您计划跟踪的实体对象。
仅此而已。
与JPA标准(JSR 338,第3.5节)相比,CUBA Platform侦听器接口具有类型,因此您无需将Object
类型的参数Object
转换为实体类型即可开始使用它。 CUBA平台为相关实体或EntityManager调用者添加了加载和修改其他实体的能力。 所有这些更改还将调用相应的实体侦听器。
CUBA平台还支持“软删除” ,这种方法不是将记录实际从数据库中删除,而是仅将它们标记为已删除,并且无法正常使用。 因此,对于软删除,平台将调用BeforeDeleteEntityListener
/ AfterDeleteEntityListener
侦听器,而标准实现将调用PreUpdate
/ PostUpdate
。
让我们来看一个例子。 在这里,事件侦听器bean仅使用一行代码即可连接到实体类: @Listeners
批注,它使用侦听器类的名称:
@Listeners("passportnumber_PersonEntityListener") @NamePattern("%s|name") @Table(name = "PASSPORTNUMBER_PERSON") @Entity(name = "passportnumber$Person") @ValidPassportNumber(groups = {Default.class, UiCrossFieldChecks.class}) @FraudDetectionFlag public class Person extends StandardEntity { ... }
人.java
侦听器实现本身看起来像这样:
@Component("passportnumber_PersonEntityListener") public class PersonEntityListener implements BeforeDeleteEntityListener<Person>, BeforeInsertEntityListener<Person>, BeforeUpdateEntityListener<Person> { @Override public void onBeforeDelete(Person person, EntityManager entityManager) { if (!checkPassportIsUnique(person.getPassportNumber(), person.getCountry(), entityManager)) { throw new ValidationException( "Passport and country code combination isn't unique"); } } @Override public void onBeforeInsert(Person person, EntityManager entityManager) {
PersonEntityListener.java
在以下情况下,实体侦听器是一个不错的选择:
- 在实体对象存储在数据库中之前,有必要检查事务中的数据;
- 在验证过程中,有必要检查数据库中的数据,例如,检查是否有足够的产品库存来接受订单;
- 您不仅需要查看诸如
Order
的实体对象,还需要查看相关的实体,例如Order
实体的OrderItems
; - 我们只想跟踪某些实体类的插入,更新或删除操作,例如,只跟踪
Order
和OrderItem
,并且我们不需要在交易过程中检查其他实体类中的更改。
交易监听器
CUBA事务侦听器也在事务上下文中起作用,但是与实体侦听器相比,每个数据库事务都会调用它们。
这些赋予了他们超强的力量:
但是,它们的缺点也决定了这一点:
- 他们更难写;
- 它们会大大降低性能;
- 应该非常仔细地编写它们:事务侦听器中的错误可能会干扰应用程序的初始加载。
因此,当您需要使用相同的算法检查不同类型的实体时,例如,使用为您的所有业务对象提供服务的单一服务检查所有数据是否存在网络欺诈,事务监听器是一个很好的解决方案。

查看一个示例,该示例检查该实体是否具有@FraudDetectionFlag
批注,如果有,则启动欺诈检测器。 我重复一遍:请记住, 在提交每个数据库事务之前 ,已在系统上调用此方法,因此代码应尝试检查尽可能少的对象。
@Component("passportnumber_ApplicationTransactionListener") public class ApplicationTransactionListener implements BeforeCommitTransactionListener { private Logger log = LoggerFactory.getLogger(ApplicationTransactionListener.class); @Override public void beforeCommit(EntityManager entityManager, Collection<Entity> managedEntities) { for (Entity entity : managedEntities) { if (entity instanceof StandardEntity && !((StandardEntity) entity).isDeleted() && entity.getClass().isAnnotationPresent(FraudDetectionFlag.class) && !fraudDetectorFeedAndFastCheck(entity)) { logFraudDetectionFailure(log, entity); String msg = String.format( "Fraud detection failure in '%s' with id = '%s'", entity.getClass().getSimpleName(), entity.getId()); throw new ValidationException(msg); } } } ... }
ApplicationTransactionListener.java
要成为事务侦听器,托管bean必须实现BeforeCommitTransactionListener
接口和beforeCommit
方法。 应用程序启动时,事务侦听器会自动绑定。 CUBA将所有实现BeforeCommitTransactionListener
或AfterCompleteTransactionListener
类注册为事务侦听器。
结论
Bean验证(JPA 303、349和980)是可以为公司项目中95%的数据验证案例提供可靠依据的方法。 这种方法的主要优点是,大多数验证逻辑直接集中在域模型类中。 因此,它易于发现,易于阅读且易于维护。 Spring,CUBA和其他许多库都支持这些标准,并在UI层上接收数据,调用经过验证的方法或通过ORM存储数据时自动执行验证检查,因此从开发人员的角度来看,Bean验证通常看起来像魔术。
一些软件开发人员将主题模型的类级别的验证视为不自然且过于复杂,他们说UI级别的数据验证是一种相当有效的策略。 但是,我认为组件和UI控制器中的众多验证点并不是最合理的方法。 , , , , , listener' .
, , :
- JPA , , DDL.
- Bean Validation — , , , . , .
- bean validation, . , , REST.
- Entity listeners: , Bean Validation, . , . Hibernate .
- Transaction listeners — , , . , , .
PS: , Java, , , .
有用的链接