新的日期和时间API
simple beautiful strong immutable thread-safe
本章内容
- 为什么在Java 8中需要引入新的日期和时间库
- 同时为人和机器表示日期和时间
- 定义时间的度量
- 操纵、格式化以及解析日期
- 处理不同的时区和历法
Java的API提供了很多有用的组件,能帮助你构建复杂的应用。不过,Java API也不总是完美 的。我们相信大多数有经验的程序员都会赞同Java 8之前的库对日期和时间的支持就非常不理想。 然而,你也不用太担心:Java 8中引入全新的日期和时间API就是要解决这一问题。
在Java 1.0中,对日期和时间的支持只能依赖java.util.Date类。正如类名所表达的,这 个类无法表示日期,只能以毫秒的精度表示时间。更糟糕的是它的易用性,由于某些原因未知的 设计决策,这个类的易用性被深深地损害了,比如:年份的起始选择是1900年,月份的起始从0 开始。这意味着,如果你想要用Date表示Java 8的发布日期,即2014年3月18日,需要创建下面 这样的Date实例:
|
|
它的打印输出效果为:
|
|
看起来不那么直观,不是吗?此外,甚至Date类的toString方法返回的字符串也容易误导 人。以我们的例子而言,它的返回值中甚至还包含了JVM的默认时区CET,即中欧时间(Central Europe Time)。但这并不表示Date类在任何方面支持时区。
随着Java 1.0退出历史舞台,Date类的种种问题和限制几乎一扫而光,但很明显,这些历史 旧账如果不牺牲前向兼容性是无法解决的。所以,在Java 1.1中,Date类中的很多方法被废弃了, 取而代之的是java.util.Calendar类。很不幸,Calendar类也有类似的问题和设计缺陷,导 致使用这些方法写出的代码非常容易出错。比如,月份依旧是从0开始计算(不过,至少Calendar 类拿掉了由1900年开始计算年份这一设计)。更糟的是,同时存在Date和Calendar这两个类,也增加了程序员的困惑。到底该使用哪一个类呢?此外,有的特性只在某一个类有提供,比如用 于以语言无关方式格式化和解析日期或时间的DateFormat方法就只在Date类里有。
DateFormat方法也有它自己的问题。比如,它不是线程安全的。这意味着两个线程如果尝试使用同一个formatter解析日期,你可能会得到无法预期的结果。
最后, Date和Calendar类都是可以变的。能把2014年3月18日修改成4月18日意味着什么 呢?这种设计会将你拖入维护的噩梦,接下来的一章,我们会讨论函数式编程,你在该章中会了 解到更多的细节。
所有这些缺陷和不一致导致用户们转投第三方的日期和时间库,比如Joda-Time。为了解决 这些问题,Oracle决定在原生的Java API中提供高质量的日期和时间支持。所以,你会看到Java 8 在java.time包中整合了很多Joda-Time的特性。
这一章中,我们会一起探索新的日期和时间API所提供的新特性。我们从最基本的用例入手, 比如创建同时适合人与机器的日期和时间,逐渐转入到日期和时间API更高级的一些应用,比如 操纵、解析、打印输出日期时间对象,使用不同的时区和年历。
LocalDate、LocalTime、Instant、Duration 以及 Period
让我们从探索如何创建简单的日期和时间间隔入手。java.time包中提供了很多新的类可以 帮你解决问题,它们是LocalDate、LocalTime、Instant、Duration和Period。
使用 LocalDate 和 LocalTime
LocalDate
|
|
开始使用新的日期和时间API时,你最先碰到的可能是LocalDate类。开始使用新的日期和时间API时,你最先碰到的可能是LocalDate类。该类的实例是一个不 可变对象,它只提供了简单的日期,并不含当天的时间信息。另外,它也不附带任何与时区相关 的信息。
你可以通过静态工厂方法of创建一个LocalDate实例。LocalDate实例提供了多种方法来 读取常用的值,比如年份、月份、星期几等,如下所示。
代码清单12-1 创建一个LocalDate对象并读取其值
|
|
你还可以使用工厂方法从系统时钟中获取当前的日期:
|
|
本章剩余的部分会探讨所有日期时间类,这些类都提供了类似的工厂方法。你还可以通过传递一个TemporalField参数给get方法拿到同样的信息。TemporalField是一个接口,它定 义了如何访问temporal对象某个字段的值。ChronoField枚举实现了这一接口,所以你可以很 方便地使用get方法得到枚举元素的值,如下所示。
代码清单12-2 使用TemporalField读取LocalDate的值
|
|
LocalTime
|
|
类似地,一天中的时间,比如13:45:20,可以使用LocalTime类表示。你可以使用of重载的 两个工厂方法创建LocalTime的实例。第一个重载函数接收小时和分钟,第二个重载函数同时还 接收秒。
同LocalDate一样,LocalTime类也提供了一些getter方法访问这些变量的值,如下 所示。
代码清单12-3 创建LocalTime并读取其值
|
|
LocalDate和LocalTime都可以通过解析代表它们的字符串创建。使用静态方法parse,你可以实现这一目的:
|
|
你可以向parse方法传递一个DateTimeFormatter。该类的实例定义了如何格式化一个日 期或者时间对象。正如我们之前所介绍的,它是替换老版java.util.DateFormat的推荐替代 品。我们会在12.2节展开介绍怎样使用DateTimeFormatter。同时,也请注意,一旦传递的字 符串参数无法被解析为合法的LocalDate或LocalTime对象,这两个parse方法都会抛出一个继 承自RuntimeException的DateTimeParseException异常。
合并日期和时间
LocalDateTime
|
|
这个复合类名叫LocalDateTime,是LocalDate和LocalTime的合体。它同时表示了日期 和时间,但不带有时区信息,你可以直接创建,也可以通过合并日期和时间对象构造,如下所示。
代码清单12-4 直接创建LocalDateTime对象,或者通过合并日期和时间的方式创建
|
|
注意,通过它们各自的atTime或者atDate方法,向LocalDate传递一个时间对象,或者向LocalTime 传递一个日期对象的方式, 你可以创建一个 LocalDateTime 对象。 你也可以使用 toLocalDate 或者 toLocalTime 方法,从 LocalDateTime 中提取LocalDate 或者 LocalTime 组件:
|
|
机器的日期和时间格式
Instant
|
|
作为人,我们习惯于以星期几、几号、几点、几分这样的方式理解日期和时间。毫无疑问, 这种方式对于计算机而言并不容易理解。从计算机的角度来看,建模时间最自然的格式是表示一 个持续时间段上某个点的单一大整型数。这也是新的 java.time.Instant 类对时间建模的方 式,基本上它是以Unix元年时间(传统的设定为UTC时区1970年1月1日午夜时分)开始所经历的 秒数进行计算。
你可以通过向静态工厂方法ofEpochSecond传递一个代表秒数的值创建一个该类的实例。静 态工厂方法ofEpochSecond还有一个增强的重载版本,它接收第二个以纳秒为单位的参数值,对 传入作为秒数的参数进行调整。重载的版本会调整纳秒参数,确保保存的纳秒分片在0到999 999 999之间。这意味着下面这些对ofEpochSecond工厂方法的调用会返回同样的Instant对象:
|
|
正如你已经在 LocalDate 及其他为便于阅读而设计的日期时间类中所看到的那样, Instant类也支持静态工厂方法now,它能够帮你获取当前时刻的时间戳。我们想要特别强调一 点,Instant的设计初衷是为了便于机器使用。它包含的是由秒及纳秒所构成的数字。所以,它 无法处理那些我们非常容易理解的时间单位。比如下面这段语句:
|
|
它会抛出下面这样的异常:
|
|
但是你可以通过Duration和Period类使用Instant,接下来我们会对这部分内容进行介绍。
定义Duration或Period
目前为止,你看到的所有类都实现了Temporal接口,Temporal接口定义了如何读取和操纵为时间建模的对象的值。之前的介绍中,我们已经了解了创建Temporal实例的几种方法。很自 然地你会想到,我们需要创建两个Temporal对象之间的duration。Duration类的静态工厂方法between就是为这个目的而设计的。你可以创建两个LocalTimes对象、两个LocalDateTimes对象,或者两个Instant对象之间的duration,如下所示:
|
|
由于LocalDateTime和Instant是为不同的目的而设计的,一个是为了便于人阅读使用, 另一个是为了便于机器处理, 所以你不能将二者混用。 如果你试图在这两类对象之间创建 duration,会触发一个DateTimeException异常。此外,由于Duration类主要用于以秒和纳秒衡量时间的长短,你不能向between方法传递一个LocalDate对象做参数(虽然编译不报错但是运行时会报UnsupportedTemporalTypeException)。
如果你需要以年、月或者日的方式对多个时间单位建模,可以使用Period类。使用该类的 工厂方法between,你可以使用得到两个LocalDate之间的时长,如下所示:
|
|
最后,Duration和Period类都提供了很多非常方便的工厂类,直接创建对应的实例;换 句话说,就像下面这段代码那样,不再是只能以两个temporal对象的差值的方式来定义它们的 对象。
代码清单12-5 创建Duration和Period对象
|
|
Duration类和Period类共享了很多相似的方法,参见表12-1所示。
截至目前,我们介绍的这些日期时间对象都是不可修改的,这是为了更好地支持函数式编程,确保线程安全,保持领域模式一致性而做出的重大设计决定。当然,新的日期和时间API也 提供了一些便利的方法来创建这些对象的可变版本。比如,你可能希望在已有的LocalDate实例 上增加3天。我们在下一节中会针对这一主题进行介绍。除此之外,我们还会介绍如何依据指定 的模式,比如dd/MM/yyyy,创建日期时间格式器,以及如何使用这种格式器解析和输出日期。
操纵、解析和格式化日期
如果你已经有一个LocalDate对象,想要创建它的一个修改版,最直接也最简单的方法是使 用withAttribute方法。withAttribute方法会创建对象的一个副本,并按照需要修改它的属性。注意,下面的这段代码中所有的方法都返回一个修改了属性的对象。它们都不会修改原来的 对象!
代码清单12-6 以比较直观的方式操纵LocalDate的属性
|
|
采用更通用的with方法能达到同样的目的,它接受的第一个参数是一个TemporalField对 象,格式类似代码清单12-6的最后一行。最后这一行中使用的with方法和代码清单12-2中的get 方法有些类似。它们都声明于Temporal接口,所有的日期和时间API类都实现这两个方法,它 们定义了单点的时间,比如LocalDate、LocalTime、LocalDateTime以及Instant。更确切 地说,使用get和with方法,我们可以将Temporal对象值的读取和修改区分开。如果Temporal 对象不支持请求访问的字段,它会抛出一个UnsupportedTemporalTypeException异常,比 如试图访问 Instant 对象的 ChronoField.MONTH_OF_YEAR 字段, 或者 LocalDate 对象的 ChronoField.NANO_OF_SECOND字段时都会抛出这样的异常。
它甚至能以声明的方式操纵LocalDate对象。比如,你可以像下面这段代码那样加上或者减 去一段时间。
代码清单12-7 以相对方式修改LocalDate对象的属性
|
|
与我们刚才介绍的get和with方法类似,代码清单12-7中最后一行使用的plus方法也是通用 方法,它和minus方法都声明于Temporal接口中。通过这些方法,对TemporalUnit对象加上 或者减去一个数字, 我们能非常方便地将 Temporal 对象前溯或者回滚至某个时间段, 通过 ChronoUnit枚举我们可以非常方便地实现TemporalUnit接口。
大概你已经猜到,像LocalDate、LocalTime、LocalDateTime以及Instant这样表示时 间点的日期时间类提供了大量通用的方法,表12-2对这些通用的方法进行了总结。
使用TemporalAdjuster
截至目前,你所看到的所有日期操作都是相对比较直接的。有的时候,你需要进行一些更加 复杂的操作,比如,将日期调整到下个周日、下个工作日,或者是本月的最后一天。这时,你可 以使用重载版本的with方法,向其传递一个提供了更多定制化选择的TemporalAdjuster对象, 更加灵活地处理日期。 对于最常见的用例, 日期和时间API已经提供了大量预定义的 TemporalAdjuster。你可以通过TemporalAdjuster类的静态工厂方法访问它们,如下所示。
代码清单12-8 使用预定义的TemporalAdjuster
|
|
表12-3提供了TemporalAdjuster中包含的工厂方法列表。
dayOfWeekInMonth解释有误
1 2 3 4 5
* @param ordinal the week within the month, unbounded but typically from -5 to 5 * @param dayOfWeek the day-of-week, not null * @return the day-of-week in month adjuster, not null */ public static TemporalAdjuster dayOfWeekInMonth(int ordinal, DayOfWeek dayOfWeek) {
1
LocalDate date4 = date1.with(dayOfWeekInMonth(1, DayOfWeek.MONDAY));
会返回本月第一周的星期一所在日期,也就是2020-08-03
正如我们看到的,使用TemporalAdjuster我们可以进行更加复杂的日期操作,而且这些方 法的名称也非常直观,方法名基本就是问题陈述。此外,即使你没有找到符合你要求的预定义的 TemporalAdjuster , 创建你自己的 TemporalAdjuster 也并非难事。 实际上, Temporal- Adjuster接口只声明了单一的一个方法(这使得它成为了一个函数式接口),定义如下。
|
|
这意味着 TemporalAdjuster 接口的实现需要定义如何将一个 Temporal 对象转换为另一 个Temporal对象。你可以把它看成一个UnaryOperator<Temporal>
。花几分钟时间完成下面的测验,练习一下我们到目前为止所学习的东西,请实现你自己的TemporalAdjuster。
实现一个定制的TemporalAdjuster
请设计一个NextWorkingDay类,该类实现了TemporalAdjuster接口,能够计算明天
的日期,同时过滤掉周六和周日这些节假日。格式如下所示:
|
|
如果当天的星期介于周一至周五之间,日期向后移动一天;如果当天是周六或者周日,则返回下一个周一。
下面是参考的NextWorkingDay类的实现。
|
|
该TemporalAdjuster通常情况下将日期往后顺延一天,如果当天是周六或者周日,则依据情况分别将日期顺延3天或者2天。注意,由于TemporalAdjuster是一个函数式接口,你也可以以Lambda表达式的方式向该adjuster接口传递行为:
|
|
你大概会希望在你代码的多个地方使用同样的方式去操作日期,为了达到这一目的,我们建议你像我们的示例那样将它的逻辑封装到一个类中。对于你经常使用的操作,都应该采用类似的方式,进行封装。最终,你会创建自己的类库,让你和你的团队能轻松地实现代码复用。
如果你想要使用Lambda表达式定义 TemporalAdjuster 对象, 推荐使用 Temporal-Adjusters类的静态工厂方法ofDateAdjuster,它接受一个UnaryOperator<LocalDate>
类型的参数,代码如下:
|
|
你可能希望对你的日期时间对象进行的另外一个通用操作是,依据你的业务领域以不同的格 式打印输出这些日期和时间对象。类似地,你可能也需要将那些格式的字符串转换为实际的日期 对象。接下来的一节,我们会演示新的日期和时间API提供那些机制是如何完成这些任务的。
打印输出及解析日期——时间对象
format
处理日期和时间对象时,格式化以及解析日期时间对象是另一个非常重要的功能。新的 java.time.format 包就是特别为这个目的而设计的。 这个包中, 最重要的类是 DateTimeFormatter。创建格式器最简单的方法是通过它的静态工厂方法以及常量。像BASIC_ISO_DATE 和 ISO_LOCAL_DATE 这 样 的 常 量 是 DateTimeFormatter 类 的 预 定 义 实 例 。 所 有 的 DateTimeFormatter实例都能用于以一定的格式创建代表特定日期或时间的字符串。比如,下 面的这个例子中,我们使用了两个不同的格式器生成了字符串:
|
|
parse
你也可以通过解析代表日期或时间的字符串重新创建该日期对象。所有的日期和时间API 都提供了表示时间点或者时间段的工厂方法,你可以使用工厂方法parse达到重创该日期对象 的目的:
|
|
和老的 java.util.DateFormat 相比较,所有的 DateTimeFormatter 实例都是线程安全 的。所以,你能够以单例模式创建格式器实例,就像DateTimeFormatter所定义的那些常量, 并能在多个线程间共享这些实例。DateTimeFormatter类还支持一个静态工厂方法,它可以按 照某个特定的模式创建格式器,代码清单如下。
代码清单12-10 按照某个模式创建DateTimeFormatter
|
|
这段代码中,LocalDate的formate方法使用指定的模式生成了一个代表该日期的字符串。 紧接着,静态的parse方法使用同样的格式器解析了刚才生成的字符串,并重建了该日期对象。 ofPattern方法也提供了一个重载的版本,使用它你可以创建某个Locale的格式器
代码清单如下所示。
代码清单12-11 创建一个本地化的DateTimeFormatter
|
|
最后,如果你还需要更加细粒度的控制,DateTimeFormatterBuilder类还提供了更复杂 的格式器,你可以选择恰当的方法,一步一步地构造自己的格式器。另外,它还提供了非常强大 的解析功能,比如区分大小写的解析、柔性解析(允许解析器使用启发式的机制去解析输入,不 精 确 地 匹 配 指 定 的 模 式 )、 填 充 , 以 及 在 格 式 器 中 指 定 可 选 节 。 比 如 , 你 可 以 通 过 DateTimeFormatterBuilder 自己编程实现我们在代码清单12-11中使用的 italianFor- matter,代码清单如下。
代码清单12-12 构造一个DateTimeFormatter
|
|
目前为止,你已经学习了如何创建、操纵、格式化以及解析时间点和时间段,但是你还不了 解如何处理日期和时间之间的微妙关系。比如,你可能需要处理不同的时区,或者由于不同的历 法系统带来的差异。接下来的一节,我们会探究如何使用新的日期和时间API解决这些问题。
处理不同的时区和历法
之前你看到的日期和时间的种类都不包含时区信息。时区的处理是新版日期和时间API新增 加的重要功能,使用新版日期和时间API时区的处理被极大地简化了。新的java.time.ZoneId 类是老版java.util.TimeZone的替代品。它的设计目标就是要让你无需为时区处理的复杂和 繁琐而操心,比如处理夏令时(Daylight Saving Time,DST)这种问题。跟其他日期和时间类一 样,ZoneId类也是无法修改的。
时区是按照一定的规则将区域划分成的标准时间相同的区间。在ZoneRules这个类中包含了 40个这样的实例。你可以简单地通过调用ZoneId的getRules()得到指定时区的规则。每个特定 的ZoneId对象都由一个地区ID标识,比如:
|
|
地区ID都为“{区域}/{城市}”的格式,这些地区集合的设定都由英特网编号分配机构(IANA) 的时区数据库提供。你可以通过Java 8的新方法toZoneId将一个老的时区对象转换为ZoneId:
|
|
一旦得到一个ZoneId对象,你就可以将它与LocalDate、LocalDateTime或者是Instant 对象整合起来,构造为一个ZonedDateTime实例,它代表了相对于指定时区的时间点,代码清单如下所示。
代码清单12-13 为时间点添加时区信息
|
|
图12-1对 ZonedDateTime 的组成部分进行了说明, 相信能够帮助你理解 LocaleDate 、 LocalTime、LocalDateTime以及ZoneId之间的差异。
通过ZoneId,你还可以将LocalDateTime转换为Instant:
|
|
你也可以通过反向的方式得到LocalDateTime对象:
|
|
利用和 UTC/格林尼治时间的固定偏差计算时区
另一种比较通用的表达时区的方式是利用当前时区和UTC/格林尼治的固定偏差。比如,基于这个理论,你可以说“纽约落后于伦敦5小时”。这种情况下,你可以使用ZoneOffset类,它 是ZoneId的一个子类,表示的是当前时间和伦敦格林尼治子午线时间的差异:
|
|
“05:00”的偏差实际上对应的是美国东部标准时间。注意,使用这种方式定义的ZoneOffset 并未考虑任何日光时的影响,所以在大多数情况下,不推荐使用。由于ZoneOffset也是ZoneId, 所以你可以像代码清单12-13那样使用它。你甚至还可以创建这样的OffsetDateTime,它使用 ISO-8601的历法系统,以相对于UTC/格林尼治时间的偏差方式表示日期时间。
|
|
新版的日期和时间API还提供了另一个高级特性,即对非ISO历法系统(non-ISO calendaring) 的支持。
使用别的日历系统
ISO-8601日历系统是世界文明日历系统的事实标准。但是,Java 8中另外还提供了4种其他的 日历系统。 这些日历系统中的每一个都有一个对应的日志类, 分别是 ThaiBuddhistDate 、 MinguoDate 、 JapaneseDate 以 及 HijrahDate 。 所 有 这 些 类 以 及 LocalDate 都 实 现 了 ChronoLocalDate接口,能够对公历的日期进行建模。利用LocalDate对象,你可以创建这些 类的实例。更通用地说,使用它们提供的静态工厂方法,你可以创建任何一个Temporal对象的 实例,如下所示:
|
|
或者,你还可以为某个Locale显式地创建日历系统,接着创建该Locale对应的日期的实例。 新的日期和时间API中, Chronology 接口建模了一个日历系统, 使用它的静态工厂方法 ofLocale,可以得到它的一个实例,代码如下:
|
|
日期及时间API的设计者建议我们使用LocalDate,尽量避免使用ChronoLocalDate,原 因是开发者在他们的代码中可能会做一些假设,而这些假设在不同的日历系统中,有可能不成立。 比如,有人可能会做这样的假设,即一个月天数不会超过31天,一年包括12个月,或者一年中包 含的月份数目是固定的。由于这些原因,我们建议你尽量在你的应用中使用LocalDate,包括存 储、操作、业务规则的解读;不过如果你需要将程序的输入或者输出本地化,这时你应该使用 ChronoLocalDate类。
伊斯兰教日历
在Java 8新添加的几种日历类型中,HijrahDate(伊斯兰教日历)是最复杂一个,因为它 会发生各种变化。Hijrah日历系统构建于农历月份继承之上。Java 8提供了多种方法判断一个月份,比如新月,在世界的哪些地方可见,或者说它只能首先可见于沙特阿拉伯。withVariant 方法可以用于选择期望的变化。 为了支持 HijrahDate 这一标准, Java 8中还包括了乌姆库拉 (Umm Al-Qura)变量。 下面这段代码作为一个例子说明了如何在ISO日历中计算当前伊斯兰年中斋月的起始和终止 日期:
小结
这一章中,你应该掌握下面这些内容。
- Java 8之前老版的 java.util.Date类以及其他用于建模日期时间的类有很多不一致及设计上的缺陷,包括易变性以及糟糕的偏移值、默认值和命名。
- 新版的日期和时间API中,日期-时间对象是不可变的。
- 新的API提供了两种不同的时间表示方式,有效地区分了运行时人和机器的不同需求。
- 你可以用绝对或者相对的方式操纵日期和时间,操作的结果总是返回一个新的实例,老的日期时间对象不会发生变化
- TemporalAdjuster让你能够用更精细的方式操纵日期,不再局限于一次只能改变它的一个值,并且你还可按照需求定义自己的日期转换器。
- 你现在可以按照特定的格式需求,定义自己的格式器,打印输出或者解析日期时间对象。这些格式器可以通过模板创建,也可以自己编程创建,并且它们都是线程安全的。
- 你可以用相对于某个地区/位置的方式,或者以与UTC/格林尼治时间的绝对偏差的方式表示时区,并将其应用到日期-时间对象上,对其进行本地化。
- 你现在可以使用不同于ISO-8601标准系统的其他日历系统了。