本文旨在尝试以一个库设计者的角度,去梳理日期 / 时间处理库中要解决的问题、抽象、以及合适的使用方式。
场景分析#
在了解了现实世界的各种时间表征机制之后,不妨重新思考下,我们在现实世界中,是如何使用时间的。
我们可以举一些简单的例子
- 与某人上一次见面的时间?10 天前
- 下一次开会的时间?2 小时后
- 任务完成所需要的时间?3 天
- 距离春节还有多久?100 天
- 这次假期有多长?2 天
- 现在是假期第几天?1 天
- 2021 年 3 月 11 日 12 时 30 分 01 秒...
- 我的假期还剩多久?3 天
- 五天后是什么日期?2021 年 10 月 11 日
- 昨天是星期几?星期三
- 每天早上 9 点的闹钟
上面这些可以涵盖我们大部分对时间的使用场景,我们可以进行简单的分类。 - 整个时间长轴中一个特定的时刻(如 2021 年 3 月 11 日 12 时 30 分 01 秒)
- 整个时间长轴中一个特定的时间段(2022 年 11 月 29 日~2022 年 11 月 30 日)
- 一个周期中精确的时间点(如每天上午 9:00)
- 一个周期中精确的时间段(如每天上午 9:00-10:00)
- 与周期无关的,特定长度的时间段(3 小时)
这是处在同一个时间表示系统中存在的集中基本使用场景。
尝试抽象#
时间轴中的特定时间点 + 时间段长(TimePosition + TimeSpan),通过这两个概念可以表示上图中的 1 和 4。通过添加可计算的特性,可以表示 3。调整时间轴从一个绝对时间轴转变成周期时间轴,可以表示 2。
- 精确的日期时间
Datetime
- 精确的日期
Date
- 时间
Time
- 时间段
Duration
我们知道,时间的表征系统不止一个,所以还要考虑到不同系统中时间的转换。通过合适的转换机制,可以适应不同的场景(适合计算、适合存储、适合人类阅读)。比如在 cs 中,相比于2022-12-21 12:20:21
UTC 时间,timestamp
是一种更受青睐的表示形式,表示自1970-01-01 00:00:00
以来结果的秒数。非常适合计算和存储,不适合人类阅读)。并且要对其他时间表征系统进行抽象。 - 现行的本地时间和标准 UTC 时间需要通过时区机制进行转换。
TimeZone
- 通过转换机制得到
LocalDate,LocalDateTime,LocalTime
- 现行的 UTC 时间中存在 闰秒。
LeapSecond
- 不同文明也有不同的历法(如格里高利历,中国阴阳历),因此需要
Calendar
- 周、月、年的时间周期,
Week,Month,Year...
- 将时间格式化为人类可读的内容
Formatter
- ...
可以得到这样一张草图。
各种编程语言 Lib 的做法#
光有一张图似乎还不够,仍然有点混乱,因此不妨参照下其他编程语言 Lib 中的各种抽象。这些库大多会解决其中一部分问题并提供常用的 API。
Time(Golang)#
golang 的 time
包非常简单,并没有对不同的日期、历法这些复杂系统进行处理。对外仅提供少数几个抽象Time,Location(timezone),Duration,Timer,Ticker
,其中 仅Time,Location,Duration
与这个话题有关。
type Time struct {
// 系统提供 wall time(一般意义上的时钟(UTC时间))
// 和 monotonic time(单调时钟(进程启动时间))
// wall 和 ext 编码 wall time 的秒、纳秒以及可选的纳秒级单调时钟读数。
// wall 字段从高位到低位分为三段,
// 0bit: 标志位(hasMonotonic)判断是否当前Time是否记录单调时钟
// 1~33bit: 秒字段
// 34~63bit: 纳秒字段[0,999999999]。
// 如果 hasMonotonic 位为 0,则 1~33bit 必须也为0,存储在 ext 中
// 如果 hasMonotonic 位为 1,则
// 1. 1~33bit 包含自 1885 年 1 月 1 日起的无符号 wall seconds
// 2. ext 则包含一个有符号 64 位单调时钟读数(表示自进程启动以来的纳秒)
wall uint64
ext int64
// loc 字段存储该时间的时区信息,nil 代表 utc
// 所有的 UTC 时间的该字段都为 nil,而非loc==&utcLoc
loc *Location
}
type Location struct {
name string
// 该位置在历史上使用过的时区
zone []zone
// 记录历史上因为时区变动而产生的时间偏移时间, 比如应用冬夏令时
tx []zoneTrans
// tz 后面可以接一个描述如何处理DST变动的字符串,当 zoneTrans 中没有记录以供参考。
// 示例格式为如 America/Los_Angeles: PST8PDT,M3.2.0,M11.1.0
// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html.
extend string
// 多数查找是用于当前时间的,因此为了避免在 tx 中进行二分查找
// 在Location被创建的时候保持一个静态的单元素缓存
// 如果 t 在范围 [cacheStart, cacheEnd] 内,可以直接返回时区(cacheZone)
// cacheStart 和 cacheEnd 的单位是自1970-01-01(UTC)以来的秒
// 也就是标准 unix 时间戳
cacheStart int64
cacheEnd int64
cacheZone *zone
}
// 表示单个时区
type zone struct {
name string // abbreviated name, "CET"
offset int // seconds east of UTC
isDST bool // is this zone Daylight Savings Time?
}
// 表示历史上的一次时区变动
type zoneTrans struct {
//变动发生时,对应的 UTC 时间
// 表示为 unix 时间戳(在那之前为负数)
when int64
// 本次转变变为哪个 zone,(Location中zone字段的索引)
index uint8
// 忽略
isstd, isutc bool
}
type Duration int64 // 代表两个时间点之间持续的second
Time and Location Explained in Go/Golang
从这些抽象及对应的实现来看 Golang 的 标准库是一贯的大道至简,只对 UTC 标准时间、时区以及简单的格式化这些功能做了处理,更多的需求则留给第三方库去满足。
Carbon(Golang)#
dromara/carbon: A simple, semantic and developer-friendly golang package for time.
carbon 做得也比较简洁。核心是一个 Carbon 结构体
type Carbon struct {
time time.Time
testNow int64 // nanosecond timestamp of test now time
weekStartsAt time.Weekday
loc *time.Location
layout string
lang *Language
Error error
}
在此基础上,增加了运算 API,getter/setter,序列化 / 反序列化支持,以及简单的历法支持 (支持转换)。
实现上非常简单直接,但 API 设计有欠考虑的地方,有一些明显的缺陷。
- 比如
time
中包含一个 Location 字段,carbon 中进行重复,但是并没有进行相应的同步。
如下面的例子,对Timezone
进行Setter/Getter
之后,结果是UTC
而非期望的。Setter 只对Carbon.loc
字段进行了 set,而Getter
是从Time.Location
中获取的。
package main
import (
"fmt"
carbon "github.com/golang-module/carbon/v2"
)
func main() {
c := carbon.NewCarbon()
c.SetTimezone("Africa/Algiers")
tz := c.Timezone()
fmt.Println(tz)
}
// expect output: Africa/Algiers
// actual output: UTC
- 对
Season
的支持也是从北半球的惯例出发,比如冬季固定12,1,2
三月,并没有考虑到南半球的 Season 与北半球是反着来的。 - 对中国农历的支持缺乏了二十四节气这样的重要组成。
- 值得注意的是,carbon 本身并没有过多考虑并发安全,与其他 datetime 库重视不可变对象不同,对 carbon 实例的变更是对其自身进行的。
- 有 issue 提到其编译后二进制产物反常的大。
DateTime (Java old api)#
java 早期的 date api 中只有 date 是比较失败的实现
- 最初的构造函数年份自 1900 算起
- 线程不安全
- 无时区
jdbc 提供了sql.time,sql.date,sql.timestamp
等类型,sql.time 只表示一日内的时间,sql.date 只表示某一日,不涉及时间,sql.timestamp 则表示时间戳 (日期 + 时间) 均继承自util.Date
。
后续又引入calendar
现代的 util.date 则囊括了日期,时间,时区等符合标准所需的内容。
总体来说,参考意义不大。
joda-time(Java LocalDatetime API)#
jdk8 吸收了 joda-time
的经验,拿出了新的java.time
包。这个包的的抽象比较复杂。
大体分为四个部分time
,temporal
,format
,zone
,chrono
time
包提供基于 ISO-8601 的 API,包括 Instant
, Duration
,TimeZone
等。
将 Duration
区分 Period
和 Duration
(比如一天,Duration 固定 24h,但是概念上,在 DST / 其他时区调整时,Period
的一天时间不一定是 24h)
time.temporal
是一个工具包,包含用于访问时间Field
和 Unit
的 API。Unit
作为通常意义上时间度量的单位,参与运算。Field
指明一个时间中的某个部分,选择其中的一部分,比如 2022年3月21日 17时21分11秒
,通过 MonthOfYear
字段,选中 3 月这个部分。逻辑上,抽象出TemporalAccssor
,TemporalAccessor
通过 TemporalField
提取时间中的信息。
time.format
包含序列化 / 反序列化的 API。 formatter 可以通过多种方式创建,包括常量、模式、本地化样式和构建器。
time.zone
包含用于处理时区的 API。 提供有关每个时区规则的详细信息, zone, zoneTransition(历史上的时区变化时间),ZoneTransitionRule(地区应用的时区变化规则,比如每年会切换 DST,本身可以作为规则被表示)。
chrono
包含 ISO 以外的日历系统可能用到的通用 API。Chronology 标识一种日历系统,ChronoZonedDateTime
包含日历中性 API 的基本部分 和替代日历系统。
Temporal (js)#
Temporal API 是 tc39 在 2017 年提出的一份 proposal,旨在改进 JS 糟糕的默认Date
处理。其文档也给出了较为清晰的抽象图。
区分 Plain
,Zoned
,Instant
三类,Plain 对象不考虑时区(可以被多个时区进行解释,有歧义),而 Instant
也是时区无关的(UTC,绝对时间,无歧义),Zoned
则是带有时区信息的时间对象,可以对应一个Instant
。
在处理完现代时间 / 日期之后,实现了 calendar 抽象,对不同的历法进行支持。
dayjs (js)#
dayjs 实现上,采取了 js 一贯的灵活作风,大部分功能是通过 plugin 进行实现的。核心是 dayjs 实例(对 JS Date 的包装),本身对时间运算和信息提取做了较好的支持,再通过插件方式进一步处理了 时区、运算、格式化、Duration
等需求。但是值得注意的是,时区的支持是二流的,其在设计之初可能并未考虑到类似 DST、行政时区变更这样的情况,对时区的支持仅仅是通过添加 offset 实现的,只对序列化 / 反序列化时产生影响。类似的,历法的支持也是如此。
date-fns (js)#
date-fns也是 JS 中的一个广泛使用的时间处理库,其特点是推崇函数式。所有的功能都通过 function 提供,所有函数的操作对象也是 JS Date 或其子类。在主库中仅包括 getter/setter/formatter 几类函数,对 duration
只有格式化上的支持(无法实现基于Duration
的计算)。时区支持也并不好(不过似乎v4
中有所改进)
luxon(js)#
luxon 作为 momentjs 的继任者,从实现上来看,完全摒弃了 JS 那别扭的 Date 。而是自己实现了 Datetime class,对时区,Duration
等具有一流的支持,对历法格式化也有基本的支持。也可以实现 JS Date 互转。总体来说要比 dayjs/date-fns 要来得更全面。但是相对的,在常用工具函数的支持上则有所欠缺。
DB 等生产应用中的时间#
尽管编程语言有处理时间的能力,但还要从数据源中获取时间信息,因此编程语言如何理解存储在应用中的时间也很重要。
MySQL#
MYSQL 中有五种时间相关的类型以及若干处理时间的函数。
DATE、TIME、DATETIME、TIMESTAMP、YEAR
其中 DATETIME、TIMESTAMP 可以表示精确的时间。
- TIMESTAMP 的表示范围有限(2038 年)。在查询时,会根据时区的不同返回不同的结果(自动转换),是糟糕的选择
- DATETIME 表示范围更广泛,在查询时不会受到服务器的时区的影响。存入什么样的值,返回的结果就是什么样,不会被时区影响,可以理解为存储的
YYYY-MM-DD HH:mm:ss
格式的字符串。 CUR_X
获取当前日期、时间的函数均会受到时区的影响,因此应该以UTC_X
取而代之。
在与客户端交互的过程中,DATETIME
类型不存储时区信息,因此若有必要,客户端需要保证存储时的时区与读取时的时区保持一致。仅接受YYYY-MM-DD HH:mm:ss
形式的输入,输入默认也保持这种形式。
至于TIME
、DATE
、YEAR
类型,与DATETIME
类似,存储与读取不会受时区影响。值得注意的是,TIME
类型可以同时兼任表示Duration
,表示时间段。
Postgres#
Postgres 中的时间相关类型有六种。
timestamp
,timestampz
,date
,time
, timez
,interval
其中 timestamp
和 timestampz
可以表示一个精确的时间,内部存储为 UTC 时间。
其中的 z
表示 with time zone
,但值得注意的是,其内部并不存储时区信息,区别在于,在与客户端交互的时候,如何解释与客户端之间传递的时间信息。
timestamp
,表示一个精确的时间,不考虑时区,传入的时间信息如果有歧义,按 UTC 进行解释并存储为 UTC,返回结果也为 UTC。timestampz
,考虑会话时区,传入的时间信息如果有歧义,按照时区进行解释,存储为 UTC。在查询时,返回结果会转为会话时区。(因此需要特别处理)date
,time
,不考虑时区的日期和时间,可以认为存取一致timez
,考虑时区的time
,在存储和查询时,会按照会话时区处理,一般不使用。interval
,时间间隔
吐槽
timestampz
本身的解释 with timezone
很具有迷惑性,似乎在说 pg 额外存储了时区相关信息,但是实际上是说,当输入 / 输出时,会以时区的基准去考虑 / 解释。
有时区考量当然很好,但在我看来,所有 under the hood 的部分,表示精确时间的内容,应该是统一成 UTC 的。时区、格式化等等需求,只有在展示给用户时,才进行转换。
重新审视需求#
在浏览了这么多库的抽象和实现之后,我们可以得到如下的功能划分。
- 现代的标准时间系统的表示全局唯一的时间点
- 时区支持
- 时区偏移 utcoffset
- 时区变更历史记录
- DST...
- 时间间隔的表示
- 绝对的时间间隔,以一个系统中的基本单位为准。
- 抽象的时间间隔,考虑时间偏移之后,在不同上下文中长度不一
- 由两个 instant 定义的一段间隔
- 在一个时间系统内部的操作
- 运算,依据当前时间,得到一个另外的时间 / 时间段
- 加减时间段
- 截断(如当前小时的起点,终点)
- 与其他时间的距离
- 比较(与其他时间(段)的关系):大于,小于,相同,位于时间段内外...
- 判断(时间自身的性质):是否闰年、是否节日,DST
- getter,提取时间自身的信息,如年份
- formatter,转为人类可读的形式,可能存在信息丢失
- 标准的格式化(ISO 8061, RFC 3339...)
- 自定义时间的格式化
- 时间段的格式化
- parser,从人类可读可读的形式,多数时候信息不全,会引入歧义,可能对应多个 instant,需要有合适的默认行为
- 标准的反序列化(ISO 8061, RFC 3339...)
- 自定义格式的反序列化
- 时间段的反序列化
- 运算,依据当前时间,得到一个另外的时间 / 时间段
- 时间系统间的转变,比如从历法 A 切换到 历法 B
- 历法支持,本身上是精度不同的另外一套时间系统
这是一个全面的时间处理库需要考虑的功能,但是全面通常也意味着臃肿。
因此有必要对这些功能进行拆分。在实际使用中,不同场景对时间处理的需求是不同的。
一个场景是系统内部的信息传递、处理。这是机器与机器之间的交流,我们只需要准确记录和传递时间点,进行基本的比较和计算。这个场景下,注重的一个时间系统内部的运算(比较、判断、getter 等),formatter 和 parser 只需要标准功能即可。因此核心是围绕 Instant
和Duration
的各种计算。在时间进行存储时,也应当保持统一。
另外一个场景是信息对外界的展示(比如对人),这时候需要更强大的 formatter、parser(如相对时间),因此标准的 formatter/parser 之外,增强的 formatter/parser 作为一个包。
在部分特殊场景,可能才需要其他 Calendar,因此与标准 Instant 的转换、内部的计算单独作为一部分。
纸上谈兵到此为止。That's all,接下来则是照葫芦画瓢构建一个玩具 datetime 库了。