本文对应源码:欢迎关注我的公众号nrsc,并在同名文章中获取本文对应源码。

1 造轮子的背景

  • N年之前我曾一度以为在Springboot项目中只能在Controller层使用注解方式进行参数校验
  • 后来进入阿里工作,发现经手的代码,也只有在Controller层才会使用注解的方式进行参数校验,更加深了我的这种认知 -- <font color = blue>其实这并不对</font>(之后如果有时间的话,应该会具体说一下)!
  • 20年的时候,偶然读到文章《1. 不吹不擂,第一篇就能提升你对Bean Validation数据校验的认知》 --> 学会了通过Util对java bean进行参数注解校验的方式, 这成为了本篇文章最重要的理论依据。

2 轮子的使用姿势

2.1 姿势1 - 使用注解方式进行参数校验

  • 步骤1 - 引入pom坐标

    <dependency>
      <groupId>com.nrsc.starter</groupId>
      <artifactId>nrsc-param-validation-sdk</artifactId>
      <version>0.0.1-SNAPSHOT</version>
    </dependency>
  • 步骤2 - 在要想进行参数校验的bean上implements一个NeedValidateBean接口,示例代码如下

    @Data
    public class Dog implements NeedValidateBean {
    @NotBlank(message = "name不能为空")
    private String name;
    private int age;
    }
  • 步骤3:在要进行参数校验的方法上加上@ParamsCheck注解,示例代码如下

    @Data
    @Component
    public class People {
    @ParamsCheck
    public String play(Dog dog) {
      return "play with " + dog.name;
    }
    }

    2.2 姿势2 - 直接使用Util方式

    参见<font color = blue>3.1章节</font>

3 实现原理

3.1 对java bean进行注解校验的原理

主要原理如下:

public class BeanValidateUtil {

    private BeanValidateUtil() {
    }

    private static final Validator VALIDATOR;

    static {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        VALIDATOR = factory.getValidator();
    }

    /***
     * java bean 数据校验
     * 参数符合要求,返回 null,否则返回错误原因(包含参数名)
     *
     * @param target 目标 bean
     */
    public static String validateWithParam(Object target, Class<?>... groups) {
        Set<ConstraintViolation<Object>> constraintViolations = VALIDATOR.validate(target, groups);
        for (ConstraintViolation<Object> error : constraintViolations) {
            return "[" + error.getPropertyPath().toString() + "]" + error.getMessage();
        }
        return null;
    }
}

常见的基于注解进行的参数校验,主要包含如下三种:

  • 基础参数校验
  • 分组参数校验
  • 自定义参数校验

基于以上原理写出的校验case如下:

public class BeanValidateUtilTest {

    @Data
    public static class Phone {
        @NotBlank(message = "[基础校验]color不能为空")
        private String color;
    }

    @Data
    public static class Woman {
        @NotBlank(message = "[分组校验]name不能为空", groups = {Insert.class})
        private String name;

        @AgeCheck(message = "[自定义校验]未满18周岁")
        private Integer age;
    }

    public interface Insert {
    }

    public interface Update {
    }

    @Target({ElementType.FIELD})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Constraint(
            // 添加效验器
            validatedBy = {AgeConstraintValidator.class}
    )
    public @interface AgeCheck {

        String message() default "{error}";

        Class<?>[] groups() default {};

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


    public static class AgeConstraintValidator implements ConstraintValidator<AgeCheck, Integer> {

        public AgeConstraintValidator() {
        }

        @Override
        public boolean isValid(Integer age, ConstraintValidatorContext context) {
            if (age == null) {
                return false;
            }
            return age > 18;
        }

    }

    @Test
    public void validateWithParam001() {

        //基础校验
        Phone phone = new Phone();
        String res1 = BeanValidateUtil.validateWithParam(phone);
        System.out.println(res1);

        System.out.println("===1,2====");

        //分组校验
        Woman woman = new Woman();
        String res2 = BeanValidateUtil.validateWithParam(woman, Insert.class);
        System.out.println(res2);

        System.out.println("===2,3====");

        //自定义校验
        woman.name = "yoyo";
        String res3 = BeanValidateUtil.validateWithParam(woman);
        System.out.println(res3);
    }
}

3.2 拦截待校验方法进行校验的原理 - 三个关键点

  • 关键点1:

    要校验哪些参数的判定,这里实现的比较简单,即只有implements 如下接口的参数才需要校验
    /**
     * Created on 2023-04-18
     * Description: 需要校验参数的bean
     * 使用接口的原因是因为java是单继承,多实现
     */
    public interface NeedValidateBean {
    }
  • 关键点2:

    本文使用的是spring的aspect(AOP)进行拦截,引入的jar包如下(这里只引入AOP整合包就可以):
    <!--整合AOP-->
    <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
    </dependency>
    拦截方法采用的是注解方式,代码如下:
    @Slf4j
    @Aspect
    public class ServiceParamValidateAspect {
      /**
       * service 层入参校验
       * @param joinPoint 切点
       */
      @Around("@annotation(paramsCheck)")
      public Object serviceParamCheckAround(ProceedingJoinPoint joinPoint, ParamsCheck paramsCheck) throws Throwable {
    
          //获取当前类方法
          Method method;
          try {
              //获取当前类方法
              method = joinPoint.getTarget().getClass().getDeclaredMethod(joinPoint.getSignature().getName(),
                      ((MethodSignature) joinPoint.getSignature()).getParameterTypes());
          } catch (NoSuchMethodException noSuchMethodException) {
              //如果获取失败,获取父类方法
              method = joinPoint.getTarget().getClass().getSuperclass()
                      .getDeclaredMethod(joinPoint.getSignature().getName(),
                              ((MethodSignature) joinPoint.getSignature()).getParameterTypes());
          }
          //参数值对象
          Object[] objects = joinPoint.getArgs();
          //参数
          Parameter[] parameters = method.getParameters();
    
          for (int i = 0; i < objects.length; i++) {
              Object arg = objects[i];
              if (arg == null) {
                  continue;
              }
              // 判断是否为 com.nrsc.i18n.ad.dsp.util.paramvalidator.NeedValidateBean 的子类
              boolean isChildClass = NeedValidateBean.class.isAssignableFrom(arg.getClass());
    
              if (isChildClass) {
                  log.info("service层参数校验,method = {}, arg = {}", method.getName(), arg.toString());
    
                  //获取Validated中的值~~~分组信息
                  Validated annotation = AnnotationUtils.findAnnotation(parameters[i], Validated.class);
    
                  String validResult;
                  if (annotation == null) {
                      // 参数校验
                      validResult = BeanValidateUtil.validateWithParam(arg);
                  } else {
                      validResult = BeanValidateUtil.validateWithParam(arg, annotation.value());
                  }
                  //如果校验失败,抛出异常
                  if (validResult != null && validResult.length() > 0) {
                      log.error("service层参数校验失败,method = {}, arg = {}", method.getName(), arg.toString());
                      throw new IllegalArgumentException(validResult);
                  }
              }
          }
          return joinPoint.proceed();
      }
    }
  • 关键点3:
若要进行参数内对象的校验(<font color = blue>多年后发现其实我当时想实现的就是@valid注解本就支持的嵌套校验</font>),需要将代理的目标对象暴露出来,只需要在某配置类中加上如下注解就OK了
//将代理的目标对象暴露出来
@EnableAspectJAutoProxy(exposeProxy = true) 

4 具体使用case

这里贴一个简单的case(更多case可以download源码进行查看):

@Slf4j
public class ParamCheckFatherAndSonTest {

    @Data
    public static class Dog implements NeedValidateBean {
        @NotBlank(message = "name不能为空")
        private String name;
        private int age;
        private List<Toy> toyList;
    }

    @Data
    public static class Toy implements NeedValidateBean {
        @NotBlank(message = "color不能为空")
        private String color;
    }

    public abstract static class Animal {
        @ParamsCheck
        String play(Dog dog, String test) {
            return "Animal play" + dog.toString();
        }
    }

    @EqualsAndHashCode(callSuper = true)
    @Component("person")
    public static class Person extends Animal {
        @Override
        @ParamsCheck
        public String play(Dog dog, String test) {
            if (dog.getToyList() != null) {
                dog.toyList.forEach(e -> ((Person) AopContext.currentProxy()).check(e));
            }
            return "Person play" + dog.toString();
        }

        @ParamsCheck //private Aspect切不到
        protected void check(Toy toy) {
        }
    }

    @EqualsAndHashCode(callSuper = true)
    @Component("man")
    public static class Man extends Animal {

    }


    @Configuration
    @ComponentScan("com.nrsc.service.param.validation")
    @EnableAspectJAutoProxy(exposeProxy = true) //指定使用CGLIB代理
    public static class Conf {
    }

    @Test
    public void test01() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(Conf.class);
        Animal bean = ac.getBean(Man.class);
        Dog dog = new Dog();

        assertThatThrownBy(() -> bean.play(dog, "test")).isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("name不能为空");
    }
    @Test
    public void test02() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(Conf.class);
        Person bean = ac.getBean(Person.class);
        Dog dog = new Dog();
        dog.setName("yoyo");
        dog.setToyList(Arrays.asList(new Toy()));

        assertThatThrownBy(() -> bean.play(dog, "test")).isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("color不能为空");
    }
}

5 写给读者

  • 多年之前其实我就将本文所述内容,应用到了我们的项目里,而且还在公司内部做过相应内容的分享
  • 但是后来发现其实Spring有相应的解决方案😭😭😭,而且用起来更爽!( 可惜当时在做内部分享时或者我将此方案发布到内网后都并没有同学给我指出来!!!)
  • 这里给读者简单提个问题:你们平常的参数校验在哪一层?是如何做的?

最后,非常感谢您宝贵的时间,愿本文能给您带来些许帮助!

本文由mdnice多平台发布


nrsc
1 声望0 粉丝