聊聊ValidX时间注解完全指南:10种时间验证注解详解

整理一篇学习笔记,把看到的一些要点和自己的理解都记下来。

ValidX时间注解完全指南:10种时间验证注解详解

引言

时间是业务系统里最容易"出小错、藏大坑"的字段:生日填成 2024-02-30、签到时间漏了秒、时间戳少一位数、活动截止时间被当成过去时间……每一个错误都真实存在,而每一个错误都能让一次线上事故变成一个 Bug 工单。

ValidX 把时间验证拆成了 10 个注解,按职责分工:

  • 格式验证(值长什么样):@Date@DateTime@HourMinute@HourMinuteSecond
  • 时间点验证(值在过去还是未来):@PastDate@FutureDate@PastDateTime@FutureDateTime
  • 特殊格式(非人类可读的时间):@Timestamp@Duration
这篇文章不做"复制粘贴注解"式教程,而是逐行对照 10 个注解背后的 Validator 源码,把三个关键机制讲透:严格解析(STRICT)到底严格在哪过去/未来判断的边界语义时间戳与时间段的正则/数值校验。文章还会指出几个连官方文档都没写清楚的实现细节——像是"@PastDate 会悄悄把 2024-02-30 变成 2024-02-29"。
文中所有结论均对照 ValidX v1.2.0 源码逐一核实,关键行为附测试验证结果。

一、全景图:10 种注解的分类与定位

1.1 总览表

注解验证东西默认格式是否含时间支持类型版本@Date纯日期格式yyyy-MM-dd否String1.1.0@DateTime日期时间格式yyyy-MM-dd HH:mm:ss是String1.1.0@PastDate过去日期yyyy-MM-dd否String1.0.0@FutureDate未来日期yyyy-MM-dd否String1.0.0@PastDateTime过去日期时间yyyy-MM-dd HH:mm:ss是String1.1.0@FutureDateTime未来日期时间yyyy-MM-dd HH:mm:ss是String1.1.0@HourMinuteHH:mm 时分固定 HH:mm纯时间String1.0.0@HourMinuteSecondHH:mm:ss 时分秒固定 HH:mm:ss纯时间String1.0.0@TimestampUnix 时间戳(秒/毫秒)10 位或 13 位数字时间戳String / Long1.0.0@Duration时间段(ISO 8601 / 简化)任意时间段String1.0.0

1.2 三大设计原则

通读全部源码后,可以提炼出 ValidX 时间验证的三个贯穿性设计:

1. 空值一律放行。10 个注解对 null 和空字符串都返回 true,把"是否必填"的职责完全交给 @NotNull / @NotEmpty。这样 @Date 可以叠加在可选字段上而不误伤。
2. pattern 强自检@Date 的 pattern 不允许出现时间符号,@DateTime 的 pattern 一定要出现时间符号——配置写错不会产生莫名其妙的解析失败,而是会得到明确的 pattern 错误:注解方式在每次校验时返回固定的错误消息(patternInvalid 分支);链式方式则返回 false 并把错误消息塞进 errors 列表(留意:并不会抛异常,原因见 7.1 坑 5)。
3. 严格优于宽松@Date / @DateTime 显式使用 ResolverStyle.STRICT,拒绝 2024-2-5 这类缺零填充、拒绝 2024-02-30 这类无效日期。但下面会看到,@PastDate / @FutureDate 系列走的却是默认的 SMART 模式,行为并不一致——这是这里想重点提醒的一个坑。


二、格式验证四件套:@Date / @DateTime / @HourMinute / @HourMinuteSecond

2.1 @Date:纯日期 + 严格模式

@Date 只验证"值是不是一个合法日期",不关心过去还是未来:

// 基础用法
@Date
private String eventDate;              // 期望 2024-01-15

// 自定义格式
@Date(pattern = "yyyy/MM/dd")
private String birthDate;

// 中文格式也支持
@Date(pattern = "yyyy年MM月dd日")
private String chineseDate;

它的核心在 DateValidatorcreateStrictFormatter

private static DateTimeFormatter createStrictFormatter(String pattern) {
    // 将 yyyy 替换为 uuuu 以支持 STRICT 模式
    String strictPattern = pattern.replace("yyyy", "uuuu")
                                 .replace("yy", "uu");

    return DateTimeFormatter.ofPattern(strictPattern, Locale.US)
            .withResolverStyle(ResolverStyle.STRICT);
}

这里藏着一个非常容易被忽略的细节:为什么要把 yyyy 替换成 uuuu

yyyy 是"纪元年份"(year-of-era),uuuu 是"公历年"(proleptic year)。在 ResolverStyle.STRICT 模式下,解析 yyyy 字段一定要同时提供纪元(era)字段——也就是 AD/BC。而用户输入的是纯数字字符串,没有纪元信息,所以 LocalDate.parse("2024-02-05", ofPattern("yyyy-MM-dd").withResolverStyle(STRICT)) 会直接抛异常,连合法日期都过不了。

我们做了一组实测验证这个替换的必要性(yyyy-MM-dd 模式、ResolverStyle.STRICT):

输入STRICT + yyyy(替换前)STRICT + uuuu(替换后,即 ValidX 实际行为)2024-02-05(合法)❌ 拒绝(缺 era 字段)✅ 通过2024-02-29(闰年)❌ 拒绝(缺 era 字段)✅ 通过2024-02-30(无效)❌ 拒绝❌ 拒绝(2月没有30号)2023-02-29(非闰年)❌ 拒绝❌ 拒绝

第一列证明了 yyyy→uuuu 替换的必要性:不替换,STRICT 模式下连合法日期都解析不了yyyy 是 year-of-era,STRICT 要求纪元字段)。第二列说明替换后的行为符合直觉:合法日期(含闰年)通过,无效日期依然被严格拒绝——严格性没有因替换而打折。

顺带一提:替换顺序是先 yyyy→uuuuyy→uu。如果反过来,yyyy 里的 yy 会先被替换成 uu,得到 uuuu 就无从替换了。

2.2 @DateTime:pattern 一定要包含时间

@DateTime@Date 是一对镜像:验证值必须同时包含日期与时间,pattern 必须包含时间符号,否则初始化失败:

@DateTime                                        // 2024-01-15 13:30:00
private String meetingTime;

@DateTime(pattern = "yyyy-MM-dd'T'HH:mm:ss")     // ISO 8601
private String isoTime;

@DateTime(pattern = "yyyy-MM-dd hh:mm:ss a")     // 12 小时制
private String appointmentTime;

它同样走 STRICT + uuuu 替换,只是把 LocalDate.parse 换成 LocalDateTime.parse

2.3 @HourMinute / @HourMinuteSecond:正则派

这两个注解不做解析,只做正则匹配,于是没有 pattern 参数,格式固定:

// HourMinuteValidator
private static final String HOUR_MINUTE_PATTERN = "^([01]\\d|2[0-3]):[0-5]\\d$";

// HourMinuteSecondValidator
private static final String HOUR_MINUTE_SECOND_PATTERN = "^([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d$";

留意这个正则的严谨之处:小时用 ([01]\d|2[0-3]),把 00-23 拆成 00-1920-23 两段,确保 24:0025:xx 进不来;分钟/秒用 [0-5]\d,确保 60 进不来。这是一个严格 24 小时制的写法,12:00 AM 这类 12 小时制表示会被拒绝。

2.4 pattern 自检机制:containsTimePatternStatic

@Date 拒绝时间 pattern、@DateTime 强制时间 pattern,判断逻辑共用 BaseDateValidator.containsTimePatternStatic

public static boolean containsTimePatternStatic(String pattern) {
    boolean inQuote = false;
    for (int i = 0; i < pattern.length(); i++) {
        char c = pattern.charAt(i);
        if (c == '\'') {
            // 处理单引号转义:'' 是字面量单引号
            if (i + 1 < pattern.length() && pattern.charAt(i + 1) == '\'') {
                i++;
            } else {
                inQuote = !inQuote;
            }
            continue;
        }
        if (!inQuote) {
            if (c == 'H' || c == 'h' || c == 'K' || c == 'k' ||
                c == 'm' || c == 's' || c == 'S' || c == 'a' ||
                c == 'A' || c == 'n' || c == 'N') {
                return true;
            }
        }
    }
    return false;
}

这段代码处理了一个非常刁钻的场景:单引号内的字面量不算时间符号。例如 pattern yyyy-MM-dd 'at noon'——noon 里的字母 n 与时间符号 n(纳秒)撞车,但因为它被单引号包裹,是字面量文本,不算数。''(两个连续单引号)表示一个字面量单引号的转义形式,同样被正确处理。

测试用例 PastDateStrictValidationTest.testLiteralOnlyNoTime 专门验证了这一点:pattern = "yyyy-MM-dd 'The date'",输入 "2020-01-15 The date" 应该被当作纯日期格式通过。


三、时间点验证四件套:@PastDate / @FutureDate / @PastDateTime / @FutureDateTime

3.1 includeToday 的边界语义

四个"过去/未来"注解都只有一个布尔参数 includeToday,默认 false。它的语义在不同注解上是不对称的,从源码看得很清楚:

// FutureDateValidator:未来
if (includeToday) {
    return !date.isBefore(today);   // 今天 或 今天之后 ✅
} else {
    return date.isAfter(today);     // 严格今天之后 ✅
}

// PastDateValidator:过去
if (includeToday) {
    return !date.isAfter(today);    // 今天 或 今天之前 ✅
} else {
    return date.isBefore(today);    // 严格今天之前 ✅
}

includeToday@FutureDate 语义@PastDate 语义false(默认)严格晚于今天严格早于今天true今天或之后今天或之前

留意 @PastDateTime / @FutureDateTime 的语义完全相同,但比较的是日期而不是精确到时分秒——FutureDateTimeValidator.parseDate 先把输入解析成 LocalDateTime,接着 .toLocalDate() 只取日期部分再比较。也就是说:

今天 23:59:59 在 @FutureDateTime(includeToday = false) 下会被拒绝(它属于今天,不属于"严格晚于今天")。如果你得"未来 5 分钟"这种精确到时刻的判断,includeToday 帮不了你——这正是 Hibernate Validator 的 @Future 用 Instant 比较的差异点,也是 ValidX 与 Hibernate 对比专题(第 47 篇)的核心素材。

3.2 模板方法模式:BaseDateValidator

四个验证器共享一个抽象基类,把"判空 → 解析 → 比较"的骨架固定下来,把变化的部分留给子类:

public abstract class BaseDateValidator
        implements ConstraintValidator {

    protected DateTimeFormatter formatter;
    protected boolean includeToday;
    protected String pattern;

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.trim().isEmpty()) {
            return true;                      // 空值放行
        }
        try {
            LocalDate date = parseDate(value); // 子类决定:解析成 LocalDate 还是 LocalDateTime
            LocalDate today = LocalDate.now();
            return isValidDate(date, today);   // 子类决定:过去还是未来
        } catch (DateTimeParseException e) {
            return false;
        }
    }

    protected abstract LocalDate parseDate(String value) throws DateTimeParseException;
    protected abstract boolean isValidDate(LocalDate date, LocalDate today);
}

四个子类只实现两个抽象方法,职责非常干净:

子类parseDate(解析成什么)isValidDate(比较规则)PastDateValidatorLocalDate过去(含/不含今天)FutureDateValidatorLocalDate未来(含/不含今天)PastDateTimeValidatorLocalDateTime.toLocalDate()过去(含/不含今天)FutureDateTimeValidatorLocalDateTime.toLocalDate()未来(含/不含今天)

3.3 实测发现:SMART 模式的"静默纠正"陷阱

这是这里最值得记住的一个发现。@Date / @DateTime 显式设置了 ResolverStyle.STRICT,但 @PastDate / @FutureDate 系列的 initialize 是:

this.formatter = DateTimeFormatter.ofPattern(strictPattern);

没有调用 .withResolverStyle(ResolverStyle.STRICT),用的是 DateTimeFormatter 的默认 ResolverStyle.SMART

两种模式对无效日期的处理截然不同。我们实际跑了一组验证:

输入SMART(@PastDate / @FutureDate 默认)STRICT(@Date / @DateTime)2024-02-30(2月只有29天)⚠️ 通过,被解析为 2024-02-29❌ 拒绝2023-02-29(非闰年)⚠️ 通过,被解析为 2023-02-28❌ 拒绝2024-02-05(合法)✅ 通过✅ 通过2024-13-01(月份越界)❌ 拒绝❌ 拒绝

结论很明确:@PastDate / @FutureDate 会把 2024-02-30 静默当作 2024-02-29 处理,而不是拒绝它。

这在业务上意味着什么?举个例子:某系统用 @FutureDate 校验"活动截止日",用户提交 2026-02-30(手滑或前端 JS 日期组件 bug),SMART 模式会把它静默解析成 2026-02-28(2026 不是闰年,2 月只有 28 天)放行——如果后端再按这个值存储,就存进去一个用户根本没填过的日期

这是实现层面 Date/DateTime 与 Past/Future 两个家族的差异,目前源码和文档都没有说明。规避方式有三种:

1. 叠加验证@FutureDate 字段同时加 @Date,先让 @Date(STRICT)把无效日期拦掉,再由 @FutureDate 判断过去未来;
2. 有 @PastDateTime / @FutureDateTime 需求的场景,可以组合 @DateTime + 自定义校验;
3. 已经识别该问题,后续版本建议统一 withResolverStyle(ResolverStyle.STRICT)——这属于库自身的改进空间。

3.4 v1.1.0 迁移:为什么 @PastDate 不再支持时间

v1.1.0 之前,@PastDate / @FutureDate 的 pattern 允许包含时间符号;v1.1.0 之后被强制禁止,新增 @PastDateTime / @FutureDateTime 承担带时间的场景。PastDateValidator.initialize 里有这样一段:

if (containsTimePattern(pattern)) {
    this.patternInvalid = true;
    this.patternErrorMessage = MessageManager.getMessage(
        "io.github.vipxieliang.validx.validator.date.pattern.contains.time");
    return;
}

迁移路径:@PastDate(pattern = "yyyy-MM-dd HH:mm:ss")@PastDateTime(pattern = "yyyy-MM-dd HH:mm:ss"),参数 includeToday 语义不变。


四、特殊时间格式:@Timestamp / @Duration

4.1 @Timestamp:秒与毫秒的"位数"与"数值"双重校验

@Timestamp 是 10 个注解里唯一支持数值类型的(StringLong,以及 Integer 等其他 Number 子类——isValid 里对非 String/LongNumber 统一走 longValue() 后按 Long 规则校验),校验逻辑因类型而异:

// 注解用法
@Timestamp                                   // 秒或毫秒均可
private String createTime;

@Timestamp(unit = TimestampUnit.SECONDS)     // 仅秒级(10位)
private String createTimeSec;

@Timestamp(unit = TimestampUnit.MILLISECONDS) // 仅毫秒级(13位)
private Long createTimeMs;

String 类型以"位数"校验为主(位数通过后还会再走一次数值范围校验,但 10 位秒 / 13 位毫秒天然落在各自范围内,位数对了范围必然通过,等效于纯位数校验):

unit允许位数SECONDS10MILLISECONDS13ANY10 或 13

Long 类型走"数值范围"校验

```
private static final long MAX_SECONDS = 9_999_999_999L; // ≈ 2286-11-20
private static final long MAX_MILLISECONDS = 9_999_999_999_999L;

switch (unit) {
case SECONDS: return value MAX_SECONDS && value


暂时整理到这里。以上都是个人理解,可能有疏漏,欢迎指正。

评论 (0)

暂无评论