likes
comments
collection
share

Spring Boot 项目中参数校验和异常拦截

作者站长头像
站长
· 阅读数 53

Spirng Boot 项目中校验前端请求参数,高效易维护的手段推荐使用@Valid@Validated注解,开发时应当尽量避免使用一大堆if else 对请求参数一个个判断校验。

一、@Valid@Validated 对比

对比项@Valid@Validated
提供方JSR-303规范,简单理解就是Java EE中定义的一套Java Bean校验规范Spring,可以理解成是对JSR-303规范规范的二次封装
包路径javax.validationorg.springframework.validation.annotation
标注位置方法、对象属性、 构造方法、 参数类、方法、参数
支持分组不支持支持
支持嵌套支持不支持

1. 关于Jar包和常用注解

(1)@Valid

jakarta.validation-api.jar 中,它是一套标注的JSR-303规范的实现,主要有以下注解:

Spring Boot 项目中参数校验和异常拦截

(2)@Validated

Spring Boot项目中主要是依赖 hibernate-validator.jar,除了提供了JSR-303的规范,还扩展了一些注解,如下图所示:

Spring Boot 项目中参数校验和异常拦截

所以在 Spring Boot 项目里面引入 spring-boot-starter-validation ,就会自动引入 hibernate-validator 的Jar包,hibernate-validator 官方文档。

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

二、@Valid@Validated 使用技巧

1. 前端使用JSON串方式提交参数

Content-Type : application/json,这种方式后端接口可以直接使用 Java Bean 对象来接受请求入参。

1.1 单个属性校验

import lombok.Getter;
import lombok.Setter;

import javax.validation.constraints.NotEmpty;
import java.io.Serializable;

@Getter
@Setter
public class ParamBody implements Serializable {
    @NotEmpty(message = "hour不能空")
    private String hour;
}    

Spring Boot 项目中参数校验和异常拦截 我们使用 @Validated 来校验参数值,@RequestBody 接受JSON串参数。

1.2 嵌套属性校验

import lombok.Getter;
import lombok.Setter;

import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.io.Serializable;
import java.util.List;

@Getter
@Setter
public class ParamBody implements Serializable {

    @Valid
    @NotNull(message = "用户信息不能为空")
    private User user;

    @NotEmpty(message = "hour不能空")
    private String hour;
}
import lombok.Getter;
import lombok.Setter;

import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import java.io.Serializable;

@Getter
@Setter
public class User implements Serializable {

    @NotEmpty(message = "用户名称不能为空")
    @Size(message = "用户名称不能超过 {max} 个字符", max = 10)
    private String username;
}

此时的嵌套意思就是校验 ParamBody时也要对User的所有属性进行校验,实现嵌套校验注意以下几点

  • 对要校验的嵌套属性必须使用 @Valid 注解标准
  • 被嵌套的对象,也就是案例里的 User 对象内部属性校验时注解请使用 javax.validation.constraints 包下的注解,不要使用 org.hibernate.validator.constraints 包下的注解,有时候会导致校验失效!!!

Spring Boot 项目中参数校验和异常拦截

1.3 分组校验

在实际项目开发中,针对多个前端方法用一个实体类来接受请求参数,但是这两个方法的请求参数校验规则不一样,此时就应该用分组校验来完成多规则校验逻辑。

  • 定义分组校验接口
public interface OneType {
}

public interface TwoType {
}

该接口中不需要定义任何方法,只是用来标记分组而已。

  • 指定分组
import javax.validation.constraints.Pattern;
import java.io.Serializable;

@Getter
@Setter
public class ParamBody implements Serializable {

    @Pattern(regexp = "1", message = "类型值应该等于1", groups = OneType.class)
    @Pattern(regexp = "2", message = "类型值应该等于2", groups = TwoType.class)
    private String type;
    
}

Spring Boot 项目中参数校验和异常拦截

核心点就是:@Validated 中的分组必须和你要校验的对象属性分组标注一致,Default.class 是框架自带的默认分组

1.4 复杂校验逻辑自定义注解

比如前端提交的某个参数必须是枚举值中的一个,我们尝试用自定义注解来完成该校验逻辑。

  • 定义校验注解
package com.example.test.valid.ex.constraints;

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

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target({METHOD, FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = {ColourConstraintValidator.class})
public @interface ContainColour {

    String message() default "必须指定颜色类型";

    // 所有允许的颜色值
    String[] colourArray() default {};

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}
  • 定义校验器 ConstraintValidator
public class ColourConstraintValidator implements ConstraintValidator<ContainColour, String> {

    private String[] colourArrays;

    // 实现自己的校验逻辑
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        List<String> colourNameList = new ArrayList<>();
        colourNameList.add("YELLOW");
        colourNameList.add("RED");
        colourNameList.add("BLUE");

        if (colourNameList.contains(value)) {
            return true;
        }
        return false;
    }

    // 初始化参数
    @Override
    public void initialize(ContainColour constraintAnnotation) {
        colourArrays = constraintAnnotation.colourArray();
    }
}

1.5 分组按照指定顺序校验

  • 默认情况下,参数校验是没有特定的顺序的。但在某些情况下,控制参数校验顺序是很有用的
  • 比如自驾游,首先要判断天气条件是否允许驾车出行,然后要检查汽车各项指标是否安全
  • 利用 @GroupSequence 便能实现这样的业务场景校验,只要有一个条件校验失败,后面的条件都不会被校验
// 天气分组
public interface WeatherChecks {
}

// 司机分组
public interface DriverChecks {
}

(1)定义分组顺序

import javax.validation.GroupSequence;
import javax.validation.groups.Default;

@GroupSequence({Default.class, WeatherChecks.class, DriverChecks.class})
public interface TravelChecks {
}

(2)定义参数接受对象

import javax.validation.constraints.Pattern;
import java.io.Serializable;

@Getter
@Setter
public class TravelBody implements Serializable {

    @Pattern(regexp = "^老司机$", message = "必须是老司机", groups = DriverChecks.class)
    private String driver;

    @Pattern(regexp = "^(晴天)$", message = "必须是晴天", groups = WeatherChecks.class)
    private String weather;
}

Spring Boot 项目中参数校验和异常拦截

(3)测试结果

Spring Boot 项目中参数校验和异常拦截

如果我们没有指定顺序的话,默认情况下可能会先校验 TravelBodydriver 参数,但当我们使用了 @GroupSequence 之后,他是按照我们指定的分组顺序进行校验的

1.6 动态添加分组校验

@GroupSequence静态地重新定义了组校验顺序,Hibernate Validator 还提供了一个 SPI,用于根据对象属性动态的添加分组校验顺序。即使用 @GroupSequenceProvider 注解。

Spring Boot 项目中参数校验和异常拦截

假如我们有如图所示的表单页面,只有当前面的复选框勾选之后才对后面的输入框填写的值进行校验,此时你该怎么动态校验呢?

(1)定义参数对象

Spring Boot 项目中参数校验和异常拦截

@Getter
@Setter
@GroupSequenceProvider(value = OvertimeGroupSequenceProvider.class)
public class OvertimeBody implements Serializable {

    @NotNull
    private Integer upDaySwitch;
    @NotNull
    @Range(min = 1, max = 2, message = "上半天加班小时填写不正确", groups = UpDayGroup.class)
    private Integer upDayHour;

    @NotNull
    private Integer downDaySwitch;
    @NotNull
    @Range(min = 1, max = 3, message = "下半天加班小时填写不正确", groups = DownDayGroup.class)
    private Integer downDayHour;
}

(2)定义校验器

import com.example.test.valid.ex.bean.OvertimeBody;
import org.hibernate.validator.spi.group.DefaultGroupSequenceProvider;

import java.util.ArrayList;
import java.util.List;

public class OvertimeGroupSequenceProvider implements DefaultGroupSequenceProvider<OvertimeBody> {
    @Override
    public List<Class<?>> getValidationGroups(OvertimeBody overtimeBody) {

        List<Class<?>> defaultGroupSequence = new ArrayList<>();
        defaultGroupSequence.add(OvertimeBody.class);

        // 这里一定要做判空处理
        if (overtimeBody == null) {
            return defaultGroupSequence;
        }

        Integer upDaySwitch = overtimeBody.getUpDaySwitch();
        Integer downDaySwitch = overtimeBody.getDownDaySwitch();
        if (upDaySwitch == 1) {
            defaultGroupSequence.add(UpDayGroup.class);
        }

        if (downDaySwitch == 1) {
            defaultGroupSequence.add(DownDayGroup.class);
        }
        return defaultGroupSequence;
    }
}

(3)接口请求校验

Spring Boot 项目中参数校验和异常拦截

  • 不做任何校验:

Spring Boot 项目中参数校验和异常拦截

  • 只校验其中一个开关

Spring Boot 项目中参数校验和异常拦截

1.7 List集合属性校验

@Getter
@Setter
public class ParamBody implements Serializable {
    @Valid
    private List<OvertimeBody> overtimeBodyList;

    @Valid
    private List<TimeInterval> breakTimes;
}

@Getter
@Setter
public class TimeInterval implements Serializable {
    @NotNull(message = "开始时间不能为空")
    private String startTime;
    @NotNull(message = "结束时间不能为空")
    private String endTime;
}

(1)使用 @GroupSequenceProvider 方式来校验

(2)参数定义时使用 ArrayList

Spring Boot 项目中参数校验和异常拦截

@Getter
@Setter
public class TimeInterval implements Serializable {
    @NotEmpty(message = "开始时间不能为空")
    private String startTime;
    @NotEmpty(message = "结束时间不能为空")
    private String endTime;
}

2. 前端使用表单方式提交参数

Content-Type : application/x-www-form-urlencoded; charset=UTF-8,比如表单提交数据如下:

Spring Boot 项目中参数校验和异常拦截

(1)接口定义

@PostMapping("/save-json6")
public String saveJsonBody6(HttpServletRequest request) {
    String data = request.getParameter("data");
    OvertimeBody body = JSON.parseObject(data, OvertimeBody.class);
    ValidatorUtil.validate(body);
    return "success";
}

(2)编程式校验工具类

BizException是定义的业务异常,ApiErrorCodeEnum 是业务错误码。

public class ValidatorUtil {

    private final static Logger log = LoggerFactory.getLogger(ValidatorUtil.class);

    private final static Validator validator;

    static {
        validator = Validation.buildDefaultValidatorFactory().getValidator();
    }

    public static void validate(Object object, Class<?>... groups) throws BizException {

        Set<ConstraintViolation<Object>> constraintViolations = validator.validate(object, groups);

        if (!constraintViolations.isEmpty()) {
            StringBuilder msg = new StringBuilder();

            Iterator<ConstraintViolation<Object>> iterator = constraintViolations.iterator();
            while (iterator.hasNext()) {
                ConstraintViolation<Object> constraint = iterator.next();
                msg.append(constraint.getMessage()).append(',');
            }
            String errorMsg = msg.substring(0, msg.toString().lastIndexOf(','));

            ApiErrorCodeEnum.PARAMETER.setMessage(errorMsg);
            throw new BizException(ApiErrorCodeEnum.PARAMETER);
        }
    }
}

三、Spirng Boot 中统一拦截参数检验异常

(1)定义错误码枚举类 ApiErrorCodeEnum

public enum ApiErrorCodeEnum implements Serializable {

    SUCCESS("1", "成功"),
    PARAMETER("2", "参数异常"),
    UNKNOWN_ERROR("99", "未知异常");

    private String code;
    private String message;

    ApiErrorCodeEnum(String code, String message) {
        this.code = code;
        this.message = message;
    }

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}

(2)定义业务异常 BizException

public class BizException extends RuntimeException {
    private ApiErrorCodeEnum codeEnum;

    public BizException() {
        super();
    }

    public BizException(ApiErrorCodeEnum codeEnum) {
        this.codeEnum = codeEnum;
    }

    public BizException(String message, ApiErrorCodeEnum codeEnum) {
        super(message);
        this.codeEnum = codeEnum;
    }

    public BizException(String message, Throwable cause, ApiErrorCodeEnum codeEnum) {
        super(message, cause);
        this.codeEnum = codeEnum;
    }

    public BizException(Throwable cause, ApiErrorCodeEnum codeEnum) {
        super(cause);
        this.codeEnum = codeEnum;
    }

    public ApiErrorCodeEnum getCodeEnum() {
        return codeEnum;
    }
}

(3)请求结果响应封装

@Setter
@Getter
public class ResultBody<T> implements Serializable {

    private boolean status;
    // 响应码
    private String code;
    // 响应描述信息
    private String message;
    // 响应数据
    private T data;

    public ResultBody() {
    }

    private ResultBody(T data) {
        this.data = data;
    }

    private ResultBody(String code, String msg) {
        this.code = code;
        this.message = msg;
    }

    public static <T> ResultBody<T> success() {
        ResultBody<T> result = new ResultBody<>();
        result.setCode("1");
        result.setStatus(Boolean.TRUE);
        result.setMessage("成功");
        return result;
    }

    public static <T> ResultBody<T> error(String code, String message) {
        ResultBody<T> result = new ResultBody<>(code, message);
        result.setStatus(Boolean.FALSE);
        return result;
    }
}

1. 全局异常拦截

为什么要做全局异常拦截?

  • 比如有时候,我在浏览器输入了一个请求地址URL,但这个URL的域名是对的,但是接口路径是错的,此时就会出现 Spring Boot 默认的 whitelabel error page 页面

Spring Boot 项目中参数校验和异常拦截

  • Spring mvc 中定义的 Interceptor 拦截器本身出现异常,此时的逻辑还没到特定要的业务 Controller,此时也需要对异常信息进行统一拦截

1.1 全局异常拦截实现

Spring Boot 项目中参数校验和异常拦截

@Controller
public class GlobalErrorController extends BasicErrorController {

    public GlobalErrorController(ErrorAttributes errorAttributes) {
        super(errorAttributes, new ErrorProperties());
    }

    @RequestMapping(consumes = MediaType.ALL_VALUE, produces = MediaType.ALL_VALUE)
    public ResponseEntity<Map<String, Object>> errorJson(HttpServletRequest request) {
        HttpStatus status = getStatus(request);
        if (status == HttpStatus.NO_CONTENT) {
            return new ResponseEntity<>(status);
        }

        Map<String, Object> resutBody = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
        resutBody.put("status", false);
        resutBody.put("code", ApiErrorCodeEnum.UNKNOWN_ERROR.getCode());
        resutBody.put("message", ApiErrorCodeEnum.UNKNOWN_ERROR.getMessage());
        return new ResponseEntity<>(resutBody, status);
    }
}

拦截结果:

Spring Boot 项目中参数校验和异常拦截

2. 业务异常拦截

使用 @ControllerAdvice + @ExceptionHandler 主键来完成

/**
 * 业务错误信息统一拦截
 */
@ControllerAdvice(basePackages = {"com.example.test.valid.ex.controller"})
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {
        return new ResponseEntity<>(handlerException(ex), HttpStatus.OK);
    }

    @ExceptionHandler(Exception.class)
    public ResultBody handlerException(Throwable e) {

        if (BizException.class.equals(e.getClass())) {
            // 业务异常处理
            ApiErrorCodeEnum apiError = ((BizException) e).getCodeEnum();
            ResultBody<Object> error = ResultBody.error(apiError.getCode(), apiError.getMessage());
            return error;
        } else if (e.getClass() == MethodArgumentNotValidException.class) {
            // 参数校验异常处理
            MethodArgumentNotValidException validException = (MethodArgumentNotValidException) e;
            BindingResult bindingResult = validException.getBindingResult();

            String defaultMessage = bindingResult.getAllErrors().get(0).getDefaultMessage();

            ApiErrorCodeEnum.PARAMETER.setMessage(defaultMessage);
            ResultBody<Object> error = ResultBody.error(ApiErrorCodeEnum.PARAMETER.getCode(), ApiErrorCodeEnum.PARAMETER.getMessage());

            log.error("restful api 参数校验异常,resultBody : {}", JSON.toJSONString(error), e);
            return error;

        } else if (e.getClass() == MissingServletRequestParameterException.class) {
            // @RequestParam 注解中 required = true 的情况拦截
            return ResultBody.error(ApiErrorCodeEnum.PARAMETER.getCode(), ApiErrorCodeEnum.PARAMETER.getMessage());

        } else {
            log.error("未知异常", e);
            return ResultBody.error(ApiErrorCodeEnum.UNKNOWN_ERROR.getCode(), ApiErrorCodeEnum.UNKNOWN_ERROR.getMessage());
        }
    }
}

Spring Boot 项目中参数校验和异常拦截 唯一需要注意的是这里的包扫描路径一定是你 controller 包所在的路径

四、写在最后

  • 本文案例使用的是 Spirng Boot 2.7.6 版本
转载自:https://juejin.cn/post/7215047682637021240
评论
请登录