Java应用中的数据校验整体解决方案

Java
353
0
0
2023-09-16

[译者注:这篇文章是开源项目CUBA Platform的作者,在这篇文章中,作者阐述了CUBA平台中关于数据校验的设计思想和使用方式,可以作为大家在设计数据校验方面一个比较好的参考。]

我接触到的很多项目中,对数据校验这方面内容都没有一个很明确的策略。这些团队常常面对即将临近的交付期压力,不明确的项目续期,所以根本没有太多时间来规划和实现项目中的校验策略。所以,你可以看到,数据校验的代码零散的分布在整个应用中:Javascript中有,Java控制器中有,业务逻辑代码中有,实体模型中有,数据库中还有约束和触发器。用于数据校验的代码,充斥着各种if..else..,在不同位置抛出完全混乱的异常,甚至想找到一个数据究竟在哪里验证的,心里面都想骂一句FUCK。长此以往,当项目越来越复杂,验证会越来越难控制,陷入极为难堪的维护境地。

那么,是否有一种优雅的,标准的,简单的方法来处理应用中的数据校验呢?这个方法既让我们的代码可读性较高,又能将大部分的数据校验代码集中管理,还能很好的集成进入目前主流的Java框架呢?

是的,有这种方法。

我们开发了CUBA Platform(),能让我们按照最佳实践的方式来完成。我们归纳除了关于校验代码的一些要求:

  1. 能重复使用,遵循DRY原则;
  2. 能自然清晰的表达验证规则;
  3. 放在程序员愿意放置的位置;
  4. 能够支持从不同的数据源中获取数据,比如用户输入,SOAP或者REST请求等;
  5. 支持并发处理;
  6. 应用隐式的去调用,而不需要处处都通过手动调用;
  7. 展示清晰,本地化的提示信息供开发者使用;
  8. 遵循业界已有标准;

在这篇文章中,我们会使用一个基于CUBA平台的应用来作为示例。CUBA是基于Spring和EclipseLink平台的,所以,文中大部分的例子也能在其他支持JPA和Java bean校验的标准平台上面执行。

数据库约束校验

最普遍,最直接的数据校验可能就是使用数据库级别的约束,比如Require(或者NOT NULL),字符串长度,唯一索引等等,特别在企业应用中,大部分以数据库为中心,这是非常常见的方法。但是,因为开发人员分工职责不同,常常在不同的应用层中,重复定义数据约束,这就非常容易出错。

我们来举一个例子,我们大多数开发都见过或者参与过。如果在需求中提出,护照这个字段需要10位数字,那么,最可能的情况回事这样:DB工程师,会在DDL中限制,后台开发会在实体和REST服务中检查,最后,UI层开发会在前端(客户端)限制。好了,过了一段时间,需求修改了,护照字段改成15位了,技术修改了数据库中的定义,但客户端仍然只能输入10位数据。

所有人都知道如何避免出现这个问题,那就是数据校验必须集中!在CUBA平台中,这个集中点就是在实体类中使用JPA注解。基于这些元数据信息,CUBA平台能够生成正确的DDL,并且在客户端生成正确的校验器,如下图所示。

如果JPA注解发生了变化,CUBA会及时生成修改补丁脚本,下一次再部署应用的时候,基于新的JPA限制会在DB和UI端更新。

为了隔离数据库的复杂性,让生成的DDL脚本保持标准,避免引入数据库相关的独特的触发器或者存储过程,JPA注解仅仅只能做一些最基本的校验,比如,保证实体字段的唯一性,必要性,或者定义字符串长度,此外,还可以使用@UniqueConstraint注解来完成复合唯一约束等,但这些仍远远不够。

在需要更加复杂的验证逻辑,比如检查字段最大最小值,或者正则表达式验证,或者完成一个特殊的数据校验,我们就需要使用Bean Validation。

Bean校验

我们知道,遵循规范是一个最佳实践。规范是根据成千上万的应用归纳和验证的,在Java Bean验证这块,现有的规范主要是JSR 380,JSR 349和JSR 303(,最出名的实现就是Hibernate Validator(和Apache BVal(。

尽管不少开发都熟悉这套工具,但是它的带来的好处却经常被误解。这是一种为遗留项目添加数据验证的有效的方法,允许你以清晰、直接,可靠的方式,以尽可能接近业务逻辑的方式表达你的验证规则。使用Bean验证能为你的应用带来很多好处:

  • 验证逻辑尽可能的靠近领域模型,在模型中定义值,方法,bean约束是很符合OOP方法的。
  • Bean验证标准提供了多种直接可以使用的验证规则(@NotNull””>#validator-defineconstraints-spec),比如:@NotNull, @Size, @Min, @Max, @Pattern, @Email, @Past,@URL, @Length,@ScriptAssert等等;
  • 允许扩展已有的约束,或者定义你自己的约束注解。你把多个其他验证约束组合起来变成一个新的校验注解,或者通过开发一个校验器,创建一个全新的校验注解。
  • 举个例子,回到我们之前的护照的例子,我们完全可以创建一个类级别的注解:@ValidPassportNumber,用这个注解根据我们的国家(country)字段来校验我们的护照号码字段。
  • 你不仅仅能够在类或者字段上面增加约束,还能够在方法和方法参数上添加约束,这种方法叫做“合约约束(validation by contract)”,这是下一节介绍的重点。

CUBA平台(或者一些其他平台)在用户提交数据的时候,会自动的执行这些验证规则,如果验证失败,用户立刻会得到错误的提示,这一些都是自动运行的,不需要手动干预或者调用。

让我们再来看看护照号码的例子,我们这次会增加一些额外的约束:

  • 用户名至少2位及以上,并且是合法的名字。这个正则式比较复杂,因为R2D2这个名字无效,但是Charles Ogier de Batz de Castelmore Comte d’Artagnan却是一个有效的名字。
  • 用户的身高应该在0到300厘米之间;
  • Email必须是一个正确的email地址;

那么,现在的Person类应该类似这样:

我想@NotNull, @DecimalMin, @Length, @Pattern这些标准的注解的用法,大家应该很熟悉,就不用做过多说明,下面来看看@ValidPassportNumber注解是如何实现的。

我们的@ValidPassportNumber注解使用Person#country配合正则表达式来检查Person#passportNumber。

首先,根据文档(CUBA或者Hibernate文档),我们使用@ValidPassportNumber注解标记我们的类,并制定分组参数(groups),UiCrossFieldChecks.class参数表示passportNumber的检查应该在每一个独立的字段检查完成之后再执行检查(所以UiCrossFieldChecks.class放在Default.class之后);

该注解的定义如下:

@Target指定该注解的标记位置;@Constraint(validatedBy = … )就是真正用于执行这个检查的检查类。这个ValidPassportNumberValidator类需要实现ConstraintValidator<…>接口,并且实现接口中定义的isValid(…)方法来完成真正的验证。

在CUBA平台中,除了需要创建我们自定义的校验器,给出错误的提示,除此之外,不需要写其他额外的代码,保证了代码的清晰和简洁。

来看看最后的工作效果:CUBA平台生成的前端脚本,能够展示错误提示,还能够对错误字段进行样式标记:

整个过程非常干净。我们只需要在业务模型上添加一些注解,就能得到一个漂亮的UI检查界面。

总结这个小节,使用bean 验证的主要好处有:

  1. 清晰,可读性高;
  2. 允许直接在业务类中定义值验证约束;
  3. 易扩展和自定义;
  4. 很多流行的ORM框架支持自动检查,并支持DDL同步更新;
  5. 一些框架支持自动生成验证UI脚本,自动调用验证(如果不支持和,通过Validator接口手动验证,也是很容易的)
  6. bean校验遵循业界的规范,有非常多的相关文档和社区支持。

那么,如果需要在方法,构造器,甚至一个被外部系统调用传入数据的REST端点上进行验证,该怎么做呢?又或者我们想检查方法的参数值,但是又不想写一堆无聊的if-else代码,又应该怎么做呢?

答案很简单,可以在方法上也使用bean验证;

合约约束

有时候,我们不仅仅只是验证应用数据模型的值,我们希望更进一步,如果使方法也能够受益于传入参数和返回值的自动验证。这代表,不仅仅能够检查从REST或者SOAP端传入的数据,还能表达方法执行的先决条件或者后决条件,或者要求返回参数在我们期望的范围之内,并且提供一定的可读性。

有了Bean验证,约束同样也可以施加在方法参数,或者方法的返回值上,或者构造方法,或者方法的先决条件或者后决条件判定上。相对于传统的参数和返回值校验,这种方式的好处有:

  1. 不需要手动执行验证(比如手动抛出IllegalArgumentException异常)。我们只需要声明我们的验证规则,这样我们代码会非常清晰和易读。
  2. 约束是可以重用的,支持灵活配置和扩展的:我们不需要每次要验证的地方都去写同样的代码:越少的代码,越少的bug。
  3. 如果一个类或者一个方法的返回值,或者一个方法的参数被标记上@Validated注解,那么每次方法的调用都会自动触发验证规则的执行。
  4. 如果使用@Documented标签,那么一个方法的先决或者后决条件会自动的包含在生成的JavaDoc中。

使用合约约束的结果就是,我们能得到干净,易于阅读和理解的代码。

下面我们来看看在CUBA应用中的一个REST控制器如何使用合约约束。PersonApiService接口允许使用getPersons()方法从数据库中获取用户列表,同时也提供了addNewPerson(…)接口添加一个用户。记住:bean验证是可以继承的,意味着,如果一个类或者字段使用了约束注解,那么所有子类或者实现了该接口的类都能得到同样的验证约束。

这个代码片段是不是看着非常清晰和易读?(@RequiredView(“_local”)可能比较碍眼,这是CUBA提供的注解,用于检查所有返回的Person对象的字段完全从PASSPORTNUMBER_PERSON表加载完成)。@Valid注解表明,通过getPersons()方法返回的每一个Person对象都需要被Person类上定义的约束检查。

CUBA将这些方法暴露成以下的服务:

  • /app/rest/v2/services/passportnumber_PersonApiService/getPersons
  • /app/rest/v2/services/passportnumber_PersonApiService/addNewPerson

我们使用POSTMAN来验证一下校验约束是否起作用:

你可能会注意到,上面的代码并没有验证护照号码。这是因为这个验证需要cross-parameter验证,passport的验证需要依赖于country值,所以这个验证会在实体类上进行验证(具体执行保存实体对象的时候验证)

Cross-parameter验证在JSR349和JSR380中已经支持。可以参考相关文档去看看如何在类/接口中的方法上实现Cross-parameter验证。

补充一些

没有什么东西是完美的,bean validation仍然有它的局限性:

  1. 可能有这样的需求,在每次对象状态发生变化时,都需要执行一个非常复杂验证。比如,你希望在你的电子商务系统中,检查客户的订单明细和你的集装箱一一匹配。这是一个非常“重”的操作,在每次用户添加一个订单项之前都要做这样一次检查,绝对不是一个好的想法。实际上,更好的做法是当所有的订单项都准备好之后,在保存到数据库之前,统一检查一次就可以了。
  2. 有的检查需要运行在事务中。比如,在电子商务系统中,需要检查订单数量在仓库中是否充足。这种检查必须要在一个事务中执行,因为库存中的数量可能在任何时间被其他事务同步修改。

CUBA平台提供了两种机制来对应这两种情况,一个叫做实体监听器(entity listener),一个叫做事务监听器(transcation listener)。我们来看看这两种机制的作用。

实体监听器(Entity Listeners)

CUBA提供的实体监听器类似JPA提供给开发的PreInsertEvent,PreUpdateEvent和PreDeleteEvent三种监听器。两种机制都允许在实体对象持久化到数据库之前或者之后进行验证。

在CUBA中定义并注册一个实体监听器也比较简单,我们只需要做如下两个事情:

  1. 按照需要实现一个实体监听器接口。提供了3种不同目标的接口:BeforeDeleteEntityListener,BeforeInsertEntityListenerand和BeforeUpdateEntityListener
  2. 使用@Listeners注解将监听器绑定在需要检查的实体对象上。

CUBA平台和JPA标准(JSR338)有点区别的地方在于,CUBA提供的实体监听器接口是泛型的,所以你不需要再强制把Object类型参数强制转化成你需要检查的实体类型。CUBA会将和当前对象有关联的其他对象加载出来,或者可以使用EntityManager去加载或修改其他对象。所有这些对象的变化都可能会触发实体监听器的执行。

同时,CUBA平台提供了逻辑删除(soft deletion),即在数据库中并不真实删除对象,仅仅只是标记为删除。针对逻辑删除,CUBA平台会调用BeforeDeleteEntityListener/AfterDeleteEntityListener代替标准的修改触发PreUpdate/PostUpdate监听器。

我们来看一个例子。实体监听器使用@Listeners注解和一个实体类型绑定在一起,只需要在@Listeners中提供监听器的名字:

一个实体监听器可能的实现如下:

实体监听器非常适合:

  • 在一个事务中,当实体对象持久化到数据库前检查数据;
  • 在检查的过程中,需要从数据库中提取一些数据辅助检查;
  • 检查数据需要依赖当前对象的关联对象,比如检查Order对象的时候,需要参考OrderItems;
  • 追踪某一些实体对象的insert/update/delete操作。比如仅仅只是想追踪Order和OrderItem两个实体对象的数据库操作。

事务监听器

CUBA事务监听器运行在事务上下文中,与实体监听器不一样,事务监听器在每一次数据库事务调用的时候被启动。因为伴随着事务,所以你需要注意:

  • 更加难以编码,
  • 如果施加了过多无效的检查,性能会显著降低,
  • 编码必须更加小心:在事务监听器中的一个bug可能会导致应用崩溃

事务监听器适用于需要用同样的算法检查多种类型的实体类的情况,比如用于检查所有业务数据的防欺诈检查。

我们来看一个事务监听器,该监听器检查所有标记了@FraudDetectionFlag注解的实体类,并使用自定义的防欺诈检查器检查。再次提醒,因为事务检查器是在每一次数据库事务提交之前进行检查,所以我们应该尽量减少用于检查的对象。

一个事务监听器必须实现BeforeCommitTransactionListener接口,并重写beforeCommit方法。事务监听器会在应用启动的时候启动。CUBA会自动将所有类型为BeforeCommitTransactionListener和AfterCompleteTransactionListener的类作为事务监听器。

小结

在一个企业项目中,Bean validation(JPA 303, 349 and 980)足以处理95%以上的数据验证情况。这种方式带来的最大的好处是基本上可以将所有验证集中在实体类上。所以验证规则集中,易读并且符合标准。Spring,CUBA以及其他很多代码库会在UI输入,验证方法调用或者ORM持久化处理过程中自动的调用验证检查,而不需要开发人员过度的关注。

在最后,我们在总结一下不同的验证方式针对的最佳的使用场景:

  • JPA validation :功能有限,适合简单的实体类型约束,并且易于将这些约束同步到DDL。
  • Bean Validation :在领域模型类中,是最灵活,集中,复用性高,易读的验证方法。如果在事务之外执行验证,这是最该值得考虑的方法。
  • Validation by Contract :基于bean验证,可以施加于方法的调用。当你想检查方法的输入参数或者返回值的时候,是非常值得考虑的方法,比如在一个REST请求处理中。
  • Entity listeners: 虽然比不上Bean Validation的声明式校验,但是在一个数据库事务中需要检查对象以及关联对象的时候,是非常好的一个办法。比如需要从数据库中查询一些数据来支持验证规则。
  • Transaction listeners :非常危险,但威力强大。运行在事务上下文中,当你需要在运行时确定需要检查哪些对象,或者当你需要使用相同规则验证多种类型对象的时候,这是一种值得考虑的验证方法。

我希望这篇文章能够刷新你对企业应用中如何使用不同的验证方法的看法,并给你一些关于如何改进正在进行的项目的验证相关架构的参考。