9.3 在Java 8中处理日期和时间

几乎所有商业应用软件都具有一些日期和时间的概念。建模真实世界的事件或活动时,知道事件什么时候发生可以对后续报告和域对象的比较都很重要。Java 8 完全改变了开发者处理日期和时间的方式。本节介绍 Java 8 引入的新概念。在之前的版本中,只能通过 java.util.Date 类处理日期和时间,而且这个类没有建模这些概念。使用旧 API 的代码应该尽早转用新 API。

9.3.1 介绍Java 8的日期和时间API

Java 8 引入了一个新包 java.time,包含了多数开发者都会用到的核心类。这个包分为四个子包。

  • java.time.chrono

开发者使用的历法不符合 ISO 标准时,需要与之交互的其他纪年法。例如日本历法。

  • java.time.format

这个包中的 DateTimeFormatter 类用于把日期和时间对象转换成字符串,以及把字符串解析成日期和时间对象。

  • java.time.temporal

包含日期和时间核心类所需的接口,还抽象了一些日期方面的高级操作(例如查询和调节器)。

  • java.time.zone

底层时区规则使用的类;多数开发者都用不到这个包。

表示时间时,最重要的概念之一是,某个实体时间轴上的瞬时点。既然这个概念在狭义相对论等理论中已经有了完善的定义,那么在计算机中表示时间就要做些假设。Java 8 使用一个 Instant 对象表示一个时间点,而且做了下述关键假设:

  • 表示的秒数不能超出 long 类型的取值范围;

  • 表示的时间不能比纳秒还精细。

因此,能表示的时间受到当前计算机系统的能力所限制。不过,还有一个基本概念需要介绍。

Instant 对象是时空中的单一事件。可是,程序员经常要处理的却是两个事件之间的时间间隔,所以 Java 8 还引入了 java.time.Duration 类。这个类会忽略可能出现的日历效应(例如夏令时)。了解瞬时和事件持续时间的基本概念之后,我们来看一下瞬时的具体表现。

1. 时间戳的组成

图 9-1 展示了使用不同方式分解时间戳得到的各个部分。

{%}

图 9-1:分解时间戳

关键是要知道,不同的地方适合使用不同的抽象方式。例如,有些商业应用主要处理的是 LocalDate 对象,此时需要的时间粒度是一个工作日。而有些应用需要亚秒级甚至是毫秒级精度。开发者要了解所需的业务逻辑,在应用中使用合适的表示方式。

2. 示例

日期和时间 API 不是一朝一夕就能完全掌握的。下面举个例子,这个示例定义了一个日志类,记录生日。如果碰巧你很容易忘记生日,那么这样的类(尤其是 getBirthdaysInNextMonth() 这样的方法)可以给你提供很大的帮助:

  1. public class BirthdayDiary {
  2. private Map<String, LocalDate> birthdays;
  3. public BirthdayDiary() {
  4. birthdays = new HashMap<>();
  5. }
  6. public LocalDate addBirthday(String name, int day, int month,
  7. int year) {
  8. LocalDate birthday = LocalDate.of(year, month, day);
  9. birthdays.put(name, birthday);
  10. return birthday;
  11. }
  12. public LocalDate getBirthdayFor(String name) {
  13. return birthdays.get(name);
  14. }
  15. public int getAgeInYear(String name, int year) {
  16. Period period = Period.between(birthdays.get(name),
  17. birthdays.get(name).withYear(year));
  18. return period.getYears();
  19. }
  20. public Set<String> getFriendsOfAgeIn(int age, int year) {
  21. return birthdays.keySet().stream()
  22. .filter(p -> getAgeInYear(p, year) == age)
  23. .collect(Collectors.toSet());
  24. }
  25. public int getDaysUntilBirthday(String name) {
  26. Period period = Period.between(LocalDate.now(),
  27. birthdays.get(name));
  28. return period.getDays();
  29. }
  30. public Set<String> getBirthdaysIn(Month month) {
  31. return birthdays.entrySet().stream()
  32. .filter(p -> p.getValue().getMonth() == month)
  33. .map(p -> p.getKey())
  34. .collect(Collectors.toSet());
  35. }
  36. public Set<String> getBirthdaysInNextMonth() {
  37. return getBirthdaysIn(LocalDate.now().getMonth());
  38. }
  39. public int getTotalAgeInYears() {
  40. return birthdays.keySet().stream()
  41. .mapToInt(p -> getAgeInYear(p,
  42. LocalDate.now().getYear()))
  43. .sum();
  44. }
  45. }

这个类展示了如何使用低层 API 实现有用的功能。这个类还用到了一些新技术,例如 Java 的流 API,而且演示了如何把 LocalDate 类视作不可变的类使用,以及如何把日期当成值处理。

9.3.2 查询

很多情况下,我们要回答一些关于某个时间对象的问题,例如:

  • 这个日期在 3 月 1 日之前吗?

  • 这个日期所在的年份是闰年吗?

  • 今天距我下一次生日还有多少天?

为了回答这些问题,可以使用 TemporalQuery 接口,其定义如下所示:

  1. public interface TemporalQuery<R> {
  2. R queryFrom(TemporalAccessor temporal);
  3. }

queryFrom() 方法的参数不能为 null,不过,如果结果表示不存在的值,可以使用 null 作为返回值。

9.3 在Java 8中处理日期和时间 - 图2 Predicate 接口实现的查询可以理解为只能回答“是”或“否”的问题。而 TemporalQuery 接口实现的查询更普适,除了能回答“是”或“否”之外,还能回答“有多少”和“哪一个”等问题。

下面看一个查询的具体示例,这个查询回答的问题是:“这个日期在一年中的哪个季度?”Java 8 不直接支持季度,因此要使用类似下面的代码:

  1. LocalDate today = LocalDate.now();
  2. Month currentMonth = today.getMonth();
  3. Month firstMonthofQuarter = currentMonth.firstMonthOfQuarter();

这样写没有把季度单独抽象出来,还需要编写专用的代码。下面我们稍微扩展一下 JDK,定义如下的枚举类型:

  1. public enum Quarter {
  2. FIRST, SECOND, THIRD, FOURTH;
  3. }

现在,可以这样编写查询:

  1. public class QuarterOfYearQuery implements TemporalQuery<Quarter> {
  2. @Override
  3. public Quarter queryFrom(TemporalAccessor temporal) {
  4. LocalDate now = LocalDate.from(temporal);
  5. if(now.isBefore(now.with(Month.APRIL).withDayOfMonth(1))) {
  6. return Quarter.FIRST;
  7. } else if(now.isBefore(now.with(Month.JULY)
  8. .withDayOfMonth(1))) {
  9. return Quarter.SECOND;
  10. } else if(now.isBefore(now.with(Month.NOVEMBER)
  11. .withDayOfMonth(1))) {
  12. return Quarter.THIRD;
  13. } else {
  14. return Quarter.FOURTH;
  15. }
  16. }
  17. }

TemporalQuery 对象可以直接使用,也可以间接使用。下面各举一个例子:

  1. QuarterOfYearQuery q = new QuarterOfYearQuery();
  2. // 直接使用
  3. Quarter quarter = q.queryFrom(LocalDate.now());
  4. System.out.println(quarter);
  5. // 间接使用
  6. quarter = LocalDate.now().query(q);
  7. System.out.println(quarter);

多数情况下,最好间接使用,即把查询对象作为参数传给 query() 方法,因为这样写出的代码更易于阅读。

9.3.3 调节器

调节器的作用是修改日期和时间对象。假如我们想获取某个时间戳所在季度的第一天:

  1. public class FirstDayOfQuarter implements TemporalAdjuster {
  2. @Override
  3. public Temporal adjustInto(Temporal temporal) {
  4. final int currentQuarter = YearMonth.from(temporal)
  5. .get(IsoFields.QUARTER_OF_YEAR);
  6. switch (currentQuarter) {
  7. case 1:
  8. return LocalDate.from(temporal)
  9. .with(TemporalAdjusters.firstDayOfYear());
  10. case 2:
  11. return LocalDate.from(temporal)
  12. .withMonth(Month.APRIL.getValue())
  13. .with(TemporalAdjusters.firstDayOfMonth());
  14. case 3:
  15. return LocalDate.from(temporal)
  16. .withMonth(Month.JULY.getValue())
  17. .with(TemporalAdjusters.firstDayOfMonth());
  18. case 4:
  19. return LocalDate.from(temporal)
  20. .withMonth(Month.OCTOBER.getValue())
  21. .with(TemporalAdjusters.firstDayOfMonth());
  22. default:
  23. return null; // 肯定不会执行到这里
  24. }
  25. }
  26. }

下面举个例子,看看如何使用调节器:

  1. LocalDate now = LocalDate.now();
  2. Temporal fdoq = now.with(new FirstDayOfQuarter());
  3. System.out.println(fdoq);

这里的关键是 with() 方法,这段代码先读取一个 Temporal 对象,然后返回修改后的另一个对象。在处理不可变对象的 API 中经常会见到这种方式。

9.3.4 过时的日期和时间API

可惜,很多应用还没有转用 Java 8 中优秀的日期和时间库。所以,为了完整性,本节简要介绍一下以前的 Java 版本对日期和时间的支持(以 java.util.Date 类为基础)。

9.3 在Java 8中处理日期和时间 - 图3 在 Java 8 环境中,别在使用过时的日期和时间类,尤其是 java.util.Date 类。

在较旧的 Java 版本中没有 java.time 包,开发者只能依赖 java.util.Date 类提供的基础支持。以前,这是表示时间戳的唯一方式。虽然这个类的名称是 Date,但其实它为日期和时间都提供了相应的组件,因此也为很多程序员带来了大量困扰。

Date 类提供的过时支持有很多问题。

  • Date 类的实现方式不正确。它表示的其实不是日期,更像是时间戳。因此需要使用不同的方式表示日期、日期和时间,以及瞬时时间戳。

  • Date 对象是可变的。创建日期的引用后,再次指向这个对象时可以修改它的值。

  • Date 类不符合 ISO-8601 标准。这是全球通用的日期标准,规定什么是有效的日期。

  • Date 类中有相当多的弃用方法。

这个版本的 JDK 使用两个构造方法创建 Date 对象:一个构造方法不接受参数,用于创建当前时间;另一个构造方法接受一个参数,即距 Epoch 时间 5 的毫秒数。

5Epoch 时间是 1970-01-01 00:00:00 UTC。——译者注