软件系统中的日期和时间

2024-04-11, 星期四, 15:16

DevJavaJavaScript培训

背景知识

GMT/UTC/CST

  • GMT:格林尼治时间(前世界标准时),依靠天文观测
  • UTC:协调世界时(现世界标准时),依靠原子钟,所以地球自转一天的时间也不一定等于 86400 秒,可能会出现闰秒,例如 23:59:60
  • CST:视上下文可解释为 Central Standard Time (USA) UT-6:00China Standard Time UT+8:00 或其他(英文缩写,很奇妙吧)

ISO 8601(-like) 标准

国际标准 ISO 8601 是国际标准化组织的日期和时间的表示方法,全称为《数据存储和交换形式·信息交换·日期和时间的表示方法》。然而 ISO 8601 并不是一个可以免费公开获取的标准,因此通常情况下我们会使用 RFC 3339,以及某些情况下,GB/T 7408.1-2023 日期和时间 信息交换表示法(等同 ISO 8601-1:2019)。

我们在软件开发过程中,通常只会用到日期时间表示法和时间区间表示法,因此本文会略去顺序日期表示法、星期日历表示法、日历星期表示法之类的东西,时间部分也不会介绍缺省小时或降低精度的表示方法,如有需求可以直接阅读 RFC 文档。

ISO 8601 与 RFC 3339 的主要区别

ijmacd - GitHub 做了一个很好的图,为了防止页面失效,我先放个截图在这里。

注意这张图不完全是准确的,或者说不能供开发人员不加验证地参考,例如图上 RFC 3339 的 2024-04-11_06:45:27.458Z 就不能被 Safari 的 Date.parse() 方法支持。

ISO 8601 允许 24:0000:00 同时存在,而 RFC3339 为了减少混淆,限制小时必须在 0 至 23 之间,23:59 过 1 分钟是第二天的 0:00

此外根据 RFC 3339 的 Appendix A. ISO 8601 Collected ABNF,ISO 8601 中作为日期时间分隔符的 T 可以省略,而 RFC 3339 允许使用空格或不区分大小写的 t。这个问题在早期的 Safari 浏览器中也得到过讨论,例如:Date: parse: ISO 8601 format YYYY-MM-DD HH:mm:ss - iOS Safari incompatibility #15401

日历日期、时间与时区表示法

  • 使用数字 YYYYMMdd 为基本格式,ISO 8601:2004 不再允许用两位数字表示年
  • 推荐使用短横线 - 间隔开年月日 YYYY-MM-dd 的扩展格式
  • 使用数字 HHmmss 为基本格式,推荐使用 : 隔开小时、分、秒的扩展格式 HH:mm:ss
    • 还可以添加 .SSS 表示毫秒
    • 添加 .SSSSSS 表示微秒
    • 添加 .SSSSSSSSS 表示纳秒
  • 日期和时间之间使用 T 分隔

如果时间在零时区,并恰好与 UTC 相同,那么不加空格地在时间最后加一个大写字母 ZZ 是相对 UTC 时间 0 偏移的代号。如下午 2 点 30 分 5 秒表示为 14:30:05Z143005Z;其他时区用实际时间加时差表示,如北京时间下午 2 点 30 分 5 秒表示为 22:30:05+08:00223005+0800

如此这般,北京时间 2024 年 4 月 11 日 14 点 57 分 45 秒 943 毫秒就可以表示为:

  • 2024-04-11T06:57:45.943Z
  • 2024-04-11T14:57:45.943+08:00

时间间隔表示法(Periods)

用来表示一段时间间隔,一般的格式是 P(n)Y(n)M(n)DT(n)H(n)M(n)S,例如 P1D 表示 1 天时间间隔。

时间范围表示法(Ranges)

从一个时间开始到另一个时间结束,或者从一个时间开始持续一个时间间隔,要在前后两个时间(或时间间隔)之间放置斜线符 /,如 19850412/19860101

为什么推荐使用 ISO 8601 标准定义的日期时间表示法

很简单,各种软件包都支持,各种各样的 DateTimeFormatter 肯定支持名为 ISO_XXXXX 的模式,而 parse 方法几乎可以百分百确定能还原正确的数据。

而其他格式,就算是今天的现代浏览器,如果你的代码构造出什么奇特的日期时间字符串,在 Chromium 中也许是正常的,在 Safari 也有可能返回 Invalid Date

(例如某个用的比较多的后台管理框架的 parseTime 方法在一堆基于正则表达式的字符替换后会尝试这样构造 Date 对象 😂)

Unix Epoch & Unix Timestamp(in Milliseconds)

Unix Epoch,或称 Unix 时间/时间戳,从 1970-01-01T00:00:00Z 起至现在,不考虑闰秒的总秒数。在多数 Unix 系统上通过 date +%s 查看。

Unix Epoch 转换为以毫秒为单位,可称为 Unix Timestamp 或 Unix 时间戳。

RFC 2822

RFC 2822 includes the shortened day of week, numerical date, three-letter month abbreviation, year, time, and time zone, displaying as 01 Jun 2016 14:31:46 -0700

RFC 2822 是电子邮件标准,因此不太可能会在其他地方看到这种方法表示的时间。

墙上时间与单调时间

程序运行期间,如果服务器进行了 NTP 校时,就有可能造成后创建的记录在时间上早于先创建的记录。这种时钟称为「墙上时间」。Java 中的 System.currentTimeMills() 就是一种墙上时间。

与之相对地,「单调时钟」可以保证时间只会递增,但是无法保证时间的准确性。在 Java 中可以用 System.nanoTime() 做 benchmark 相关的应用。

有关分布式系统中的单调时钟(或称逻辑时钟),可参考 Lamport timestamp 主题。

Use In Java

Java Time API(from JDK 8)

几个常用的时间类

Date-time classes Java Time API legacy
Moment in UTC Java.time.Instant java.util.Date
java.sql.Timestamp
Moment with offset-from-UTC
(HH:mm:ss)
java.time.OffsetDateTime -
Moment with time zone java.time.ZonedDateTime java.util.GregorianCalendar
Date & Time-of-day (no offset, no time zone)
Not a moment
java.time.LocalDateTime -

java.time.Instant 代表了时间轴上的一个确定点,原点为 1970-01-01T00:00:00.000Z,精确到纳秒级别。

应用不需要跨时区工作时,使用 LocalDateTime 存储时间可以安全地丢弃时区信息;否则应当在 Instant, OffsetDateTime, ZonedDateTime 之间择一使用。

// parse
assert Instant.ofEpochSecond(1640931907L)
        .equals(OffsetDateTime.of(LocalDateTime.of(2021, 12, 31, 6, 25, 7), ZoneOffset.UTC)
                .toInstant());
assert Instant.ofEpochMilli(1640931907L * 1000)
        .equals(OffsetDateTime.of(LocalDateTime.of(2021, 12, 31, 6, 25, 7), ZoneOffset.UTC)
                .toInstant());
);
// format
assert OffsetDateTime.of(LocalDateTime.of(2021, 12, 31, 6, 25, 7), ZoneOffset.UTC)
        .toInstant().getEpochSecond()
        == 1640931907L;

DateTimeFormatter

DateTimeFormatter 支持在 java.time 类型和格式化字符串间转换

Predefined Formatters

DateTimeFormatter 预先定义了一些常用格式,可见于 Predefined FormattersDateTimeFormatter.ISO_OFFSET_DATE_TIME 即为预定义的 ISO 8601 扩展格式转换器。

// parse
assert ZonedDateTime.parse("2021-12-02T08:59:03Z", DateTimeFormatter.ISO_OFFSET_DATE_TIME)
        .isEqual(ZonedDateTime.of(
                LocalDateTime.of(2021, 12, 2, 8, 59, 3),
                ZoneId.of("UTC")));

assert ZonedDateTime.parse("2021-12-02T08:59:03+00:00", DateTimeFormatter.ISO_OFFSET_DATE_TIME)
        .isEqual(ZonedDateTime.of(
                LocalDateTime.of(2021, 12, 2, 8, 59, 3),
                ZoneId.of("UTC")));

assert ZonedDateTime.parse("2021-12-02T08:59:03+08:00", DateTimeFormatter.ISO_OFFSET_DATE_TIME)
        .isEqual(ZonedDateTime.of(
                LocalDateTime.of(2021, 12, 2, 8, 59, 3),
                ZoneId.of("UTC+8")));

assert ZonedDateTime.parse("2021-12-02T08:59:03+08:00", DateTimeFormatter.ISO_OFFSET_DATE_TIME)
        .isEqual(ZonedDateTime.of(
                LocalDateTime.of(2021, 12, 2, 8, 59, 3),
                ZoneId.of("CTT", SHORT_IDS)));
// format
assert ZonedDateTime.of(
        LocalDateTime.of(2021, 12, 2, 8, 59, 3),
        ZoneId.of("UTC+8")
).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME).equalsIgnoreCase("2021-12-02T08:59:03+08:00");

注意: 尽管标准本身支持简化模式,DateTimeFormatter.ISO_OFFSET_DATE_TIME 只支持扩展模式。parse("20211202T085903+0800") 将抛出 DateTimeParseException

注意:LocalDateTime 丢弃了 UTC 偏移信息,不能代表一个时刻,因此:

  • 尽管是两个不同的时刻,对 2021-12-02T08:59:03+08:002021-12-02T08:59:03Z 的解析都会得到 LocalDateTime.of(2021, 12, 2, 8, 59, 3)
  • LocalDateTime 不支持通过 format 方法获得 ISO 8601 标准的字符串。

Pattern

当预定义的格式不能满足需求时,也可以使用字母和符号组合模式构建自定义的 Formatter。简单情况下可以使用 DateTimeFormatterofPattern 方法。

LocalDate date = LocalDate.of(2022, 1, 1);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd");
assert date.format(formatter).equalsIgnoreCase("2022/01/01");
assert LocalDate.parse("2022/01/01", formatter).isEqual(date);

复杂情况下可以使用 DateTimeFormatterBuilder,如下方示例就创建了一个行为与 DateTimeFormatter.ISO_OFFSET_DATE_TIME 基本等效的 Formatter

new DateTimeFormatterBuilder()
    .appendPattern("yyyy-MM-dd'T'HH:mm:ss")
    .parseLenient()
    .appendOffset("+HH:MM", "Z")
    .toFormatter();

可使用的字母符号组合模式见 Patterns for Formatting and Parsing

Jackson Date

要令 Jackson 支持 java time API,需额外引入 com.fasterxml.jackson.datatype:jackson-datatype-jsr310 依赖,并且 ObjectMapper 需要注册 JavaTimeModule

new ObjectMapper().registerModule(new JavaTimeModule());

Jackson 3.0 的计划支持的最低 JDK 版本为 1.8,届时将不再需要特地引用该依赖。

@JsonFormat

@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd-MM-yyyy hh:mm:ss")
public LocalDateTime eventDateTime;

extends StdDeserializer<T> & extends StdSerializer<T>

总是可以通过扩展 StdDeserializer<T> 接口实现自定义的 deserializer

@Override
public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
    return LocalDateTime.parse(jsonParser.getText(), ISO8601DateTimeFormatter);
}

在 POJO 上使用注解指明即可

@JsonDeserialize(using = MyCustomizedStdDeserializer.class)
private LocalDateTime currentDeviceTime;

对于序列化过程,扩展 StdSerializer<T> 接口并使用 @JsonSerialize 注解。

Use In JavaScript

Date 对象基于 Unix Timestamp,即自 1970-01-01T00:00:00.000Z 起经过的毫秒数。

因此 Unix Epoch 需要换算成 Unix Timestamp(即毫秒单位)才能用于直接创建 Date 对象。

> new Date(1640931907)
1970-01-19T23:48:51.907Z
> new Date(1640931907 * 1000)
2021-12-31T06:25:07.000Z

Date 对象的 getTime 方法可获取 Unix 时间戳。

一般情况下不推荐使用 Date 构造函数(或与其等价的 Date.parse)来解析日期字符串,除非是比较清晰的格式。以下代码在 Node.js v20.5.1、Safari 17.4.1 上进行了测试,可以看到符合 ISO 8601 / RFC 3339 的表达式构造出了正确的对象:

> new Date("2024-04-11T10:11:55.000+08:00")
2024-04-11T02:11:55.000Z
> new Date("2024-04-11T10:11:55+08:00")
2024-04-11T02:11:55.000Z
> new Date("2024-04-11T02:11:55.000Z")
2024-04-11T02:11:55.000Z

如果使用 HTML 中 <input /> 控件产生的 value

<input type="date" id="date-selector">
<input type="datetime-local" id="datetime-selector">
> new Date("2024-04-11T10:11")
2024-04-11T02:11:00.000Z
> new Date("2024-04-11")
2024-04-11T00:00:00.000Z

没有时区/时间偏移信息的字符串会按系统时区的当地时间处理,相当于拼接 +08:00。仅有日期的字符串会被添加一个 UTC 零时,相当于拼接 T00:00:00.000Z

到目前为止,大部分行为还符合预期,或者虽然有悖直觉,也不会出错。不过有的框架喜欢用 / 做日期间的分隔,那么没有时间偏移信息的字符串会按系统时区的当地时间处理,相当于拼接 +08:00。仅有日期的字符串会被添加一个系统时区的零时,拼接 T00:00:00.000+08:00

> new Date("2024/04/11 10:11:55")
2024-04-11T02:11:55.000Z
> new Date("2024/04/11")
2024-04-10T16:00:00.000Z

如果你还没看出什么问题的话:

> new Date("2024/04/11").getTime() == new Date("2024/04/11 00:00:00").getTime()
true
> new Date("2024-04-11").getTime() == new Date("2024-04-11 00:00:00").getTime()
false

要进行比较复杂的日期时间处理,还是推荐一些第三方库。Moment.js 已进入维护状态,不建议使用。可以使用 Luxon 或者 Day.js

计算时间间隔

支持使用两个 Date 对象或其 getTime 方法输出相减获得两个时间点的间隔(按毫秒记)。然而,如果浏览器支持 Web Performance APIPerformance.now() 会比 Date.now() 获得的结果更加可靠、精确。

Temporal

本文写作时,Temporal 仍处于 stage 2 in the TC39 standards approval process,意味着在大部分浏览器和 Node.js 中都会是 Temporal is not defined,需要 polyfill。

Use In MySQL

TIMESTAMP 是一种保存日期和时间的数据格式,格式为YYYY-MM-DD HH:MM:SS

通过 SET time_zone='+00:00' 设置会话使用的时区,写入数据时 MySQL 会结合时区与插入数据将其转换为 UTC 后存储。查询时 MySQL 会按会话时区计算当地时间并返回。

其他时间数据类型如 DATETIME 并不涉及时区的换算,记录的是本地时间。

DDL 中使用 DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP 确保插入新行和修改行时更新时间戳。

TIMESTAMP 在 MySQL 中的默认行为

如果原本的目的只是要记录行被修改过而不需要关心值的变化,auto-updated 就不可被信任了,这时候可以手动对其赋值为 CURRENT_TIMESTAMP 或使用 NOW()

explicit_defaults_for_timestamp 决定了 MySQL 对 TIMESTAMP 类型字段默认值的处理方式。在 MySQL 5.7 中默认为 OFF,在 MySQL 8.0 中默认为 ON。当 explicit_defaults_for_timestamp 设置为 OFF 时:

  • TIMESTAMP 默认使用 NOT NULL 修饰
  • 表中第一个没有使用 NULL 修饰且未指定初始化和更新方法的 TIMESTAMP 列将会自动添加 DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  • 表中其余没有使用 NULL 修饰的 TIMESTAMP 字段默认会分配 DEFAULT '0000-00-00 00:00:00',取决于 NO_ZERO_DATE 的设置,赋值为 0000-00-00 00:00:000 在某些情况下是非法的,这会导致 DDL 执行失败