qinghuan
← 回到知识探索

后端现场 / 动手实验

日期解析成功了,为什么 2 月 29 日却变成了 28 日?

DateTimeFormatter 默认 SMART 会调整部分无效日期。把同一输入交给三种解析风格,再看严格模式为何还要分清 uuuu 与 yyyy。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 同一个字符串,先对照三种结果
  4. 先读出字段,再决定字段能否组成日期
  5. 为什么改成 STRICT 后,合法日期也可能失败
  6. 日历合法之后,业务校验仍然要继续
  7. 保存完整程序,核对真实输出
  8. 结论要落在实际使用的格式与日历上
  9. 记住这些要点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
  13. 找一份资料继续练
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
解析风格
把已经读出的年、月、日等字段组合成日期时采用的规则。
纪元年
某个纪元内部的年份,与不带纪元含义的 year 字段不同。
打开完整术语小抄 ↗

导入生日字段时,你用 DateTimeFormatter.ofPattern("uuuu-MM-dd") 解析 2023-02-29,程序没有报错。把结果打印出来,得到的却是 2023-02-28。原始文字中的日期不存在,为什么会成功?

因为“按这个格式读取”还包含“把读出的年、月、日组合成日期”的规则。ofPattern 默认使用 SMART。它遇到某些无效组合时会作调整,而不是一律拒绝。

把例子带到自己的电脑上

下载完整练习包

需要 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • Main.java:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 Main.java
java -cp . Main

解压后,在含 Main.java 的目录打开终端。命令分别回车执行。固定输入的 Java 17 单文件实验;两套真实 JDK 输出可复核,互动页只回放这些记录。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

同一段日期文字,解析成功还可能改变年月日

正在准备实验控件…

先预测:会保留原日期、调整日期,还是拒绝解析?再查看实测记录。

十条记录均为本页 Java 程序在 JDK 17/21 的真实输出。页面只回放固定 ISO LocalDate 输入,Locale.ROOT;没有执行 JVM,也没有模拟时区、其他历法或生日业务规则。

同一个字符串,先对照三种结果

对 2023-02-29 使用 uuuu-MM-dd:STRICT 抛 DateTimeParseException;SMART 得到 2023-02-28;LENIENT 得到 2023-03-01。2023 年的二月没有第 29 天,这三个结果体现了三种不同处理规则。

再看 2024-02-30。2024 年是闰年,SMART 调整到 2024-02-29,LENIENT 得到 2024-03-01,STRICT 仍拒绝。最后放入本来就有效的 2024-02-29,三种风格在本例中都保留该日期。

这不是程序读取了机器当前日期,也不是时区把日期推前一天。本例只用 LocalDate、固定输入与 Locale.ROOT,没有时区转换。互动区可切换输入和风格,直接比较这些实测记录。

先读出字段,再决定字段能否组成日期

可以把解析理解成两步:先从文字中得到年、月、日等字段,再把字段组合成某个日期。第二步就是这里讨论的 resolve。文字长得像“年-月-日”,并不意味着这个年、月、日的组合真的存在。

在本例的 ISO 日历中,STRICT 要求组合有效;SMART 对处于日字段允许范围、却超过当月末日的数值调整到月末;LENIENT 则把多出的天数按日期运算继续向后推。三种风格各有用途,导入人工填写的生日通常希望明确拒绝错误,而不能悄悄改成另一天。

注意 SMART 不等于“任意错误都能修好”。例如字段本身超出其允许范围,与一个正常日数落在较短月份,是不同情况。本文完整运行覆盖的是列出的三组输入,不把它们推广为所有非法文本的处理表。

为什么改成 STRICT 后,合法日期也可能失败

再做一组对照:同为 2024-02-29、同为 STRICT,uuuu-MM-dd 可以得到日期,yyyy-MM-dd 在本例却抛异常。问题已经不是闰年,而是模式提取了什么字段。

u 表示 year,y 表示 year-of-era,也就是某个纪元中的年份。对于本文没有纪元字段的 yyyy-MM-dd 模式,严格解析不能像宽松方式那样替你默认补齐纪元信息,因此不足以组成需要的 LocalDate。

如果接口约定就是这种 ISO 数字日期,可以明确使用 uuuu-MM-dd 配合 STRICT。若业务真的需要纪元年,则要把纪元表达与日历系统一起设计;不要只机械地替换字母。本文没有覆盖其他历法、纪元名称或负年份输入。

日历合法之后,业务校验仍然要继续

严格解析可以回答“这个输入是否能构成约定格式下的有效日期”。它不知道生日是否在未来、预约是否落在营业日、有效期是否超过业务上限。解析成功后仍要用业务规则检查这些条件,并保留明确的失败信息。

导入时也应保留原始输入,便于定位是哪一行、哪个字段需要修改。如果你选择允许调整的规则,应在接口约定和展示中说清楚调整结果;不能只因为 parse 没抛异常,就声称原始文字完整保留了它表达的日期。

保存完整程序,核对真实输出

程序打印 3 个输入 × 3 种风格,再打印一次 yyyy 与 STRICT 的对照,共 10 行。输出只保留异常类型,避免把随版本变化的英文错误消息当成测试合同;内部还核对 ofPattern 的默认风格确实为 SMART。

2026-10-26 实际使用 Microsoft OpenJDK 17.0.20.1 和 21.0.12,两个版本分别以 --release 17 编译并运行三次,输出一致。页面互动区回放这些固定记录,浏览器没有启动 JVM。固定源码阅读使用 OpenJDK jdk17u 的 jdk-17.0.17+10 标签,它是解释机制的参考版本,不冒充本机发行版的精确源码。

展开收起代码 · 小实验java · 41 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.util.Locale;

public class Main {
    static String parse(String input, String pattern, ResolverStyle style) {
        DateTimeFormatter formatter = DateTimeFormatter
                .ofPattern(pattern, Locale.ROOT).withResolverStyle(style);
        try {
            return LocalDate.parse(input, formatter).toString();
        } catch (DateTimeParseException e) {
            return e.getClass().getSimpleName();
        }
    }

    static void show(String input, String pattern, ResolverStyle style) {
        String result = parse(input, pattern, style);
        System.out.printf(
                "{\"input\":\"%s\",\"pattern\":\"%s\",\"style\":\"%s\",\"result\":\"%s\"}%n",
                input, pattern, style, result);
    }

    public static void main(String[] args) {
        if (DateTimeFormatter.ofPattern("uuuu-MM-dd").getResolverStyle()
                != ResolverStyle.SMART) throw new AssertionError("unexpected default");
        String[] inputs = {"2023-02-29", "2024-02-30", "2024-02-29"};
        for (String input : inputs) {
            for (ResolverStyle style : ResolverStyle.values()) {
                show(input, "uuuu-MM-dd", style);
            }
        }
        show("2024-02-29", "yyyy-MM-dd", ResolverStyle.STRICT);
        if (!parse("2023-02-29", "uuuu-MM-dd", ResolverStyle.SMART).equals("2023-02-28")) {
            throw new AssertionError("wrong SMART resolution");
        }
        if (!parse("2023-02-29", "uuuu-MM-dd", ResolverStyle.STRICT)
                .equals("DateTimeParseException")) throw new AssertionError("invalid date accepted");
    }
}

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12 复跑这份源码并核对完整输出。固定输入的 Java 17 单文件实验;两套真实 JDK 输出可复核,互动页只回放这些记录。

运行后对照结果
{"input":"2023-02-29","pattern":"uuuu-MM-dd","style":"STRICT","result":"DateTimeParseException"}
{"input":"2023-02-29","pattern":"uuuu-MM-dd","style":"SMART","result":"2023-02-28"}
{"input":"2023-02-29","pattern":"uuuu-MM-dd","style":"LENIENT","result":"2023-03-01"}
{"input":"2024-02-30","pattern":"uuuu-MM-dd","style":"STRICT","result":"DateTimeParseException"}
{"input":"2024-02-30","pattern":"uuuu-MM-dd","style":"SMART","result":"2024-02-29"}
{"input":"2024-02-30","pattern":"uuuu-MM-dd","style":"LENIENT","result":"2024-03-01"}
{"input":"2024-02-29","pattern":"uuuu-MM-dd","style":"STRICT","result":"2024-02-29"}
{"input":"2024-02-29","pattern":"uuuu-MM-dd","style":"SMART","result":"2024-02-29"}
{"input":"2024-02-29","pattern":"uuuu-MM-dd","style":"LENIENT","result":"2024-02-29"}
{"input":"2024-02-29","pattern":"yyyy-MM-dd","style":"STRICT","result":"DateTimeParseException"}

结论要落在实际使用的格式与日历上

这份实验覆盖固定四位正年份、两位月日、ISO LocalDate、Locale.ROOT 和三个解析风格。它没有验证含时区时间戳、用户地区格式、前后空格、非 ISO 历法或业务期限。

阅读固定版 IsoChronology 源码,可以看到 LENIENT 从年初加月份和天数、SMART 调整月末,以及 STRICT 下纪元年字段处理的路径。源码用来解释本例结果,真实接入仍要为你接受的输入集合设计测试。

记住这些要点

  • ofPattern 默认使用 SMART,解析成功不保证日期没有被调整。
  • 用 STRICT 检查日历组合,并为接口选择准确的年份模式。
  • uuuu 的年与 yyyy 的纪元年不是同一个字段。
  • 格式解析、日历合法性与业务限制,分别需要明确规则。

别把例子用过头

  • 把“不抛异常”当成“原始年月日一定存在”。
  • 只添加 STRICT,却沿用没有纪元字段的 yyyy 模式并忽略含义。
  • 把 SMART 理解为可修复所有非法日期。
  • 认为严格日期解析已经验证了生日、预约或有效期的业务条件。

自己试一小步

为一个只接受 ISO 数字生日的接口,怎样写出可解释的拒绝规则?

  1. 增加 2024-13-01、2024-01-00 和 2024-02-31,先预测三种风格的差异再运行。
  2. 把“日历日期有效”和“生日不能晚于固定的今天”拆成两个判断。
  3. 选择一个固定今天,让失败样例不随机器时间变化;分别给格式错误与业务错误取清楚的名称。

做到这些,就算完成

  • 记录每个输入的原始文字、模式、风格和输出,不能只记是否抛异常。
  • 验证正常日期没有被无意修改,同时验证应拒绝的日期确实被拒绝。
  • 不把本页未执行的新输入写成已验证结果;自己运行后再补实验记录。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

找一份资料继续练

先选一项,按下面的范围继续学;不必从头读完整门课程。