66 months ago

Java中的数据验证

我经常看见很多项目没有数据验证的策略和意识。他们的团队在交付日期的重压下,面对不清楚的需求,没有时间去考虑用合适并且统一的方法对数据进行验证。所以在这样的项目中,到处能看见数据验证的代码:在前端JS中,在后端页面控制器中,在业务逻辑的bean中,在数据模型实体中,在数据库的约束和触发器中。这些代码都是一些 if-else 的语句,抛出一些不同的未检查的异常,所以有时会很难找到这些该死的数据到底是在哪里做的验证。因此,一段时间之后,当项目成长到足够大的时候就很难并且需要耗费很多精力来统一这些验证,并且后面的需求也一样模糊不清。

那有没有做数据验证比较标准、优雅而且还简洁的方法呢?这个方法不会导致代码的不可读,这个方法能帮我们将大部分数据验证的代码维护在统一的地方,而且有没有可能一些流行框架的开发者已经替我们做了大部分的工作呢?

当然有!

作为我们CUBA平台的开发者来说,让我们的用户也遵循最佳实践非常重要。我们认为,数据验证的代码应该是:

  1. 可重用但不重复,遵循DRY原则(Don’t Repeat Yourself)。
  2. 用干净和自然的方式表达出来。
  3. 放在开发人员期望看到的地方。
  4. 能对不同数据来源的数据进行检查:用户输入,SOAP或者REST 调用等。
  5. 能处理并发。
  6. 由应用程序隐式统一调用而不需要手动调用这些检查代码。
  7. 能用简洁的弹窗为用户展示清晰,本地语言的消息。
  8. 遵循标准。

这篇文章里,我将使用基于CUBA平台开发的应用程序来演示所有的例子。由于CUBA是基于Spring和EclipseLink的,所以这些例子对于使用JPA和bean验证的其他Java框架也适用。

数据库约束验证

也许,最常用最直接的数据验证方法就是使用数据库级别的约束,比如非空,字符串长度,唯一索引等。对于企业级应用来说,这个方法很自然,因为这种类型的软件通常都是以数据为中心。但是,即便是这种情况,开发者也经常出错,在应用程序的各个数据层级分别定义了约束。这个问题主要是由于开发人员的不同责任分工引起的。

我们看一个几乎大家都会面对的例子,有的人甚至干过这样的事 :)。 假设有个规定要求护照号码字段需要有10个数字,很可能到处都会做这个规则检查:数据库设计者用DDL检查,后台开发人员在相应的实体和REST服务中检查,最后前端工程师在客户端代码中检查。之后这个需求变了,要求护照字段升到15个数字。技术支持人员可能只修改了数据库约束,但是这样对于用户来说等于什么都没改,因为后台和前台的检查还没修改呢。

大家都知道避免这个问题的方法,验证需要中心化。在CUBA,这种验证的中心点在是实体的JPA注解。基于这个元数据信息,CUBA Studio可以生成正确的DDL脚本并且能在客户端采用相应的验证器。

text

此时,如果JPA注解改变的话,CUBA会自动更新DDL脚本以及生成数据库迁移脚本,所以下次部署项目的时候,新的基于JPA的限制将会在UI和DB生效。

这种方式简单、也能实施到底层数据库级别,因此能完全防破解。但是JPA注解的局限性在于,只能使用在最简单、可以用标准的DDL表述、而不需要引入特定数据库的触发器或者存储过程的情况。所以基于JPA的约束可以用来保证实体字段是唯一的,或者必须的,抑或也能定义varchar字段的最大长度。还有,可以使用 @UniqueConstraint 注解来为一组字段定义唯一性约束。但也就这些了。

如果在需要更加复杂的验证逻辑的的时候,比如检查某个字段的最大最小值或者对一个字段使用正则表达式进行验证,此时我们就需要使用众所周知的叫做 “bean 验证” 的方法了。

Bean 验证

我们知道,遵循标准是很好的实践,通常这种方式有更长的生命周期而且有几千个项目实战证明过了。Java 的 Bean验证是早就写在石头上的方案了:在JSR 380349 和 303也有些成熟的实现:Hibernate Validator和 Apache BVal

很多开发者都熟悉这个方法,但是这个方法的好处却总是被低估。用这个方法甚至可以很容易在遗留项目中添加数据验证,并且还能以清晰、直接、可靠最贴近业务逻辑的方式表达需要做的验证。

使用Bean验证能为项目带来很多好处:

  • 验证逻辑集中在数据模型附近:使用最自然的方法定义针对值、方法和bean的约束,因此可以将OOP推进到下一个级别(验证也可以OOP)。
  • Bean验证的标准提供了几十种 开箱即用的验证注解比如 @NotNull, @Size, @Min, @Max, @Pattern, @Email, @Past, 不太标准的比如 @URL, @Length,强大的 @ScriptAssert,另外还有很多其他的。
  • l 不会受限于仅使用预定义的约束,还可以自定义约束注解。可以定义一个注解来将其他几个注解绑定到一起,或者定义一个全新的注解,然后定义一个相应的Java类作为验证器。比如,之前那个例子中,可以定义一个类级别的注解 @ValidPassportNumber 用来检查护照号码是否符合正确的格式,号码也许还依赖 country 字段的值。
  • 不止可以在类和字段上加约束,也可以添加到方法和方法参数上。这个叫做“合同验证”,后面会介绍。

CUBA平台(以及一些其他平台)会在用户提交数据的时候自动调用这些验证,所以一旦验证失败用户会马上看到错误消息,不需要考虑手动执行这些bean验证

我们一起再看看护照号码验证的例子,但是这次我们还需要在实体添加几个其他的验证:

  • 人物姓名(例子中是用的英文名)至少有2个单词或者可以更多,必须是格式化很好的姓名。检查的正则表达式很复杂,比如 Charles Ogier de Batz de Castelmore Comte d'Artagnan 能通过检查,但是 R2D2 却不能通过。
  • 人物身高的区间:0< height <=300厘米。
  • 邮件地址需要是正确的邮件地址格式。

因此,带有所有这些检查,Person类看起来是这样:

text

那些标准的注解,比如 @NotNull, @DecimalMin, @Length, @Pattern 还有其他几个都是非常清楚的不需要过多解释。主要看看自定义的 @ValidPassportNumber 是怎么实现的。

我们全新的 @ValidPassportNumber 会检查 Person#passportNumber 是否符合针对每个国家(Person#country)定义的正则表达式。

首先,按照文档(CUBA或 Hibernate文档是很好的参考)的描述,我们需要使用新的注解来标记实体类,以及将约束分组传递给这个注解。CUBA文档有说,UiCrossFieldChecks.class 应当在所有单独的字段检查完之后,才执行跨字段的检查,Default.class 能将约束添加到默认的验证组。

注解的定义是这样的:

text

@Target(ElementType.TYPE) 定义了注解在运行时生效的对象是一个类,@Constraint(validatedBy = … ) 声明注解的实现在 ValidPassportNumberValidator 类中,此类需要实现 ConstraintValidator<...> 接口,在isValid(...) 方法中添加验证代码,方法也很直接:

text

好了,足够了。使用CUBA平台不需要多写任何代码来保证这个验证的运行,也不需要添加代码在用户输入错误的时候给用户发送消息通知。很简单吧?

现在,我们看看这些东西都是怎么工作的,CUBA还做了一些额外的事情:不但给用户展示错误消息,而且还将有问题的表单字段高亮出来,这些漂亮的描红字段没有通过单一字段的bean验证:

text

是不是很简洁?在用户的浏览器显示漂亮的错误提醒,只需要在实体中添加几个简单的注解就好了。

作为本章节的总结,我们再简单列举一下实体的Bean验证有什么好处:

  1. 清晰可读
  2. 可以直接在实体模型中定义值的约束
  3. 可扩展、可定制化
  4. 跟很多流行的ORM集成,检查都是在实体保存在数据库之前自动调用的
  5. 有些框架也能在用户从UI提交数据的时候自动运行bean验证(但是如果不支持的话,很难手动调用 Validator 接口)
  6. Bean验证是众所周知的标准,网上能找到很多相关文档

但是如果我们需要将验证放到方法、构造器上或者放到某个REST终端来验证从外部来的数据呢?或者我们想用声明式的方法验证方法参数而不是在每个方法内写很多if-else这种枯燥的检查参数的方法?

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

合同验证

有时候,我们需要前进一步,不只是做到应用的数据模型验证。如果能做到参数和返回值自动验证,那么写方法的时候就会容易很多。这个需求可能不只是用在检查REST或者SOAP接入的数据,也会用在针对方法的输入参数和返回值上。用来做所谓的前置条件和后置条件检查,确保在方法体执行前对输入参数的检查,以及在方法执行后对返回值范围的检查,或者只是希望能声明式的用在参数上限定参数的范围以达到代码更好的可读性。

使用合同验证,就可以在任何Java类型的方法、构造器的参数和返回值上使用验证。相对传统的检查参数和返回值的办法,这个方案的优点是:

  1. 不需要以极端的方式执行检查(比如,抛出类似 illegalArgumentException 这样的异常)。我们会更愿意使用声明式的约束,这样会形成可读性表达性更强的代码。
  2. 约束都是可重用、可配置、可定制化的:不需要每次都写验证代码,更少的代码意味着更少的bug。
  3. 如果类、方法的返回值或参数使用了 @Validated 注解,平台会在每个方法调用的时候自动执行约束检查。
  4. 如果一个可执行程序使用了 @Documented 注解,那么它的前置条件和后置条件会自动包含在生成的JavaDoc中。

因此,使用合同验证方案,会有清晰、相对少的代码,更易于维护和理解。

我们看看在CUBA应用的REST控制器中,使用合同验证的代码大概是什么样的。通过 PersonApiService 接口的 getPerson() 方法可以从数据库获取用户的列表,使用 addNewPerson(…) 方法可以添加新用户。需要注意的是,bean验证是可以继承的!也就是说,如果用验证的注解标记了某些类,字段或者方法,那么这个类的后代或者接口的实现类都会受到这些验证的影响。

这个代码片段看起来怎样,是不是非常清晰,可读性也不错?(除了 @RequiredView(“_local”) 注解,这个是CUBA平台的专有注解,确保返回的Person对象会有 PASSPORTNUMBER_PERSON 表的所有字段)。

@Valid 注解指定 getPerson() 方法的返回列表中的每个对象需要使用 Person 类的验证进行检查。

CUBA会自动生成下列路径用来执行这些API:

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

我们打开Postman试试这些验证是否都好用:

text
 你可能会注意到,上面的例子没有验证护照号码。这是因为这个需要在 addNewPerson 做跨参数验证,passportNumber 的验证正则表达式依赖 country 的值。这种跨参数的验证跟实体类级别约束是一样的。

JSR 349 和 380 支持跨参数验证, 可以查阅 hibernate 文档 了解如何为类/接口方法实施自定义的跨参数验证。

超越Bean验证

世上没有什么是完美的,bean验证也有局限性:

  1. 有时候需要在保存更改之前检查复杂的对象关系图的状态。比如,可能需要检查客户在你电商网站的订单中购买的所有东西是否能装到一个快递箱子中。这是个比较繁重的检查,因此每次客户订单的商品变更的时候都做这个检查不合适。所以这个检查应该只需要在Order对象和它的OrderItem对象保存到数据库之前做一次。
  2. 有些检查需要在数据库的事务中做。比如,电商系统需要在订单保存到数据库之前检查是否有足够的库存。这些检查只能在事务级别,因为系统是并发的,库存的数量是实时变化的。

CUBA平台提供了两个在数据提交之前做验证的机制:实体监听器事务监听器我们仔细看看。

实体监听器

CUBA的实体监听器跟JPA提供的 PreInsertEvent, PreUpdateEvent 和 PredDeleteEvent 监听器非常相似。这两种机制都都可以在实体对象持久化到数据库之前或者之后做检查。

在CUBA中定义和组织实体监听器不难,只需要两步:

  1. 创建实现了实体监听器接口的托管bean。作为数据验证方面的考虑,其中三个接口比较重要:BeforeDeleteEntityListener,BeforeInsertEntityListener以及BeforeUpdateEntityListener。
  2. 在需要做验证的实体用 @Listeners 注解标记

可以了。

跟JPA标准(JSR 338 3.5)不一样,CUBA的监听器接口是带数据类型的,所以不需要在方法内做类型转换,可以直接使用实体。CUBA平台还提供了跟当前实体关联的实体以及通过EntityManager去加载或者更改其他任何实体的机制。这些改动也会调用相应的实体监听器。

另外,CUBA平台支持“软删除(soft deletion)”,实体在数据库只是标记为删除,但是不会真正删除数据库记录。所以对于软删除,CUBA平台会调用 BeforeDeleteEntityListener / AfterDeleteEntityListener 而标准的实现则会调用 PreUpdate / PostUpdate。

看看下面的例子吧。事件监听器的bean跟实体类连接,只需要一行注解:@Listeners,注解使用的参数是监听器类的名称。

text

看看下面的例子吧。事件监听器的bean跟实体类连接,只需要一行注解:@Listeners,注解使用的参数是监听器类的名称。

text

实体监听器有时候很有用:

  • 在实体持久化到数据库之前需要在事务内做检查
  • 需要在验证的过程中访问数据库信息,比如在保存订单之前先检查库存的数量
  • 需要遍历实体关联或者组合的实体,比如Order里面的OrderItem实体
  • 需要跟踪某些实体的增/删/改操作,比如希望跟踪Order和OrderItem的变化情况

事务监听器

CUBA 事务监听器也在事务的上下文环境中工作,但是跟实体监听器不一样的是,事务监听器是在事务级别被调用的。

因此,事务监听器是终极大杀器,能监管到所有的数据库交互,但是这样也带来了弱点:

  • 不是很好编码
  • 如果做太多检查会显著的降低性能
  • 编码需要很小心,一个bug可能会导致整个应用都启动不了

所以事务监听器在需要用同一算法检查很多不同类型的实体的时候是个好办法。比如需要给支持所有业务的“欺诈侦探器”填充数据的时候。

text

我们看看下面这个例子,检查是否有实体带有 @FraudDetectionFlag 注解,如果有的话,调用欺诈侦探器来检查一下。注意,这个方法会在每次数据库提交的事务都调用,所以代码需要尽可能少的检查数据对象,并且越快越好。

text

只需要实现 BeforeCommitTransactionListener 接口的 beforeCommit 方法,托管bean就会变成事务监听器。事务监听器会在应用启动的时候自动装载。CUBA会将所有实现了 BeforeCommitTransactionListener 或者 AfterCompleteTransactionListener 接口的类注册为事务监听器。

结论

Bean 验证(JPA 303 349 980)基本能满足企业级应用中 95% 的数据验证的情况。这个方案最大的优点是,大部分验证的逻辑都集中到了数据模型类中。因此很容易找到代码,可读性强还容易维护。Spring,CUBA以及很多类库都能知道这些标准并且在UI输入值的时候,调用方法的时候或者做ORM持久化的时候自动调用验证代码,从开发者角度来说,这些验证就像是小魔法。

有些软件工程师认为,在数据模型层面做的验证复杂且带有侵入性,觉得在UI层做验证就够了。但是,我个人觉得,在UI或者UI控制器中写很多验证点是很容易出问题的。另外,我们这里讨论的验证方法在跟平台集成的时候,并不是侵入性的代码,因为平台会感知这些验证器、监听器然后将它们自动集成到客户端层。

最后,我们制定一个经验规则来选择最佳的验证方法:

  • JPA验证:功能有限,但是在实体类上做最简单的约束是最好的选择。要求这些约束能映射成DDL
  • Bean验证:灵活、简洁、声明式、可重用而且易读。基本上能覆盖模型中需要的所有验证,如果不需要在事务中进行验证的话,这是最好的选择
  • 合同验证:也是一种bean验证,不过是应用在方法上。如果需要检查输入和输出参数,比如REST调用,可以使用这个方法*
  • 实体监听器:尽管不像bean验证那样是使用全部声明式的方式,但是可以在数据库事务中对比较复杂的对象关系图做验证。比如需要从数据库加载一些信息来做决定。Hibernate也有类似的监听器
  • 事务监听器:危险但是这是事务级别的终极武器。如果需要在运行时对实体进行验证或者需要对很多不同类型的实体使用同一种验证方法的时候可以选用

我希望这篇文章能刷新你对于Java企业级应用中验证方法的记忆,也希望在提升项目架构方面提供一点点参考。