Spring Boot优雅参数校验实战:告别冗余的if-else

掌握Spring Boot参数校验技巧,告别冗余if-else。文章涵盖常用注解、全局异常处理、分组校验、嵌套对象校验、快速失败和自定义校验规则。

原文标题:优雅的参数校验,告别冗余if-else

原文作者:阿里云开发者

冷月清谈:

本文深入探讨了在Java Spring Boot开发中如何利用JSR 303和Hibernate Validator进行参数校验,旨在消除繁琐的if-else判断语句。文章从参数校验的基础概念入手,详细介绍了JSR 303标准及Hibernate Validator作为其参考实现的具体应用。内容涵盖了常用校验注解的使用,如@NotNull、@Size、@NotBlank等,以及如何进行全局异常处理,统一管理校验失败时的返回信息。同时,文章还讲解了分组校验的概念,允许针对不同的业务场景应用不同的校验规则,避免了为每个请求定制VO类的需求。此外,还介绍了嵌套对象校验,通过@Valid注解实现对复杂对象内部属性的校验。为了提升校验效率,文章提供了快速失败配置的方案,一旦发现校验错误立即停止后续检查。最后,还介绍了如何自定义校验规则,以满足框架自带注解无法覆盖的特殊业务需求,例如手机号格式校验。通过本文的学习,开发者可以掌握一套完整的Spring Boot参数校验方案,提升代码的健壮性和可维护性。

怜星夜思:

1、文章提到了快速失败配置能提高校验效率,但实际项目中,快速失败和返回所有错误信息,哪种方式用户体验更好?你们怎么选择?
2、文章中自定义校验规则的例子是手机号校验,感觉有点多余,现在已经有很多成熟的手机号校验工具类库。大家在实际工作中,都自定义过哪些比较有用的校验规则?
3、文章里提到了分组校验,感觉挺实用的,可以避免创建过多的VO类。大家在实际项目中,除了CreateGroup和UpdateGroup,还用过哪些其他的分组?

原文内容

阿里妹导读


本文介绍了在 Java Spring Boot 开发中如何使用 JSR 303 和 Hibernate Validator 进行参数校验,以避免冗余的if-else判断。文章涵盖了基本注解的使用、全局异常处理、分组校验、嵌套对象校验、快速失败配置以及自定义校验规则等实用技巧。



一、参数校验简介

在实际工作中,得到数据得到的第一步就是校验数据的正确性,如果存在录入上的问题,一般会通过注解校验,发现错误后返回给用户,但是对于一些逻辑上的错误,比如购买金额=购买数量*单价,这样的规则就很难使用注解方式验证了,此时可以使用Spring提供的验证器(Validator)规则去验证。

由于Validator框架通过硬编码完成数据校验,在实际开发中会显得比较麻烦,因此现在开发更加推荐使用JSR 303完成服务端的数据校验。
Spring3开始支持JSR 303验证框架,JSR 303是Java为Bean数据合法性校验所提供的标准框架。JSR 303支持XML风格的和注解风格的验证,通过在Bean属性上标注类似于@NotNull、@Max等的标准注解指定校验规则,并通过标准的验证接口对Bean进行验证。
可以查看详细内容并下载JSR 303 Bean Validation。JSR 303 不需要编写验证器,它定义了一套可标注在成员变量、属性方法上的校验注解,如下表所示:
约束
说明
@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)
被直接的元素必须符合指定的正则表达式

Hibernate Validator是JSR 303的一个参考实现,除了支持所有标准的校验注解之外,还扩展了如下表所示的注解。

约束
说明
@NotBlank
检查被注解的元素是不是Null,以及被去掉前后空格的长度是否大于0
@Email
被注解的元素必须是电子邮件格式
@URL
被注解的元素必须是合法的URL地址
@Length
被注解的字符串的字符串的大小必须在指定的范围内
@NotEmpty
校验集合元素或数组元素或者字符串是否非空。通常作用于集合字段或数组字段,此时需要集合或者数字的元素个数大于0。也可以作用于字符串,此时校验字符串不能为null或空串(可以是一个空格)。
@Range
被注解的元素必须在合适的范围内
① http://jcp.org/en/jsr/detail?id=303

二、实战 

spring-boot-starter-validation是Spring Boot 框架中的一个模块,它构建在 Hibernate Validator 的基础之上,并提供了对 Bean Validation 的集成支持。它可以通过 SpringBoot 的自动配置机制轻松集成到 Spring 应用程序中。使用 spring-boot-starter-validation,你可以在SpringBoot应用程序中使用 Hibernate Validator 的功能,而不需要显式地配置 Hibernate Validator。
因此,Hibernate Validator是一个独立的验证框架,而 spring-boot-starter-validation 则是一个为 Spring Boot应用程序提供集成 Hibernate Validator 的模块。
在这里搭建一个简单的 SpringBoot 项目,并引入如下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

2.1 全局异常处理


@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {
//参数校验异常
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
public Result error(MethodArgumentNotValidException e){
log.warn(e.getMessage());
return Result.fail()
.code(ResultCodeEnum.ARGUMENT_VALID_ERROR.getCode())
.message(e.getBindingResult().getFieldError().getDefaultMessage());
}
//参数校验异常
@ExceptionHandler(BindException.class)
@ResponseBody
public Result error(BindException e){
log.warn(e.getMessage());
StringBuilder sb = new StringBuilder();
for (ObjectError error : e.getBindingResult().getAllErrors()) {
sb.append(error.getDefaultMessage());
}
return Result.fail()
.code(ResultCodeEnum.ARGUMENT_VALID_ERROR.getCode())
.message(sb.toString());
}
//参数校验异常
@ExceptionHandler(ConstraintViolationException.class)
@ResponseBody
public Result error(ConstraintViolationException e){
log.warn(e.getMessage());
StringBuilder sb = new StringBuilder();
for (ConstraintViolation<?> violation : e.getConstraintViolations()) {
sb.append(violation.getMessage());
}
return Result.fail()
.code(ResultCodeEnum.ARGUMENT_VALID_ERROR.getCode())
.message(sb.toString());
}
//全局异常
@ExceptionHandler(Exception.class)
@ResponseBody
public Result error(Exception e){
log.warn(e.getMessage());
return Result.fail().message("执行了全局异常处理");
}
}

2.2 快速开始

2.2.1 实体类
@Data
public class UserCreateVO {
@NotBlank(message = "用户名不能为空")
private String userName;

@NotBlank(message = “姓名不能为空”)
private String name;

@Size(min=11,max=11,message = “手机号长度不符合要求”)
private String phone;

@NotNull(message = “性别不能为空”)
private Integer sex;
}

@Data
public class UserUpdateVO {

@NotBlank(message = “id不能为空”)
private String id;

@NotBlank(message = “用户名不能为空”)
private String userName;

@NotBlank(message = “姓名不能为空”)
private String name;

@Size(min=11,max=11,message = “手机号长度不符合要求”)
private String phone;

@NotNull(message = “性别不能为空”)
private Integer sex;
}

2.2.1 定义请求接口
@Validated
@RestController
@RequestMapping("/user")
public class UserController {

@PostMapping(“create”)
public Result createUser(@Validated @RequestBody UserCreateVO userCreateVo){
return Result.ok(“参数校验成功”);
}

@PostMapping(“update”)
public Result updateUser(@Validated @RequestBody UserUpdateVO userUpdateVo){
return Result.ok(“参数校验成功”);
}
}

2.2.2 测试

新增接口测试:

这里故意不写用户名

更新接口测试:

2.3 路径传参校验

有时候我们是通过 @Pathvariable 注解实现参数传递的,这个时候校验如下:

@GetMapping("getUserById/{id}")
public Result getUserById(@PathVariable @Size(min=2,max=5,message = "id长度不符合要求") String id){
return Result.ok("参数校验成功");
}
可以看到,校验规则是生效的。

2.4 分组校验

我们前边写了两个 VO 类:UserCreateVO 和 UserUpdateVO。但是如果每个请求都要定制 VO 类的话,那我们还不如直接用 if else 梭哈呢。
比如我们新增的时候一般不需要 id,但是修改的时候需要传入 id。
2.4.1 定义分组校验接口
//分组校验
public class UserGroup {
public interface CreateGroup extends Default {
}
public interface UpdateGroup extends Default {
}
}
2.4.2 统一VO校验类
@Data
public class UserVo {

@NotBlank(message = “id不能为空”,groups = UserGroup.UpdateGroup.class)
private String id;

@NotBlank(message = “用户名不能为空”,groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private String userName;

@NotBlank(message = “姓名不能为空”,groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private String name;

@Size(min=11,max=11,message = “手机号长度不符合要求”,groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private String phone;

@NotNull(message = “性别不能为空”,groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private Integer sex;
}

2.4.3 修改接口

参数上边添加 @Validated 接口,并指定分组名称,并统一使用 UserVO 类接收参数。

@Validated
@RestController
@RequestMapping("/user")
public class UserController {

@PostMapping(“create”)
public Result createUser(@Validated(UserGroup.CreateGroup.class) @RequestBody UserVO userVO) {
return Result.ok(“参数校验成功”);
}

@PutMapping(“update”)
public Result updateUser(@Validated(UserGroup.UpdateGroup.class) @RequestBody UserVO userVO) {
return Result.ok(“参数校验成功”);
}

@GetMapping(“getUserById/{id}”)
public Result getUserById(@PathVariable @Size(min = 2, max = 5, message = “id长度不符合要求”) String id) {
return Result.ok(“参数校验成功”);
}
}

2.4.4 测试

新增接口:

修改接口:

2.5 嵌套对象校验

存在嵌套对象校验的时候,使用 @Valid 注解解决。

2.5.1 添加一个角色实体
@Data
public class Role {
@NotBlank(message = "角色名称不能为空")
private String roleName;
}
2.5.2 修改VO类

在 User 类中引入角色实体,并加入 @Valid 注解。

@Data
public class UserVo {

@NotBlank(message = “id不能为空”,groups = UserGroup.UpdateGroup.class)
private String id;

@NotBlank(message = “用户名不能为空”,groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private String userName;

@NotBlank(message = “姓名不能为空”,groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private String name;

@Size(min=11,max=11,message = “手机号长度不符合要求”,groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private String phone;

@NotNull(message = “性别不能为空”,groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private Integer sex;

@Valid
@NotNull(message = “角色信息不能为空”)
private Role role;
}

2.5.3 测试

测试新增接口,故意不传角色信息:

测试修改接口,故意不传角色信息:

2.6 快速失败配置

默认 validation 的校验规则默认会检查所有属性的校验规则,在每一条规则都校验完成后才抛出异常,这样子难免效率会很低,我们希望在发现错误后就不再向后检查,可以通过“快速失败”配置解决,参考代码如下:
/**
* 参数校验相关配置
*/
@Configuration
public class ValidConfig {
/**
* 快速返回校验器
* @return
*/
@Bean
public Validator validator(){
ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
.configure()
//快速失败模式
.failFast(true)
.buildValidatorFactory();
return validatorFactory.getValidator();
}

/**

  • 设置快速校验,返回方法校验处理器
  • 使用MethodValidationPostProcessor注入后,会启动自定义校验器
  • @return
    */
    @Bean
    public MethodValidationPostProcessor methodValidationPostProcessor(){
    MethodValidationPostProcessor methodValidationPostProcessor = new MethodValidationPostProcessor();
    methodValidationPostProcessor.setValidator(validator());
    return methodValidationPostProcessor;
    }
    }

2.7 自定义校验规则

有时候框架自带的校验无法满足我们业务的需求,这个时候可以根据自己的需求定制校验规则。
这里我们自定义一个手机号校验的注解,仅作示例使用,因为官方已经提供了手机号的校验注解
2.7.1 定义注解
@Documented


@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Constraint(validatedBy = PhoneValidator.class)
public @interface PhoneValid {
String message() default “请填写正确的手机号”;

Class<?> groups() default {};

Class<? extends Payload> payload() default {};

}

@Constraint 注解是 Hibernate Validator 的注解,用于实现自定义校验规则,通过 validatedBy 参数指定进行参数校验的实现类。
2.7.2 校验实现类

实现 ConstraintValidator 接口。

在 ConstraintValidator 中,第一个参数为注解,即 Annotation,第二个参数是泛型。
public class PhoneValidator implements ConstraintValidator<PhoneValid,Object> {
/**
* 11位手机号的正则表达式,以13、14、15、17、18头
* ^:匹配字符串的开头
* 13\d:匹配以13开头的手机号码
* 14[579]:匹配以145、147、149开头的手机号
* 15[^4\D]:匹配以15开头且第3位数字不为4的手机号码
* 17[^49\D]:匹配以17开头且第3位数字部位4或9的手机号码
* 18\d :匹配以18开头的手机号码
* \d{8}:匹配手机号码的后8位,即剩余的8个数字
* $:匹配字符串的结尾
*/
public static final String REGEX_PHONE="^(13\\d|14[579]|15[^4\\D]|17[^49\\D]|18\\d)\\d{8}$";

//初始化注解
@Override
public void initialize(PhoneValid constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
}

//校验参数,true表示校验通过,false表示校验失败
@Override
public boolean isValid(Object o, ConstraintValidatorContext constraintValidatorContext) {
String phone = String.valueOf(o);
if(phone.length()!=11){
return false;
}
//正则校验
return phone.matches(REGEX_PHONE);
}
}

2.7.3 在实体中添加自定义注解
2.7.4 测试自定义校验规则

先使用一个正确的手机号测试,校验成功。

再用一个错误的手机号测试,校验失败。
就介绍到这里,其实参数校验的用法很多,这里仅列举常用功能。
参考文献

[01]. Hibernate Validator

https://hibernate.org/validator/documentation/

[02]. SpringBoot + validator优雅参数校验,消除if-else

https://juejin.cn/post/7246800194463957053

[03]. 新来的一个同事,把SpringBoot参数校验玩的那叫一个优雅

https://juejin.cn/post/7322275119592996927

[04]. 快速失败(Fail Fast) Spring Validation

https://blog.csdn.net/liuqiang211/article/details/107979269

分组校验确实是神器!除了Create和Update,我经常用到的还有DeleteGroup,用于删除操作的参数校验。比如,删除用户时,可能只需要校验用户ID是否存在即可。另外,我还用过SearchGroup,用于搜索操作的参数校验。搜索条件可能会有很多,但每次搜索只需要校验部分参数即可。还有一种比较特殊的分组是AdminGroup,用于管理员操作的参数校验。管理员权限比较高,可能需要校验一些额外的参数。

确实,手机号校验现在轮子太多了,没必要自己造。我之前做金融项目的时候,自定义过校验身份证号码、银行卡号的规则,因为这些号码的格式和校验逻辑比较复杂,而且不同国家/地区的规则还不一样,用现成的库不太好满足需求。另外,我还自定义过校验交易金额的规则,比如限制单笔交易金额不能超过一定额度,或者每天的交易总额不能超过一定限额。这些规则跟具体的业务逻辑强相关,只能自己写。

我之前做过一个电商平台,商品上架时需要经过多重审核。我定义了三个分组:BasicInfoGroup、DetailInfoGroup、AuditInfoGroup。BasicInfoGroup用于校验商品基本信息,如商品名称、价格等;DetailInfoGroup用于校验商品详细信息,如商品描述、规格参数等;AuditInfoGroup用于校验审核信息,如审核人、审核意见等。这样,不同角色在审核商品时,只需要校验对应的分组即可,简化了审核流程。

我分享一个我自定义的校验规则:校验用户上传的文件类型是否安全。因为用户可能会上传恶意文件,比如伪装成图片的可执行文件,如果服务端不进行校验,可能会导致安全漏洞。我的做法是读取文件的Magic Number,然后跟允许的文件类型进行比对,确保文件类型是合法的。当然,这种方法也不是万无一失,但可以大大提高安全性。

这个问题很有意思!我觉得两种方式各有优劣,不能一概而论。快速失败能及时终止校验,尽早提示用户,但可能需要用户多次修改提交。返回所有错误信息可以一次性告知用户所有问题,减少交互次数,但是如果错误太多,用户可能会感到沮丧。 具体选择取决于应用场景和用户群体。如果用户对错误比较敏感,或者修改成本较低,快速失败可能更合适。如果用户比较宽容,或者修改成本较高,返回所有错误信息可能更好。我们团队通常会根据具体情况进行A/B测试,看看哪种方式的用户转化率更高。

我们项目里用过一个EnableGroup 和 DisableGroup,用于启用和禁用某个功能。启用时可能需要校验一些前提条件是否满足,禁用时可能需要校验是否有依赖项。这种分组方式可以清晰地表达业务意图,而且代码也比较简洁。

我司之前做过一个项目,需要校验用户输入的密码强度。除了常见的长度、是否包含大小写字母、数字、特殊字符等规则外,我们还增加了一个规则:禁止使用常见的弱密码,比如“123456”、“password”等。我们维护了一个弱密码字典,校验时会先判断用户输入的密码是否在字典中,如果在,则提示用户修改密码。这个规则虽然简单,但效果很好,大大降低了用户密码被破解的风险。

我之前做过一个电商项目,注册页面就采用了快速失败策略,结果用户疯狂吐槽,说每次都要试错好几次才能注册成功。后来改成一次性返回所有错误,用户明显满意多了。但是,在后台管理系统中,我们还是倾向于使用快速失败,因为后台用户通常是内部员工,对系统的容错性更高,而且快速失败可以帮助他们更快地定位问题。所以说,没有绝对的好坏,只有是否适合。

从技术角度来看,快速失败模式确实能提升服务器性能,减少不必要的资源消耗。但从用户体验角度,我更倾向于一次性返回所有错误信息。设想一下,用户填完一个复杂的表单,提交后只提示一个错误,修改后再提交又提示另一个错误,来回几次,用户体验会非常糟糕。当然,如果表单非常简单,只有少数几个字段,快速失败也未尝不可。所以,关键在于权衡技术效率和用户体验,找到一个最佳平衡点。