【实战案例】JSR303统一校验与SpringBoot项目的整合

前后端分离项目中,当前前端请求后端接口的时候通常需要传输参数,对于参数的校验应该在哪一步进行校验?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完成对请求参数的基本合法性进行校验。

首先在项目所属或依赖工程中添加相应依赖:

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

在javax.validation.constraints包下有很多校验注解用于定义校验规则。
在这里插入图片描述

Constraint 详细信息
@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(regex=) 被注释的元素必须符合指定的正则表达式
@Valid 被注释的元素需要递归验证
@Email 被注释的元素必须是电子邮箱地址
@Length(min=下限, max=上限) 被注释的字符串的大小必须在指定的范围内
@NotEmpty 被注释的元素的必须非空并且size大于0
@NotBlank 被注释的元素必须不为空且不能全部为’ '(空字符串)
@Range(min=最小值, max=最大值) 被注释的元素必须在合适的范围内

例如项目中存在一个接口用于创建课程:

@ApiOperation("新增课程")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto) {
    Long companyId = 10000L;
    CourseBaseInfoDto courseBase = courseBaseInfoService.createCourseBase(companyId, addCourseDto);
    return courseBase;
}

此接口使用AddCourseDto模型对象接收参数,可进入AddCourseDto类,在属性上添加校验规则。

package com.gavin.content.model.dto;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import java.math.BigDecimal;

/**
 * @author Gavin
 * @description 添加课程dto
 * @date 2024/10/14
 */
@Data
@ApiModel(value = "AddCourseDto", description = "新增课程基本信息")
public class AddCourseDto {

    @NotEmpty(message = "课程名称不能为空")
    @ApiModelProperty(value = "课程名称", required = true)
    private String name;

    @NotEmpty(message = "适用人群不能为空")
    @Size(message = "适用人群内容过少", min = 10)
    @ApiModelProperty(value = "适用人群", required = true)
    private String users;

    @ApiModelProperty(value = "课程标签")
    private String tags;

    @NotEmpty(message = "课程分类不能为空")
    @ApiModelProperty(value = "大分类", required = true)
    private String mt;

    @NotEmpty(message = "课程分类不能为空")
    @ApiModelProperty(value = "小分类", required = true)
    private String st;

    @NotEmpty(message = "课程等级不能为空")
    @ApiModelProperty(value = "课程等级", required = true)
    private String grade;

    @ApiModelProperty(value = "教学模式(普通,录播,直播等)", required = true)
    private String teachmode;

    @ApiModelProperty(value = "课程介绍")
    private String description;

    @ApiModelProperty(value = "课程图片", required = true)
    private String pic;

    @NotEmpty(message = "收费规则不能为空")
    @ApiModelProperty(value = "收费规则,对应数据字典", required = true)
    private String charge;

    @ApiModelProperty(value = "价格")
    private Float price;
    @ApiModelProperty(value = "原价")
    private Float originalPrice;


    @ApiModelProperty(value = "qq")
    private String qq;

    @ApiModelProperty(value = "微信")
    private String wechat;
    @ApiModelProperty(value = "电话")
    private String phone;

    @ApiModelProperty(value = "有效期")
    private Integer validDays;
}

上述程序中@NotEmpty表示属性不能为空,@Size表示限制属性内容的长短。
定义好校验规则还需要开启校验,在controller方法中添加@Validated注解,如下:

@ApiOperation("新增课程")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody @Validated AddCourseDto addCourseDto) {
    //获取到用户所属机构的id
    Long companyId = 10000L;
    CourseBaseInfoDto courseBase = courseBaseInfoService.createCourseBase(companyId, addCourseDto);
    return courseBase;
}

如果校验出错Spring会抛出MethodArgumentNotValidException异常,为了使得报错信息更加明确,可以在统一异常处理器中捕获异常,解析出异常信息。在全局异常处理器中添加对该类异常的解析处理(全局异常处理类的建立见【实战案例】SpringBoot项目中异常处理通用解决方案),如下:

@ResponseBody
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse methodArgumentNotValidException(MethodArgumentNotValidException e) {
    BindingResult bindingResult = e.getBindingResult();
    List<String> msgList = new ArrayList<>();
    //将错误信息放在msgList
    bindingResult.getFieldErrors().stream().forEach(item->msgList.add(item.getDefaultMessage()));
    //拼接错误信息
    String msg = StringUtils.join(msgList, ",");
    log.error("【系统异常】{}",msg);
    return new RestErrorResponse(msg);
}

经过测试可在不合法的数据参数情况下返回类似如下的异常信息:

{
  "errMessage": "课程名称不能为空,适用人群内容过少"
}

可见校验器生效。

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

public class ValidationGroups {

 public interface Insert{};
 public interface Update{};
 public interface Delete{};

}

定义校验规则时指定分组:

@NotEmpty(groups = {ValidationGroups.Insert.class},message = "添加课程名称不能为空")
@NotEmpty(groups = {ValidationGroups.Update.class},message = "修改课程名称不能为空")
@ApiModelProperty(value = "课程名称", required = true)
private String name;

在Controller方法中启动校验规则指定要使用的分组名:

@ApiOperation("新增课程基础信息")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody @Validated({ValidationGroups.Insert.class}) AddCourseDto addCourseDto){
    Long companyId = 10000L;
  	return courseBaseInfoService.createCourseBase(companyId,addCourseDto);
}

通过上述配置则可实现不同类型的异常信息在不同类型的请求过来使用模型类的时候加以区分。

如果javax.validation.constraints包下的校验规则满足不了需求可通过手写校验代码或自定义校验规则注解。

自定义校验规则注解示例如下:
定义一个自定义注解 @OnlyLetters,用于验证字段是否只包含字母字符:

package com.gavin.validation;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

// 指定注解的适用范围(字段级别)
@Target({ElementType.FIELD, ElementType.METHOD})
// 指定注解的生命周期(在运行时可用)
@Retention(RetentionPolicy.RUNTIME)
// 关联校验器类
@Constraint(validatedBy = OnlyLettersValidator.class)
public @interface OnlyLetters {

    // 错误信息
    String message() default "只能包含字母字符";

    // 用于分组校验
    Class<?>[] groups() default {};

实现 OnlyLettersValidator 类,编写具体的校验逻辑:

package com.gavin.validation;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class OnlyLettersValidator implements ConstraintValidator<OnlyLetters, String> {

    // 初始化方法(可以用来获取注解的属性值)
    @Override
    public void initialize(OnlyLetters constraintAnnotation) {
        // 不需要初始化操作
    }

    // 校验逻辑
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // 如果字段为空,不进行验证(可以在其他注解中处理空值校验)
        if (value == null) {
            return true;
        }
        // 校验字符串是否只包含字母
        return value.matches("[a-zA-Z]+");
    }
}

通过上述自定义注解,即可跟前序流程一样进行使用。

上一篇:商业化的畅想:404的众包平台,也许是园子商业化的未来


下一篇:06_实现watch