Spring 整合 Java Bean Validation

Spring 整合 Java Bean Validation

官方文档

Validation by Using Spring’s Validator Interface

Java Bean Validation

参考博客

深入了解数据校验(Bean Validation):基础类打点(ValidationProvider、ConstraintDescriptor、ConstraintValidator)【享学Java】_方向盘(YourBatman)的博客-CSDN博客_constraintvalidator

深入了解数据校验:Java Bean Validation 2.0(JSR380) - YourBatman - 博客园

详述Spring对数据校验支持的核心API:SmartValidator - YourBatman - 博客园

Spring方法级别数据校验:@Validated + MethodValidationPostProcessor - YourBatman - 博客园

@Validated和@Valid的区别?校验级联属性(内部类) - YourBatman - 博客园

让Controller支持对平铺参数执行@Valid数据校验 - YourBatman - 博客园

从深处去掌握数据校验@Valid的作用(级联校验) - YourBatman - 博客园

Bean Validation完结篇:你必须关注的边边角角(约束级联、自定义约束、自定义校验器、国际化失败消息...) - YourBatman - 博客园

解决多字段联合逻辑校验问题【享学Spring MVC】 - YourBatman - 博客园

Spring 简单集成 Bean Validation

org.springframework.validation

接口结构

Validator

应用程序特定对象的验证器,注意区别于 javax.validation.Validator,这是 Spring 自己另起炉灶搞的一套。这个接口完全脱离了任何基础设施或上下文,也就是说,它没有耦合到只验证 Web 层、数据访问层或任何层中的对象。它支持应用于程序内的任何层

SmartValidator

继承 Validator,增加校验分组:hints

SpringValidatorAdapter

实现 SmartValidator。PS:此类同时实现了 javax.validation.Validator 和 org.springframework.validation.Validator

这个实现类 Class 是非常重要的,它是 javax.validation.Validator 到 Spring 的 org.springframework.validation.Validator 的适配,通过它就可以对接到 JSR 的校验器来完成校验工作了,实际上可以理解为一个包装器,将 javax.validation.Validator 包一层,外面是 org.springframework.validation.Validator。实际上所有的 Spring 接口的校验方法,最终都委托给了 org.springframework.validation.Validator

CustomValidatorBean

继承 SpringValidatorAdapter。也就是实现了 org.springframework.validation.Validator

此类同时又显式地实现了 javax.validation.Validator,实际上用的还是 SpringValidatorAdapter 中的实现。

这个类地功能是,可以配置 ValidatorFactory 验证器工厂、MessageInterpolator 插值器和 traversableResolver 遍历解析器,在 afterPropertiesSet 方法中将已经配置好的这个 Validator 设置进去

大部分的时候用于直接进行校验,而不在 AOP 中进行使用

LocalValidatorFactoryBean

Spring 上下文中 javax.validation 的中心配置类

其实作用跟 CustomValidatorBean 一样,都是为了自定义配置,提供 javax.validation.Validator,并设置到 targetValidator(从 SpringValidatorAdapter 中继承)中,类的结构也类似,都是在 afterPropertiesSet 方法中进行配置的自定义,最终校验工作都是委托给 javax.validation.Validator

不同的是,CustomValidatorBean 的 validator 校验器是从上下文拿的,这里是从工厂拿的

它和 CustomValidatorBean 平级,都是继承自 SpringValidatorAdapter,但是它提供的能力更加的强大,比如 Spring 处理校验这块最重要的处理器 MethodValidationPostProcessor 就是依赖于它来给提供验证器。

备注:虽然命名后缀是 FactoryBean,但它并不是 org.springframework.beans.factory.FactoryBean 这个接口的子类。
其实这是断句问题,正确断句方式是:Local ValidatorFactory Bean~

因为父类实现了 javax.validation.Validator,而 LocalValidatorFactoryBean 本身也是配置 javax.validation.Validator 的,因此,LocalValidatorFactoryBean 就是我们在《Java Bean Validation + Hibernate Validator》中说的全局单例 javax.validation.Validator 实例。

大部分的时候用于在 AOP 或者 PostProcessor 中进行使用

实践

若你想使用 org.springframework.validation.SmartValidator 来完成对 Bean 的校验,那就手动定义一个这样的 Bean,然后自行调用 API 校验完成校验(还是不够方便),写个例子来看看

先引入相关依赖:以下依赖为 Spring 以外的依赖

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.8.2</version>
    <scope>test</scope>
</dependency>
<!-- Lombok    -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.24</version>
    <scope>provided</scope>
</dependency>
<!--日志相关-->
<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.17.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api -->
<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-api</artifactId>
    <version>2.17.2</version>
</dependency>
<!--   BeanValidation 2.0-->
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.17.Final</version>
</dependency>
<!-- 引入 Expression Language 3.0,实现-->
<dependency>
    <groupId>org.apache.tomcat.embed</groupId>
    <artifactId>tomcat-embed-el</artifactId>
    <version>8.5.29</version>
    <!-- 如果是web项目,则跟servlet-api一样,tomcat已经提供了,所以scope 是provided-->
    <!--<scope>provided</scope> 打包时最用此scope-->
</dependency>

为了在错误信息中能够打印完整的方法参数名,我们需要在编译的时候加上编译参数 -parameters,当我们使用 Maven 的时候,添加以下配置

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <compilerArgs>
                    <arg>-parameters</arg>
                </compilerArgs>
            </configuration>
        </plugin>
    </plugins>
</build>

Spring 配置类

@Configuration
@ComponentScan(basePackages = {"xyz.xiashuo"})
@PropertySource({"classpath:extra.properties"})
public class SpringConfig {

    @Bean(name = "customValidatorBean")
    public CustomValidatorBean getCustomValidatorBean() {
        return new CustomValidatorBean();
    }

}

待校验的 bean

@Data
@Component
public class User {

    @NotNull(message = "姓名不能为空",groups = Simple.class)
    @NotBlank(message = "姓名不能为空",groups = Simple.class)
    private String name;

    @NotNull(message = "性别不能为空",groups = Simple.class)
    @NotBlank(message = "性别不能为空",groups = Simple.class)
    private String gender;

    @Min(value = 0,message = "年龄不能小于0",groups = Simple.class)
    @Max(value = 100,message = "年龄不能大于100",groups = Simple.class)
    @NotNull(message = "年龄不能为空",groups = Simple.class)
    private Integer age;

    @NotNull(message = "生日不能为空",groups = Advanced.class)
    @PastOrPresent(message = "生日不能为将来的时间",groups = Advanced.class)
    private Date birthDate;

    @NotNull(message = "工作职位不能为空",groups = Advanced.class)
    @NotBlank(message = "工作职位不能为空",groups = Advanced.class)
    private String occupation;

}

两个分组接口

public interface Simple {
}
public interface Advanced {
}

测试类

@SpringJUnitConfig(classes = SpringConfig.class)
public class UserTest {
    @Autowired
    private User user;

    // 直接获取Validator对象
    @Autowired
    private SmartValidator smartValidator;

    @Test
    public void test() {
        //这里可以设置一下 user 的各种属性
        Errors simpleErrors = new DirectFieldBindingResult(user, "user");
        // ValidationUtils 非常方便
        ValidationUtils.invokeValidator(smartValidator, user, simpleErrors, Simple.class);
        System.out.println("Simple Validation");
        System.out.println(simpleErrors);
        System.out.println("================================================");
        Errors advancedErrors = new DirectFieldBindingResult(user, "user");
        ValidationUtils.invokeValidator(smartValidator, user, advancedErrors, Advanced.class);
        System.out.println("Advanced Validation");
        System.out.println(advancedErrors);
    }

}

输出日志

Simple Validation
org.springframework.validation.DirectFieldBindingResult: 8 errors
Field error in object 'user' on field 'age': rejected value [null]; codes [NotNull.user.age,NotNull.age,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.age,age]; arguments []; default message [age]]; default message [年龄不能为空]
Field error in object 'user' on field 'name': rejected value [null]; codes [NotNull.user.name,NotNull.name,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.name,name]; arguments []; default message [name]]; default message [姓名不能为空]
Field error in object 'user' on field 'name': rejected value [null]; codes [NotNull.user.name,NotNull.name,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.name,name]; arguments []; default message [name]]; default message [姓名不能为空]
Field error in object 'user' on field 'name': rejected value [null]; codes [NotBlank.user.name,NotBlank.name,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.name,name]; arguments []; default message [name]]; default message [姓名不能为空]
Field error in object 'user' on field 'gender': rejected value [null]; codes [NotNull.user.gender,NotNull.gender,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.gender,gender]; arguments []; default message [gender]]; default message [性别不能为空]
Field error in object 'user' on field 'age': rejected value [null]; codes [NotNull.user.age,NotNull.age,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.age,age]; arguments []; default message [age]]; default message [年龄不能为空]
Field error in object 'user' on field 'gender': rejected value [null]; codes [NotNull.user.gender,NotNull.gender,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.gender,gender]; arguments []; default message [gender]]; default message [性别不能为空]
Field error in object 'user' on field 'gender': rejected value [null]; codes [NotBlank.user.gender,NotBlank.gender,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.gender,gender]; arguments []; default message [gender]]; default message [性别不能为空]
================================================
Advanced Validation
org.springframework.validation.DirectFieldBindingResult: 5 errors
Field error in object 'user' on field 'birthDate': rejected value [null]; codes [NotNull.user.birthDate,NotNull.birthDate,NotNull.java.util.Date,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.birthDate,birthDate]; arguments []; default message [birthDate]]; default message [生日不能为空]
Field error in object 'user' on field 'birthDate': rejected value [null]; codes [NotNull.user.birthDate,NotNull.birthDate,NotNull.java.util.Date,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.birthDate,birthDate]; arguments []; default message [birthDate]]; default message [生日不能为空]
Field error in object 'user' on field 'occupation': rejected value [null]; codes [NotNull.user.occupation,NotNull.occupation,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.occupation,occupation]; arguments []; default message [occupation]]; default message [工作职位不能为空]
Field error in object 'user' on field 'occupation': rejected value [null]; codes [NotNull.user.occupation,NotNull.occupation,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.occupation,occupation]; arguments []; default message [occupation]]; default message [工作职位不能为空]
Field error in object 'user' on field 'occupation': rejected value [null]; codes [NotBlank.user.occupation,NotBlank.occupation,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.occupation,occupation]; arguments []; default message [occupation]]; default message [工作职位不能为空]

org.springframework.validation.beanvalidation

BeanValidationPostProcessor - 校验 bean 字段/属性

一个普通的 BeanPostProcessor。传入 javax.validation.Validator 或者 javax.validation.ValidatorFactory 对象,然后在 postProcessBeforeInitialization 方法中,根据校验注解,调用 javax.validation.Validator 原生的 API(跟 org.springframework.validation.Validator 一毛钱关系都没有)对准备初始化的 bean 的属性\字段进行校验,从而决定允不允许它初始化完成。若校验不通过,在违反约束的情况下就会抛出异常,阻止容器的正常启动。

注意:默认的 BeanValidationPostProcessor 不支持分组校验,但是我们可以继承 BeanValidationPostProcessor,然后添加上分组。

`BeanValidationPostProcessor 默认没有被装配进容器的,需要手动注册到 IOC 容器中。

总的来说,这个其实用的少,因为大部分的时候,我们放到容器中的 bean 的属性都是处于默认状态的

实践

在 SpringConfig 中注册 BeanValidationPostProcessor

@Bean(name = "beanValidationPostProcessor")
public BeanValidationPostProcessor getBeanValidationPostProcessor(Validator customValidatorBean) {
    BeanValidationPostProcessor beanValidationPostProcessor = new BeanValidationPostProcessor();
    beanValidationPostProcessor.setValidator(customValidatorBean);
    return beanValidationPostProcessor;
}

添加测试 bean

@Data
@Component
public class Student {

    @NotNull(message = "姓名不能为空")
    @NotBlank(message = "姓名不能为空")
    private String name;

    @NotNull(message = "性别不能为空")
    @NotBlank(message = "性别不能为空")
    private String gender;

    @Min(value = 0,message = "年龄不能小于0")
    @Max(value = 100,message = "年龄不能大于100")
    @NotNull(message = "年龄不能为空")
    private Integer age;

    @NotNull(message = "生日不能为空")
    @PastOrPresent(message = "生日不能为将来的时间")
    private Date birthDate;

    @Min(value = 0,message = "得分不能小于0")
    @Max(value = 100,message = "得分不能大于100")
    @NotNull(message = "得分不能为空")
    private Integer score;

}

测试类

@SpringJUnitConfig(classes = SpringConfig.class)
class StudentTest {

    @Autowired
    private Student student;

    @Test
    public void testStudent() throws Exception {
        System.out.println(student.toString());
    }
}

输出日志

Caused by: org.springframework.beans.factory.BeanInitializationException: Bean state is invalid: age - 年龄不能为空; name - 姓名不能为空; name - 姓名不能为空; score - 得分不能为空; age - 年龄不能为空; gender - 性别不能为空; gender - 性别不能为空; birthDate - 生日不能为空; score - 得分不能为空; gender - 性别不能为空; birthDate - 生日不能为空; name - 姓名不能为空

MethodValidationPostProcessor - 校验方法参数/返回值 重点使用

在 JavaSE 中,每个方法要校验参数,都要手动调用一遍校验方法方法,侵入性太强了,写起来也非常不方便,在 Spring 中,我们可以使用 AOP,将参数校验代码放到切面中,在进入方法前执行,这就是 MethodValidationPostProcessor。

实际上 MethodValidationPostProcessor 的作用是添加通知 MethodValidationInterceptor,方法校验的实际执行地是通知中。在 MethodValidationInterceptor 的校验逻辑中,若入参校验失败了,直接就报错了,方法体是不会执行的,返回值校验也是不会执行的。

需要配合@Validated 注解一起使用

MethodValidationPostProcessor 支持分组,分组需要在@Validated 注解中标注,@Validated 也只有这一个参数

MethodValidationPostProcessor 默认没有被装配进容器的,需要手动注册到 IOC 容器中

使用 @Validated 去校验方法 Method,不管从使用上还是原理上,都是非常简单和简约的,建议大家在企业应用中多多使用。

关于分组
  1. 校验注解可以属于多个分组

  2. 如果校验注解没有带分组,那么校验注解默认属于 Default 分组,如果指定的分组中没有显示包含 Default 分组(Default.class),则不会自动添加 Default 分组

  3. @Validated 可以指定多个分组

  4. @Validated 如果没有指定分组,则默认校验 Default 分组,如果指定的分组中没有显示包含 Default 分组(Default.class),则不会自动添加 Default 分组

@Validated 和@Valid 的异同点和关联
实践

注册 MethodValidationPostProcessor。

@Bean(name = "methodValidationPostProcessor")
public MethodValidationPostProcessor getMethodValidationPostProcessor(Validator validation) {
    MethodValidationPostProcessor methodValidationPostProcessor = new MethodValidationPostProcessor();
    methodValidationPostProcessor.setValidator(validation);
    return methodValidationPostProcessor;
}

添加接口,然后将注解都写在接口上,写在接口上的原因,我们在 JavaBeanValidation+HibernateValidator 中讨论过。当参数为自定义对象比如 User、Student 的时候,在参数前面添加@Valid 注解即可实现级联校验。这里就不演示了。

public interface UserService {

    @NotNull @Valid User addUser(@NotNull @NotBlank(groups = Simple.class) String name, @NotBlank(groups = Simple.class) @NotEmpty String occupation, @NotNull @Min(value = 1, groups = Simple.class) @Max(100) Integer age);

}

实现类,添加@Validated 注解,先不指定分组,PS:@Validated 注解它写在实现类/接口上均可

@Service
@Validated
public class UserServiceImpl implements UserService {

    @Override
    public User addUser(String name, String occupation, Integer age) {
        User user = new User();
        //user.setName(name);
        //user.setOccupation(occupation);
        //user.setAge(age);
        return user;
    }

}

测试类

@SpringJUnitConfig(classes = SpringConfig.class)
class UserFunctionTest {

    @Autowired
    private UserService userService;

    @Test
    public void testStudent() throws Exception {
        User user = userService.addUser("", "", 0);
        System.out.println(user.toString());
    }

}

日志,在默认分组的校验注解中,只有第二个参数 occupation 不符合@NotEmpty 约束,所以报错

javax.validation.ConstraintViolationException: addUser.occupation: 不能为空

填上之后

User user = userService.addUser("", "1", 0);

日志正常,虽然 User 前面有 @Valid 级联校验,但是因为 User 中的字段注解要么是 Simple 分组要么是 Advanced 分组,都不是默认分组,所以这里都不会校验

User(name=null, gender=null, age=null, birthDate=null, occupation=null)

在@Validated 中指定分组

@Service
@Validated(Simple.class)
public class UserServiceImpl implements UserService {

    @Override
    public User addUser(String name, String occupation, Integer age) {
        User user = new User();
        //user.setName(name);
        //user.setOccupation(occupation);
        //user.setAge(age);
        return user;
    }

}

测试

@SpringJUnitConfig(classes = SpringConfig.class)
class UserFunctionTest {

    @Autowired
    private UserService userService;

    @Test
    public void testStudent() throws Exception {
        User user = userService.addUser("", "", 0);
        System.out.println(user.toString());
    }

}

输出日志,Simple 分组的三个约束均不满足

javax.validation.ConstraintViolationException: addUser.age: 最小不能小于1, addUser.name: 不能为空, addUser.occupation: 不能为空

添加参数

User user = userService.addUser("xiashuo", "worker", 12);

再次测试,User 对象中,仅 Simple 分组的字段报错

javax.validation.ConstraintViolationException: addUser.<return value>.age: 年龄不能为空, addUser.<return value>.gender: 性别不能为空, addUser.<return value>.gender: 性别不能为空, addUser.<return value>.name: 姓名不能为空, addUser.<return value>.name: 姓名不能为空, addUser.<return value>.gender: 性别不能为空, addUser.<return value>.age: 年龄不能为空, addUser.<return value>.name: 姓名不能为空
@Validated 类中的方法相互调用

接口

public interface CommonService {

    @NotNull(groups = Advanced.class) @Valid User getUser(@NotNull(groups = Advanced.class) @NotBlank(groups = Advanced.class) String userName, @NotNull(groups = Advanced.class) @Min(value = 1,groups = Advanced.class) @Max(100) Integer userAge);

    void printUserInfo(@NotNull(groups = Advanced.class) @Valid User user);

}

实现类,在 getUser 方法中,调用 printUserInfo 方法

@Service
@Validated(Advanced.class)
public class CommonServiceImpl implements CommonService{

    @Autowired
    ApplicationContext applicationContext;

    @Override
    public User getUser(String userName, Integer userAge) {
        User user = new User();
        CommonService commonService = (CommonService) AopContext.currentProxy();
        commonService.printUserInfo(user);
        return user;
    }

    @Override
    public void printUserInfo(User user) {
        System.out.println(user.toString());
    }
}

测试类

@SpringJUnitConfig(classes = SpringConfig.class)
class CommonServiceImplTest {
    @Autowired
    private CommonService commonService;

    @Test
    public void testStudent() throws Exception {
        commonService.getUser("xiashuo", 2);
    }

}

这其实是一个 AOP 问题,参考《Spring5-AOP》中 被代理类的内部调用被代理类的另一个方法,会走代理类吗? 小节,很容易想到的方案是 @EnableAspectJAutoProxy(exposeProxy = true) 配合在方法内部通过 AopContext.currentProxy() 获取代理对象,再调用另一个方法

但是实际 AopContext.currentProxy() 报错,

java.lang.IllegalStateException: Cannot find current proxy: Set 'exposeProxy' property on Advised to 'true' to make it available, and ensure that AopContext.currentProxy() is invoked in the same thread as the AOP invocation context.

根据提示,在 SpringConfig 上添加 @EnableAspectJAutoProxy(exposeProxy = true),依然报错。

为什么会报错,如何解决这个报错?

参考博客:

使用@Async异步注解导致该Bean在循环依赖时启动报BeanCurrentlyInCreationException异常的根本原因分析,以及提供解决方案【享学Spring】_方向盘(YourBatman)的博客-CSDN博客_async循环依赖原理

从@Async案例找到Spring框架的bug:exposeProxy=true不生效原因大剖析+最佳解决方案【享学Spring】_方向盘(YourBatman)的博客-CSDN博客_exposeproxy

SpringBoot2.x—方法参数/返回值的注解版校验(解决方法内事务不生效)

@EnableAspectJAutoProxy(exposeProxy = true) 设置无效的根本原因是:

@EnableAspectJAutoProxy 主要配置 AnnotationAwareAspectJAutoProxyCreator(AbstractAutoProxyCreator 的子类)的各种属性,这是系统默认的处理 Advisor 从而创建代理类的代理创造器,而@Validated 注解修饰的类的代理的创建,不是走的 AnnotationAwareAspectJAutoProxyCreator,而是这个 MethodValidationPostProcessor 自己处理 Advisor 自己来生产代理(继承 AbstractBeanFactoryAwareAdvisingPostProcessor),所以 @EnableAspectJAutoProxy(exposeProxy = true) 的配置无法作用到@Validated 注解修饰的类的代理对象,AopContext.currentProxy() 会报错,为了不报错,我们需要手动设置代理生产者的 exposeProxy 属性,也就是 MethodValidationPostProcessor 的 exposeProxy 属性

这个 MethodValidationPostProcessor 的角色有点像一个代理生产者了,跟 AbstractAutoProxyCreator 类似。

MethodValidationPostProcessor 并不是一个简单的 PostProcessor,它继承了 AbstractBeanFactoryAwareAdvisingPostProcessor,由 AbstractBeanFactoryAwareAdvisingPostProcessor 的父类 AbstractAdvisingBeanPostProcessor 对生产出来的 advisor 进行进一步的处理,AbstractAdvisingBeanPostProcessor 还继承了 ProxyProcessorSupport,所以可以设置其 exposeProxy 属性。

具体细节看《Spring5-AOP 源码相关》Advisor 如何生效 - AbstractAutoProxyCreator

解决方案:

无需注解 @EnableAspectJAutoProxy(exposeProxy = true),只需要我们手动设置注解对应的 beanpostprocessor 的 exposeProxy 属性即可。创建类 MyBeanFactoryPostProcessor

@Component
public class MyBeanFactoryPostProcessor implements BeanFactoryPostProcessor {
    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        BeanDefinition methodValidationPostProcessor = beanFactory.getBeanDefinition("methodValidationPostProcessor");
        methodValidationPostProcessor.getPropertyValues().add("exposeProxy", true);
    }
}

然后即可正常使用 AopContext.currentProxy(),输出日志

javax.validation.ConstraintViolationException: printUserInfo.user.birthDate: 生日不能为空, printUserInfo.user.occupation: 工作职位不能为空, printUserInfo.user.occupation: 工作职位不能为空, printUserInfo.user.birthDate: 生日不能为空, printUserInfo.user.occupation: 工作职位不能为空

全局异常处理

方法参数/返回值的校验信息,如果可以通过结合 Spring 全局统一的异常处理进行抛出,就能让整个工程都能尽显完美之势。(错误消息可以从异常 ConstraintViolationExceptiongetConstraintViolations() 方法里获得的)

请看《SpringMVC- 第七篇:控制器方法异常处理.md

Spring 自定义 message

请看《Spring-MessageSource 相关解析.md

Spring MVC 集成 Java Bean Validation - 重点

Controller 提供的使用 @Valid 便捷校验 JavaBean 的原理,和Spring 方法级别的校验支持的原理是有很大差异的。

常见例子 - @PostMapping + @RequestBody @Valid/@Validated 校验 Java Bean

Java Bean,或者叫 POJO

@Data
@Component
public class Student {

    @NotNull(message = "姓名不能为空")
    @NotBlank(message = "姓名不能为空")
    private String name;

    @NotNull(message = "性别不能为空")
    @NotBlank(message = "性别不能为空")
    private String gender;

    @Min(value = 0,message = "年龄不能小于0")
    @Max(value = 100,message = "年龄不能大于100")
    @NotNull(message = "年龄不能为空")
    private Integer age;

    @NotNull(message = "生日不能为空")
    @PastOrPresent(message = "生日不能为将来的时间")
    private Date birthDate;

    @Min(value = 0,message = "得分不能小于0")
    @Max(value = 100,message = "得分不能大于100")
    @NotNull(message = "得分不能为空")
    private Integer score;

}

控制器方法,若方法入参不写 BindingResult result 这个参数,请求得到的直接是 400 错误,因为若有校验失败的服务端会抛出异常 org.springframework.web.bind.MethodArgumentNotValidException。若写了,那就调用者自己处理

/**
    * 若方法入参不写BindingResult result这个参数,请求得到的直接是400错误,因为若有校验失败的服务端会抛出异常org.springframework.web.bind.MethodArgumentNotValidException。
    * 若写了,那就调用者自己处理
    *
    * @param student
    * @param result
    * @return
    */
@PostMapping("/postStudent")
public String postStudent(@RequestBody @Valid Student student, BindingResult result) {
    System.out.println("------------------------");
    System.out.println(student.toString());
    System.out.println(result);
    System.out.println("------------------------");
    //设置视图名称
    return "index";
}

测试类

@Test
void testPostStudent() throws Exception {
    this.mockMvc.perform(post("/postStudent")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("{\"name\":\"xiashuo\",\"gender\":\"男\"}")
            )
            .andExpect(status().isOk());
}

日志

------------------------
Student(name=xiashuo, gender=男, age=null, birthDate=null, score=null)
org.springframework.validation.BeanPropertyBindingResult: 6 errors
Field error in object 'student' on field 'birthDate': rejected value [null]; codes [NotNull.student.birthDate,NotNull.birthDate,NotNull.java.util.Date,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [student.birthDate,birthDate]; arguments []; default message [birthDate]]; default message [生日不能为空]
Field error in object 'student' on field 'score': rejected value [null]; codes [NotNull.student.score,NotNull.score,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [student.score,score]; arguments []; default message [score]]; default message [得分不能为空]
Field error in object 'student' on field 'birthDate': rejected value [null]; codes [NotNull.student.birthDate,NotNull.birthDate,NotNull.java.util.Date,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [student.birthDate,birthDate]; arguments []; default message [birthDate]]; default message [生日不能为空]
Field error in object 'student' on field 'age': rejected value [null]; codes [NotNull.student.age,NotNull.age,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [student.age,age]; arguments []; default message [age]]; default message [年龄不能为空]
Field error in object 'student' on field 'score': rejected value [null]; codes [NotNull.student.score,NotNull.score,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [student.score,score]; arguments []; default message [score]]; default message [得分不能为空]
Field error in object 'student' on field 'age': rejected value [null]; codes [NotNull.student.age,NotNull.age,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [student.age,age]; arguments []; default message [age]]; default message [年龄不能为空]
------------------------

一般我们推荐使用 @Validated,还可以指定分组,反正 @Validated@Valid 本身的功能没有差别,只要是 Valid 开头的注解就行

@Data
@Component
public class User {

    @NotNull(message = "姓名不能为空",groups = Simple.class)
    @NotBlank(message = "姓名不能为空",groups = Simple.class)
    private String name;

    @NotNull(message = "性别不能为空",groups = Simple.class)
    @NotBlank(message = "性别不能为空",groups = Simple.class)
    private String gender;

    @Min(value = 0,message = "年龄不能小于0",groups = Simple.class)
    @Max(value = 100,message = "年龄不能大于100",groups = Simple.class)
    @NotNull(message = "年龄不能为空",groups = Simple.class)
    private Integer age;

    @NotNull(message = "生日不能为空",groups = Advanced.class)
    @PastOrPresent(message = "生日不能为将来的时间",groups = Advanced.class)
    private Date birthDate;

    @NotNull(message = "工作职位不能为空",groups = Advanced.class)
    @NotBlank(message = "工作职位不能为空",groups = Advanced.class)
    private String occupation;

}

控制器

@PostMapping("/postUser")
public String postUser(@RequestBody @Validated(Simple.class) User user, BindingResult result) {
    System.out.println("------------------------");
    System.out.println(user.toString());
    System.out.println(result);
    System.out.println("------------------------");
    //设置视图名称
    return "index";
}

测试

@Test
void testPostUser() throws Exception {
    this.mockMvc.perform(post("/postUser")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("{\"name\":\"xiashuo\",\"gender\":\"男\"}")
            )
            .andExpect(status().isOk());
}

输出日志,只输出了 age 一个字段的校验信息,因为只有它这一个 Simple 分组的字段不通过校验。

------------------------
User(name=xiashuo, gender=男, age=null, birthDate=null, occupation=null)
org.springframework.validation.BeanPropertyBindingResult: 2 errors
Field error in object 'user' on field 'age': rejected value [null]; codes [NotNull.user.age,NotNull.age,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.age,age]; arguments []; default message [age]]; default message [年龄不能为空]
Field error in object 'user' on field 'age': rejected value [null]; codes [NotNull.user.age,NotNull.age,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.age,age]; arguments []; default message [age]]; default message [年龄不能为空]
------------------------

常见例子的分析

Controller 提供的使用 @Valid 便捷校验 JavaBean 主要是通过 WebDataBinder 中的 validate 方法(OptionalValidatorFactoryBean 类型)实现,和 Spring 方法级别的校验 (MethodValidationPostProcessor) 的原理是有很大差异的(可类比 Spring MVC 拦截器和 Spring AOP 的差异区别)。

处理入参的处理器:HandlerMethodArgumentResolver,处理 @RequestBody 最终使用的实现类是:RequestResponseBodyMethodProcessorSpring 借助此处理器完成一系列的消息转换器、数据绑定、数据校验等工作。RequestResponseBodyMethodProcessor 中,不需要 MethodValidationPostProcessor 的帮助,在解析参数的时候,会自动进行参数校验和参数绑定,在校验之前,会检查参数前是否有 Valid 开头的注解,也就是说,只要注解是 Valid 开头的就会校验,参数标注了@Validated 注解或者@Valid 注解都会有效。你自定义注解,名称只要 Valid 开头都成。而且只要有一个就够。

这是使用 @RequestBody 结合 @Valid 完成数据校验的基本原理。其实当 Spring MVC 在处理 @RequestPart 注解入参数据时,也会执行绑定、校验的相关逻辑。对应处理器是 RequestPartMethodArgumentResolver,原理大体上和这相似,它主要处理 Multipart 相关。

如果想要自定义 WebDataBinder 中的 Valdator,请看 自定义全局校验器Validator 小节。

非@RequestBody 注解也可以进行 Java Bean 校验

请注意:并不一样要求是请求 Body 体哦,比如 get 请求的入参若用 JavaBean 接收的话,依旧能启用校验。原因跟上一小节中提到的大同小异,因为不需要 MethodValidationPostProcessor 的帮助,在解析参数的并将其注入到 Java Bean 类型的形参中的时候,会自动进行参数校验和参数绑定

@GetMapping("/getUser")
public String getUser(@Validated(Simple.class) User user, BindingResult result) {
    System.out.println("------------------------");
    System.out.println(user.toString());
    System.out.println(result.toString());
    System.out.println("------------------------");
    //设置视图名称
    return "index";
}

测试类

@Test
void testGetUser() throws Exception {
    this.mockMvc.perform(get("/getUser")
                    .param("name","xiashuo")
                    .param("gender","男")
            )
            .andDo(print());
}

输出日志

------------------------
User(name=xiashuo, gender=男, age=null, birthDate=null, occupation=null)
org.springframework.validation.BeanPropertyBindingResult: 2 errors
Field error in object 'user' on field 'age': rejected value [null]; codes [NotNull.user.age,NotNull.age,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.age,age]; arguments []; default message [age]]; default message [年龄不能为空]
Field error in object 'user' on field 'age': rejected value [null]; codes [NotNull.user.age,NotNull.age,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.age,age]; arguments []; default message [age]]; default message [年龄不能为空]
------------------------

@PostMapping + @RequestBody @Valid 校验Java Bean 不同的是,controller 方法中不添加 , BindingResult result 参数 controller 也不会中止,只是会报错 BindException.class,这个时候,可以手动添加全局异常处理。

@ExceptionHandler(value = {BindException.class})
public String handleMethodArgumentNotValid2(BindException ex ) {
    StringBuilder stringBuilder = new StringBuilder();
    BindingResult bindingResult = ex.getBindingResult();
    for (FieldError error : bindingResult.getFieldErrors()) {
        String field = error.getField();
        Object value = error.getRejectedValue();
        String msg = error.getDefaultMessage();
        String message = String.format("错误字段:%s,错误值:%s,原因:%s;", field, value, msg);
        stringBuilder.append(message).append("\r\n");
    }
    return  stringBuilder.toString();
}

Controller 方法非 JavaBean 类型的参数的校验

大部分的场景下,我们需要校验 Controller 中零散的参数,而不是 Java Bean,这种情况使用@Valid 并不能覆盖

方案一 MethodValidationPostProcessor + 全局异常处理 - 常用

注册 springframework.validation.Validatororg.springframework.validation.beanvalidation.MethodValidationPostProcessor

<bean id="validator" class="org.springframework.validation.beanvalidation.LocalValidatorFactoryBean">
    <property name="providerClass" value="org.hibernate.validator.HibernateValidator"/>
</bean>

<bean id="methodValidationPostProcessor" class="org.springframework.validation.beanvalidation.MethodValidationPostProcessor">
    <property name="validator" ref="validator"/>
</bean>

在控制器上添加 @Validated 注解

@Controller
@Validated({Simple.class,Advanced.class})
public class ValidatorController {

    @GetMapping("/getInfo")
    public String getInfo(@NotBlank(message = "姓名不能为空", groups = Simple.class) String name, @NotBlank(message = "性别不能为空", groups = Simple.class) String gender, @NotNull(groups = Advanced.class) @Min(value = 1, groups = Advanced.class) Integer age) {
        System.out.println("------------------------");
        System.out.println(name);
        System.out.println(gender);
        System.out.println(age);
        System.out.println("------------------------");
        //设置视图名称
        return "index";
    }

}

添加全局异常处理,处理的是 ConstraintViolationException

@ControllerAdvice
@ResponseBody
public class MethodArgumentNotValidExceptionHandler {

    @ExceptionHandler(ConstraintViolationException.class)
    public String handleMethodArgumentNotValid(ConstraintViolationException ex) {
        StringBuilder stringBuilder = new StringBuilder();
        Set<ConstraintViolation<?>> constraintViolations = ex.getConstraintViolations();
        for (ConstraintViolation<?> constraintViolation : constraintViolations) {
            String propertyPath = constraintViolation.getPropertyPath().toString();
            Object invalidValue = constraintViolation.getInvalidValue();
            String infp = constraintViolation.getMessage();
            String message = String.format("错误字段:%s,错误值:%s,原因:%s;", propertyPath, invalidValue, infp);
            stringBuilder.append(message).append("\r\n");
        }
        return  stringBuilder.toString();
    }

}

然后开始测试

@Test
void testGetUser() throws Exception {
    this.mockMvc.perform(get("/getInfo")
                    .param("name","")
                    .param("gender","")
            )
            .andDo(print());
}

输出日志

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /getInfo
       Parameters = {name=[], gender=[]}
          Headers = []
             Body = <no character encoding set>
    Session Attrs = {}

Handler:
             Type = xyz.xiashuo.springmvcbeanvalidation.controller.ValidatorController
           Method = xyz.xiashuo.springmvcbeanvalidation.controller.ValidatorController#getInfo(String, String, Integer)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = javax.validation.ConstraintViolationException

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"text/html;charset=UTF-8", Content-Length:"234"]
     Content type = text/html;charset=UTF-8
             Body = 错误字段:getInfo.age,错误值:null,原因:must not be null;
错误字段:getInfo.name,错误值:,原因:姓名不能为空;
错误字段:getInfo.gender,错误值:,原因:性别不能为空;

    Forwarded URL = null
   Redirected URL = null
          Cookies = []

可谓非常方便,优雅

这种方案一样有一个非常值得注意但是很多人都会忽略的地方:因为我们希望能够代理 Controller 这个 Bean,所以仅仅只在父容器中配置 MethodValidationPostProcessor 是无效的,必须在子容器(web 容器)的配置文件中再配置一个 MethodValidationPostProcessor,请务必注意

有小伙伴问我了,为什么它的项目里只配置了一个 MethodValidationPostProcessor 也生效了呢? 我的回答是:检查一下你是否是用的 SpringBoot。

可是我用的是原生的 SpringMVC,也只配置了一次 MethodValidationPostProcessor 就生效了。

其实关于配置一个还是多个 MethodValidationPostProcessor 的 case,其实是个 Bean 覆盖有很大关系的,这方面内容可参考:【小家Spring】聊聊Spring的bean覆盖(存在同名name/id问题),介绍Spring名称生成策略接口BeanNameGenerator

方案二 自定义 HandlerInterceptor - 太复杂,不常用

设计思路:Controller 拦截器 + @Validated 注解 + 自定义校验器

添加拦截器

// 注意:此处只支持@RequesrMapping方式~~~~
@Component
public class ValidationInterceptor implements HandlerInterceptor, InitializingBean {

    @Autowired
    @Qualifier("validator")
    private LocalValidatorFactoryBean validatorFactoryBean;
    @Autowired
    private RequestMappingHandlerAdapter adapter;
    private List<HandlerMethodArgumentResolver> argumentResolvers;

    @Override
    public void afterPropertiesSet() throws Exception {
        argumentResolvers = adapter.getArgumentResolvers();
    }

    // 缓存
    private final Map<MethodParameter, HandlerMethodArgumentResolver> argumentResolverCache = new ConcurrentHashMap<>(256);
    private final Map<Class<?>, Set<Method>> initBinderCache = new ConcurrentHashMap<>(64);

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 只处理HandlerMethod方式
        if (handler instanceof HandlerMethod) {
            HandlerMethod method = (HandlerMethod) handler;
            Validated valid = method.getMethodAnnotation(Validated.class); //
            if (valid != null) {
                // 根据工厂,拿到一个校验器
                ValidatorImpl validatorImpl = (ValidatorImpl) validatorFactoryBean.getValidator();

                // 拿到该方法所有的参数们~~~  org.springframework.core.MethodParameter
                MethodParameter[] parameters = method.getMethodParameters();
                Object[] parameterValues = new Object[parameters.length];

                //遍历所有的入参:给每个参数做赋值和数据绑定
                for (int i = 0; i < parameters.length; i++) {
                    MethodParameter parameter = parameters[i];
                    // 找到适合解析这个参数的处理器~
                    HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter);
                    Assert.notNull(resolver, "Unknown parameter type [" + parameter.getParameterType().getName() + "]");

                    ModelAndViewContainer mavContainer = new ModelAndViewContainer();
                    mavContainer.addAllAttributes(RequestContextUtils.getInputFlashMap(request));

                    WebDataBinderFactory webDataBinderFactory = getDataBinderFactory(method);
                    // 调用此方法有个前提,就是控制器方法的必须配置注解表示参数的名称,比如 @PathVariable("paramName") 或者 @RequestParam("name"),一般在不影响功能的前提下,使用 @RequestParam("name") 最合适
                    // 否则会报错 Request processing failed; nested exception is java.lang.IllegalArgumentException: Name for argument of type [java.lang.String] not specified, and parameter name information not found in class file either.
                    Object value = resolver.resolveArgument(parameter, mavContainer, new ServletWebRequest(request, response), webDataBinderFactory);
                    parameterValues[i] = value; // 赋值
                }

                // 对入参进行统一校验
                Set<ConstraintViolation<Object>> violations = validatorImpl.validateParameters(method.getBean(), method.getMethod(), parameterValues, valid.value());
                // 若存在错误消息,此处也做抛出异常处理 javax.validation.ConstraintViolationException
                if (!violations.isEmpty()) {
                    System.err.println("方法入参校验失败~~~~~~~");
                    throw new ConstraintViolationException(violations);
                }
            }

        }

        return true;
    }

    private WebDataBinderFactory getDataBinderFactory(HandlerMethod handlerMethod) {
        Class<?> handlerType = handlerMethod.getBeanType();
        Set<Method> methods = this.initBinderCache.get(handlerType);
        if (methods == null) {
            // 支持到@InitBinder注解
            methods = MethodIntrospector.selectMethods(handlerType, RequestMappingHandlerAdapter.INIT_BINDER_METHODS);
            this.initBinderCache.put(handlerType, methods);
        }
        List<InvocableHandlerMethod> initBinderMethods = new ArrayList<>();
        for (Method method : methods) {
            Object bean = handlerMethod.getBean();
            initBinderMethods.add(new InvocableHandlerMethod(bean, method));
        }
        return new ServletRequestDataBinderFactory(initBinderMethods, adapter.getWebBindingInitializer());
    }

    private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
        HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
        if (result == null) {
            for (HandlerMethodArgumentResolver methodArgumentResolver : this.argumentResolvers) {
                if (methodArgumentResolver.supportsParameter(parameter)) {
                    result = methodArgumentResolver;
                    this.argumentResolverCache.put(parameter, result);
                    break;
                }
            }
        }
        return result;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    }

}

自定义拦截器

<mvc:interceptors>
    <mvc:interceptor>
        <mvc:mapping path="/validationByInterceptor/**"/>
<!--            <mvc:exclude-mapping path="/index/*"/>-->
        <bean class="xyz.xiashuo.springmvcbeanvalidation.interceptor.ValidationInterceptor"/>
    </mvc:interceptor>
</mvc:interceptors>

控制器方法

@Controller
@RequestMapping("/validationByInterceptor")
public class ValidatorByInterceptorController {

    @Validated
    @GetMapping("/getInfo")
    public String getInfo(@NotBlank(message = "姓名不能为空") @RequestParam("name") String name, @NotBlank(message = "性别不能为空") @RequestParam("gender") String gender, @NotNull @Min(value = 1) @RequestParam("age") Integer age) {
        System.out.println("------------------------");
        System.out.println(name);
        System.out.println(gender);
        System.out.println(age);
        System.out.println("------------------------");
        //设置视图名称
        return "index";
    }

}

因为拦截器中使用了 resolver.resolveArgument,用此方法有个前提,就是控制器方法的必须配置注解表示参数的名称,比如 @PathVariable("paramName") 或者 @RequestParam("name"),一般在不影响功能的前提下,使用 @RequestParam("name") 最合适。否则会报错:

Request processing failed; nested exception is java.lang.IllegalArgumentException: Name for argument of type [java.lang.String] not specified, and parameter name information not found in class file either.

添加全局异常处理,处理的是 ConstraintViolationException

@ControllerAdvice
@ResponseBody
public class MethodArgumentNotValidExceptionHandler {

    @ExceptionHandler(ConstraintViolationException.class)
    public String handleMethodArgumentNotValid(ConstraintViolationException ex) {
        StringBuilder stringBuilder = new StringBuilder();
        Set<ConstraintViolation<?>> constraintViolations = ex.getConstraintViolations();
        for (ConstraintViolation<?> constraintViolation : constraintViolations) {
            String propertyPath = constraintViolation.getPropertyPath().toString();
            Object invalidValue = constraintViolation.getInvalidValue();
            String infp = constraintViolation.getMessage();
            String message = String.format("错误字段:%s,错误值:%s,原因:%s;", propertyPath, invalidValue, infp);
            stringBuilder.append(message).append("\r\n");
        }
        return  stringBuilder.toString();
    }

}

测试类

@SpringJUnitWebConfig(locations = "classpath:springMVC.xml")
class ValidatorByInterceptorControllerTest {
    MockMvc mockMvc;

    @BeforeEach
    void setup(WebApplicationContext wac) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
    }

    @Test
    void testGetUser() throws Exception {
        this.mockMvc.perform(get("/validationByInterceptor/getInfo")
                        .param("name","")
                        .param("gender","")
                        .param("age", String.valueOf(10))
                )
                .andDo(print());
    }
}

输出日志

---------------- 方法入参校验失败 ----------------

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /validationByInterceptor/getInfo
       Parameters = {name=[], gender=[], age=[10]}
          Headers = []
             Body = <no character encoding set>
    Session Attrs = {}

Handler:
             Type = xyz.xiashuo.springmvcbeanvalidation.controller.ValidatorByInterceptorController
           Method = xyz.xiashuo.springmvcbeanvalidation.controller.ValidatorByInterceptorController#getInfo(String, String, Integer)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = javax.validation.ConstraintViolationException

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"text/html;charset=UTF-8", Content-Length:"156"]
     Content type = text/html;charset=UTF-8
             Body = 错误字段:getInfo.gender,错误值:,原因:性别不能为空;
错误字段:getInfo.name,错误值:,原因:姓名不能为空;

    Forwarded URL = null
   Redirected URL = null
          Cookies = []

Controller 方法校验失败后的全局异常处理示例

对于不同的参数解析方式,Spring 做参数校验时会抛出不同的异常,而且这些异常没有继承关系,通过异常获取校验结果的方式也各不相同(好坑爹~)。

这里我们以用的最多的 @RequestBody 校验为例,当校验失败时,Spring 会抛出 MethodArgumentNotValidException 异常,该异常会持有校验结果对象 BindingResult,从而获得校验失败信息。

全局异常处理器

@ControllerAdvice
@ResponseBody
public class MethodArgumentNotValidExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public String handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
        BindingResult bindingResult = ex.getBindingResult();

        StringBuilder stringBuilder = new StringBuilder();
        for (FieldError error : bindingResult.getFieldErrors()) {
            String field = error.getField();
            Object value = error.getRejectedValue();
            String msg = error.getDefaultMessage();
            String message = String.format("错误字段:%s,错误值:%s,原因:%s;", field, value, msg);
            stringBuilder.append(message).append("\r\n");
        }
        return  stringBuilder.toString();
    }

}

控制器方法

@PostMapping("/postUserThrowException")
public String postUserThrowException(@RequestBody @Validated(Simple.class) User user) {
    System.out.println("------------------------");
    System.out.println(user.toString());
    System.out.println("------------------------");
    //设置视图名称
    return "index";
}

测试类方法

@Test
void testPostUserThrowException() throws Exception {
    this.mockMvc.perform(post("/postUserThrowException")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("{\"name\":\"xiashuo\",\"gender\":\"男\"}")
            )
            .andDo(print());
}

控制器的异常由 MethodArgumentNotValidExceptionHandlerhandleMethodArgumentNotValid 方法处理,并返回,输出日志

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /postUserThrowException
       Parameters = {}
          Headers = [Content-Type:"application/json", Content-Length:"33"]
             Body = <no character encoding set>
    Session Attrs = {}

Handler:
             Type = xyz.xiashuo.springmvcbeanvalidation.controller.HelloController
           Method = xyz.xiashuo.springmvcbeanvalidation.controller.HelloController#postUserThrowException(User)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = org.springframework.web.bind.MethodArgumentNotValidException

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"text/html;charset=UTF-8", Content-Length:"144"]
     Content type = text/html;charset=UTF-8
             Body = 错误字段:age,错误值:null,原因:年龄不能为空;
错误字段:age,错误值:null,原因:年龄不能为空;

    Forwarded URL = null
   Redirected URL = null
          Cookies = []

处理所有 Controller 参数校验异常的集大成者

@ExceptionHandler({ConstraintViolationException.class, MethodArgumentNotValidException.class, ServletRequestBindingException.class, BindException.class})
@ResponseBody
public ResultBean<?> handleValidationException(Exception e) {
    String msg = "";
    if (e instanceof MethodArgumentNotValidException) {
        MethodArgumentNotValidException t = (MethodArgumentNotValidException) e;
        msg = t.getBindingResultgetDefaultMessage).collect(Collectors.joining(",");
    } else if (e instanceof BindException) {
        BindException t = (BindException) e;
        msg = t.getBindingResultgetDefaultMessage).collect(Collectors.joining(",");
    } else if (e instanceof ConstraintViolationException) {
        ConstraintViolationException t = (ConstraintViolationException) e;
        msg = t.getConstraintViolationsgetMessage).collect(Collectors.joining(",");
    } else if (e instanceof MissingServletRequestParameterException) {
        MissingServletRequestParameterException t = (MissingServletRequestParameterException) e;
        msg = t.getParameterName() + " 不能为空";
    } else if (e instanceof MissingPathVariableException) {
        MissingPathVariableException t = (MissingPathVariableException) e;
        msg = t.getVariableName() + " 不能为空";
    } else {
        msg = "必填参数缺失";
    }
    log.warn("参数校验不通过,msg: {}", msg);
    return ResultBean.fail(msg);
}

SpringMVC 中自定义 message 信息

背景知识,请看《Spring-MessageSource 相关解析.md

JavaBeanValidation+HibernateValidator 中使用的是 Hibernate Validation 内置的对国际化的支持,由于大部分情况下我们都是在 Spring 环境下使用数据校验,因此有必要讲讲 Spring 加持情况下的国际化做法。我们知道 Spring MVC 是有专门做国际化的模块的,因此国际化这个动作当然也是可以交给 Spring 自己来做的

其实跟使用的是 Hibernate Validation 内置的对国际化的支持的时候的使用方法差别不大,主要的差别在于如何加载国际化的资源文件

Controller 提供的使用 @Valid 校验 JavaBean 主要是通过 WebDataBinder 中的 validate 方法实现,系统内部有一个自动生成的 Validator,类型是 OptionalValidatorFactoryBean,此 Validator 在 WebMvcConfigurationSupport 的 mvcValidator 方法中生成

我们使用 MethodValidationPostProcessor 的时候,一般是自己自定义一个 Validator:org.springframework.validation.beanvalidation.LocalValidatorFactoryBean

<bean id="validator" class="org.springframework.validation.beanvalidation.LocalValidatorFactoryBean">
    <property name="providerClass" value="org.hibernate.validator.HibernateValidator"/>
    <property name="validationMessageSource" ref="messageSource"/>
</bean>

<bean id="methodValidationPostProcessor" class="org.springframework.validation.beanvalidation.MethodValidationPostProcessor">
    <property name="validator" ref="validator"/>
</bean>

我们需要设置做的事情其实很简单,自定义一个 Validator 并设置 validationMessageSource 属性,然后替换 WebDataBinder 中使用的 validator即可。

实践

JavaBean

@Data
@Component
public class UserWithCustomizeMessage {

    @NotNull(message = "{xyz.xiashuo.pojo.User.name.NotNull}")
    @NotBlank(message = "{xyz.xiashuo.pojo.User.name.NotBlank}")
    private String name;

    @NotNull(message = "{xyz.xiashuo.pojo.User.gender.NotNull}")
    @NotBlank(message = "{xyz.xiashuo.pojo.User.gender.NotBlank}")
    private String gender;

    @NotNull(message = "{xyz.xiashuo.pojo.User.occupation.NotNull}")
    @NotBlank(message = "{xyz.xiashuo.pojo.User.occupation.NotBlank}")
    private String occupation;

    @NotNull(message = "{xyz.xiashuo.pojo.User.age.NotNull}")
    @Min(value = 18, message = "{xyz.xiashuo.pojo.User.age.Min}")
    private int age;

}

控制器

@Controller
@RequestMapping("/CustomizeMessage")
public class CustomizeMessageController {

    @Autowired
    @Qualifier("validator")
    private LocalValidatorFactoryBean localValidatorFactoryBean;

    @InitBinder
    public void InitBinder(WebDataBinder binder) {
        binder.setValidator(localValidatorFactoryBean);
    }

    @PostMapping("/getInfo")
    public String getInfo(@RequestBody @Valid UserWithCustomizeMessage user) {
        System.out.println("------------------------");
        System.out.println(user.toString());
        System.out.println("------------------------");
        //设置视图名称
        return "index";
    }

}

全局异常处理

@ControllerAdvice
@ResponseBody
public class MethodArgumentNotValidExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public String handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
        BindingResult bindingResult = ex.getBindingResult();

        StringBuilder stringBuilder = new StringBuilder();
        for (FieldError error : bindingResult.getFieldErrors()) {
            String field = error.getField();
            Object value = error.getRejectedValue();
            String msg = error.getDefaultMessage();
            String message = String.format("错误字段:%s,错误值:%s,原因:%s;", field, value, msg);
            stringBuilder.append(message).append("\r\n");
        }
        return  stringBuilder.toString();
    }

}

Validator 的配置

<bean id="validator" class="org.springframework.validation.beanvalidation.LocalValidatorFactoryBean">
    <property name="providerClass" value="org.hibernate.validator.HibernateValidator"/>
    <property name="validationMessageSource" ref="messageSource"/>
</bean>

<bean id="methodValidationPostProcessor" class="org.springframework.validation.beanvalidation.MethodValidationPostProcessor">
    <property name="validator" ref="validator"/>
</bean>

<bean id="messageSource" class="org.springframework.context.support.ReloadableResourceBundleMessageSource">
    <property name="basename" value="classpath:validationMessage"/>
    <property name="defaultEncoding" value="utf-8"/>
    <property name="useCodeAsDefaultMessage" value="false"/>
    <property name="cacheSeconds" value="60"/>
</bean>

同时在 resources 目录下添加文件 validationMessage.properties

// 此处可以使用占位符{value}读取注解对应属性上的值
xyz.xiashuo.pojo.User.name.NotNull = [自定义消息]姓名不能为空
xyz.xiashuo.pojo.User.name.NotBlank = [自定义消息]姓名不能为空
xyz.xiashuo.pojo.User.gender.NotNull = [自定义消息]性别不能为空
xyz.xiashuo.pojo.User.gender.NotBlank = [自定义消息]性别不能为空
xyz.xiashuo.pojo.User.occupation.NotNull = [自定义消息]职位不能为空
xyz.xiashuo.pojo.User.occupation.NotBlank = [自定义消息]职位不能为空
xyz.xiashuo.pojo.User.age.NotNull = [自定义消息]年龄不能为空
xyz.xiashuo.pojo.User.age.Min = [自定义消息]年龄最小值不能小于{value}

测试方法

@Test
void getInfo() throws Exception {
    this.mockMvc.perform(post("/CustomizeMessage/getInfo")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("{\"name\":\"\",\"gender\":\"\"}")
            )
            .andDo(print());
}

输出日志

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /CustomizeMessage/getInfo
       Parameters = {}
          Headers = [Content-Type:"application/json", Content-Length:"23"]
             Body = <no character encoding set>
    Session Attrs = {}

Handler:
             Type = xyz.xiashuo.springmvcbeanvalidation.controller.CustomizeMessageController
           Method = xyz.xiashuo.springmvcbeanvalidation.controller.CustomizeMessageController#getInfo(UserWithCustomizeMessage)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = org.springframework.web.bind.MethodArgumentNotValidException

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"text/html;charset=UTF-8", Content-Length:"559"]
     Content type = text/html;charset=UTF-8
             Body = 错误字段:occupation,错误值:null,原因:[自定义消息]职位不能为空;
错误字段:age,错误值:0,原因:[自定义消息]年龄最小值不能小于18;
错误字段:occupation,错误值:null,原因:[自定义消息]职位不能为空;
错误字段:name,错误值:,原因:[自定义消息]姓名不能为空;
错误字段:gender,错误值:,原因:[自定义消息]性别不能为空;
错误字段:occupation,错误值:null,原因:[自定义消息]职位不能为空;

    Forwarded URL = null
   Redirected URL = null
          Cookies = []

自定义全局校验器 Validator,即 WebDataBinder 中使用的那个 Validator

只要引入 hibernate-validator,系统内部有一个自动生成的 Validator,类型是 OptionalValidatorFactoryBean,

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.17.Final</version>
</dependency>

在使用 XML 配置 SpringMVC 的时候,在 AnnotationDrivenBeanDefinitionParser 的静态代码块中,根据 classpath 中是否存在 javax.validation.Validator 来判断是否创建 OptionalValidatorFactoryBean,参考《SpringMVC- 第九篇:基于 XML 配置 SpringMVC》。

在使用注解配置 SpringMVC 的时候,在 WebMvcConfigurationSupport 的 mvcValidator 方法中,根据 classpath 中是否存在 javax.validation.Validator 来判断是否创建 OptionalValidatorFactoryBean,参考《SpringMVC- 第十篇:基于注解配置 SpringMVC》

此全局 Validator 可通过 @Autowired 注解直接引用。如果想要全局替换,继承 WebMvcConfigurerAdapter 重写 getValidator 即可。

@Configuration
@EnableWebMvc
public class WebMvcConfig extends WebMvcConfigurerAdapter {
    ...
    @Override
    public Validator getValidator() {
        // return "global" validator
        return new LocalValidatorFactoryBean();
    }
    ...
}

当然,如果你不想全局控制,只想在当前 Controller 中进行个性化设置,你还可以使用 @InitBinder 来设置自定义 WebDataBinder,比如你可以使用自定校验器实现各种私有的、比较复杂的逻辑判断。

例子请看 SpringMVC中自定义message信息