# 一、JSR303 校验

# 1.1、统一校验的需求

前端请求后端接口传输参数,是在 controller 中校验还是在 service 中校验

答案是都需要校验,只是分工不同

controller 中校验请求参数的合法性,包括:必填项校验,数据格式校验,比如:是否符合一定的日期格式,等。

service 中要校验的是业务规则相关的内容,比如:课程已经审核通过所以提交失败

service 中根据业务规则去校验不方便写成通用代码,controller 中则可以将校验代码的代码写成通用代码

早在 JavaEE6 规范中就定义了参数校验的规范,它就是 JSR-303,它定义了 Bean Validation,即对 bean 属性进行校验

SpringBoot 提供了 JSR-303 的支持,它就是 spring-boot-starter-validation,它的底层使用 Hibernate Validator,Hibernate Validator 是 Bean Validation 的参考实现

所以,我们准备在 Controller 层使用 spring-boot-starter-validation 完成对请求参数的基本合法性进行校验

# 基本校验规则

1.@NotNull

不能为 null,但可以为 empty,一般用在 Integer 类型的基本数据类型的非空校验上,而且被其标注的字段可以使用 @size、@Max、@Min 对字段数值进行大小的控制

2.@NotEmpty

不能为 null,且长度必须大于 0,一般用在集合类上或者数组上

3.@NotBlank

只能作用在接收的 String 类型上,注意是只能,不能为 null,而且调用 trim () 后,长度必须大于 0 即:必须有实际字符

  • 注意在使用 @NotBlank 等注解时,一定要和 @valid 一起使用,否则 @NotBlank 不起作用。
  • 一个 BigDecimal 的字段使用字段校验标签应该为 @NotNull。
  • 在使用 @Length 一般用在 String 类型上可对字段数值进行最大长度限制的控制。
  • 在使用 @Range 一般用在 Integer 类型上可对字段数值进行大小范围的控制

如下图

image-20240607085616569

String name = null;
@NotNull: false
@NotEmpty:false
@NotBlank:false
String name = “”;
@NotNull:true
@NotEmpty: false
@NotBlank: false
String name = " ";
@NotNull: true
@NotEmpty: true
@NotBlank: false
String name =Hello World!”;
@NotNull: true
@NotEmpty:true
@NotBlank:true

# 常用的校验注解:

包路径:javax.validation.constraints.xxx

注解说明
@Null被注释的元素必须为 null
@NotNull被注释的元素不能为 null
@AssertTrue被注释的元素必须为 true
@AssertFalse被注释的元素必须为 false
@Min(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Max(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@DecimalMax(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@Size(max,min)被注释的元素的大小必须在指定的范围内。
@Digits(integer,fraction)被注释的元素必须是一个数字,其值必须在可接受的范围内
@Past被注释的元素必须是一个过去的日期
@Future被注释的元素必须是一个将来的日期
@Pattern(value)被注释的元素必须符合指定的正则表达式。
@Email被注释的元素必须是电子邮件地址
@Length被注释的字符串的大小必须在指定的范围内
@NotEmpty被注释的字符串必须非空
@Range被注释的元素必须在合适的范围内

PS:@JsonFormat

有时使用 @JsonFormat 注解时,查到的时间可能会比数据库中的时间少八个小时,这是由于时区差引起的,JsonFormat 默认的时区是 Greenwich Time, 默认的是格林威治时间,而我们是在东八区上,所以时间会比实际我们想得到的时间少八个小时。需要在后面加上一个时区,如下示例:

@JsonFormat(pattern="yyyy-MM-dd",timezone="GMT+8")
private Date date;

# 1.2、统一校验实现

首先在 Bean 工程添加 spring-boot-starter-validation 的依赖

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

在 javax.validation.constraints 包下有很多这样的校验注解,直接使用注解定义校验规则即可。

在实体类的字段上添加注解:

@Data
public class AddCourseDto {
 // 不能为空,否则抛出异常
 @NotEmpty(message = "新增课程名称不能为空")
 @NotEmpty(message = "修改课程名称不能为空")
 private String name;
 @NotEmpty(message = "适用人群不能为空")
 @Size(message = "适用人群内容过少",min = 10)
 private String users;
 //message 异常信息,min 最小值,不能小于 10 否则抛出异常
 @Size(message = "课程描述内容过少",min = 10)
 private String description;
 private String pic;
 @NotEmpty(message = "收费规则不能为空")
 private String charge;
}

在 controller 层的接口的参数列表中添加注解

// 使用 @Validated 来激活 Validation 框架进行统一校验
@PostMapping("course")
public CourseBaseInfoDto createCourseBase(@RequestBody @Validated AddCourseDto addCourseDto)
{
   // 用户登录后会获取它的机构 id
   Long companyId = 1232141425L;
   return courseBaseInfoService.createCourseBase(companyId, addCourseDto);
}

在 controller 层进行了统一校验后那么就不需要在 service 层进行校验了,如果有校验的代码可以注释掉了,没用了

通过 HTTPClient 进行测试:

这里故意不添加 name 的值引出一个异常看看结果

### 新增课程
POST /content/course
Content-Type: application/json
{
  "charge": "201001",
  "name": "",
  "pic": "dkxdkx",
  "users": "初级人员",
  "description": "Java网络编程高级"
}

HTTPClient 结果如下:

前端需要获取到的异常信息并不是如下的:执行过程异常,请重试,这是怎么回事呢?

原因:它报异常后由于异常的类型并不是我们系统自定义异常类型所以走了捕获 Exception 异常的函数了,我们需要对这个 JSR303 框架的异常类型进行解析然后返回给前端看。

HTTP/1.1 500 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 28 Oct 2023 09:06:39 GMT
Connection: close

{
  "errMessage": "执行过程异常, 请重试"
}
Response file saved.
> 2023-10-28T170639.500.json

服务的异常信息如下:

c.x.b.exception.GlobalExceptionHandler   : 系统异常 Validation failed for argument [0] in public com.xuecheng.content.model.dto.CourseBaseInfoDto [NotEmpty.addCourseDto.name,NotEmpty.name,NotEmpty.java.lang.String,NotEmpty];  [addCourseDto.name,name]; arguments []; default message [name]]; default message [新增课程名称不能为空]] [Field error in object 'addCourseDto' on field 'name': rejected value []; codes [NotEmpty.addCourseDto.name,NotEmpty.name,NotEmpty.java.lang.String,NotEmpty];  [addCourseDto.name,name]; arguments []; default message [name]]; default message [修改课程名称不能为空]] 

[NotEmpty.addCourseDto.name,NotEmpty.name,NotEmpty.java.lang.String,NotEmpty]; [addCourseDto.name,name]; arguments []; default message [name]]; default message [新增课程名称不能为空]] [Field error in object 'addCourseDto' on field 'name': rejected value []; codes [NotEmpty.addCourseDto.name,NotEmpty.name,NotEmpty.java.lang.String,NotEmpty];  [addCourseDto.name,name]; arguments []; default message [name]]; default message [修改课程名称不能为空]] 

报出的异常信息类型为:MethodArgumentNotValidException ,那么我们需要在 GlobalExceptionHanlder 异常捕获类中进行解析

// 指定异常的类型来进行捕获然后处理
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse exception(MethodArgumentNotValidException e)
{
   // 获取校验框架的异常
   BindingResult bindingResult = e.getBindingResult();
   // 存储错误信息的集合
   List<String> errorList = new ArrayList<>();
   // 解析字段异常
   bindingResult.getFieldErrors().stream().forEach(item -> {
      errorList.add(item.getDefaultMessage());
   });
   // 将错误信息进行拼接
   String joinError = StringUtils.join(errorList, ",");
   // 记录异常日志
   log.error("系统异常 {}", e.getMessage(), joinError);
   // 解析出异常信息
   return new RestErrorResponse(joinError);
}

使用 HTTPClient 进行测试

可以看到 debug 抛出异常就会被这个解析 JSR303 框架的函数捕获到然后进行解析返回给前端看

image-20231028172316867

HTTPClient 结果如下:

HTTP/1.1 500 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 28 Oct 2023 09:24:21 GMT
Connection: close

{
  "errMessage": "修改课程名称不能为空,新增课程名称不能为空"
}
Response file saved.
> 2023-10-28T172421.500.json

idea 服务信息如下:

2023-10-28 17:24:17.464 ERROR 17500 --- [io-63040-exec-1] c.x.b.exception.GlobalExceptionHandler   : 系统异常 Validation failed for argument [0] in public com.xuecheng.content.model.dto.CourseBaseInfoDto com.xuecheng.content.api.CourseBaseInfoController.createCourseBase(com.xuecheng.content.model.dto.AddCourseDto) with 2 errors: [Field error in object 'addCourseDto' on field 'name': rejected value []; codes [NotEmpty.addCourseDto.name,NotEmpty.name,NotEmpty.java.lang.String,NotEmpty]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [addCourseDto.name,name]; arguments []; default message [name]]; default message [修改课程名称不能为空]] [Field error in object 'addCourseDto' on field 'name': rejected value []; codes [NotEmpty.addCourseDto.name,NotEmpty.name,NotEmpty.java.lang.String,NotEmpty]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [addCourseDto.name,name]; arguments []; default message [name]]; default message [新增课程名称不能为空]] 

但是有一个问题,如果多个接口使用同一个实体类。新增课程用它,修改课程也用它。而新增课程和修改课程校验的数据可能不一样,举个例子:新增课程时,课程名称为空。修改课程时,课程名称有值。这时就会出现问题

# 1.3、分组校验

有时候在同一个属性上设置一个校验规则不能满足要求,比如:订单编号由系统生成,在添加订单时要求订单编号为空,在更新订单时要求订单编号不能为空。此时就用到了分组校验。用一个属性定义多个校验规则属于不同的分组,比如:添加订单定义 @NULL 规则则属于 insert 分组,更新订单定义 @NOtEmpty 规则属于 update 分组,insert 和 update 是分组的名称,是可以修改的。

下边举例说明

我们用 class 类型来表示不同的分组,所以我们定义不同的接口类型 (空接口) 表示不同的分组,由于校验分组是公用的,所以定义在 base 工程中。如下:

package com.xuecheng.base.exception.validation;
/**
 * @author Dkx
 * @version 1.0
 * @2023/10/2818:27
 * @function
 * @comment 用于分组校验,定义一些常用的组
 */
public class ValidationGroups {
    public interface Insert{};
    public interface Update{};
    public interface Delete{};
}

然后在实体类中的代码如下:

// 不能为空,否则抛出异常
@NotEmpty(message = "新增课程名称不能为空", groups = {ValidationGroups.Insert.class})
@NotEmpty(message = "修改课程名称不能为空", groups = {ValidationGroups.Update.class})
private String name;

最后在 controller 层的接口参数中来指定这个接口要对这个业务执行什么样的操作即可

// 使用 @Validated 来激活 Validation 框架进行统一校验
@PostMapping("course")
public CourseBaseInfoDto createCourseBase(@RequestBody @Validated({ValidationGroups.Insert.class, ValidationGroups.Update.class}) AddCourseDto addCourseDto)
{
   // 用户登录后会获取它的机构 id
   Long companyId = 1232141425L;
   return courseBaseInfoService.createCourseBase(companyId, addCourseDto);
}

# 1.4、校验规则不满足?

如果 javax.validation.constraints 包下的校验规则满足不了需求怎么办?

1、手写校验代码

2、自定义校验规则注解