Tch
Tch
Published on 2024-12-01 / 50 Visits
0
0

如何修正@FutureOrPresent注解以支持天级精度校验

1. 注解@FutureOrPresent的使用

1.1 举个小栗子

在Jakarta Bean Validation中,@FutureOrPresent注解用于校验日期字段是否在当前时间之后或等于当前时间。

在DemoDto中有个Date类型的变量date1,添加了校验注解@FutureOrPresent。

@Data
public class DemoDto implements Serializable {

    private static final long serialVersionUID = 2251918778365338096L;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
    @FutureOrPresent(message = "date1必须是以后或现在的时间")
    private Date date1;

}

随便写个接口测试一下这个注解。

@RestController
public class DemoController {

    @PostMapping("/test")
    public String test(@Validated @RequestBody DemoDto demoDto) {
        return "OK";
    }
}

测试之前先看下当前时间,北京时间2024年12月1日17:28。

用"2024-12-2"测试了一下,返回了OK,结果符合预期。

1.2 上强度

在DemoDto里新增了date2和date3,date1用来测试过去日期,date2测试当前日期,date3测试以后的日期。

@Data
public class DemoDto implements Serializable {

    private static final long serialVersionUID = 2251918778365338096L;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
    @FutureOrPresent(message = "date1必须是以后或现在的时间")
    private Date date1;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
    @FutureOrPresent(message = "date2必须是以后或现在的时间")
    private Date date2;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
    @FutureOrPresent(message = "date3必须是以后或现在的时间")
    private Date date3;

}

发送请求,看看结果(由于对数据校验异常MethodArgumentNotValidException设置了全局异常处理,所以只返回校验失败的message)。

  • date1:"2024-11-30"是过去的日期,报错了没问题。

  • date2: "2024-12-1"是今天的日期,报错了?有问题。

  • date3:"2024-12-2"是以后得日期,没报错没问题。

1.3 问题来了

@FutureOrPresent注解用于校验日期字段是否在当前时间之后或等于当前时间,那么问题来了,今天的日期为什么会报错?

2. 今天的日期为什么会校验不过

2.1 找原因

看下源代码,校验是在AbstractInstantBasedTimeValidator的isValid方法里做的,value就是前边请求的参数date2的值("2024-12-1")。

/org/hibernate/validator/hibernate-validator/6.2.5.Final/hibernate-validator-6.2.5.Final.jar!/org/hibernate/validator/internal/constraintvalidators/bv/time/AbstractInstantBasedTimeValidator.class

点进去看看getInstant(value) ,在getInstant方法里,参数value仍旧是date2的值("2024-12-1")。

/org/hibernate/validator/hibernate-validator/6.2.5.Final/hibernate-validator-6.2.5.Final.jar!/org/hibernate/validator/internal/constraintvalidators/bv/time/futureorpresent/FutureOrPresentValidatorForDate.class

接着往下走,就是/jdk-17.jdk/Contents/Home/lib/src.zip!/java.base/java/time/Instant.java的ofEpochMilli方法。

参数epochMilli的值是1732982400000,其实就是date2("2024-12-1")的毫秒值。

ofEpochMilli方法就是用给定的一个毫秒值创建一个Instant对象。

到这就不看了,再回头去看referenceClock.instant() 。

其实是/jdk-17.jdk/Contents/Home/lib/src.zip!/java.base/java/time/Clock.java下的instant方法。

该方法返回的是一个当前时间的Instant。

最后这个compareTo方法就是对两个Instant作比较了。

返回的比较结果是-1。

再往下在/org/hibernate/validator/internal/constraintvalidators/bv/time/futureorpresent/AbstractFutureOrPresentInstantBasedValidator.java的方法isValid里,当入参为-1时,返回false。

到这里就知道为什么今天的日期校验不过了。

2.2 怎么解决

真没想到@FutureOrPresent注解在校验时竟然比较的是两个Date类型的毫秒值,直呼一声好严谨。

但是如此严谨并不符合我的要求,我只需要在天这个精度下作校验就行了。

怎么做呢,我想到了三个办法。

  1. 手写一个if判断一下,能够实现但不够优雅。

  2. 自定义一个注解,优雅但我还是想使用@FutureOrPresent这个注解(自定义注解的方式参考以下这篇文章)。

    https://tch.cool/archives/AjP11V6r
  3. 对@FutureOrPresent的校验方式作个修改,够优雅。

3. 解决办法

3.1 解决方法

从上文2.1可以看到,校验的内容都是写在方法isValid里的,所以只需要实现接口ConstraintValidator,并重写isValid方法就行了。

需要说明的是FutureOrPresent是校验的注解,Date是校验的数据类型。

isValid方法先默认返回true,具体的内容稍后再实现,先确认这个做法的可行性。

public class CustomConstraintValidator implements ConstraintValidator<FutureOrPresent, Date> {

    @Override
    public boolean isValid(Date date, ConstraintValidatorContext context) {
        return true;
    }
}

为了使Spring使用自定义的验证器,需要在配置文件中指定它,可通过ConstraintValidatorFactory来实现。

在key是FutureOrPresentValidatorForDate时,替换成自定义的校验器CustomConstraintValidator。

/**
 * 通过自定义的 ConstraintValidatorFactory 替换默认的 FutureOrPresentValidatorForDate 验证器为 CustomConstraintValidator。
 * 当应用中使用 @FutureOrPresent 注解时,实际使用的是自定义验证器CustomConstraintValidator。
 */
class CustomConstraintValidatorFactory implements ConstraintValidatorFactory {

    @Override
    public <T extends ConstraintValidator<?, ?>> T getInstance(Class<T> key) {
        try {
            // FutureOrPresentValidatorForDate时替换成自定义校验器
            if (key.equals(FutureOrPresentValidatorForDate.class)) {
                return (T) new CustomConstraintValidator();
            }
            return key.getDeclaredConstructor().newInstance();
        } catch (Exception e) {
            // 抛出自定义异常,用于全局异常捕获
            throw new BusinessException(e.getMessage());
        }
    }

    @Override
    public void releaseInstance(ConstraintValidator<?, ?> instance) {

    }
}

然后自定义配置类,设置自定义的约束验证器工厂类CustomConstraintValidatorFactory。

@Configuration
public class ValidationConfig {

    @Bean
    public Validator validator() {
        ValidatorFactory factory = Validation.byProvider(HibernateValidator.class)
                .configure()
                // 设置自定义的CustomConstraintValidatorFactory,用于创建约束验证器实例
                .constraintValidatorFactory(new CustomConstraintValidatorFactory())
                .buildValidatorFactory();

        return factory.getValidator();
    }
}

3.2 测试一下

还是这三个参数,这次都校验通过了。说明这种方式是可行的,接下来就是真正实现isValid这个方法了。

3.3 实现isValid方法

原理很简单,就是比较两个Date类型的数据了,实现如下。

public boolean isValid(Date date, ConstraintValidatorContext context) {
    // 指定时区为Asia/Shanghai
    ZoneId shanghaiZone = ZoneId.of("Asia/Shanghai");
    // 获取当前日期的
    LocalDate now = LocalDate.now(shanghaiZone);
    // 要校验的日期转换为LocalDate
    LocalDate compare = date.toInstant().atZone(shanghaiZone).toLocalDate();

    // 判断校验的日期是否是今天或以后
    return !now.isAfter(compare);
}

可以看到now和compare转换为LocalDate是同一天。

还是同样的三个参数,现在只有date1没有校验通过,说明已经实现功能。

打完收工。


Comment