Springboot-参数校验

215 阅读11分钟

概述

在日常的接口开发中,为了防止非法参数对业务造成影响,经常需要对接口的参数做校验。例如登录的时候需要校验用户名和密码是否为空,创建用户的时候需要校验邮件、手机号格式是否准确。靠代码对接口参数一个一个校验的话就太繁琐了,代码可读性极差。

JSR303简介

JSR:Java Specification RequestsJava规范提案),指的是向JCP,也就是Java Community Process提出新增一个标准化技术规范的正式请求.任何人都可以提交JSR,来向Java平台新增API和服务

JSR 303的含义:

  • JavaEE 6中的一项子规范,叫作Bean Validation
  • Hibernate Validatior 是 Bean Validation 的接口实现
  • Hibernate Validator 提供JSR 303规范中所有内置约束constraint的实现,而且还有一些附加的约束constraint,比如@Length、@Email等

JSR 303的作用:

  • 用于对Java Bean的字段值进行校验,确保输入的数据在语义上的正确性,使得验证逻辑和业务代码相分离
  • JSR 303是运行时数据验证框架,验证后的错误信息会立即返回

总的来说:Java API 规范(JSR303) 定义了 Bean 校验的标准 validation-api,但却没有提供实现。hibernate validation 是对这个规范的实现,并增加了校验注解如等

Hibernate官网

依赖导入

Spring Validation 是对 hibernate validation 的二次封装,用于支持 Spring mvc 参数自动校验

  • Springboot版本小于2.3.x,spring-boot-starter-web会自动传入hibernate-validator依赖
  • Springboot版本大于2.3.x,则需要手动引入依赖
  • 没有正确引入依赖,不会对参数进行校验
<!--validation-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

spring-boot-starter-validation依赖了hibernate-validator,hibernate-validator依赖了jakarta.validation-api

image-20230818161503820

在编写的的示例代码中,会发现主要使用到了javax.validation.constraint包下的注解,而这个包主要来自于 jakarta.validation-api 这个依赖。

image-20230818161656585

如果引入依赖的时候直接引入 jakarta.validation-api 是无法实现参数校验功能的,因为它只定义了规范,而没有具体实现。但是 hibernate-validator 实现了这个规范,直接引入 hibernate-validator也是可以实现参数校验功能的

常用验证注解

空值检查
注解说明
@NotBlank用于字符串,字符串不能为null 也不能为空字符串
@NotEmpty字符串同上,对于集合(Map,List,Set)不能为空,必须有元素
@NotNull不能为 null
@Null必须为 null
数值检查
注解说明
@DecimalMax(value)被注释的元素必须为数字,其值必须小于等于指定的值
@DecimalMin(value)被注释的元素必须为数字,其值必须大于等于指定的值
@Digits(integer, fraction)被注释的元素必须为数字,其值的整数部分精度为 integer,小数部分精度为 fraction
@Positive被注释的元素必须为正数
@PositiveOrZero被注释的元素必须为正数或 0
@Max(value)被注释的元素必须小于等于指定的值
@Min(value)被注释的元素必须大于等于指定的值
@Negative被注释的元素必须为负数
@NegativeOrZero被注释的元素必须为负数或 0
Boolean 检查
注解说明
@AssertFalse被注释的元素必须值为 false
@AssertTrue被注释的元素必须值为 true
长度检查
注解说明
@Size(min,max)被注释的元素长度必须在 minmax 之间,可以是 String、Collection、Map、数组
日期检查
注解说明
@Future被注释的元素必须是一个将来的日期
@FutureOrPresent被注释的元素必须是现在或者将来的日期
@Past被注释的元素必须是一个过去的日期
@PastOrPresent被注释的元素必须是现在或者过去的日期
其他检查
注解说明
@Email被注释的元素必须是电子邮箱地址
@Pattern(regexp)被注释的元素必须符合正则表达
整体的注解

image-20230818162305149

image-20230822154048295

如何使用注解

基本描述

对于 web 服务来说,为了防止非法参数对业务造成影响,在 Controller 层做参数校验必不可少,大部分情况下,请求参数分为三种

  • POST、PUT 请求:使用 RequestBody 传递参数
  • GET 请求:使用 RequestParam、PathVariable 传递参数
  • 表单请求:直接使用DTO 传递参数
RequestParam/PathVariable

GET 请求一般会使用 RequestParam、PathVariable 传参,如果参数较多(比如超过6个),推荐使用 DTO 对象接收

在 Controller 类上标注 @Validated注解,并在入参上声明约束注解(如@Max等),如果校验失败,会抛出 ConstraintViolationException 异常

@Slf4j
@Api(tags = "Auth")
@Validated
@RestController
@RequestMapping("/api/auth")
public class AuthController {
    @ApiOperation(value = "测试Get PathVariable请求", httpMethod = "GET")
    @GetMapping("/test/pathVariable/{id}")
    public ResponseUtil<String> getPathVariable(@Max(value = 3,message = "id 异常") @PathVariable Integer id ) {
        log.info("id: {}", id);
​
        return ResponseUtil.success("get success");
    }
​
    @ApiOperation(value = "测试Get RequestParam请求", httpMethod = "GET")
    @GetMapping("/test/requestParam")
    public ResponseUtil<String> getRequestParam(@Max(value = 3,message = "id 异常") @RequestParam("id") Integer id ) {
        log.info("id: {}", id);
​
        return ResponseUtil.success("get success");
    }
}
RequestBody 参数校验
  • POST、PUT 请求一般使用 RequestBody 传递参数,这种情况下,后端通常使用 DTO(Data Transfer Object-数据传输对象)进行接收

  • 在 DTO 对象的属性上设置 @NotBlank、@Email 等约束注解

    @Data
    public class RequestBodyDTO {
        @NotBlank(message = "用户名不能为空")
        @Email(message = "邮箱格式不正确")
        private String email;
    ​
        @NotBlank(message = "密码不能为空")
        private String password;
    ​
        @NotBlank(message = "用户名不能为空")
        private String username;
    }
    
  • 在控制层请求方法的入参前加上 @Validated或@Valid注解表示对此 DTO 对象的属性开启校验。(只有实体类属性上加了约束注解且控制层方法入参前开启校验才会生效,此时类上有没有 @Validated 注解都无所谓)

    @ApiOperation(value = "测试Post requestBody请求", httpMethod = "POST")
    @PostMapping("/test/requestBody")
    public ResponseUtil<String> postRequestBody(@Validated @RequestBody RequestBodyDTO dto) {
      log.info("dto: {}", dto);
    ​
      return ResponseUtil.success("get success");
    }
    
  • 如果校验失败,后台会抛出 MethodArgumentNotValidException 异常,Spring mvc 默认会将其转为 400(Bad Request) 请求,并提供详细的错误信息,比如:不能为 null,长度需要在 min 和 max 之间 等等。

表单认证
  • 在 DTO 对象的属性上设置 @NotNull、@Length 等约束注解

    @Data
    public class FormDTO {
        @NotBlank(message = "用户名不能为空")
        @Email(message = "邮箱格式不正确")
        private String email;
    ​
        @NotBlank(message = "密码不能为空")
        private String password;
    ​
        @NotBlank(message = "用户名不能为空")
        private String username;
    }
    
  • 在控制层请求方法的入参前加上 @Validated或@Valid 注解表示对此 DTO 对象的属性开启校验

    @ApiOperation(value = "测试Get From请求", httpMethod = "GET")
    @GetMapping("/test/getForm")
    public ResponseUtil<String> getForm(@Validated FormDTO dto) {
      log.info("dto: {}", dto);
    ​
      return ResponseUtil.success("get success");
    }
    
  • 如果校验失败,会抛出 BindException 异常
嵌套认证

如果 DTO 对象中的某个属性关联了其它 DTO 对象,则可以使用嵌套校验,被关联的 DTO 属性上使用 @Valid 注解再加上其它约束注解

  • 在入参上添加@Validated注解,并在DTO中声明约束注解(如@Min等)
  • 在嵌套的属性上添加@Valid注解,如果校验失败,会抛出 BindException 异常
@Data
public class RequestBodyDTO {
    @NotBlank(message = "用户名不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;
​
    @NotBlank(message = "密码不能为空")
    private String password;
​
    @NotBlank(message = "用户名不能为空")
    private String username;
​
    @Valid
    private Job job;
  
    @Data
    public static class Job {
        @NotBlank(message = "公司不能为空")
        private String company;
​
        @NotBlank(message = "职位不能为空")
        private String position;
    }
}

异常单独处理

如果校验失败Spring Validation内部会直接抛出异常,只需要在Controller方法上添加一个参数 BindingResult 即可(但只能用于@RequestBody/@RequestPart的后面)

@ApiOperation(value = "测试Post =", httpMethod = "POST")
@PostMapping("/test/requestBody")
public ResponseUtil<String> postRequestBody(@Validated @RequestBody RequestBodyDTO dto, BindingResult bindingResult) {
  log.info("dto: {}", dto);
​
  // bindingResult.hasErrors() 有值则表示校验未通过
  if (bindingResult.hasErrors()) {
    String message = bindingResult
      .getAllErrors()
      .stream()
      .map(DefaultMessageSourceResolvable::getDefaultMessage)
      .findFirst()
      .orElse("invalid param");
​
    return ResponseUtil.fail(message);
  }
​
  return ResponseUtil.success("post success");
}

分组异常校验(不推荐)

在实际项目中可能多个控制层方法需要使用同一个 DTO 类来接收参数,而不同方法的校验规则很可能是不一样的。比如保存的时候主键是可为空的,而更新的时候,主键不允许为空。

这个时候需要使用约束注解的 groups 属性,它用于对约束注解进行分组。

  • 创建公共的分组类
public class GroupValidator {
    /**
     * 新增
    **/
    public interface Insert {}
​
    /**
     * 更新
     **/
    public interface Update {}
​
    /**
     * 删除
     **/
    public interface Delete {}
​
    @GroupSequence({Insert.class, Update.class, Delete.class})
    public interface All {}
}
  • 创建DTO并添加约束注解
@Data
public class RequestBodyDTO {
    @NotBlank(message = "用户名不能为空",groups = {GroupValidator.Insert.class,GroupValidator.Update.class})
    @Email(message = "邮箱格式不正确",groups = {GroupValidator.Insert.class})
    private String email;
}

@Validated 注解上指定校验分组

  /**
     * RequestBody DTO 参数校验时,@Validated 必须标注在入参前,类上面是不生效的
     * Validated 注解的 value 属性是一个 Class 数组,用于指定校验分组。
     * Validated 指定了校验的分组时,只会对目标分组的属性校验,标记了约束注解,但是未分组的 DTO 属性也不会校验。
     * Validated 未指定校验的分组时,则只会对 DTO 属性上的未分组约束注解进行校验。
     */
    @PostMapping("/requestBody/save")
    public ResultData<UserDTO> saveUser(@RequestBody @Validated({GroupValidator.Insert.class}) RequestBodyDTO dto) {
        // 校验通过,才会执行业务逻辑处理
        System.out.println("dto=" + dto);
        
        return ResponseUtil.success("post success");
    }
 
    @PostMapping("/requestBody/update")
    public ResultData<UserDTO> updateUser(@RequestBody @Validated({GroupValidator.Update.class}) RequestBodyDTO dto) {
        // 校验通过,才会执行业务逻辑处理
        System.out.println("dto=" + dto);
        
        return ResponseUtil.success("post success");
    }

自定义验证器

虽然原生包 javax.validation.constraints 包、以及 hibernate 扩展包 org.hibernate.validator.constraints 下已经提供了许多常用的约束注解,但通常还是很难满足业务需求,此时可以自定义校验

自定义 Spring validation 非常简单,一共两步:

  • 自定义约束注解(比如官方的 @NotBlank、@URL)
  • 实现 ConstraintValidator 接口编写约束校验器(比如官方的 NotBlankValidator、link URLValidator)。
自定义约束注解
package com.fs.project.application.validates;
​
/**
 * @author: smile
 * @title:
 * @projectName:
 * @description: TODO
 * @date: 2023/8/21 3:35 下午
 */import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
​
import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
​
@Documented
@Constraint(validatedBy = {MobileNumberValidator.class})
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
public @interface MobileNumber {
    //校验未通过时,默认错误信息
    String message() default "手机号码";
​
    //校验分组
    Class<?>[] groups() default {};
​
    //负载
    Class<? extends Payload>[] payload() default {};
​
    //注解描述信息
    String dec() default "";
}
自定义约束器
package com.fs.project.common.validates;
​
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
​
/**
 * @author: smile
 * @title:
 * @projectName:
 * @description: TODO
 * @date: 2023/8/21 3:40 下午
 */
public class MobileNumberValidator implements ConstraintValidator<MobileNumber, String> {
    /**
     * 验证手机号正则表达式
     **/
    private static final Pattern MOBILE_PATTERN = Pattern.compile("1[3-9]\d{9}");
​
    /**
     * 校验初始化方法,先于  isValid 方法执行
     * 用于做一些校验之前的初始化操作,比如获取自定义约束注解上的属性值
    **/
    @Override
    public void initialize(MobileNumber constraintAnnotation) {
    }
​
    /**
     * 返回 true 表示校验通过,返回 false 表示未通过
     **/
    @Override
    public boolean isValid(String mobile, ConstraintValidatorContext constraintValidatorContext) {
        if (mobile == null || mobile.length() == 0) {
            return false;
        }
​
        Matcher matcher = MOBILE_PATTERN.matcher(mobile);
​
        return matcher.matches();
    }
}
测试自定义验证器
@Data
public class FormDTO {
    @MobileNumber(message = "手机号码不正确")
    private String mobileNumber;
}

image-20230821155037747

全局统一异常处理

如果校验失败,会抛出MethodArgumentNotValidException、 ConstraintViolationException 、BindException异常,整个请求状态码变成了 400,这显然是不合适的

而要求系统无论发送什么异常,http 的状态码必须返回 200,用户的请求必须成功,再由业务码和消息进行区分并提示

  • ConstraintViolationException
public class ConstraintViolationException extends ValidationException {}
  • MethodArgumentNotValidException
public class MethodArgumentNotValidException extends BindException {}

创建集中处理器

@Slf4j
@RestControllerAdvice
public class GlobalExceptionConfig {
    /**
     * 参数异常
     * 请求参数异常
     **/
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ApiResult<Map<String, String>> methodArgumentNotValidException(MethodArgumentNotValidException exception) {
        Map<String, String> errors = methodArgumentNotValidExceptionDescribe(exception);
​
        // 提取第一条错误信息作为 message
        String message = errors.values().stream()
                .findFirst()
                .orElse(ServiceError.PARAMETERS_INVALID.getMessage());
​
        return fail(errors, message, ServiceError.PARAMETERS_INVALID);
    }
​
    /**
     * 格式化请求参数异常
     **/
    private Map<String, String> methodArgumentNotValidExceptionDescribe(MethodArgumentNotValidException exception) {
        return exception.getBindingResult()
                .getFieldErrors()
                .stream()
                .filter(fieldError -> StringUtils.isNotBlank(fieldError.getDefaultMessage()))
                .collect(Collectors.toMap(
                        FieldError::getField,
                        FieldError::getDefaultMessage,
                        (o1, o2) -> o1
                ));
    }
    
    /**
     * 参数校验 异常
     **/
    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    private ApiResult<Map<String, String>> constraintViolationException(ConstraintViolationException exception) {
        Map<String, String> errors = constraintViolationExceptionDescribe(exception);
​
        String message = errors.values().stream()
                .findFirst()
                .orElse(ServiceError.PARAMETERS_INVALID.getMessage());
​
        return fail(errors,message,ServiceError.PARAMETERS_INVALID);
    }
​
    /**
     * 格式化请求参数异常
     **/
    private Map<String, String> constraintViolationExceptionDescribe(ConstraintViolationException exception) {
        return exception.getConstraintViolations()
                .stream()
                .filter(fieldError -> StringUtils.isNotEmpty(fieldError.getMessage()))
                .collect(Collectors.toMap(filed -> {
                    String filedName = filed.getPropertyPath().toString();
​
                    if (StringUtils.isBlank(filedName)) {
                        filedName = "unknown";
                    } else {
                        String[] parts = filedName.split("\.");
                        filedName =  parts.length > 0 ? parts[parts.length - 1] : filedName;
                    }
​
                    return filedName;
                }, ConstraintViolation::getMessage,(o1,o2) -> o1));
    }
}

@Validated、@Valid区别

基本概念
项目@Valid@Validated
来源Jakarta Bean Validation 规范(jakarta.validation.Valid 或旧版 javax.validation.ValidSpring 框架(org.springframework.validation.annotation.Validated
触发机制触发 Bean Validation 规则(JSR 303/380)在 Spring 容器中触发 Bean Validation,增强了分组和方法级别参数校验能力
依赖必须有 Bean Validation 实现(如 Hibernate Validator)依赖同上,Spring Boot 默认已引入 Hibernate Validator
功能差异
功能@Valid@Validated
Bean 字段校验✅ 支持✅ 支持
嵌套对象递归校验✅ 需要在属性上加 @Valid✅ 需要在属性上加 @Valid
方法参数校验(对象类型)✅ 支持✅ 支持
方法参数校验(简单类型,如 @RequestParam @PathVariable❌ 不支持✅ 支持
分组校验❌ 不支持✅ 支持,@Validated(Group.class)
方法返回值校验✅ 支持✅ 支持
嵌套(递归)校验规则
  • 无论用 @Valid 还是 @Validated,递归校验的前提:

    1. 嵌套属性必须标注 @Valid
    2. Spring MVC 数据绑定后,该属性不为 null(否则不会进入递归)
    3. 如果要强制非空,需在嵌套属性上额外加 @NotNull

    示例:

    @Data
    public class UserDTO {
        @NotBlank
        private String username;
    ​
        @Valid // 递归关键
        @NotNull(message = "profile 不能为空")
        private Profile profile;
    }
    ​
    @Data
    public class Profile {
        @NotBlank
        private String address;
    }
    
经验建议

DTO 请求体@RequestBody)校验 → @Validated

简单类型参数@RequestParam / @PathVariable) → @Validated

需要分组@Validated

嵌套对象要递归校验 → 嵌套字段必须加 @Valid

参考

blog.csdn.net/wangmx19933…