SpringBoot表单验证:自定义注解和参数验证器

序言

在进行SpringBoot的Web程序开发中,常常需要对客户端发起的请求中的表单参数进行有效性认证,能够在一定程度上确保系统的正常安全运行和避免意外情况,如

  • 强制规定密码长度不得少于6位
  • 某个参数不允许出现某些特殊字符
  • 标识某个参数值所需的权限级别和判断用户是否具备传入该值的权限(参数动态鉴权)
  • 判断传入的某个参数在系统中是否为有效的记录值(如,有效且存在的文章编号,用户ID,资源编号等)

前两者的情况我们可以通过项目中集成的hibernate-validator使用@Pattern传入允许的正则表达式就能实现,而后两者则稍微复杂一些,自带的验证规则就无法满足我们的需求的,需要我们自己自定义验证规则。

使用自定义注解和参数验证器的优势:

  1. 参数安全校验代码与业务代码解耦
  2. 逻辑复用方便简单
本文的演示目标为利用自定义表单验证器实现控制器的参数动态鉴权

本篇文章使用的环境

  • JDK - 1.8.0_221
  • SpringBoot - 2.4
  • 操作系统 - Windows 10 20H2 64位

组件依赖

请确保项目依赖中包含以下starter依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    <version>2.4.5</version>
    <type>pom</type>
</dependency>

起步,Coding走起

第一步:先定义注解

/**
 * 验证当前用户是否具有操作目标用户ID的权限
 * 除管理员外,每个用户只能操作自己的资源(即目标资源的UID标识为当前登录用户ID)
 */
@Target({ElementType.PARAMETER, ElementType.FIELD}) // 允许在参数,字段中使用
@Retention(RetentionPolicy.RUNTIME)                 // 保留策略当然选运行时的RUNTIME啦,方便我们在运行时获取注解的属性
@Constraint(validatedBy = UIDValidator.class)       // 设定相关联的验证器类,将在该验证器类中编写验证逻辑
public @interface UID {
    /**
     * 是否仅允许管理员操作
     */
    boolean value() default false;

    // 根据 JSR 303 提案,用于验证参数的注解中必须有下面三个属性

    // 验证不通过时的错误信息
    String message() default "无权操作资源";

    // 校验分组,本文暂不探究该属性,这里直接用默认
    Class<?>[] groups() default {};

    // 由验证客户端将一些元数据信息与给定的约束声明相关联,本文暂不探究该属性,这里直接用默认
    Class<? extends Payload>[] payload() default {};
}

第二步:编写验证器类

比较简单,实现ConstraintValidator接口即可

文档里该接口的定义:
public interface ConstraintValidator<A extends Annotation, T> {
    ...
}
泛型说明
  • A 由该验证器处理的注解类型,这里就用前面编写的注解@UID

    原文:the annotation type handled by an implementation

  • T 支持被校验的数据类型,这里用Integer表示只支持整数型(使用Object可以支持任意类型)

    原文:the target type supported by an implementation

验证器代码编写:
注意: 不需要给该类添加@Component注解来持久化单实例对象,原因看这里验证器生命周期与实例化过程

public class UIDValidator implements ConstraintValidator<UID, Integer> {
    // 注解的对象实例
    private UID validUID;

    @Override
    public boolean isValid(Integer value, ConstraintValidatorContext context) {
        // 我这里使用了SpringSecurity来处理用户登录鉴权,可通过此方法获取已通过鉴权的用户对象实例
        User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();

        // 使用session的话可以采用以下方法获取user对象实例,根据项目实际情况修改session的Attribute名
        // User user = (User) ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest().getSession().getAttribute("user");

        if (validUID.value() == true && !user.isAdmin()) {
            return false;   // 若注解value为true时,非管理员用户直接拒绝
        } else if (user.getId() != value && !user.isAdmin()) {
            return false;   // 非管理员用户操作非自己ID的资源时拒绝
        } else {
            return true;    // 通过验证
        }
    }

    /**
     * 验证器被初始化时会执行的方法(类似于对象的构造方法,该方法会在验证器被实例化时执行)
     * @param constraintAnnotation 校验注解的对象实例
     */
    @Override
    public void initialize(UID constraintAnnotation) {
        validUID = constraintAnnotation;
    }
}

在控制器中使用

控制器代码

/**
 * 一个示例控制器
 * 若控制器类没有用@Validated标注,则需要在该对应方法参数中加@Valid注解才能使校验注解生效
 */
@RestController
@Validated
public FooController {

    /**
     * 删除一个用户
     * 注:这里给注解@UID传入了参数value,值为true
     */
    @DeleteMapping("/api/user/{uid}")
    public JsonResult deleteUser(@PathVariable @UID(true) int uid) {
        // 控制器代码
        return JsonResult.getInstance();
    }

    /**
     * 删除文件或目录
     */
    @DeleteMapping("/api/user/{uid}/file/**")
    public JsonResult delete(@PathVariable @UID int uid) throws IOException {
        // 控制器代码
        return JsonResult.getInstance();
    }
}

到这里,我们编写的自定义注解和表单验证器已经开始正常工作了,只有通过验证器验证处理的参数才能开始Web控制器的进一步处理。接下来的最后一步就是对无法通过表单验证的请求进行处理

处理校验拒绝

当表单校验不通过时,框架会抛出org.springframework.web.bind.MethodArgumentNotValidException异常,Spring会默认响应一个错误页面或RESTful风格的默认信息,我们也可以程自定义自己的错误响应。

捕获框架异常

既然已经得知表单验证被拒绝时会抛出MethodArgumentNotValidException异常,那么只需要在被@ControllerAdvice@RestControllerAdvice注解标注的类中编写对应的异常处理方法即可,以下是一个简单的样例

@RestControllerAdvice
public class ControllerAdvice {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public JsonResult validFormError(MethodArgumentNotValidException e) {

        // 当验证出错时,不会立即停止校验,而是继续执行其他需要被验证的参数验证
        // 因此我们可以通过这个异常对象的BindingResult拿到所有的验证错误的参数
        BindingResult bindingResult = e.getBindingResult();
        StringBuilder sb = new StringBuilder();

        /**
         * 我把所有错误信息用';'分割后来响应给前端
         * 这里的JsonResult是我自己封装的RESTful模板,根据实际项目情况灵活修改吧
         */
        bindingResult.getFieldErrors().forEach(error -> sb.append(error.getDefaultMessage()).append(";"));
        return JsonResult.getInstance(422, null, sb.toString());
    }

在被@ControllerAdvice@RestControllerAdvice注解标注的类方法中添加@ExceptionHandler注解就能标记当代码抛出异常时执行的方法。
该方法的返回值的作用与控制器的返回值作用相同。

验证器的生命周期与实例化过程

也许你注意到了,前面编写验证器的时候,并不需要使用@Component注解来将类注入到Spring IOC容器(当然,用了也不是不行,只是一般情况下这么做没有意义,浪费内存),那么验证器会以什么样的方式进行实例化呢,下面将对魔法进行解密

生命周期与实例化总结

先说结论吧(注解的groups和Payload均为默认空值的情况,非空时的情况我还没探究过,等我用到了且与本结论有出入再更新)

  • SpringBoot启动后,第一次验证参数时会对验证器进行实例化并缓存,下次需要验证时直接从缓存中获取验证器对象,生命周期为持久化的单例对象
  • 具体的实例化操作由Spring的BeanFactory执行,因此可以处理验证器的@Resource,@Autowired注解和构造器注入
  • 使用同一个注解时候,每个属性不相同的注解都会有一个对应的验证器

源码逻辑简易分析

参数验证是基于代理实现的,这个也是很容易想到实现方式。本文只关注实例化的过程
我用Debug跑了下,简单分析了源码和校验执行过程

当需要对参数进行验证时,ConstraintValidatorManagerImpl(ConstraintValidatorManager的默认实现类)会先从缓存中获取对应的验证器,关键代码如下:

// 创建根据注解信息和待验证数据的数据类型创建key
CacheKey key = new CacheKey( descriptor.getAnnotationDescriptor(), validatedValueType, constraintValidatorFactory, initializationContext );

// 尝试依据key从缓存中获取验证器实例对象
@SuppressWarnings("unchecked")
ConstraintValidator<A, ?> constraintValidator = (ConstraintValidator<A, ?>) constraintValidatorCache.get( key );

// 获取不到则创建并初始化验证器,并将其缓存
if ( constraintValidator == null ) {
    constraintValidator = createAndInitializeValidator( validatedValueType, descriptor, constraintValidatorFactory, initializationContext );
    constraintValidator = cacheValidator( key, constraintValidator );
} else {
    LOG.tracef( "Constraint validator %s found in cache.", constraintValidator );
}

而创建验证器的方法中,使用的BeanFactory是ConstraintValidatorFactory,这个类有个beanFactory属性,值为org.springframework.beans.factory.support.DefaultListableBeanFactory(spring框架默认的Bean工厂),由这个类把验证器作为一个Spring Bean去创建,至此就完成了验证器类的实例化。

由于使用的是Spring框架的Bean工厂进行创建,因此也会处理验证器中的@Resource@Autowired注解和构造器注入使得我们的验证器也可以很方便地获取IOC容器中的Bean

值得注意的是,这个缓存内部是使用Map来实现的,分析key的生成规则后可发现有两个关键的属性会影响key

  • descriptor.getAnnotationDescriptor() 注解的约束描述符信息,包含注解类名,各项属性和值,hashCode,注解实例对象(被代理)
  • validatedValueType 被验证属性的数据类型

因此,使用本文示例的注解时,@UID(true)@UID的属性value不相同,就会导致descriptor.getAnnotationDescriptor()的信息不一样,最终导致的结果就是会创建两个不同的验证器实例

验证器不需要@Component注解的解释

由于获取验证器时,框架是依据注解的各项属性值(约束描述符)来生成key,以key获取对应的验证器实例来执行验证逻辑的,而我们手动用@Component创建的实例对象并没有与ConstraintValidatorCache的key相关联,在验证的过程中不会被调用,只会白白浪费内存

参考文档