Spring Boot 项目中参数校验和异常拦截
在Spirng Boot
项目中校验前端请求参数,高效易维护的手段推荐使用@Valid
和 @Validated
注解,开发时应当尽量避免使用一大堆if else
对请求参数一个个判断校验。
一、@Valid
和 @Validated
对比
对比项 | @Valid | @Validated |
---|---|---|
提供方 | JSR-303规范,简单理解就是Java EE中定义的一套Java Bean校验规范 | Spring,可以理解成是对JSR-303规范规范的二次封装 |
包路径 | javax.validation | org.springframework.validation.annotation |
标注位置 | 方法、对象属性、 构造方法、 参数 | 类、方法、参数 |
支持分组 | 不支持 | 支持 |
支持嵌套 | 支持 | 不支持 |
1. 关于Jar包和常用注解
(1)@Valid
在 jakarta.validation-api.jar
中,它是一套标注的JSR-303规范的实现,主要有以下注解:
(2)@Validated
在 Spring Boot
项目中主要是依赖 hibernate-validator.jar
,除了提供了JSR-303的规范,还扩展了一些注解,如下图所示:
所以在 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;
}
我们使用
@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
包下的注解,有时候会导致校验失效!!!
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;
}
核心点就是:@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;
}
(3)测试结果
如果我们没有指定顺序的话,默认情况下可能会先校验 TravelBody
的 driver
参数,但当我们使用了 @GroupSequence
之后,他是按照我们指定的分组顺序进行校验的。
1.6 动态添加分组校验
@GroupSequence
静态地重新定义了组校验顺序,Hibernate Validator
还提供了一个 SPI
,用于根据对象属性动态的添加分组校验顺序。即使用 @GroupSequenceProvider
注解。
假如我们有如图所示的表单页面,只有当前面的复选框勾选之后才对后面的输入框填写的值进行校验,此时你该怎么动态校验呢?
(1)定义参数对象
@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)接口请求校验
- 不做任何校验:
- 只校验其中一个开关
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
@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
,比如表单提交数据如下:
(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 mvc
中定义的Interceptor
拦截器本身出现异常,此时的逻辑还没到特定要的业务Controller
,此时也需要对异常信息进行统一拦截
1.1 全局异常拦截实现
@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);
}
}
拦截结果:
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());
}
}
}
唯一需要注意的是这里的包扫描路径一定是你 controller 包所在的路径
四、写在最后
- 本文案例使用的是
Spirng Boot 2.7.6
版本
转载自:https://juejin.cn/post/7215047682637021240