前言 在日常开发中,避不开的就是参数校验,有人说前端不是会在表单提交之前进行校验的吗?在后端开发中,不管前端怎么样校验,后端都需要进行再次校验,这是为了系统安全。因为前端的校验很容易被绕过,当使用 PostMan 来测试时,如果后端没有校验,容易引发安全问题。值得一提的是,本文适用于 Spring Boot 与 Spring Cloud 项目。
JSR303 简介 JSR-303 是 JAVA EE 6 中的一项子规范,叫做 Bean Validation
,官方的参考实现是 Hibernate Validator。值得一提的是,Hibernate Validator 提供了 JSR-303 规范中所有内置 Constraint 的实现,除此之外还有一些附加的 Constraint。
常用约束注解 约束注解的名称 约束注解的说明 @Null 用于校验对象为 Null @NotNull 用于校验对象不能为 Null,无法校验长度为 0 的字符串 @NotBlank 用于校验 String 类,不能为 Null,且 trim()
之后的 size
大于零 @NotEmpty 用于校验集合类、String 类不能为 Null,且 size
大于零,但是带有空格的字符串校验不出来 @Size 用于校验对象(Array、Collection、Map、String)长度是否在给定的范围之内 @Length 用于校验 String 对象的大小必须在指定的范围内 @Pattern 用于校验 String 对象是否符合正则表达式的规则 @Email 用于校验 String 对象是否符合邮箱格式 @Min 用于校验 Number 和 String 对象是否大等于指定的值 @Max 用于校验 Number 和 String 对象是否小等于指定的值 @AssertTrue 用于校验 Boolean 对象是否为 true @AssertFalse 用于校验 Boolean 对象是否为 false
常用校验注解 校验注解有两个,分别是 @Validated
与 @Valid
,两者的区别如下:
@Validated
注解:
Spring 提供的 支持分组校验 可以用在类型、方法和方法参数上,但是不能用在成员对象属性上 由于无法加在成员对象属性上,所以无法单独完成级联校验,需要配合 @Valid
一起使用 @Valid
注解:
JDK 提供的(标准 JSR-303 规范) 不支持分组校验 可以用在方法、构造函数、方法参数和成员对象属性上 可以加在成员对象属性上,能够独自完成级联校验 提示
@Validated
注解一般是用到分组校验时才使用。
一个学校对象里有很多个学生对象,学校和学生都需要校验参数;此时可以在学校的 Controller 类的方法参数前添加 @Validated
注解,同时在学校对象的学生属性上添加 @Valid
注解,不加则无法对学生对象里的属性进行校验。示例代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 @Data public class School { private Long id; @NotBlank private String name; @Valid @NotNull private List<Student> list; }
1 2 3 4 5 6 7 8 9 @Data public class Student { private Long id; @NotBlank private String name; }
1 2 3 4 5 6 7 8 9 10 @RestController @RequestMapping("/school") public class SchoolController { @PostMapping("/add") public Result add (@Validated @RequestBody School school) { } }
JSR303 入门 整合案例 引入 Maven 依赖 1 2 3 4 5 6 7 8 9 10 11 <dependency > <groupId > javax.validation</groupId > <artifactId > validation-api</artifactId > <version > 2.0.1.Final</version > </dependency > <dependency > <groupId > org.hibernate.validator</groupId > <artifactId > hibernate-validator</artifactId > <version > 8.0.0.Final</version > </dependency >
统一返回类型 为了统一返回给前端的结果格式,应该定义一个返回结果类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 public class R extends HashMap <String , Object > { private static final long serialVersionUID = 1L ; public R () { put("code" , 0 ); put("msg" , "success" ); } public static R error () { return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "系统未知异常,请联系管理员" ); } public static R error (String msg) { return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg); } public static R error (int code, String msg) { R r = new R(); r.put("code" , code); r.put("msg" , msg); return r; } public static R ok (String msg) { R r = new R(); r.put("msg" , msg); return r; } public static R ok (Map<String, Object> map) { R r = new R(); r.putAll(map); return r; } public static R ok () { return new R(); } public R put (String key, Object value) { super .put(key, value); return this ; } }
添加约束注解 在 JavaBean(例如 Entity、VO、DTO)类的成员属性上添加约束注解,表明某个成员属性的校验规则是什么。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 @Data public class BrandVo implements Serializable { private Long brandId; @NotBlank(message = "can't be empty") private String name; @URL(message = "must be an url address") @NotBlank(message = "can't be empty") private String logo; @NotBlank(message = "can't be empty") private String descript; @NotNull(message = "can't be null") private Integer showStatus; @Pattern(regexp = "^[a-zA-Z]$", message = "must be a letter") @NotBlank(message = "can't be empty") private String firstLetter; @Min(value = 0, message = "must be greater than or equal to zero") @NotNull(message = "can't be null") private Integer sort; }
添加校验注解 在 Controller 类的方法参数前面添加 @Valid
校验注解,表明某个方法的接口调用需要检验参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @RestController @RequestMapping("/brand") public class BrandController { @Autowired private BrandService brandService; @RequestMapping("/save") public R save (@Valid @RequestBody BrandVo brand) { brandService.save(brand); return R.ok(); } }
当前端提交过来的参数不符合校验规则,服务端会自动返回 400
的 HTTP 状态码给前端。
获取校验结果 在 Controller 类的方法参数列表里添加一个 BindingResult
参数(Spring MVC 会自动注入对应的值),这样就可以获取到参数的校验结果,同时也很方便进一步返回友好的错误提示信息给前端。
注意
一般情况下,不建议使用以下的方式来单独处理参数校验结果,因为这样会出现很多冗余代码,且后期不容易维护。建议采用后面介绍的 全局异常处理
方案,这样可以统一处理校验结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @RestController @RequestMapping("/brand") public class BrandController { @Autowired private BrandService brandService; @RequestMapping("/save") public R save (@Valid @RequestBody BrandVo brand, BindingResult validResult) { if (validResult.hasErrors()) { Map<String, String> map = new HashMap<>(); validResult.getFieldErrors().forEach(item -> { String errorMsg = item.getDefaultMessage(); String fieldName = item.getField(); map.put(fieldName, errorMsg); }); return R.error(400 , "提交的数据不合法" ).put("data" , map); } brandService.save(brand); return R.ok(); } }
若参数校验不通过,最终的返回结果如下:
1 2 3 4 5 6 7 { "msg" : "提交的数据不合法" , "code" : 400 , "data" : { "name" : "can't be empty" } }
全局异常处理案例 统一错误码 为了方便标识不同的异常信息,建议使用枚举类型来统一存储不同的错误码和错误信息。
错误码建议定义为 5 位数字 前两位表示业务场景,例如:10
表示通用业务,11
表示商品业务,12
表示订单业务 最后三位表示错误码,例如 000
表示系统未知异常,001
表示参数校验异常 完整的错误码,例如:10000
,其中的 10
表示通用,000
表示系统未知异常 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public enum BizCodeEnum { UNKNOW_EXCEPTION(10000 , "系统未知异常" ), VALID_EXCEPTION(10001 , "参数格式校验失败" ); private int code; private String msg; BizCodeEnum(int code, String msg) { this .code = code; this .msg = msg; } public int getCode () { return code; } public String getMsg () { return msg; } }
统一返回类型 为了统一返回给前端的结果格式,应该定义一个返回结果类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 public class R extends HashMap <String , Object > { private static final long serialVersionUID = 1L ; public R () { put("code" , 0 ); put("msg" , "success" ); } public static R error () { return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "系统未知异常,请联系管理员" ); } public static R error (String msg) { return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg); } public static R error (int code, String msg) { R r = new R(); r.put("code" , code); r.put("msg" , msg); return r; } public static R ok (String msg) { R r = new R(); r.put("msg" , msg); return r; } public static R ok (Map<String, Object> map) { R r = new R(); r.putAll(map); return r; } public static R ok () { return new R(); } public R put (String key, Object value) { super .put(key, value); return this ; } }
全局异常处理 使用 @ControllerAdvice
和 @ExceptionHandler
注解实现全局的异常处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 @Slf4j @ControllerAdvice public class GlobalExceptionHandler { @ResponseBody @ExceptionHandler(value = MethodArgumentNotValidException.class) public R handleValidException (MethodArgumentNotValidException e) { log.error("发生参数校验异常:{}" , e.getMessage()); BindingResult validResult = e.getBindingResult(); StringBuffer messages = new StringBuffer(); if (validResult.hasErrors()) { validResult.getFieldErrors().forEach(item -> { String errorMsg = item.getDefaultMessage(); String fieldName = item.getField(); messages.append(fieldName + ": " + errorMsg + "\n" ); }); } return R.error(BizCodeEnum.VALID_EXCEPTION.getCode(), BizCodeEnum.VALID_EXCEPTION.getMsg()).put("details" , messages.toString()); } @ResponseBody @ExceptionHandler(value = Throwable.class) public R handleException (Throwable throwable) { log.error("发生系统未知异常:{}" , throwable.getMessage()); return R.error(BizCodeEnum.UNKNOW_EXCEPTION.getCode(), BizCodeEnum.UNKNOW_EXCEPTION.getMsg()); } }
添加约束注解 在 JavaBean(例如 Entity、VO、DTO)类的成员属性上添加约束注解,表明某个成员属性的校验规则是什么。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 @Data public class BrandVo implements Serializable { private Long brandId; @NotBlank(message = "can't be empty") private String name; @URL(message = "must be an url address") @NotBlank(message = "can't be empty") private String logo; @NotBlank(message = "can't be empty") private String descript; @NotNull(message = "can't be null") private Integer showStatus; @Pattern(regexp = "^[a-zA-Z]$", message = "must be a letter") @NotBlank(message = "can't be empty") private String firstLetter; @Min(value = 0, message = "must be greater than or equal to zero") @NotNull(message = "can't be null") private Integer sort; }
添加校验注解 在 Controller 类的方法参数前面添加 @Valid
校验注解,表明某个方法的接口调用需要检验参数。由于实现了全局异常处理,这里不再需要在 Controller 类的方法的参数列表里添加一个 BindingResult
参数来单独处理校验结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @RestController @RequestMapping("/brand") public class BrandController { @Autowired private BrandService brandService; @RequestMapping("/save") public R save (@Valid @RequestBody BrandVo brand) { brandService.save(brand); return R.ok(); } }
若参数校验不通过,最终的返回结果如下:
1 2 3 4 5 { "msg" : "参数格式校验失败" , "code" : 10001 , "details" : "sort: must be greater than or equal to zero\n name: can't be empty\n" }
自定义校验器案例 自定义校验器 实现 ConstraintValidator
接口,编写自定义的校验器。下述的校验器,用于校验前端提交的参数值(Integer
类型)是否在指定的值内。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 import javax.validation.ConstraintValidator;import javax.validation.ConstraintValidatorContext;public class ListValueValidatorForInteger implements ConstraintValidator <ListValue , Integer > { private final Set<Integer> set = new HashSet<>(); @Override public void initialize (ListValue constraintAnnotation) { int [] values = constraintAnnotation.values(); for (int value : values) { set.add(value); } } @Override public boolean isValid (Integer value, ConstraintValidatorContext context) { if (value != null ) { return set.contains(value); } return false ; } }
自定义约束注解 自定义约束注解,通过 @Constraint
注解的 validatedBy
属性来指定自定义的校验器,详细的写法建议参考 @NotBlank
注解的源码实现。值得一提的是,validatedBy
属性可以指定多个自定义校验器,Spring MVC 会根据参数的类型(例如 Integer
、Double
、String
类型)来自动选择合适的校验器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import javax.validation.Constraint;import javax.validation.Payload;@Documented @Constraint(validatedBy = {ListValueValidatorForInteger.class}) @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE}) @Retention(RUNTIME) public @interface ListValue { String message () default " {com.shop.common.validator.constraints.ListValue.message}"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; int[] values() default {}; }
自定义提示信息 在项目(模块)的 src/main/resources
目录下创建 ValidationMessages.properties
配置文件,用于存放校验结果的提示信息。
1 com.shop.common.validator.constraints.ListValue.message = the specified value must be submitted
添加约束注解 在 JavaBean(例如 Entity、VO、DTO)类的成员属性上添加约束注解,包括自定义的约束注解 @ListValue
,表明某个成员属性的校验规则是什么。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 @Data public class BrandVo implements Serializable { private Long brandId; @NotBlank(message = "can't be empty") private String name; @URL(message = "must be an url address") @NotBlank(message = "can't be empty") private String logo; @NotBlank(message = "can't be empty") private String descript; @NotNull(message = "can't be null") @ListValue(values = {0, 1}) private Integer showStatus; @Pattern(regexp = "^[a-zA-Z]$", message = "must be a letter") @NotBlank(message = "can't be empty") private String firstLetter; @Min(value = 0, message = "must be greater than or equal to zero") @NotNull(message = "can't be null") private Integer sort; }
添加校验注解 在 Controller 类的方法参数前面添加 @Valid
校验注解,表明某个方法的接口调用需要检验参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @RestController @RequestMapping("/brand") public class BrandController { @Autowired private BrandService brandService; @RequestMapping("/save") public R save (@Valid @RequestBody BrandVo brand) { brandService.save(brand); return R.ok(); } }
若自定义的校验器校验不通过,则最终的返回结果如下:
1 2 3 4 5 { "msg" : "参数格式校验失败" , "code" : 10001 , "details" : "showStatus: the specified value must be submitted\n" }
分组校验案例 在做参数校验的时候,通常会遇到同一个实体类的新增和修改操作,它们的参数校验规则是不同的;例如新增时 id
允许为空,修改时则不允许 id
为空。为了解决这种业务场景,可以使用分组校验,这样可以少建一个冗余的实体类。
添加分组接口 创建校验用的分组接口,该接口只用于标识不同业务场景的参数校验。
1 2 3 4 5 6 public interface AddGroup {}
1 2 3 4 5 6 public interface UpdateGroup {}
添加约束注解 在 JavaBean(例如 Entity、VO、DTO)类的成员属性上添加约束注解,同时还需要指定对应的分组,表明某个成员属性在某个分组下的校验规则是什么。
特别注意
默认没有指定分组的约束注解,在使用分组校验的情况下是不会生效的,只会在没有使用分组校验的情况下才生效(例如使用 @Valid
注解)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 @Data public class BrandVo implements Serializable { @Null(message = "must be null", groups = {AddGroup.class}) @NotNull(message = "can't be null", groups = {UpdateGroup.class}) private Long brandId; @NotBlank(message = "can't be empty", groups = {AddGroup.class, UpdateGroup.class}) private String name; @URL(message = "must be an url address", groups = {AddGroup.class, UpdateGroup.class}) @NotBlank(message = "can't be empty", groups = {AddGroup.class, UpdateGroup.class}) private String logo; @NotBlank(message = "can't be empty", groups = {AddGroup.class, UpdateGroup.class}) private String descript; @NotNull(message = "can't be null", groups = {AddGroup.class, UpdateGroup.class}) private Integer showStatus; @Pattern(regexp = "^[a-zA-Z]$", message = "must be a letter", groups = {AddGroup.class, UpdateGroup.class}) @NotBlank(message = "can't be empty", groups = {AddGroup.class, UpdateGroup.class}) private String firstLetter; @Min(value = 0, message = "must be greater than or equal to zero", groups = {AddGroup.class, UpdateGroup.class}) @NotNull(message = "can't be null", groups = {AddGroup.class, UpdateGroup.class}) private Integer sort; }
添加校验注解 在 Controller 类的方法参数前面添加 @Validated
校验注解,同时指定对应的分组。值得一提的是,这里不能使用 @Valid
注解,因为该注解不支持分组校验。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @RestController @RequestMapping("/brand") public class BrandController { @Autowired private BrandService brandService; @RequestMapping("/save") public R save (@Validated(AddGroup.class) @RequestBody BrandVo brand) { brandService.save(brand); return R.ok(); } @RequestMapping("/update") public R update (@Validated(UpdateGroup.class) @RequestBody BrandVo brand) { brandService.updateById(brand); return R.ok(); } }
若调用新增接口时,指定了 id
参数,则最终的返回结果如下:
1 2 3 4 5 { "msg" : "参数格式校验失败" , "code" : 10001 , "details" : "brandId: must be null\n" }
参数校验工具类 若希望手动校验参数是否合法,可以参考以下代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import javax.validation.ConstraintViolation;import javax.validation.Validation;import javax.validation.Validator;import java.util.Set;public class ValidatorUtils { private static final Validator VALIDATOR; static { VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator(); } public static void validateParameter (Object object, Class<?>... groups) throws GlobalException { Set<ConstraintViolation<Object>> constraintViolations = VALIDATOR.validate(object, groups); if (!constraintViolations.isEmpty()) { StringBuilder message = new StringBuilder(); for (ConstraintViolation<Object> constraint : constraintViolations) { message.append(constraint.getMessage()).append("\n" ); } ErrorCode errorCode = ErrorCode.PARAMETER_ERROR; errorCode.setDescription(message.toString()); throw new GlobalException(errorCode); } } }