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

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


目录
相关文章
|
6天前
|
安全 Java 数据库
一天十道Java面试题----第四天(线程池复用的原理------>spring事务的实现方式原理以及隔离级别)
这篇文章是关于Java面试题的笔记,涵盖了线程池复用原理、Spring框架基础、AOP和IOC概念、Bean生命周期和作用域、单例Bean的线程安全性、Spring中使用的设计模式、以及Spring事务的实现方式和隔离级别等知识点。
|
4天前
|
Java
Spring5入门到实战------9、AOP基本概念、底层原理、JDK动态代理实现
这篇文章是Spring5框架的实战教程,深入讲解了AOP的基本概念、如何利用动态代理实现AOP,特别是通过JDK动态代理机制在不修改源代码的情况下为业务逻辑添加新功能,降低代码耦合度,并通过具体代码示例演示了JDK动态代理的实现过程。
Spring5入门到实战------9、AOP基本概念、底层原理、JDK动态代理实现
|
4天前
|
XML Java 数据格式
Spring5入门到实战------2、IOC容器底层原理
这篇文章深入探讨了Spring5框架中的IOC容器,包括IOC的概念、底层原理、以及BeanFactory接口和ApplicationContext接口的介绍。文章通过图解和实例代码,解释了IOC如何通过工厂模式和反射机制实现对象的创建和管理,以及如何降低代码耦合度,提高开发效率。
Spring5入门到实战------2、IOC容器底层原理
|
6天前
|
Java 程序员 数据库连接
女朋友不懂Spring事务原理,今天给她讲清楚了!
该文章讲述了如何解释Spring事务管理的基本原理,特别是针对女朋友在面试中遇到的问题。文章首先通过一个简单的例子引入了传统事务处理的方式,然后详细讨论了Spring事务管理的实现机制。
女朋友不懂Spring事务原理,今天给她讲清楚了!
|
4天前
|
XML Java 数据格式
Spring Cloud全解析:注册中心之zookeeper注册中心
使用ZooKeeper作为Spring Cloud的注册中心无需单独部署服务器,直接利用ZooKeeper服务端功能。项目通过`spring-cloud-starter-zookeeper-discovery`依赖实现服务注册与发现。配置文件指定连接地址,如`localhost:2181`。启动应用后,服务自动注册到ZooKeeper的`/services`路径下,形成临时节点,包含服务实例信息。
|
4天前
|
XML Java 数据库
Spring5入门到实战------15、事务操作---概念--场景---声明式事务管理---事务参数--注解方式---xml方式
这篇文章是Spring5框架的实战教程,详细介绍了事务的概念、ACID特性、事务操作的场景,并通过实际的银行转账示例,演示了Spring框架中声明式事务管理的实现,包括使用注解和XML配置两种方式,以及如何配置事务参数来控制事务的行为。
Spring5入门到实战------15、事务操作---概念--场景---声明式事务管理---事务参数--注解方式---xml方式
|
4天前
|
XML Java 数据格式
Spring5入门到实战------2、IOC容器底层原理
这篇文章深入探讨了Spring5框架中的IOC容器,包括IOC的概念、底层原理、以及BeanFactory接口和ApplicationContext接口的介绍。文章通过图解和实例代码,解释了IOC如何通过工厂模式和反射机制实现对象的创建和管理,以及如何降低代码耦合度,提高开发效率。
Spring5入门到实战------2、IOC容器底层原理
|
15天前
|
Java API 数据格式
Spring Boot API参数读取秘籍大公开!6大神器助你秒变参数处理大师,让你的代码飞起来!
【8月更文挑战第4天】Spring Boot凭借其便捷的开发和配置特性,成为构建微服务的热门选择。高效处理HTTP请求参数至关重要。本文介绍六种核心方法:查询参数利用`@RequestParam`;路径变量采用`@PathVariable`;请求体通过`@RequestBody`自动绑定;表单数据借助`@ModelAttribute`或`@RequestParam`;请求头使用`@RequestHeader`;Cookie则依靠`@CookieValue`。每种方法针对不同场景,灵活运用可提升应用性能与用户体验。
39 9
|
17天前
|
XML 前端开发 Java
Spring MVC接收param参数(直接接收、注解接收、集合接收、实体接收)
Spring MVC提供了灵活多样的参数接收方式,可以满足各种不同场景下的需求。了解并熟练运用这些基本的参数接收技巧,可以使得Web应用的开发更加方便、高效。同时,也是提高代码的可读性和维护性的关键所在。在实际开发过程中,根据具体需求选择最合适的参数接收方式,能够有效提升开发效率和应用性能。
43 3
|
18天前
|
XML 前端开发 Java
Spring MVC接收param参数(直接接收、注解接收、集合接收、实体接收)
Spring MVC提供了灵活多样的参数接收方式,可以满足各种不同场景下的需求。了解并熟练运用这些基本的参数接收技巧,可以使得Web应用的开发更加方便、高效。同时,也是提高代码的可读性和维护性的关键所在。在实际开发过程中,根据具体需求选择最合适的参数接收方式,能够有效提升开发效率和应用性能。
41 2

热门文章

最新文章

推荐镜像

更多