1.前言
在属性访问中,我们实现了为指定字段赋值的功能,但这样还远远不够。主要有两方面的问题,一是属性访问中可能出现的异常没有处理,二是除了预先定义的几个异常,还需要更细粒度、更灵活的校验规则。下面的示例代码模拟了 web 应用中的登录流程,在真正执行业务逻辑之前,需要对传入的参数进行验证。
//示例代码
public String login(User user) {
if("".equals(user.getPhone().trim() || "".equals(user.getPwd().trim()) {
return "手机号或密码不能为空";
}
if (user.getPhone().length != 11) {
return "手机号必须是11位";
}
......; //登录逻辑略
}
我们发现,字符串不能为 null 或空串,或者字符串的长度是指定值,这些校验规则是固定的,只是用在了不同的字段上。而且不光是登录,其他操作也可能需要进行验证。我们需要一种机制,预先设置若干校验规则,然后根据实际情况使用不同规则的组合进行校验。一种简单的方式是将校验规则封装到工具类的方法中,但这样做不够优雅,而且不能将校验出错的提示信息一并封装,局限性比较大。鉴于此,Spring 提供了数据绑定的功能,将属性访问和数据验证两个操作联系在一起,解决了对象在赋值过程中可能出现的类型转换或校验异常。
2. 数据绑定原理
数据绑定包括属性访问和数据验证两个操作,并且这两个操作是有先后顺序的。属性访问在前,称为赋值阶段;数据验证在后,称为校验阶段。无论是在赋值还是校验阶段,都有可能产生异常,而且这两个操作是有联系的,因此我们需要一个中间媒介,BindingResult 充当了这一角色。
数据绑定的基本原理如图所示,BindingResult 持有目标对象和错误列表,赋值和校验的对象都是 Target,产生的错误都存放在 errors 字段中。具体的流程如下:
- 赋值阶段:将外部数据通过
PropertyAccessor赋值给目标对象,如果产生异常则添加到错误列表中 - 校验阶段:先从
BindingResult中拿到赋值后的目标对象,然后使用验证器来校验对象,并将校验产生的错误添加到错误列表中
3. 绑定结果
3.1 继承结构
BindingResult 的继承体系可以分为两组,第一组是对错误的抽象,由 Errors 接口和相关类描述。
Errors:负责保存数据绑定和验证的错误信息ObejctError:表示一个对象在访问过程中的错误FieldError:表示一个字段在访问过程中的错误(字段也可以看成对象)
第二组是 BindingResult 接口,主要功能是管理目标对象以及错误列表。
BindingResult:表示绑定结果,拥有添加异常的能力AbstractBindingResult:负责管理错误列表AbstractPropertyBindingResult:通过 Spring 的属性访问机制来为字段赋值BeanPropertyBindingResult:指定了属性访问的方式
3.2 Errors
Errors 接口的作用是存储和暴露指定对象在数据绑定和验证过程中的错误信息。由此可见,Errors 接口只是数据绑定和验证两个阶段的缓存类,本身并不处理异常。
public interface Errors {
//获取目标对象的名称
String getObjectName();
//是否存在错误
boolean hasErrors();
//获取指定字段的错误
FieldError getFieldError(String field);
//获取第一个字段错误
FieldError getFieldError();
//获取所有的字段错误
List<FieldError> getFieldErrors();
}
ObjectError 封装了一个对象错误,也就是对象拒绝访问的全局原因。objectName 字段表示对象名称,code 字段表示错误代码,defaultMessage 表示默认的消息。
public class ObjectError {
private final String objectName;
private final String code;
private final String defaultMessage;
}
FieldError 是 ObjectError 的子类,表示一个字段错误(字段也可以看做是一个对象)。FieldError 新增了一些属性,其中 field 表示字段名,rejectedValue 表示未绑定成功的值,bindingFailure 标记是否绑定成功。
public class FieldError extends ObjectError {
private final String field; //字段名
private final Object rejectedValue; //未绑定成功的值
private final boolean bindingFailure; //是否绑定失败
}
3.3 BindingResult
BindingResult 接口表示绑定结果,所谓结果包括两个部分,一是目标对象,二是错误列表。如果绑定成功,得到的是赋值后的对象,否则需要寻找错误原因。getModal 方法的作用是获取模型数据,实际上是目标对象和 BindingResult 实例本身。
public interface BindingResult extends Errors {
//获取目标对象
Object getTarget();
//获取模型数据
Map<String, Object> getModel();
//添加一个错误
void addError(ObjectError error);
}
AbstractBindingResult 作为抽象类实现了 Errors 和 BindingResult 接口的大多数方法。errors 字段用于保存在绑定和校验过程中可能产生的错误。getModel 方法每次都会返回新的 Map 对象,这个 Map 有两个元素,第一个是目标对象,第二个是 BindingResult 本身。
public abstract class AbstractBindingResult implements BindingResult {
private final List<ObjectError> errors = new LinkedList<>();
@Override
public Map<String, Object> getModel() {
Map<String, Object> model = new LinkedHashMap<>(2);
model.put(getObjectName(), getTarget()); //目标对象
model.put(MODEL_KEY_PREFIX + getObjectName(), this); //错误实例
return model;
}
}
AbstractPropertyBindingResult 进一步明确了所谓的绑定是对属性进行绑定,因此需要 PropertyAccessor 组件来提供属性访问的功能。getPropertyAccessor 是一个模板方法,由子类实现,这是因为 PropertyAccessor 接口有两个实现类。
public abstract class AbstractPropertyBindingResult extends AbstractBindingResult {
public void initConversion(ConversionService conversionService) {
if (getTarget() != null) {
getPropertyAccessor().setConversionService(conversionService);
}
}
public abstract ConfigurablePropertyAccessor getPropertyAccessor();
}
BeanPropertyBindingResult 是主要的实现类,通过 BeanWrapper 来提供属性访问功能。target 字段代表目标对象,beanWrapper 字段则提供了属性访问的功能。
注:
AbstractPropertyBindingResult还有一个实现类DirectFieldBindingResult,是通过DirectFieldAccessor提供属性访问功能的。我们在第一章的属性访问一节中提到过该类,这里不做过多解读,仅说明它们之间有一定的联系。
public class BeanPropertyBindingResult extends AbstractPropertyBindingResult {
private final Object target;
private transient BeanWrapper beanWrapper;
}
4. 数据校验
4.1 概述
Spring 基于 Java 提供的验证相关 API 以及 Hibernate 框架的实现类,通过适配的方式间接地实现了数据校验的功能。从类图中可以看到,Validator 接口定义了 Spring 验证器的基本行为,SmartValidator 接口扩展了分组验证的功能,SpringValidator 则作为桥梁,将实际的验证工作委托给 ValidationImpl 处理。
4.2 Java 验证器
javax.validation 包定义了数据校验的 API,Validator 接口表示一个验证器,定义了若干验证对象或属性的方法。我们来看一个比较典型的方法,validate 方法负责验证一个对象,返回 ConstraintViolation 集合。ConstraintViolation 的字面意思是违反约束,实际上封装了验证失败的相关信息。
public interface Validator {
<T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups);
}
Java 并没有提供 Validator 的实现类,由第三方框架予以实现,比如 Hibernate 框架实现了 ValidatorImpl,因此我们需要引入 Hibernate 框架的验证模块(javax.validation 包也会被间接引入)。
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>5.3.5.Final</version>
<optional>true</optional>
</dependency>
要使验证器发挥作用,还需要对目标对象指定验证规则。验证规则也称约束,使用不同的注解来表示。Java 提供了一部分校验规则的注解,常用的注解如下所示:
@NotNull:不能为空@Size:数值的最大值和最小值(相当于@Max和@Min的组合)@Pattern:指定正则表达式
Hibernate 框架在此基础上进行了扩展,这里介绍几个常用的注解,如下所示:
-
@NotBlank:不能为 null 或空串,尾部空格会被忽略 -
@NotEmpty:不能为 null 或空串 -
@Length:指定字符串的长度范围
4.3 Spring 验证器
Validator 接口定义了验证器的行为,supports 方法在具体实现中,只要目标对象不为空就可以验证。validate 方法需要传入两个参数,一个是目标对象,另一个 Erros 参数
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
SmartValidator 接口新增了分组验证的功能。(出于简化代码的考虑,不实现分组验证的功能,该接口仅作为继承结构的一环存在)
public interface SmartValidator extends Validator{
void validate(Object target, Errors errors, Object... validationHints);
}
4.4 SpringValidatorAdapter
SpringValidatorAdapter 作为适配器,持有一个 javax.validation.Validator 实例。在构造器中调用 initValidator 方法初始化 Java 验证器,实际上是基于 Hibernate 实现的。
public class SpringValidatorAdapter implements SmartValidator, Validator {
private Validator targetValidator;
public SpringValidatorAdapter() {
initValidator();
}
private void initValidator() {
this.targetValidator = Validation.byDefaultProvider().configure()
.messageInterpolator(new ParameterMessageInterpolator())
.buildValidatorFactory()
.getValidator();
}
}
数据绑定的赋值和验证两个操作是独立的,虽然我们先介绍的是数据验证,但可以认为属性访问已经执行过了。无论赋值的过程是否出现异常,BindingResult 都持有目标对象和错误列表,validate 方法的两个参数来源于此。
validate 方法分为两步,第一步,调用 Hibernate 验证器的验证方法,我们不关心具体的实现细节,返回一组 ConstraintViolation 对象。第二步,ConstraintViolation 如果不为空,说明存在违反约束的情况。
@Override
public void validate(Object target, Errors errors) {
//1. 数据校验
Set<ConstraintViolation<Object>> violations = this.targetValidator.validate(target);
//2. 处理校验结果
if(!violations.isEmpty()){
processConstraintViolations(violations, errors);
}
}
接下来是 processConstraintViolations 方法的实现。遍历约束集合,如果当前字段的错误存在,说明在赋值阶段已经出错了,不需要继续处理。否则构建一个错误对象(主要是字段错误),并添加到 BindingResult 中。
protected void processConstraintViolations(Set<ConstraintViolation<Object>> violations, Errors errors) {
for (ConstraintViolation<Object> violation : violations) {
String field = violation.getPropertyPath().toString();
FieldError fieldError = errors.getFieldError(field);
if (fieldError == null || !fieldError.isBindingFailure()) {
ConstraintDescriptor<?> descriptor = violation.getConstraintDescriptor();
//获取注解的简单类名,比如NotBlank
String errorCode = descriptor.getAnnotation().annotationType().getName();
if (errors instanceof BindingResult) {
BindingResult bindingResult = (BindingResult) errors;
//对象错误
if("".equals(field)){
bindingResult.addError(new ObjectError(errors.getObjectName(), errorCode, violation.getMessage()));
}
//字段错误
else{
Object rejectedValue = violation.getInvalidValue();
bindingResult.addError(new FieldError(errors.getObjectName(), field,
rejectedValue, false, errorCode, violation.getMessage()));
}
}
}
}
}
5. 错误处理
5.1 概述
经过以上分析,我们知道数据校验阶段产生的异常,以 FieldError 或 ObjectError 的形式保存在 BindingResult 中。接下来的处理由业务代码完成,比如返回一个适当的消息给客户端。我们知道,数据校验的前提是对象已经赋值,现在的问题是如何将赋值和校验这两个密切关联的操作整合在一起?
AbstractPropertyBindingResult 持有一个属性访问器,已经实现了属性访问的功能。因此,我们只需要将绑定阶段产生的异常保存到 BindingResult 上即可,这项工作是由 BindingErrorProcessor 接口完成的。如图所示,如果赋值阶段出现异常,则通过 BindingErrorProcessor 组件将异常信息保存到 BindingResult 中。这样一来,BindingResult 作为中间桥梁,将赋值和校验这两个操作关联起来。
5.2 BindingErrorProcessor
BindingErrorProcessor 接口定义了 processPropertyAccessException 方法,用于处理属性访问时产生的异常。
public interface BindingErrorProcessor {
void processPropertyAccessException(PropertyAccessException ex, BindingResult bindingResult);
}
DefaultBindingErrorProcessor 作为默认实现类,具体的处理流程是将异常包装成一个字段错误,并添加到 BindingResult 的错误列表中。这样一来,属性访问时产生的异常不会被抛出,而是由用户决定什么时候来处理。
public class DefaultBindingErrorProcessor implements BindingErrorProcessor {
@Override
public void processPropertyAccessException(PropertyAccessException ex, BindingResult bindingResult) {
String field = ex.getPropertyName();
String code = ex.getErrorCode();
Object rejectedValue = ex.getValue();
bindingResult.addError(new FieldError(bindingResult.getObjectName(), field,
rejectedValue, true, code, ex.getLocalizedMessage()));
}
}
6. 测试
6.1 属性访问
测试类 LoginModel 是一个普通的 Java Bean,在 name 和 phone 字段上声明了校验注解,age 和 regDate 字段用来测试类型转换和格式化操作。
//测试类
public class LoginModel {
@NotBlank(message = "姓名不能为空")
private String name;
@Size(min = 11, message = "手机号必须是11位")
private String phone;
private int age;
private Date regDate;
}
测试方法分为三步:
-
准备数据,使用
Map来模拟 HTTP 请求。age字段故意使用abc这种非数值, -
创建
BeanPropertyBindingResult实例,然后将准备好的参数赋值给LoginModel空对象。这里需要捕获异常,使用BindingErrorProcessor组件将属性访问的异常转换成FieldError并缓存起来。 -
检查
BindingResult是否存在异常,并打印异常信息。
//测试方法
@Test
public void testBindError() {
//1. 准备工作,模拟http请求
Map<String, Object> params = new HashMap<>();
params.put("name", "Tom");
params.put("age", "abc"); //非数字类型,报错
//2. 绑定参数
BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new LoginModel(), "loginModel");
BindingErrorProcessor processor = new DefaultBindingErrorProcessor();
try {
bindingResult.getPropertyAccessor().setPropertyValues(new MutablePropertyValues(params));
} catch (PropertyAccessException e) {
//捕获属性访问时的异常
processor.processPropertyAccessException(e, bindingResult);
}
//3. 处理异常
if(bindingResult.hasErrors()){
FieldError fieldError = bindingResult.getFieldError();
System.out.println("字段[" + fieldError.getField() + "]错误: " + fieldError.getDefaultMessage());
}
}
从测试结果可以看到,数据类型转换的异常是存在的。
字段[age]错误: Failed to convert value of type 'java.lang.String' to required type 'int'; nested exception is cn.stimd.spring.beans.TypeMismatchException: Failed to convert value of type 'java.lang.String' to required type 'int';
6.2 数据验证
在测试方法中,首先模拟 http 请求参数,其中 phone 字段故意设置为非 11 位的手机号,目的是引发校验异常。接下来需要先给目标对象赋值,然后再调用 SpringValidatorAdapter 的 validate 方法。从这里可以看到,赋值和校验的操作是分开的。同样地,校验操作也不会抛出异常,我们需要拿到 BindingResult 之后进行分析。
//测试方法
@Test
public void testValidateError() {
//1. 准备工作,模拟http请求
Map<String, Object> params = new HashMap<>();
params.put("name", "Tom");
params.put("phone", "12305"); //手机号不是11位,校验失败
//2. 为实体类赋值并校验
//先绑定
BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new LoginModel(), "loginModel");
bindingResult.getPropertyAccessor().setPropertyValues(new MutablePropertyValues(params));
//再校验
SpringValidatorAdapter validator = new SpringValidatorAdapter();
validator.validate(bindingResult.getTarget(), bindingResult);
//3. 异常处理
for (FieldError fieldError : bindingResult.getFieldErrors()) {
System.out.println("字段[" + fieldError.getField() + "]错误: " + fieldError.getDefaultMessage());
}
}
从测试结果可以看到,phone 字段校验失败了。
字段[phone]错误: 手机号必须是11位
7. 总结
本节介绍了数据绑定的基本实现,这一过程分为两个阶段。首先是赋值阶段,将外部数据赋给一个空对象。仅仅为对象赋值还不够,有时候还要根据业务对数据进行校验。我们发现,赋值阶段相对固定,类型转换都是有一定规律可循的,比如 abc 不能转换成数值类型。而校验阶段更加灵活,校验规则主要取决于具体的业务。
JDK 提供了数据校验的功能,javax.validation.Validator 接口对校验行为进行抽象,具体实现是由第三方框架提供。Spring 框架通过适配的方式,将数据校验的功能委托给 Hibernate 框架的 ValidatorImpl 实现类完成。Spring 验证器的工作是将校验结果保存在 BindingResult 中。
BindingResult 作为中间桥梁,将赋值和校验两个操作联系在一起。具体表现在两个方面,一是赋值和校验针对的都是同一个目标对象,二是赋值和校验阶段产生的异常都保存在 BindingResult 中。用户可以通过统一地方式来管理异常,并访问目标对象。
8. 项目信息
新增修改一览,新增(16),修改(1)。
context
├─ src
│ ├─ main
│ │ └─ java
│ │ └─ cn.stimd.spring
│ │ └─ validation
│ │ ├─ annotation
│ │ │ └─ Validated.java (+)
│ │ ├─ beanvalidation
│ │ │ └─ SpringValidatorAdapter.java (+)
│ │ ├─ AbstractBindingResult.java (+)
│ │ ├─ AbstractPropertyBindingResult.java (+)
│ │ ├─ BeanPropertyBindingResult.java (+)
│ │ ├─ BindException.java (+)
│ │ ├─ BindingErrorProcessor.java (+)
│ │ ├─ BindingResult.java (+)
│ │ ├─ DefaultBindingErrorProcessor.java (+)
│ │ ├─ Errors.java (+)
│ │ ├─ FieldError.java (+)
│ │ ├─ ObjectError.java (+)
│ │ ├─ SmartValidtor.java (+)
│ │ └─ Validator.java (+)
│ └─ test
│ └─ java
│ └─ context
│ └─ data
│ ├─ LoginModel.java (+)
│ └─ ValidatorTest.java (+)
└─ pom.xml(*)
注:+号表示新增、*表示修改
注:项目的 master 分支会跟随教程的进度不断更新,如果想查看某一节的代码,请选择对应小节的分支代码。
欢迎关注公众号【Java编程探微】,加群一起讨论。
原创不易,觉得内容不错请关注、点赞、收藏。