日期校验别让 SMART 替用户改值:实现严格的年月日输入协议
- 发布时间:2026-10-23 22:36:22
- 本文热度:浏览 1 赞 0 评论 0
- 文章标签: Java 日期校验 DateTimeFormatter
- 全文共1字,阅读约需1分钟
输入2023-02-30,解析成功后变成2023-02-28。如果系统要求用户提交一个真实存在的日期,这种“帮忙调整”不是成功,而是把一份无效输入改成了另一份有效数据。
DateTimeFormatter的解析有不同resolver策略。SMART会在某些日历边界采用调整行为;STRICT则适合拒绝不成立的日期,但还要选对年份字段。直接把yyyy格式串加STRICT,可能连合法日期都无法按期望解析成LocalDate。
这次不写一个含糊的“日期工具类”,而是实现一份明确的接口输入:只接收十个ASCII字符的年月日,不自动裁剪首尾空白,不接受省略前导零,日期必须存在,年份限制为1900到2100。只生成LocalDate,不擅自添加时区或午夜时刻。
四类错误,分别在什么位置拒绝
错误分类先写成调用方可理解的协议:
| 类别 | 输入例子 | 判断含义 |
|---|---|---|
| MISSING | null、空文本、全空白 | 没有可用输入 |
| SHAPE | 2024-2-09、末尾带文字 | 不满足十字符格式 |
| CALENDAR | 2023-02-29 | 格式正确,但日期不存在 |
| RANGE | 1800-01-01 | 日期存在,但不在业务年份范围 |
这个顺序是刻意的。比如2101-02-29既超出业务年份,又不是有效日期,本实现先报CALENDAR;若产品要求优先报告年份范围,需要一起修改规则与测试。错误分类不是解析器能替所有业务决定的固定答案。
格式层也不只检查能不能解析。某个解析器愿意接受少一位数字,不代表接口就应放宽。保留精确形状可以让客户端、日志和导出采用相同协议,避免服务器默默接收更多形式以后形成兼容负担。
直接运行的实现
需要完整JDK 17,保存为 StrictDateInput.java。无参数运行自带回归样本;传入一个日期则执行单次解析。源码模式命令为java StrictDateInput.java,后面可以再加2024-02-29。
import java.time.LocalDate;
import java.time.format.*;
import java.util.Locale;
import java.util.regex.Pattern;
public class StrictDateInput {
static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("uuuu-MM-dd", Locale.ROOT)
.withResolverStyle(ResolverStyle.STRICT);
static final Pattern SHAPE = Pattern.compile("[0-9]{4}-[0-9]{2}-[0-9]{2}");
static final class DateProblem extends IllegalArgumentException {
final String code;
DateProblem(String code) { super(code); this.code = code; }
}
static LocalDate parse(String input) {
if (input == null || input.isBlank()) throw new DateProblem("MISSING");
if (input.length() != 10 || !SHAPE.matcher(input).matches()) throw new DateProblem("SHAPE");
LocalDate date;
try { date = LocalDate.parse(input, FORMAT); }
catch (DateTimeParseException ex) { throw new DateProblem("CALENDAR"); }
if (date.getYear() < 1900 || date.getYear() > 2100) throw new DateProblem("RANGE");
return date;
}
static void rejected(String input, String expected) {
try { parse(input); throw new AssertionError("expected " + expected); }
catch (DateProblem ex) {
if (!ex.code.equals(expected)) throw new AssertionError(ex.code);
System.out.println(String.valueOf(input) + "=" + ex.code);
}
}
public static void main(String[] args) {
if (args.length == 1) { System.out.println(parse(args[0])); return; }
if (args.length != 0) throw new IllegalArgumentException("one date argument expected");
System.out.println("smart=" + LocalDate.parse("2023-02-30",
DateTimeFormatter.ofPattern("uuuu-MM-dd", Locale.ROOT)));
try {
LocalDate.parse("2024-02-29", DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.ROOT)
.withResolverStyle(ResolverStyle.STRICT)); throw new AssertionError();
} catch (DateTimeParseException expected) { System.out.println("year_of_era=REJECTED"); }
System.out.println("valid=" + parse("2024-02-29"));
System.out.println("century_leap=" + parse("2000-02-29"));
rejected("1900-02-29", "CALENDAR"); rejected("2100-02-29", "CALENDAR");
rejected("2023-02-29", "CALENDAR"); rejected("2024-2-09", "SHAPE");
rejected("2024-02-29x", "SHAPE"); rejected("1800-01-01", "RANGE");
rejected(null, "MISSING");
System.out.println("lower=" + parse("1900-01-01"));
System.out.println("upper=" + parse("2100-12-31"));
}
}
无参数核对结果:
smart=2023-02-28
year_of_era=REJECTED
valid=2024-02-29
century_leap=2000-02-29
1900-02-29=CALENDAR
2100-02-29=CALENDAR
2023-02-29=CALENDAR
2024-2-09=SHAPE
2024-02-29x=SHAPE
1800-01-01=RANGE
null=MISSING
lower=1900-01-01
upper=2100-12-31
第一行故意保留SMART反例,让默认行为可观察。第二行证明简单把yyyy加STRICT并不等于本文的年月日协议;后面的案例才使用修正后的解析器。测试不仅检查抛异常,还核对稳定错误分类。
uuuu 与 yyyy 在这里不是拼写偏好
uuuu表示连续年份,yyyy表示纪元内年份。严格解析时,纪元内年份是否能完整解析,需要相应纪元信息参与;而输入协议只提供一个普通数字年份。用uuuu表达本例需求,比依赖SMART补足相关解释更直接。
这不代表所有格式里都应该机械替换yyyy。涉及纪元展示、历史历法或特定外部格式时,需要按其契约选择字段。这里范围限定为ISO日历、1900到2100,因此连续年份加业务范围检查能够形成清楚模型。
DateTimeFormatter不可变且可安全共享,适合放为静态常量。不要为了复用解析规则又绕回共享可变SimpleDateFormat,并在多线程中依赖偶然结果。本文的状态都在局部输入和返回LocalDate里,不需要每次修改全局地区或时区。
正则只验证形状,不能验证日历
十字符检查和固定正则只负责ASCII数字、横线与位置。它们无法理解每月天数和闰年,2023-02-29完全可以通过形状检查。真正的日历合法性由STRICT解析完成。
反过来,也不能只用try/catch解析,然后把所有失败都叫格式错误。缺失、日历不成立和超出业务范围可能需要不同提示,接口还可能据此决定是否允许重试。分类明确以后,调用方不必依赖底层DateTimeParseException的英文消息。
长度先检查,也让形状验证处理的输入范围固定。这里没有允许无限长文本再交给正则回溯,也没有先trim把与协议不同的输入悄悄改写。全空白被视为缺失,非空日期两端混入空格则是形状错误,两条规则同时写在测试协议里。
业务范围独立于 LocalDate 的表示范围
LocalDate支持的年份远大于本例允许值。能被这个类型表示,不代表系统账期、报表或下游接口都支持。解析成功后仍检查年份,正是在表达更窄的业务规则。
1900年不是闰年,2000年是,2100年又不是。只测试2024这个常见四年闰年,会漏掉世纪边界。程序保留这些样本,并核对允许范围两端的真实日期。形状、日历和业务范围三层都得到独立观察,不用某个失败顺便证明所有规则。
若未来允许范围改动,调整范围常量与对应边界测试即可;不要顺便把格式改成支持任意多位年份。协议扩展可以做,但它应该是明确变更,而不是一次“修复解析报错”带出的意外能力。
校验结果怎样进入真实接口
Controller可以把DateProblem中的code映射成字段错误,内部日志保留必要输入位置与原因。不要把完整用户请求都打出来,也不必把Java堆栈返回客户端。成功以后交付LocalDate,让业务方法不再反复解析同一字符串。
如果外部JSON字段已经由框架自动绑定LocalDate,失败可能发生在调用业务解析器以前。那时要核对框架消息转换器的日期策略,或者让专用输入DTO先接收字符串并调用本协议。两套解析器同时存在,却只有其中一套受测试覆盖,是日期规则走样的常见入口。
表单、批量文件和消息消费者也应该复用同一份明确契约,或承认它们采用不同协议并分别命名。不要把一个宽松展示解析器用于导入,再用严格接口解析器接收同样字段,却期待所有入口天然一致。
这里返回的是日期,不是某个瞬间。需要执行时刻的业务,必须继续指定时区和处理不存在或重叠钟点的规则;只有自然日的生日、账期等数据,则不应为了统一存储而随手变成服务器午夜。
迁移已有宽松数据时,先观察调整过什么
从SMART改成STRICT可能让以前接受的输入开始失败。这通常符合新规则,却仍需要知道历史客户端是否一直依赖宽松形式。先用脱敏样本统计被拒绝类别,核对真实需求,再决定修复客户端还是提供显式迁移入口。
历史数据若已经被SMART调整保存,通常不能只靠当前LocalDate恢复原始输入。2023-02-28既可能来自合法28日,也可能来自被改写的30日。没有原始字段或可靠审计证据,就不应伪造“已还原”的修复。
这份小解析器把调整行为关掉,但更重要的是留下可复现协议:哪些字符可进来,哪些日期存在,业务允许哪些年份,以及每种失败怎样向调用方表达。下一次重构或换框架时,先运行这组样本,而不是只看一个合法日期能不能解析。