Qicz’s Thoughts HUB

The creative and technical writing. Do more, challenge more, know more, be more.

Spring Boot Bean动态校验

springboot中的bean校验使用的是hibernate-validator,相对来说,这种通过注解的校验,对业务代码的“污染”会很少很多很多了。但是,这种方式的局限也很明显:

使用注解的方式,可以说是一种明确的、静态的校验方式,因为“校验”都通过注解呈现了,也就是已经固化了。当需要根据接口的入参去校验关联的属性时,就无法完成了。

如果要实现这种“根据接口的入参去校验关联的属性”就不得不进行根据入参的动态校验,同时还不能影响原有的校验机制,就是静态校验和动态校验的并存,且不能对业务有过多的“污染”。这也是此文的由来了,接下来会讲解一种静态、动态校验可并存的机制。

举例:校验用户的信息,如果用户是微信用户,需要校验微信用户相关的信息,如果用户18岁以上,还需要校验身份证信息;如果是微博用户,需要校验微博相关的信息。

这个场景中,使用静态的方式就不能完成所有场景的校验了。那么传统的做法,可能需要根据org.openingo.account.User#userType的具体取值去判断,比如是org.openingo.account.UserTypeEnum#WECHAT时去校验org.openingo.account.User#weChatAccount等等,如果对应的数据不合法,则抛出对应的异常。这其实就是动态校验的核心逻辑了,下面看具体的源码:

 1@Getter
 2@AllArgsConstructor
 3public enum UserTypeEnum {
 4
 5    WECHAT(1),
 6    WEIBO(2),
 7    ;
 8
 9    private Integer code;
10
11    @JsonCreator
12    public static UserTypeEnum newByCode(Integer code) {
13        return Stream.of(values()).filter(e -> code.equals(e.getCode())).findFirst().orElse(null);
14    }
15}
 1@Data
 2public class User implements Serializable {
 3
 4    @NotNull(message = "用户类型不可为null")
 5    private UserTypeEnum userType;
 6
 7    private WeChatAccount weChatAccount;
 8
 9    private WeiboAccount weiboAccount;
10}
 1@Data
 2public class WeChatAccount implements Serializable {
 3
 4    @NotBlank(message = "账号id不能为空")
 5    private String accountId;
 6
 7    @NotBlank(message = "昵称不能为空")
 8    private String nickname;
 9
10    @NotNull(message = "性别不能为空")
11    @Pattern(regexp = "^man$|^woman$", message = "取值不合法")
12    private String sex;
13    
14    @NotNull(message = "年龄不能为空")
15    private Integer age;
16
17    @NotBlank(message = "头像不能为空")
18    private String avatarUri;
19
20    private IdCard idCard;
21}
1@Data
2public class IdCard implements Serializable {
3
4    @NotBlank(message = "id不能为null")
5    private String id;
6
7    @NotBlank(message = "address不能为null")
8    private String address;
9}
 1@Data
 2public class WeiboAccount implements Serializable {
 3
 4    @NotBlank(message = "账号id不能为空")
 5    private String accountId;
 6
 7    @NotBlank(message = "昵称不能为空")
 8    private String nickname;
 9
10    @NotNull(message = "性别不能为空")
11    @Pattern(regexp = "^man$|^woman$", message = "取值不合法")
12    private String sex;
13    
14    @NotNull(message = "年龄不能为空")
15    @Min(value = 18, message = "只能18岁以上用户可以使用")
16    private Integer age;
17
18    @NotBlank(message = "头像不能为空")
19    private String avatarUri;
20
21    @NotBlank(message = "签名不能为空")
22    private String signature;
23}

要完成动态校验就需要对应的实体实现DynamicValidatorDynamicValidator已经集成到spring-boot-x2.9.5及以上版本

DynamicValidator是建立在动态校验参数的基础之上完成的,把具体的细节都封装了,简化了使用。

目前的DynamicValidator支持

  • ABC——链式校验;
  • ACD——跳跃式模式的校验。

可以从这里看到具体的DynamicValidator实现,其提供了:

  • org.openingo.spring.validator.DynamicValidator#silentDynamicValidate静默校验,适合在service层使用,如果有不合法的数据,会返回错误的message
  • org.openingo.spring.validator.DynamicValidator#validateField针对某个具体的字段的校验—— throwValidationException
  • org.openingo.spring.validator.DynamicValidator#validateSelf(T, java.lang.Class<?>…)对实体自身的校验
  • org.openingo.spring.validator.DynamicValidator#dynamicValidate(T, java.lang.String, java.lang.Class<?>...)指定数据不合法返回错误的message及校验组groups
  • org.openingo.spring.validator.DynamicValidator#addConstraintViolation自定义校验时,定义消息模板

针对上面的场景,应该如何实现呢 ? 根据描述的场景适合ACD跳跃校验:User就是入口,org.openingo.account.WeChatAccount#age就是跳跃点。从入口着手,看看具体实现:

 1@Data
 2public class User implements Serializable, DynamicValidator {
 3
 4    @NotNull(message = "用户类型不可为null")
 5    private UserTypeEnum userType;
 6
 7    private WeChatAccount weChatAccount;
 8
 9    private WeiboAccount weiboAccount;
10
11    @Override
12    public void dynamicValidate() {
13        // 根据用户的类型确定要校验的数据
14        if (UserTypeEnum.WECHAT.equals(this.userType)) {
15            // 直接调用dynamicValidate校验对应的字段,
16            // 会进行非null及字段的dynamicValidate的进一步校验
17            this.dynamicValidate(this.weChatAccount, "微信数据不合法");
18        } else {
19            this.dynamicValidate(this.weiboAccount, "微博数据不合法");
20        }
21    }
22}

进一步校验就会到具体的数据中进行了,那么看看WeChatAccount中的实现:

 1@Data
 2public class WeChatAccount implements Serializable, DynamicValidator {
 3
 4    @NotBlank(message = "账号id不能为空")
 5    private String accountId;
 6
 7    @NotBlank(message = "昵称不能为空")
 8    private String nickname;
 9
10    @NotNull(message = "性别不能为空")
11    @Pattern(regexp = "^man$|^woman$", message = "取值不合法")
12    private String sex;
13
14    @NotNull(message = "年龄不能为空")
15    private Integer age;
16
17    @NotBlank(message = "头像不能为空")
18    private String avatarUri;
19
20    private IdCard idCard;
21
22    @Override
23    public void dynamicValidate() {
24        // 如果年龄大于18岁,需要校验身份信息是否合法
25        if (this.age >= 18) {
26            // 同样的dynamicValidate将对idCard进行非null及进一步的校验
27            this.dynamicValidate(this.idCard, "身份信息不合法");
28            // IdCard如未实现DynamicValidator可以按照如下方式进行校验
29            // this.validateField(null == this.idCard, "身份信息不合法");
30            // this.validateSelf(this.idCard);
31        }
32    }
33}

如果到了最后一级校验,如此处的IdCard,其可不实现DynamicValidator

再来看看,实现DynamicValidator后的IdCard

 1@Data
 2public class IdCard implements Serializable, DynamicValidator {
 3
 4    @NotBlank(message = "id不能为null")
 5    private String id;
 6
 7    @NotBlank(message = "address不能为null")
 8    private String address;
 9
10    @Override
11    public void dynamicValidate() {
12        // 啥也不用写
13    }
14}

因为到了最后一级,这也是为什么说,到最后一级可不实现DynamicValidator的原因。

最后看看,WeiboAccount的实现:

 1@Data
 2public class WeiboAccount implements Serializable, DynamicValidator {
 3
 4    @NotBlank(message = "账号id不能为空")
 5    private String accountId;
 6
 7    @NotBlank(message = "昵称不能为空")
 8    private String nickname;
 9
10    @NotNull(message = "性别不能为空")
11    @Pattern(regexp = "^man$|^woman$", message = "取值不合法")
12    private String sex;
13
14    @NotNull(message = "年龄不能为空")
15    @Min(value = 18, message = "只能18岁以上用户可以使用")
16    private Integer age;
17
18    @NotBlank(message = "头像不能为空")
19    private String avatarUri;
20
21    @NotBlank(message = "签名不能为空")
22    private String signature;
23
24    @Override
25    public void dynamicValidate() {
26        // 在这个场景中,WeiboAccount其实与IdCard相当,都是最后一级校验,
27        // 所以可以不必实现DynamicValidator
28    }
29}

在这个场景中,WeiboAccount其实与IdCard相当,都是最后一级校验,所以可以不必实现DynamicValidator。

如何使用?

 1@RestController
 2@Validated
 3public class UserController {
 4
 5    @Autowired
 6    IUserService userService;
 7
 8    @PostMapping("/user/sync")
 9    public RespData syncUser(@RequestBody @Validated User user) {
10        user.dynamicValidate();
11        this.userService.syncUser(user);
12        return RespData.success();
13    }
14}

可以看到与通常的使用几乎没有差异,只需在实际业务处理前,“手动”再次触发“dynamicValidate”即可。看看具体的效果:

  • 未提供为何数据时
1{
2    "sc": "400",
3    "sm": "用户类型不可为null"
4}
  • 用户类型为Wechat
1{
2    "sc": "500",
3    "sm": "微信数据不合法"
4}
  • 用户类型为Wechat,提供了wechatAccount数据为空对象时
1{
2    "userType": 1,
3    "weChatAccount":{
4        
5    }
6}
1{
2    "sc": "500",
3    "sm": "昵称不能为空"
4}
  • 用户类型为Wechatage < 18
 1{
 2    "userType": 1,
 3    "weChatAccount": {
 4        "nickname": "qicz",
 5        "avatarUri": "http://...",
 6        "accountId": "123",
 7        "age": 12,
 8        "sex": "man"
 9    }
10}
1{
2    "sc": "success",
3    "sm": "response successful"
4}
  • 用户类型为Wechatage >= 18
 1{
 2    "userType": 1,
 3    "weChatAccount": {
 4        "nickname": "qicz",
 5        "avatarUri": "http://...",
 6        "accountId": "123",
 7        "age": 19,
 8        "sex": "man"
 9    }
10}
1{
2    "sc": "500",
3    "sm": "身份信息不合法"
4}
  • 用户类型为Wechatage >= 18,提供了idCard数据为空对象时
 1{
 2    "userType": 1,
 3    "weChatAccount": {
 4        "nickname": "qicz",
 5        "avatarUri": "http://...",
 6        "accountId": "123",
 7        "age": 19,
 8        "sex": "man",
 9        "idCard": {}
10    }
11}
1{
2    "sc": "500",
3    "sm": "id不能为null"
4}
  • 用户类型为Wechatage >= 18,仅提供了idCardid 数据时
 1{
 2    "userType": 1,
 3    "weChatAccount": {
 4        "nickname": "qicz",
 5        "avatarUri": "http://...",
 6        "accountId": "123",
 7        "age": 19,
 8        "sex": "man",
 9        "idCard": {
10            "id": "idddd"
11        }
12    }
13}
1{
2    "sc": "500",
3    "sm": "address不能为null"
4}
  • 用户类型为Wechatage >= 18,提供了完成的idCard数据时
 1{
 2    "userType": 1,
 3    "weChatAccount": {
 4        "nickname": "qicz",
 5        "avatarUri": "http://...",
 6        "accountId": "123",
 7        "age": 19,
 8        "sex": "man",
 9        "idCard": {
10            "id": "idddd",
11            "address": "bj"
12        }
13    }
14}
1{
2    "sc": "success",
3    "sm": "response successful"
4}

针对微博数据的校验示例,这里就不一一罗列了。

至此,关于动态校验的介绍就over了!通过DynamicValidator可以极大的简化针对动态校验的场景,也方便了在不规模修改的情况下,对静态和动态校验进行了整合。

这里是完成的代码示例。