整理一篇学习笔记,把看到的一些要点和自己的理解都记下来。
ValidX时间注解完全指南:10种时间验证注解详解
引言
时间是业务系统里最容易"出小错、藏大坑"的字段:生日填成 2024-02-30、签到时间漏了秒、时间戳少一位数、活动截止时间被当成过去时间……每一个错误都真实存在,而每一个错误都能让一次线上事故变成一个 Bug 工单。
ValidX 把时间验证拆成了 10 个注解,按职责分工:
- 格式验证(值长什么样):
@Date、@DateTime、@HourMinute、@HourMinuteSecond - 时间点验证(值在过去还是未来):
@PastDate、@FutureDate、@PastDateTime、@FutureDateTime - 特殊格式(非人类可读的时间):
@Timestamp、@Duration
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;
它的核心在 DateValidator 的 createStrictFormatter:
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→uuuu 再 yy→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-19 和 20-23 两段,确保 24:00、25: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 个注解里唯一支持数值类型的(String、Long,以及 Integer 等其他 Number 子类——isValid 里对非 String/Long 的 Number 统一走 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)
暂无评论