Spring 参数校验最佳实践及原理解析

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 背景在参数校验框架出现前,业务逻辑代码中经常会充斥着参数校验的代码,将参数校验视为业务逻辑的一部分不失为是一种较为灵活的做法,然而对于一些通用的校验,如字符串不能为空、数值范围限制等,如果还放在业务逻辑中,则会导致业务代码出现大量的重复,为了避免这种问题,Java 社区提出了 JSR-303 规范,用于对 bean 进行校验。

背景


在参数校验框架出现前,业务逻辑代码中经常会充斥着参数校验的代码,将参数校验视为业务逻辑的一部分不失为是一种较为灵活的做法,然而对于一些通用的校验,如字符串不能为空、数值范围限制等,如果还放在业务逻辑中,则会导致业务代码出现大量的重复,为了避免这种问题,Java 社区提出了 JSR-303 规范,用于对 bean 进行校验。


Spring 框架横空出世后,它又提出了一套更为简单易用的校验接口,校验作为 Spring 的核心特性之一,能够和 Spring 其他组件有机地整合到一起。


应用场景


Spring 内部主要将校验应用于 WEB 环境下参数绑定后的校验,并预留给用户一些接口用于常规校验。


常规参数校验


Spring 参数校验作为 spring-context 模块的一部分存在,Validator 是 Spring 参数校验的核心接口,我们先看下这个接口。


public interface Validator {
  // 当前验证器是否支持给定的类型
  boolean supports(Class<?> clazz);
  // 校验给定的 target,提供的 errors 对象用于存储和获取错误信息
  void validate(Object target, Errors errors);
}


接口中只存在两个方法,supports 方法用于校验前确认是否支持给定的类型,validate方法用于参数校验,比较奇怪的是校验结果使用参数中的 Errors 存储及获取。Validator 的类图如下。


image.png


Spring 未提供单独的 Validator 实现,而是将 JSR-303 中的 Validator 与 Spring Validator 整合到一起,SpringValidatorAdpter 作为 Spring Validator 与 JSR-303 Validator 的适配器,底层使用 JSR-303 作为实现,除此之外 使用最多的是 LocalValidatorFactoryBean,这个类可以配置创建 JSR-303 Validator 的参数。关于 JSR-303 java bean validation,你还可以参考《Java Bean Validation 详解》这篇文章,内容相对比较详实。


Errors 同样是一个相对重要的概念,其类图如下。


5.png


比较常用的 Errors 实现是 BeanPropertyBindingResult,这个类不仅表示用于表示校验结果,还表示数据绑定到对象的属性上的结果。


有了上述 Spring 校验相关的基础知识后,我们就可以在应用中使用 Spring Validator。首先需要引入 JSR-303 规范的实现,这里我们引入的是 hibernate-validator。


        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-validator</artifactId>
            <version>6.0.22.Final</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish</groupId>
            <artifactId>javax.el</artifactId>
            <version>3.0.1-b09</version>
        </dependency>


示例代码如下。


@Data
public class LoginDTO {
    @NotBlank(message = "用户名不能为空")
    private String username;
    @NotBlank(message = "密码不能为空")
    private String password;
}
public class App {
    public static void main(String[] args) {
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(App.class);
        Validator validator = context.getBean(LocalValidatorFactoryBean.class);
        LoginDTO loginDTO = new LoginDTO();
        Errors errors = new BeanPropertyBindingResult(loginDTO, "login");
        validator.validate(loginDTO, errors);
        errors.getAllErrors().forEach(System.out::println);
        context.close();
    }
    @Bean
    public LocalValidatorFactoryBean localValidatorFactoryBean() {
        return new LocalValidatorFactoryBean();
    }
}


打印结果如下。


Field error in object 'login' on field 'password': rejected value [null]; codes [NotBlank.login.password,NotBlank.password,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [login.password,password]; arguments []; default message [password]]; default message [密码不能为空]
Field error in object 'login' on field 'username': rejected value [null]; codes [NotBlank.login.username,NotBlank.username,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [login.username,username]; arguments []; default message [username]]; default message [用户名不能为空]


可以看到,成功将校验的结果存到了 Errors 中,如果你使用的是 spring-boot,还可以直接引入 spring-boot-starter-validation,Spring 会自动引入相关依赖并注册 LocalValidatorFactoryBean 作为 bean,此时可以直接把 Validator 注入 bean 中。


web 环境参数校验


web 环境下,Spring 会从 request 中获取相关数据,然后绑定到 conroller 方法的参数上。有关校验的参数,具体可以分为三类。


简单类型的参数


Spring 从 request 中根据参数名获取数据然后设置到参数中,支持的注解包括 @CookieValue、@MatrixVariable、@PathVariable、@RequestAttribute、@RequestHeader、@RequestParam、@SessionAttribute。


对于这类参数,可以设置注解的 required 参数来配置参数是否必须(默认必须),如果参数必须且未传参数则会抛出 ServletRequestBindingException 异常。示例代码如下。


@RestController
@RequestMapping("/user")
public class UserController {
    @PostMapping("/login")
    Result<LoginBO> login(@RequestParam(required = true) String username, @RequestParam(required = true) String password) {
        return null;
    }
}


那如果想对方法参数进行其他校验怎么办呢?Spring 内部还提供了一个 MethodValidationPostProcessor,这个处理器会利用 AOP 特性拦截标注了 @Validated 注解的 bean 的方法的执行,在目标方法执行前后执行校验。因此,将 MethodValidationPostProcessor 注册为 bean,然后在目标方法的参数上添加校验注解即可。


spring-boot 环境下可直接引入 spring-boot-starter-validation ,这个依赖同样会自动注册 MethodValidationPostProcessor 作为 bean。需要注意的是如果校验不通过抛出的是 JSR-303 规范中定义的 ConstraintViolationException 异常。


示例代码如下。


@Validated
@RestController
@RequestMapping("/user")
public class UserController {
    @PostMapping("/login")
    Result<LoginBO> login(@NotBlank String username, @NotBlank String password) {
        return null;
    }
}


非简单类型的表单参数


对于 get 请求或 x-www-form-urlencoded 类型的 post 请求,可以把单个简单类型的参数存入一个普通的 Java 对象中,将这个 Java 对象作为 controller 方法的参数,Spring 会自动从请求中获取参数信息然后实例化这个类并作为参数值传入方法中。


对于这类参数,在类的字段上添加校验相关注解,然后在 controller 方法参数上添加 @Validated 或 @Valid 或以 @Valid 开头命名的注解才会开启注解功能,此时如果校验不通过将会抛出 BindException 异常。校验的示例代码如下。


@Data
public class LoginDTO {
    @NotBlank(message = "用户名不能为空")
    private String username;
    @NotBlank(message = "密码不能为空")
    private String password;
}
@RestController
@RequestMapping("/user")
public class UserController {
    @PostMapping("/login")
    Result<LoginBO> login(@Validated LoginDTO dto) {
        return null;
    }
}


如果不想抛出异常,可以将要校验的方法参数的后一个参数设置为 Errors 类型或其子类型,由这个 Errors 接受参数校验结果,由于方法参数和数据绑定有关,通常我们可以设置为 BindingResult。示例代码如下。


@RestController
@RequestMapping("/user")
public class UserController {
    @PostMapping("/login")
    Result<LoginBO> login(@Validated LoginDTO dto, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            List<String> errorMessageList = bindingResult.getAllErrors().stream()
                    .map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.toList());
        }
        return null;
    }
}


非简单类型的其他请求体参数


除了表单类型,目前我们使用最多的请求内容类型是 application/json,校验方式和表单类型一样,在参数前添加 @Validated 开启校验,后一个参数使用 Errors 类型可以接收校验结果。与表单类型校验不用的是,如果后一个参数不是 Errors 类型,抛出的异常是 MethodArgumentNotValidException。示例代码如下。


@RestController
@RequestMapping("/user")
public class UserController {
    @PostMapping("/login")
    Result<LoginBO> login(@Validated @RequestBody LoginDTO dto) {
        return null;
    }
}


参数校验全局异常处理


手动使用 Validator 进行参数校验,或者在处理器方法参数中配置 Errors 类型的参数都可以直接取到校验结果,然而这种写法仍然和业务代码耦合在一起,虽然比较灵活,但是大多数情况如果校验不通过我们直接返回失败原因即可,因此对于这种通用的情况,我们可以定义一个全局的异常处理器,捕获 Spring 校验抛出的异常,并将异常转换为错误消息返回到前端。


@RestControllerAdvice
public class GlobalExceptionHandler {
    // 处理标注了 @Validated 的类的方法调用参数校验失败导致的异常
    @ExceptionHandler(ConstraintViolationException.class)
    public Result<?> handleConstraintViolationException(ConstraintViolationException e) {
        String message = e.getConstraintViolations().stream().map(ConstraintViolation::getMessage).collect(Collectors.joining("|"));
        return Result.fail(message);
    }
    // 处理表单类型请求普通参数缺失导致的异常
    @ExceptionHandler(ServletRequestBindingException.class)
    public Result<?> handleServletRequestBindingException(ServletRequestBindingException e) {
        String message = e.getMessage();
        return Result.fail(message);
    }
    // 处理表单类型请求的复杂参数校验失败导致的异常
    @ExceptionHandler(BindException.class)
    public Result<?> handleBindException(BindException e) {
        String message = e.getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining("|"));
        return Result.fail(message);
    }
    // 处理 application/json 类型请求的参数校验失败导致的异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining("|"));
        return Result.fail(message);
    }
}


Spring Web 环境 Controller 方法参数校验原理解析


Spring 允许 Controller 处理器方法上定义请求相关的不同参数,Spring 在调用处理器方法时需要收集参数值,为此,Spring 定义了一个 HandlerMethodArgumentResolver 接口用于根据参数元数据解析出参数值,解析参数值时同时也会对参数值进行校验,这个接口最终会被 DispatchServlet 间接调用。


先看 HandlerMethodArgumentResolver 的接口定义。


public interface HandlerMethodArgumentResolver {
  // 是否支持参数
  boolean supportsParameter(MethodParameter parameter);
  // 解析参数值
  Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
               NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;
}


接口比较简单,只有两个方法,如果支持给定的方法参数,则会解析参数值,像常见的 @RequestParam、@RequestBody 就是由不同的实现处理的。以处理 @RequestBody 注解参数的 RequestResponseBodyMethodProcessor 为例,其参数校验的核心代码如下。


public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
  @Override
  public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
                  NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
    parameter = parameter.nestedIfOptional();
    Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
    String name = Conventions.getVariableNameForParameter(parameter);
    if (binderFactory != null) {
      WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
      if (arg != null) {
        // 解析出参数后进行参数校验
        validateIfApplicable(binder, parameter);
        if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
          throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
        }
      }
      if (mavContainer != null) {
        mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
      }
    }
    return adaptArgumentIfNecessary(arg, parameter);
  }
}
public abstract class AbstractMessageConverterMethodArgumentResolver implements HandlerMethodArgumentResolver {
  // 参数校验
  protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
    Annotation[] annotations = parameter.getParameterAnnotations();
    for (Annotation ann : annotations) {
      Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
      if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
        Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
        Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[]{hints});
        binder.validate(validationHints);
        break;
      }
    }
  }
}


从这里可以看到 Spring 先解析参数值,解析后进行参数校验,并且还可以看出 Spring 会把 @Validated 的 value 参数值作为校验分组,而 @Valid 注解则无法指定分组,这也是 @Validated 和 @Valid 在 Spring 中的不同之处。


总结

本篇主要介绍了 Spring 使用 Validator 的各种方式,包括手动调用 Validator 接口方法校验、通过 MethodValidationPostProcessor 拦截目标对象方法执行校验、以及 web 环境下 Spring 内部自动进行的数据绑定和参数校验。数据绑定除了应用在 web 环境的处理器方法参数,还会应用到 Spring 内部的其他地方,是 Spring 的核心特性之一,下篇进行分析。


目录
相关文章
|
13天前
|
运维 持续交付 云计算
深入解析云计算中的微服务架构:原理、优势与实践
深入解析云计算中的微服务架构:原理、优势与实践
43 1
|
17天前
|
Java 开发者 Spring
Spring AOP 底层原理技术分享
Spring AOP(面向切面编程)是Spring框架中一个强大的功能,它允许开发者在不修改业务逻辑代码的情况下,增加额外的功能,如日志记录、事务管理等。本文将深入探讨Spring AOP的底层原理,包括其核心概念、实现方式以及如何与Spring框架协同工作。
|
14天前
|
前端开发 Java 开发者
Spring MVC中的请求映射:@RequestMapping注解深度解析
在Spring MVC框架中,`@RequestMapping`注解是实现请求映射的关键,它将HTTP请求映射到相应的处理器方法上。本文将深入探讨`@RequestMapping`注解的工作原理、使用方法以及最佳实践,为开发者提供一份详尽的技术干货。
40 2
|
14天前
|
前端开发 Java Spring
探索Spring MVC:@Controller注解的全面解析
在Spring MVC框架中,`@Controller`注解是构建Web应用程序的基石之一。它不仅简化了控制器的定义,还提供了一种优雅的方式来处理HTTP请求。本文将全面解析`@Controller`注解,包括其定义、用法、以及在Spring MVC中的作用。
33 2
|
15天前
|
前端开发 Java Maven
深入解析:如何用 Spring Boot 实现分页和排序
深入解析:如何用 Spring Boot 实现分页和排序
34 2
|
14天前
|
前端开发 Java 开发者
Spring MVC中的控制器:@Controller注解全解析
在Spring MVC框架中,`@Controller`注解是构建Web应用程序控制层的核心。它不仅简化了控制器的定义,还提供了灵活的请求映射和处理机制。本文将深入探讨`@Controller`注解的用法、特点以及在实际开发中的应用。
31 0
|
14天前
|
存储 供应链 算法
深入解析区块链技术的核心原理与应用前景
深入解析区块链技术的核心原理与应用前景
39 0
|
15天前
|
Java 测试技术 数据库连接
使用Spring Boot编写测试用例:实践与最佳实践
使用Spring Boot编写测试用例:实践与最佳实践
38 0
|
24天前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
64 2
|
2月前
|
缓存 Java 程序员
Map - LinkedHashSet&Map源码解析
Map - LinkedHashSet&Map源码解析
72 0

推荐镜像

更多