SpringBoot——自定义validation注解(支持多字段属性关联校验)

背景

validation中提供的注解都是针对单个参数的,如果两个参数之间有关联关系就只能在代码里判断了,比如:

@Data
@ApiModel(value = "User用户登录", description = "用户登录")
public class User {

    @ApiModelProperty(value = "手机号")
    @NotBlank(message = "手机号不能为空")
    private String mobile;

    @ApiModelProperty(value = "身份证号")
    private String idcardNo;

    @ApiModelProperty(value = "密码")
    private String password;

    @ApiModelProperty(value = "登录短信验证")
    private String messageCode;

    @ApiModelProperty(value = "登录类型: 手机号密码登录:1, 手机号短信登录:2, 身份证密码登录:3", required = true)
    @NotNull(message = "登录类型不能为空!")
    @Min(value = 1, message = "登录类型值不能小于1")
    @Max(value = 3, message = "登录类型值不能大于3")
    private Long loginTypeCode;
}

根据loginTypeCode不同的值去判断mobile、idcardNo、password、messageCode关联是否为空。

一、使用@ScriptAssert注解

@Data
@ApiModel(value = "User用户登录", description = "用户登录")
@ScriptAssert.List(value = {
        @ScriptAssert(script = "_this.loginTypeCode == 1 && _this.mobile != null && _this.password != null",
                lang = "javascript",
                message = "手机号密码登录,手机号和密码不能为空"),
        @ScriptAssert(script = "_this.loginTypeCode == 2 && _this.mobile != null && _this.messageCode != null",
                lang = "javascript",
                message = "手机号验证码登录,手机号和验证码不能为空"),
        @ScriptAssert(script = "_this.loginTypeCode == 3 && _this.idcardNo != null && _this.password != null",
                lang = "javascript",
                message = "身份证号密码登录,身份证号和密码不能为空")
})
public class User {

    @ApiModelProperty(value = "手机号")
    @NotBlank(message = "手机号不能为空")
    private String mobile;

    @ApiModelProperty(value = "身份证号")
    private String idcardNo;

    @ApiModelProperty(value = "密码")
    private String password;

    @ApiModelProperty(value = "登录短信验证")
    private String messageCode;

    @ApiModelProperty(value = "登录类型: 手机号密码登录:1, 手机号短信登录:2, 身份证密码登录:3", required = true)
    @NotNull(message = "登录类型不能为空!")
    @Min(value = 1, message = "登录类型值不能小于1")
    @Max(value = 3, message = "登录类型值不能大于3")
    private Long loginTypeCode;
}

这种方法需要使用javascript,对于部分人来说可能不够直观也很难调试。

二、自定义注解+spring表达式

针对这种情况我利用spring表达式写了一个自定义注解来解决这个问题。

2.1 自定义注解

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @Author 黄义波
 * @Date 2024/12/30 16:31
 * @Description
 * 多属性关联校验注解
 * 用于校验多个属性之间的关联关系
 * 当when条件满足时,必须满足must条件否则校验不通过
 * 注意:如果解析spel表达式错误将抛出异常
 */
@Documented
@Constraint(validatedBy = {MultiFieldAssociationCheckValidator.class })
@Target({ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(MultiFieldAssociationCheck.List.class)
public @interface MultiFieldAssociationCheck {

    /**
     * 错误信息描述,必填
     */
    String message();

    /**
     * 分组校验
     */
    Class<?>[] groups() default { };

    /**
     * 负载
     */
    Class<? extends Payload>[] payload() default { };

    /**
     * 当什么条件下校验,必须是一个spel表达式
     */
    String when();

    /**
     * 必须满足什么条件,必须是一个spel表达式
     */
    String must();

    @Target({ElementType.TYPE_USE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @interface List {
        MultiFieldAssociationCheck[] value();
    }
}

2.2 编写注解校验类

import com.xtm.anno.MultiFieldAssociationCheck;
import com.xtm.utils.SpelUtils;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

/**
 * @Author 黄义波
 * @Date 2024/12/30 16:35
 * @Description 多属性关联校验注解的实现类
 */

public class MultiFieldAssociationCheckValidator implements ConstraintValidator<MultiFieldAssociationCheck, Object> {

    private static final String SPEL_TEMPLATE = "%s%s%s";
    private static final String SPEL_PREFIX = "#{";
    private static final String SPEL_SUFFIX = "}";
    private String when;

    private String must;

    @Override
    public void initialize(MultiFieldAssociationCheck constraintAnnotation) {
        this.when = constraintAnnotation.when();
        this.must = constraintAnnotation.must();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {

        if (StringUtils.isBlank(when) || StringUtils.isBlank(must)) {
            return true;
        }
        Map<String, Object> spelMap = getSpelMap(value);
        //when属性是一个spel表达式,执行这个表达式可以得到一个boolean值
        boolean whenCheck = SpelUtils.parseSpel(String.format(SPEL_TEMPLATE, SPEL_PREFIX, when, SPEL_SUFFIX), spelMap);
        if (whenCheck) {
            //判断must是否满足条件
            boolean mustCheck = SpelUtils.parseSpel(String.format(SPEL_TEMPLATE, SPEL_PREFIX, must, SPEL_SUFFIX), spelMap);
            if (!mustCheck) {
                //获取注解中的message属性值
                String message = context.getDefaultConstraintMessageTemplate();
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(message).addConstraintViolation();
                return false;
            }
        }
        return true;
    }

    @SneakyThrows
    private Map<String, Object> getSpelMap(Object value) {
        Field[] declaredFields = value.getClass().getDeclaredFields();
        Map<String, Object> spelMap = new HashMap<>();
        for (Field declaredField : declaredFields) {
            declaredField.setAccessible(true);
            //将对象中的属性名和属性值放入map中
            spelMap.put(declaredField.getName(), declaredField.get(value));
        }
        return spelMap;
    }
}

2.3 SpelUtils

import org.apache.commons.lang3.StringUtils;
import org.springframework.context.expression.BeanFactoryAccessor;
import org.springframework.context.expression.MapAccessor;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.common.TemplateParserContext;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

import java.util.Map;

/**
 * @Author 黄义波
 * @Date 2024/12/31 9:17
 * @Description
 */

public class SpelUtils {

    public static Boolean parseSpel( String spel, Map<String, Object> map) {
        if (StringUtils.isBlank(spel)) {
            return Boolean.FALSE;
        } else {
            ExpressionParser parser = new SpelExpressionParser();
            StandardEvaluationContext context = new StandardEvaluationContext();
            context.setVariables(map);
            context.addPropertyAccessor(new MapAccessor());
            context.addPropertyAccessor(new BeanFactoryAccessor());
            return parser.parseExpression(spel, new TemplateParserContext()).getValue(context, Boolean.class);
        }
    }
}

2.4 使用注解,这个注解使用在类上

@Data
@ApiModel(value = "User用户登录", description = "用户登录")
@MultiFieldAssociationCheck.List(
        value = {
                @MultiFieldAssociationCheck(when = "#loginTypeCode == 1", must = "#mobile != null && #password != null", message = "手机号密码登录,手机号和密码不能为空"),
                @MultiFieldAssociationCheck(when = "#loginTypeCode == 2", must = "#mobile != null && #messageCode != null", message = "手机号验证码登录,手机号和验证码不能为空"),
                @MultiFieldAssociationCheck(when = "#loginTypeCode == 3", must = "#idcardNo != null && #password != null", message = "身份证号密码登录,身份证号和密码不能为空")
        }
)
public class User {

    @ApiModelProperty(value = "手机号")
    @NotBlank(message = "手机号不能为空")
    private String mobile;

    @ApiModelProperty(value = "身份证号")
    private String idcardNo;

    @ApiModelProperty(value = "密码")
    private String password;

    @ApiModelProperty(value = "登录短信验证")
    private String messageCode;

    @ApiModelProperty(value = "登录类型: 手机号密码登录:1, 手机号短信登录:2, 身份证密码登录:3", required = true)
    @NotNull(message = "登录类型不能为空!")
    @Min(value = 1, message = "登录类型值不能小于1")
    @Max(value = 3, message = "登录类型值不能大于3")
    private Long loginTypeCode;
}

三、单字段自定义注解校验

3.1 观察现有校验规则

尽管框架提供一些校验规则,难免遇到一些现有规则不能覆盖的情况,这里我们就一些特定情况做个自定义的参数校验。

我们可以先观察下现有校验规则是怎样的:

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(Min.List.class)
@Documented
@Constraint(
    validatedBy = {}
)
public @interface Min {
    String message() default "{jakarta.validation.constraints.Min.message}";
 
    Class<?>[] groups() default {};
 
    Class<? extends Payload>[] payload() default {};
 
    long value();
 
    @Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface List {
        Min[] value();
    }
}
@Documented
@Constraint(
    validatedBy = {}
)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(NotBlank.List.class)
public @interface NotBlank {
    String message() default "{jakarta.validation.constraints.NotBlank.message}";
 
    Class<?>[] groups() default {};
 
    Class<? extends Payload>[] payload() default {};
 
    @Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface List {
        NotBlank[] value();
    }
}

在做自定义的参数校验的时候,除了自定义的注解接口,另外我们还需要再实现一个接口 ConstraintValidator。
我们在 User 类中加个参数,比如加个用户地区,限定 Asia 或 Ameraica,只有这两个地区的人才可以注册。

  • Address
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
 
import java.lang.annotation.*;
 
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {AddressValidator.class})
@Target({ElementType.METHOD, ElementType.FIELD,
        ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR,
        ElementType.PARAMETER, ElementType.TYPE_USE})
public @interface Address {
    String message() default "不在合法地区范围内";
 
    Class<?>[] groups() default {};
 
    Class<? extends Payload>[] payload() default {};
}
  • AddressValidator
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
 
import java.util.Arrays;
 
public class AddressValidator implements ConstraintValidator<Address, String> {
    private static  String[] addresses = {"Asia", "America"};
 
    @Override
    public boolean isValid(String address, ConstraintValidatorContext context) {
        if (Arrays.asList(addresses).contains(address)) {
            return true;
        }
 
        return false;
    }
}
  • 在 User类 中加入 address 字段:
public String getAddress() {
    return address;
}
 
public void setAddress(String address) {
    this.address = address;
}
 
@NotBlank(message = "地址非空")
@Address
private String address;

参考:
https://blog.csdn.net/zhen_csdn/article/details/131941489

https://www.cnblogs.com/davis12/p/17879793.html

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,110评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,443评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,474评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,881评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,902评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,698评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,418评论 3 419
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,332评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,796评论 1 316
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,968评论 3 337
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,110评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,792评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,455评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,003评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,130评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,348评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,047评论 2 355

推荐阅读更多精彩内容