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

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 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 的核心特性之一,下篇进行分析。


目录
相关文章
|
14天前
|
存储 缓存 算法
HashMap深度解析:从原理到实战
HashMap,作为Java集合框架中的一个核心组件,以其高效的键值对存储和检索机制,在软件开发中扮演着举足轻重的角色。作为一名资深的AI工程师,深入理解HashMap的原理、历史、业务场景以及实战应用,对于提升数据处理和算法实现的效率至关重要。本文将通过手绘结构图、流程图,结合Java代码示例,全方位解析HashMap,帮助读者从理论到实践全面掌握这一关键技术。
55 13
|
2月前
|
运维 持续交付 云计算
深入解析云计算中的微服务架构:原理、优势与实践
深入解析云计算中的微服务架构:原理、优势与实践
70 1
|
7天前
|
设计模式 XML Java
【23种设计模式·全精解析 | 自定义Spring框架篇】Spring核心源码分析+自定义Spring的IOC功能,依赖注入功能
本文详细介绍了Spring框架的核心功能,并通过手写自定义Spring框架的方式,深入理解了Spring的IOC(控制反转)和DI(依赖注入)功能,并且学会实际运用设计模式到真实开发中。
【23种设计模式·全精解析 | 自定义Spring框架篇】Spring核心源码分析+自定义Spring的IOC功能,依赖注入功能
|
14天前
|
NoSQL Java Redis
Spring Boot 自动配置机制:从原理到自定义
Spring Boot 的自动配置机制通过 `spring.factories` 文件和 `@EnableAutoConfiguration` 注解,根据类路径中的依赖和条件注解自动配置所需的 Bean,大大简化了开发过程。本文深入探讨了自动配置的原理、条件化配置、自定义自动配置以及实际应用案例,帮助开发者更好地理解和利用这一强大特性。
63 14
|
8天前
|
网络协议 安全 网络安全
探索网络模型与协议:从OSI到HTTPs的原理解析
OSI七层网络模型和TCP/IP四层模型是理解和设计计算机网络的框架。OSI模型包括物理层、数据链路层、网络层、传输层、会话层、表示层和应用层,而TCP/IP模型则简化为链路层、网络层、传输层和 HTTPS协议基于HTTP并通过TLS/SSL加密数据,确保安全传输。其连接过程涉及TCP三次握手、SSL证书验证、对称密钥交换等步骤,以保障通信的安全性和完整性。数字信封技术使用非对称加密和数字证书确保数据的机密性和身份认证。 浏览器通过Https访问网站的过程包括输入网址、DNS解析、建立TCP连接、发送HTTPS请求、接收响应、验证证书和解析网页内容等步骤,确保用户与服务器之间的安全通信。
51 1
|
13天前
|
JSON 自然语言处理 Java
OpenAI API深度解析:参数、Token、计费与多种调用方式
随着人工智能技术的飞速发展,OpenAI API已成为许多开发者和企业的得力助手。本文将深入探讨OpenAI API的参数、Token、计费方式,以及如何通过Rest API(以Postman为例)、Java API调用、工具调用等方式实现与OpenAI的交互,并特别关注调用具有视觉功能的GPT-4o使用本地图片的功能。此外,本文还将介绍JSON模式、可重现输出的seed机制、使用代码统计Token数量、开发控制台循环聊天,以及基于最大Token数量的消息列表限制和会话长度管理的控制台循环聊天。
102 7
|
2月前
|
存储 缓存 监控
后端开发中的缓存机制:深度解析与最佳实践####
本文深入探讨了后端开发中不可或缺的一环——缓存机制,旨在为读者提供一份详尽的指南,涵盖缓存的基本原理、常见类型(如内存缓存、磁盘缓存、分布式缓存等)、主流技术选型(Redis、Memcached、Ehcache等),以及在实际项目中如何根据业务需求设计并实施高效的缓存策略。不同于常规摘要的概述性质,本摘要直接点明文章将围绕“深度解析”与“最佳实践”两大核心展开,既适合初学者构建基础认知框架,也为有经验的开发者提供优化建议与实战技巧。 ####
|
28天前
|
监控 数据管理 测试技术
API接口自动化测试深度解析与最佳实践指南
本文详细介绍了API接口自动化测试的重要性、核心概念及实施步骤,强调了从明确测试目标、选择合适工具、编写高质量测试用例到构建稳定测试环境、执行自动化测试、分析测试结果、回归测试及集成CI/CD流程的全过程,旨在为开发者提供一套全面的技术指南,确保API的高质量与稳定性。
|
26天前
|
PHP 开发者 容器
PHP命名空间深度解析及其最佳实践####
本文深入探讨了PHP中引入命名空间的重要性与实用性,通过实例讲解了如何定义、使用及别名化命名空间,旨在帮助开发者有效避免代码冲突,提升项目的模块化与可维护性。同时,文章还涉及了PHP-FIG标准,引导读者遵循最佳实践,优化代码结构,促进团队协作效率。 ####
25 1
|
30天前
|
Java 数据库连接 开发者
Java中的异常处理机制:深入解析与最佳实践####
本文旨在为Java开发者提供一份关于异常处理机制的全面指南,从基础概念到高级技巧,涵盖try-catch结构、自定义异常、异常链分析以及最佳实践策略。不同于传统的摘要概述,本文将以一个实际项目案例为线索,逐步揭示如何高效地管理运行时错误,提升代码的健壮性和可维护性。通过对比常见误区与优化方案,读者将获得编写更加健壮Java应用程序的实用知识。 --- ####

推荐镜像

更多