ValidX时间段验证详解:ISO 8601标准与简化格式
📋 目录
- 引言
- 一、时间段是什么:先分清"时间点"与"时间段"
- [二、两种格式总览与 API 入口](#二、两种格式总览与 API 入口)
- [三、ISO 8601 标准格式详解](#三、ISO 8601 标准格式详解)
- [3.1 标准结构 PnYnMnDT\[nHnMnS]](#3.1 标准结构 P[nY][nM][nD][T[nH][nM][nS]])
- [3.2 验证器对 ISO 格式的约束](#3.2 验证器对 ISO 格式的约束)
- 四、简化格式详解
- [4.1 数字+单位组合规则](#4.1 数字+单位组合规则)
- [4.2 mo 与 m 的区分:月还是分钟](#4.2 mo 与 m 的区分:月还是分钟)
- [五、format 参数:ISO / SIMPLE / ANY 三种模式](#五、format 参数:ISO / SIMPLE / ANY 三种模式)
- 六、注解方式实战
- 七、链式方式实战
- 八、空值与判空语义
- 九、常见有效/无效对照表
- 十、典型业务场景
- 十一、容易忽略的实现细节与坑
- 总结
- 项目地址
引言
"视频时长不超过 15 分钟""优惠券有效期为 3 天""任务最多重试 2 小时 30 分钟"------这些业务里到处是时间段 (Duration)校验。但相比日期/时间点,时间段校验常被新手用"手写正则 + 各自约定"糊弄:有人存 "2.5h",有人存 "150min",有人存 "PT2H30M",格式五花八门,最终线上解析全靠运气。
ValidX 从 v1.0.0 起就内置了专门的时间段验证器 @Duration(链式为 isDuration),统一支持两套主流写法:
- ISO 8601 标准 :
PT2H30M、P1DT12H、P1Y2M3D------跨系统交换的通用语言 - 简化格式 :
2h30m、1d12h、1y2mo3d------人类可读的日常写法
本文直接对照 DurationValidator 源码与测试,讲清两套格式的完整规则、ISO_8601 / SIMPLE / ANY 三种模式的区别,以及最容易踩的 mo/m 混淆等实现细节。
文中结论对照 ValidX v1.2.0 源码与单元测试核实。
一、时间段是什么:先分清"时间点"与"时间段"
ValidX 的时间验证按语义分两类,别混用:
| 维度 | 时间点(Date/Time) | 时间段(Duration) |
|---|---|---|
| 回答的问题 | "现在是几点?" | "持续了多久?" |
| 例子 | 2024-01-15、14:30:00 |
PT2H30M、2h30m |
| ValidX 注解 | @Date、@DateTime、@HourMinute... |
@Duration |
| 语义 | 日历上的某个时刻 | 一段长度,不锚定到某个日期 |
关键区别:PT2H30M 不代表"2 点 30 分",而是"两个半小时"这个长度。一个时间段可以和任意起点相加,本身没有日历位置。
二、两种格式总览与 API 入口
2.1 总览表
| 格式 | 类型名 | 代表值 | 特点 |
|---|---|---|---|
| ISO 8601 | DurationFormat.ISO_8601 |
PT2H30M、P1DT12H、P1Y2M3D |
标准、跨平台通用 |
| 简化格式 | DurationFormat.SIMPLE |
2h30m、1d12h、1y2mo3d |
人类可读、书写简洁 |
| 任意(默认) | DurationFormat.ANY |
两者之一 | 两种都接受 |
2.2 API 入口
ValidX 提供注解与链式两条等价入口:
java
// 注解方式
@Duration
private String taskDuration; // 两种格式均可
// 链式方式(ANY:默认两种都接受)
ValidX validator = ValidX.init();
validator.isDuration("PT2H30M"); // true
validator.isDuration("2h30m"); // true
// 指定只收 ISO 格式
validator.isDuration("PT2H30M", Duration.DurationFormat.ISO_8601);
注解属性只有一个 format(默认 ANY),对应上面枚举的三种模式。
三、ISO 8601 标准格式详解
3.1 标准结构 PnYnMnDT\[nHnMnS]
ISO 8601 时间段以字母 P 开头,核心结构为:
P [nY] [nM] [nD] [T [nH] [nM] [nS]]
各部分含义([] 表示可选):
| 符号 | 含义 | 例子 |
|---|---|---|
P |
Period(必需前缀) | P... |
nY |
年 | P1Y(1 年) |
nM |
月(日期部分) | P2M(2 个月) |
nD |
天 | P3D(3 天) |
T |
时间分隔符:其后是时分秒 | PT... |
nH |
小时(T 之后) | PT4H(4 小时) |
nM |
分钟(T 之后) | PT5M(5 分钟) |
nS |
秒,支持小数 | PT6S、PT0.5S |
因此:
PT2H30M= 2 小时 30 分钟(纯时间部分,P后直接跟T)P1DT12H= 1 天 12 小时P1Y2M3D= 1 年 2 个月 3 天(纯日期部分,无T)P1Y2M3DT4H5M6S= 1 年 2 月 3 天 4 小时 5 分 6 秒(完整形式)
大写 T 的作用是把"日期部分"和"时间部分"分开 :T 之前的 M 是月,T 之后的 M 是分钟------同一个字母,位置决定含义。
3.2 验证器对 ISO 格式的约束
对照源码,isValidIso8601Duration 在正则匹配之外还做了三条硬性检查:
-
P和PT单独出现不合法------正则能匹配上空壳,但验证器会显式拒绝:javaif (duration.equalsIgnoreCase("P") || duration.equalsIgnoreCase("PT")) { return false; }"P"、"PT"都是 false。 -
不含
T时,必须带有天(D):javaif (!duration.toUpperCase().contains("T") && !duration.toUpperCase().matches("^P\\d+D$")) { return false; }也就是说
P1Y、P2M(只有年月、没有天)会被拒绝;合法纯日期形式必须有D,如P1D、P1Y2M3D。 -
含
T时,T后必须有至少一个时间单位 ------PT后为空则拒绝。
此外正则 CASE_INSENSITIVE,所以 ISO 格式大小写不敏感 :pt2h30m 与小写 PT2H30M 等价(有测试覆盖)。
四、简化格式详解
4.1 数字+单位组合规则
简化格式是"数字+单位"顺序拼接,供日常快速书写:
[ny][nmo][nd][nh][nm][ns]
支持的 6 个单位(必须按 年→月→天→时→分→秒 顺序出现,不可乱序):
| 单位 | 含义 | 例子 |
|---|---|---|
y / Y |
年 | 1y |
mo / MO |
月 | 6mo |
d / D |
天 | 3d |
h / H |
小时 | 2h |
m / M |
分钟 | 30m |
s / S |
秒 | 45s |
合法值举例(均有测试覆盖):
java
validator.isDuration("2h"); // true
validator.isDuration("2h30m"); // true
validator.isDuration("1h30m15s"); // true
validator.isDuration("1d12h30m"); // true
validator.isDuration("1y6mo"); // true
validator.isDuration("90s"); // true
与 ISO 一样大小写不敏感:"2H30M"、"2h30m" 等价。
校验强制"至少一个非零单位" :纯数字("123")、无效字符("2h30x")都会失败。
4.2 mo 与 m 的区分:月还是分钟
简化格式最容易踩的坑是月与分钟的字母冲突:
- 月 → 必须写
mo(month) - 分钟 → 只写
m(minute)
因为 m 已被分钟占用,所以月份必须用双字母 mo 明确区分:
java
validator.isDuration("6mo"); // 6 个月(月 = mo)
validator.isDuration("6m"); // 6 分钟(分钟 = m)→ 不同含义!
validator.isDuration("1y2mo3d"); // 1 年 2 个月 3 天
验证器在解析时会特意排除 mo 里的 m(源码用"检查 m 后一位是否紧跟 o"来区分),确保 1y2mo3d 不会被误当成"1 年 2 分钟..."。
建议:跨系统/跨语言传递时优先用 ISO 格式避免歧义;简化格式多用于配置项等人类书写的内部场景。
五、format 参数:ISO / SIMPLE / ANY 三种模式
@Duration 的 format 属性与链式 isDuration 的第二参数一致,控制"收哪种写法":
| 模式 | 行为 | 适用 |
|---|---|---|
ANY(默认) |
ISO 与简化都接受 | 兼容历史数据 / 宽松校验 |
ISO_8601 |
只收 ISO 格式 | 跨系统协议字段、API 对接 |
SIMPLE |
只收简化格式 | 内部配置、人工录入场景 |
模式是互斥白名单,不是"优先尝试":
java
// ISO_8601 模式下,简化格式会被拒绝
validator.isDuration("PT2H30M", DurationFormat.ISO_8601); // true
validator.isDuration("2h30m", DurationFormat.ISO_8601); // false
// SIMPLE 模式下,ISO 格式会被拒绝
validator.isDuration("2h30m", DurationFormat.SIMPLE); // true
validator.isDuration("PT2H30M", DurationFormat.SIMPLE); // false
提示:注解里的枚举路径可写
Duration.DurationFormat.ISO_8601;若单独import了注解内部枚举,直接写DurationFormat.ISO_8601即可。
六、注解方式实战
6.1 基础用法:两种格式都收
java
public class TaskDTO {
// 默认 ANY:ISO 与简化都合法
@Duration(message = "任务时长格式不合法")
private String duration; // "PT2H30M" 或 "2h30m" 均可
}
6.2 只收 ISO 8601:对接第三方协议
java
public class VideoUploadDTO {
// 视频平台协议要求 ISO 8601 时长
@Duration(format = Duration.DurationFormat.ISO_8601,
message = "视频时长必须为ISO 8601格式,如 PT1H30M")
private String duration;
}
6.3 只收简化格式:人工配置
java
public class RetryConfigDTO {
// 运维人员手填配置,用人类可读的简化格式
@Duration(format = Duration.DurationFormat.SIMPLE,
message = "重试窗口格式:如 2h30m / 1d12h")
private String retryWindow;
}
6.4 必填 + 格式:别忘了判空注解
java
public class CouponDTO {
// 必填且格式正确:@NotBlank 管空值,@Duration 管格式
@NotBlank(message = "有效期不能为空")
@Duration(message = "有效期格式不合法,如 PT24H 或 24h")
private String validity;
}
与 ValidX 所有格式注解一致,
@Duration对null/""返回通过(放行),是否必填由@NotBlank等决定------这是设计上的关注点分离,不是 bug。
七、链式方式实战
链式 isDuration 适合 Service 层动态校验,例如从外部接口/配置中心取回的字符串:
java
public void validateTaskTimeLimit(String timeLimit) {
ValidX validator = ValidX.init()
.config(ValidXConfig.GLOBAL_NOT_EMPTY) // 必填
.field("任务时限").isDuration(timeLimit); // 默认 ANY
if (!validator.passed()) {
throw new ValidationException(validator.getErrors());
}
}
指定格式并搭配字段标签与局部覆盖:
java
// 字段标签让报错更可读;allowNull 允许个别可选
ValidX validator = ValidX.init()
.config(ValidXConfig.GLOBAL_NOT_NULL)
.field("视频时长").isDuration(video.duration(), Duration.DurationFormat.ISO_8601)
.field("封面描述时长(可选)").allowNull().isDuration(optionalText);
if (!validator.passed()) {
System.out.println(validator.getErrors()); // ["视频时长: ...", ...]
}
多种值混合在同一链上验证(来自官方测试):
java
ValidX validator = ValidX.init()
.isDuration("PT2H30M")
.isDuration("2h30m")
.isDuration("P1DT12H");
validator.passed(); // true:三种合法写法都被 ANY 接受
八、空值与判空语义
DurationValidator.isValid 的判空逻辑与 ValidX 全线注解一致:
| 输入 | 结果 | 说明 |
|---|---|---|
null |
通过(true) | 由 @NotNull 等负责判空 |
"" |
通过(true) | 由 @NotBlank/@NotEmpty 负责判空 |
| 非 String 类型 | 失败(false) | 只支持字符串 |
| 合法时间段字符串 | 通过 | 见上文规则 |
链式模式下受全局/局部配置影响(有专门测试覆盖):
java
// 默认:null 与空串都放行
ValidX v1 = ValidX.init().isDuration(null); // true
ValidX v2 = ValidX.init().isDuration(""); // true
// GLOBAL_NOT_NULL 下 null 失败;allowNull 可局部豁免
ValidX v3 = ValidX.init().config(ValidXConfig.GLOBAL_NOT_NULL);
v3.isDuration(null); // false
v3.allowNull().isDuration(null); // true
// GLOBAL_NOT_EMPTY 下空串失败;allowEmpty 可豁免
ValidX v4 = ValidX.init().config(ValidXConfig.GLOBAL_NOT_EMPTY);
v4.isDuration(""); // false
v4.allowEmpty().isDuration(""); // true
九、常见有效/无效对照表
| 写法 | ISO_8601 模式 | SIMPLE 模式 | ANY 模式 |
|---|---|---|---|
PT2H30M |
✅ | ❌ | ✅ |
P1DT12H |
✅ | ❌ | ✅ |
P1Y2M3D |
✅ | ❌ | ✅ |
P1Y2M3DT4H5M6S |
✅ | ❌ | ✅ |
pt2h30m(小写) |
✅ | ❌ | ✅ |
P1D |
✅ | ❌ | ✅ |
P1Y / P2M(无 D 无 T) |
❌ | ❌ | ❌ |
P / PT(空壳) |
❌ | ❌ | ❌ |
PT 后无单位 |
❌ | ❌ | ❌ |
2h30m |
❌ | ✅ | ✅ |
1d12h30m |
❌ | ✅ | ✅ |
1y2mo3d |
❌ | ✅ | ✅ |
6mo(6 个月) |
❌ | ✅ | ✅ |
1y6mo |
❌ | ✅ | ✅ |
90s |
❌ | ✅ | ✅ |
123(纯数字) |
❌ | ❌ | ❌ |
2h30x(无效字符) |
❌ | ❌ | ❌ |
2.5h(简化格式小数) |
❌ | ❌ | ❌ |
null / "" |
✅ | ✅ | ✅ |
十、典型业务场景
| 场景 | 建议格式 | 示例代码片段 |
|---|---|---|
| 视频/音频上传时长 | ISO 8601(协议对接) | @Duration(format = ISO_8601) private String videoDuration; |
| 任务/批处理超时窗口 | 简化格式(人工配置) | @Duration(format = SIMPLE) private String timeout = "2h30m"; |
| 优惠券/会员有效期 | ISO 8601(跨系统) | @Duration(format = ISO_8601) private String validity = "P30D"; |
| 缓存/Token TTL | 简化或 ISO 均可 | 链式 .field("TTL").isDuration(ttl, DurationFormat.ANY) |
| 活动倒计时/间隔 | 任选一种并全局统一 | 建 DTO 统一用 @Duration + 约定格式 |
工程建议 :一个系统内只选一种主格式 (推荐 ISO 8601),把另一种仅用于兼容存量数据;入库前用 @Duration 校验,别让格式问题拖到解析阶段才暴露。
十一、容易忽略的实现细节与坑
P1Y单独不合法 。按严格 ISO,P1Y(1 年)其实是合法的;但 ValidX 验证器要求"无T时必须有D",P1Y、P2M会被拒绝。需要"X 年"这种表示时写成P1Y会踩坑------若你的业务必须支持纯年月,注意这是当前验证器的收窄约束。mo是月、m是分钟 ,简化格式里二者不能混写;6mo≠6m,语义差了一个数量级。- 单位必须按序书写 。简化格式的正则固定为
年→月→日→时→分→秒,30m2h这类乱序写法不合法。 P/PT空壳会被显式拒绝 ,不是"解析失败"而是"格式非法",错误消息会进errors列表。- 大小写不敏感 :
pt2h30m、2H30M都能通过,字段标准化时注意统一。 - 秒可带小数(仅 ISO) :ISO 的
S部分正则支持\d+(\.\d+)?(如PT0.5S);简化格式不支持 小数,2.5h会失败。 - 只支持 String :
@Duration只处理字符串,非 String 直接判失败;用Duration.parse之类强类型对象前先转成字符串或另走原生 API。 - 判空分离 :
@Duration放行null/"",必填场景必须叠加@NotBlank(或链式notEmpty()/config(GLOBAL_NOT_EMPTY)),这与 ValidX 全局规则一致。
总结
| 主题 | 结论 |
|---|---|
| 定位 | 时间段验证 = "持续多长",区别于时间点的"是哪一刻" |
| ISO 8601 | P[nY][nM][nD][T[nH][nM][nS]],T 前 M 是月、T 后 M 是分钟 |
| 简化格式 | [ny][nmo][nd][nh][nm][ns],单位按序;mo=月、m=分钟 |
| 三种模式 | ANY(默认都收)/ ISO_8601 / SIMPLE,互斥白名单 |
| API | 注解 @Duration(format=...) + 链式 isDuration(value[, format]) |
| 空值 | 放行 null/"",必填叠加 @NotBlank 或全局/局部非空配置 |
| 两个最易踩的坑 | ①P1Y 无 D/T 会被拒;②mo(月) 与 m(分钟) 混用 |
时间段校验的通用心法是:一个系统统一一种格式、入库前必校验、跨系统必用 ISO 。ValidX 的 @Duration 把两种格式 + 模式切换做成了开箱即用的注解和链式方法,直接声明即可,不必再手写正则。