设计一个 datetime 库

4512
最后修改于

本文旨在尝试以一个库设计者的角度,去梳理日期 / 时间处理库中要解决的问题、抽象、以及合适的使用方式。

场景分析#

在了解了现实世界的各种时间表征机制之后,不妨重新思考下,我们在现实世界中,是如何使用时间的。
我们可以举一些简单的例子

  • 与某人上一次见面的时间?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包。这个包的的抽象比较复杂。
大体分为四个部分timetemporalformatzonechrono
time 包提供基于 ISO-8601 的 API,包括 Instant, DurationTimeZone等。
Duration 区分 PeriodDuration(比如一天,Duration 固定 24h,但是概念上,在 DST / 其他时区调整时,Period 的一天时间不一定是 24h)
time.temporal 是一个工具包,包含用于访问时间FieldUnit 的 API。Unit作为通常意义上时间度量的单位,参与运算。Field 指明一个时间中的某个部分,选择其中的一部分,比如 2022年3月21日 17时21分11秒,通过 MonthOfYear 字段,选中 3 月这个部分。逻辑上,抽象出TemporalAccssorTemporalAccessor 通过 TemporalField 提取时间中的信息。

time.format 包含序列化 / 反序列化的 API。 formatter 可以通过多种方式创建,包括常量、模式、本地化样式和构建器。

time.zone 包含用于处理时区的 API。 提供有关每个时区规则的详细信息, zone, zoneTransition(历史上的时区变化时间),ZoneTransitionRule(地区应用的时区变化规则,比如每年会切换 DST,本身可以作为规则被表示)。

chrono 包含 ISO 以外的日历系统可能用到的通用 API。Chronology 标识一种日历系统,ChronoZonedDateTime包含日历中性 API 的基本部分 和替代日历系统。

Temporal (js)#

Temporal documentation

Temporal API 是 tc39 在 2017 年提出的一份 proposal,旨在改进 JS 糟糕的默认Date处理。其文档也给出了较为清晰的抽象图。
区分 PlainZonedInstant 三类,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 形式的输入,输入默认也保持这种形式。
    至于 TIMEDATEYEAR 类型,与 DATETIME类似,存储与读取不会受时区影响。值得注意的是,TIME类型可以同时兼任表示 Duration ,表示时间段。
Postgres#

Postgres 中的时间相关类型有六种。
timestamp,timestampz,date,time, timez,interval
其中 timestamptimestampz可以表示一个精确的时间,内部存储为 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 只需要标准功能即可。因此核心是围绕 InstantDuration 的各种计算。在时间进行存储时,也应当保持统一。
另外一个场景是信息对外界的展示(比如对人),这时候需要更强大的 formatter、parser(如相对时间),因此标准的 formatter/parser 之外,增强的 formatter/parser 作为一个包。
在部分特殊场景,可能才需要其他 Calendar,因此与标准 Instant 的转换、内部的计算单独作为一部分。

纸上谈兵到此为止。That's all,接下来则是照葫芦画瓢构建一个玩具 datetime 库了。

  • 🥳0
  • 👍0
  • 💩0
  • 🤩0