Java 8实战:日期和时间API

        在Java 1.0中,对日期和时间的支持只能依赖 $java.util.Date$ 类。这个类无法表示日期,只能以毫秒的精度表示时间。而且由于某些原因未知的设计决策,这个类的易用性也不高。例如一个表示 $2014$ 年 $3$ 月 $18$ 日的 $Date$ 需要用以下方式创建:

Date date = new Date(114, 2, 18);

        此外,$Date.toString$ 方法的返回值中虽然包含时区,但 $Date$ 并不支持时区。所以,在Java 1.1中,$Date$ 的许多方法被废弃,取而代之的是 $java.util.Calendar$ 类,但是这个类同样也存在着很多问题。比如月份依旧是从 $0$ 开始,而且没有提供 $DateFormat$ 方法。如果使用 $DateFormat$ 方法,那么又会带来并发问题,因为它不是线程安全的。为了解决上述问题,Java 8中新增的 $java.time$ 包中添加了新的日期和时间API
        $java.time$ 包中提供了一些新类:$LocalDate$ 、$LocalTime$ 、$Instant$ 、$Duration$ 和 $Period$ 。

1. LocalDate/LocalTime/LocalDateTime

        $LocalDate$ 的实例是一个不可变对象,它只提供了简单的日期,并不包含当天的时间信息。另外,它也不附带任何与时区相关的信息。可以通过静态工厂方法 $of$ 创建一个 $LocalDate$ 实例,或者通过 $now$ 方法从系统时钟中获取当前日期,并通过 $getYear$ 、$getMonth$ 、$getDayOfMonth$ 等方法读取常用值。

LocalDate date = LocalDate.of(2014, 3, 18); // 2014-03-18
int year = date.getYear(); // 2014
Month month = date.getMonth(); // MARCH
int day = date.getDayOfMonth(); // 18
DayOfWeek dow = date.getDayOfWeek(); // TUESDAY
int len = date.lengthOfMonth(); // 31
boolean leap = date.isLeapYear(); // false
LocalDate today = LocalDate.now();

        $TemporalField$ 是一个接口,定义了如何访问 $temporal$ 对象某个字段的值。$ChronoField$ 枚举实现了这一接口,通过 $get$ 方法可以获取对应字段的值。

int year = date.get(ChronoField.YEAR);
int month = date.get(ChronoField.MONTH_OF_YEAR);
int day = date.get(ChronoField.DAY_OF_MONTH);

        类似的,一天中的时间可以使用 $LocalTime$ 表示。

LocalTime time = LocalTime.of(13, 45, 20); // 13:45:20
int hour = time.getHour(); // 13
int minute = time.getMinute(); // 45
int second = time.getSecond(); // 20

        $LocalDate$ 和 $LocalTime$ 都可以通过解析字符串的形式创建。一旦传递的字符串参数无法被解析为合法的对象时就会抛出一个 $DateTimeParseException$ 异常。

LocalDate date = LocalDate.parse("2014-03-18");
LocalTime time = LocalTime.parse("13:45:20");

        $LocalDateTime$ 是 $LocalDate$ 和 $LocalTime$ 的复合体,既可以表示日期,也可以表示时间,但不带有时区信息。通过 $atTime$ 或者 $atDate$ 方法传递一个时间或者日期对象,可以创建一个 $LocalDateTime$ 。此外,也可以通过 $toLocalDate$ 和 $toLocalTime$ 方法,从 $LocalDateTime$ 中创建 $LocalDate$ 和 $LocalTime$ 。

LocalDateTime dt1 = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45, 20);
LocalDateTime dt2 = LocalDateTime.of(date, time);
LocalDateTime dt3 = date.atTime(13, 45, 20);
LocalDateTime dt4 = date.atTime(time);
LocalDateTime dt5 = time.atDate(date);

LocalDate date1 = dt1.toLocalDate();
LocalTime time1 = dt1.toLocalTime();

2. Instant

        从计算机的角度来看,建模时间最自然的格式是表示一个持续时间段某个点的单一大整形数,这也是 $Instant$ 类对时间建模的方式,基本上它是以Unix元年时间(传统的设定为UTC时区 $1970$ 年 $1$ 月 $1$ 日午夜时分)开始所经历的秒数进行计算。
        静态工厂方法 $ofEpochSecond$ 接收秒数,创建 $Instant$ 实例。此外,它还提供了一个重载版本,额外接收一个纳秒。

Instant.ofEpochSecond(3);
Instant.ofEpochSecond(2, 1_000_000_000);
Instant.ofEpochSecond(2, -1_000_000_000);
Instant.now();

        虽然 $Instant$ 也支持 $now$ 方法,但是它包含的是由秒及纳秒所构成的数字,目的是方便机器使用,所以无法处理一些时间单位。但是我们可以通过 $Duration$ 和 $Period$ 类使用 $Instant$ 。

3. Duration/Period

        到目前为止的所有类都实现了 $Temporal$ 接口,该接口定义了如何读取和操纵为时间建模的对象的值。$Duration$ 类表示两个 $Temporal$ 对象之间的时间,提供了一个静态工厂方法 $between$ 用于创建该类型实例。

Duration d1 = Duration.between(time1, time2);
Duration d2 = Duration.between(dateTime1, dateTime2);
Duration d3 = Duration.between(instant1, instant2);

        由于 $Duration$ 类主要用于以秒和纳秒衡量时间的长短,所以不能只接收 $LocalDate$ 作为参数。而且由于 $LocalDateTime$ 和 $Instant$ 是为不同的目的而设计的,所以也不能将二者同时作为参数传入,否则会抛出 $DateTimeException$ 异常。
        $Period$ 类与 $Duration$ 类相对,接收两个 $LocalDate$ ,表示它们之间的时长。

Period tenDays = Period.between(LocalDate.of(2014, 3, 8),
                                LocalDate.of(2014, 3, 18));

        除了 $between$ 之外,$Duration$ 和 $Period$ 也提供了很多方便的工厂类,用于直接创建实例。

方法名 是否为静态方法 描述
$between$ 创建两个时间点之间的间隔
$from$ 由一个临时时间点创建间隔
$of$ 由它的组成部分创建间隔
$parse$ 由字符串创建间隔
$addTo$ 创建间隔副本并叠加到指定对象上
$get$ 读取间隔
$isNegative$ 检查间隔是否为负值
$isZero$ 检查间隔是否为零
$minus$ 减去一定时间创建间隔副本
$multipliedBy$ 将间隔值乘以某个标量创建副本
$negated$ 忽略某个时长的方式创建间隔副本
$plus$ 增加某个时长的方式创建间隔副本
$subtractFrom$ 从指定对象中减去间隔

4. 操作日期

        $with$ 方法允许我们创建对象的副本,并按照需要修改它的属性。此外,也可以使用类似于四则运算的方法来创建对象副本。

LocalDate date1 = LocalDate.of(2014, 3, 18); // 2014-03-18
LocalDate date2 = date1.withYear(2011); // 2011-03-18
LocalDate date3 = date2.withDayOfMonth(25); // 2011-03-25
LocalDate date4 = date3.with(ChronoField.MONTH_OF_YEAR, 9); // 2011-09-25
LocalDate date5 = date1.plusWeeks(1); // 2014-03-25;
LocalDate date6 = date5.minusYear(3); // 2011-03-25
LocalDate date7 = date6.plus(6, ChronoField.MONTHS); // 2011-09-25

        此外,$LocalDate$ 、$LocalTime$ 、$LocalDateTime$ 以及 $Instant$ 中都提供了大量通用方法。

方法名 是否为静态方法 描述
$from$ 根据对象创建实例
$now$ 根据系统时钟创建实例
$of$ 根据对象的某部分创建实例
$parse$ 根据字符串创建实例
$atOffset$ 将对象和某个时区偏移结合
$atZone$ 将对象和某个时区结合
$format$ 使用某个格式器将对象转换为字符串($Instant$ 类不支持)
$get$ 读取对象某部分值
$minus$ 将对象减去一定时长创建副本
$plus$ 将对象加上一定时长创建副本
$with$ 对对象某些部分进行修改创建副本

5. TemporalAdjuster

        $with$ 方法可以接收一个 $TemporalAdjuster$ 对象,允许我们更加灵活地处理日期。对于一些常见的用例,日期和时间API已经提供了大量预定义的 $TemporalAdjuster$ 。

LocalDate date1 = LocalDate.of(2014, 3, 18); // 2014-03-18
LocalDate date2 = date1.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); // 2014-03-23
LocalDate date3 = date2.with(TemporalAdjusters.lastDayOfMonth()); // 2014-03-31
方法名 描述
$dayOfWeekInMonth$ 同月份同星期中每周第几天
$firstDayOfMonth$ 当月第一天
$firstDayOfNextMonth$ 下月第一天
$firstDayOfNextYear$ 下年第一天
$firstDayInMonth$ 同月份第一个星期几
$lastDayOfMonth$ 当月最后一天
$lastDayOfNextMonth$ 下月最后一天
$lastDayOfNextYear$ 下年最后一天
$lastDayOfYear$ 今年最后一天
$lastInMonth$ 同月份最后一个星期几
$next$ / $previous$ 之前或之后第一个星期几
$nextOrSame$ / $previousOrSame$ 之前或之后第一个星期几,包括当前日期

        如果预定义的 $TemporalAdjuster$ 不能满足需求,我们可以定义更加复杂的操作,即创建自定义的 $TemporalAdjuster$ 。实际上,$TemporalAdjuster$ 接收是一个函数式接口,只声明了一个方法。我们可以将其视为 $UnaryOperator$<$Temporal$> 。

@FunctionalInterface
public interface TemporalAdjuster {
    Temporal adjustInto(Temporal temporal);
}

        由于该接口为函数式接口,所以我们可以通过Lambda表达式进行传递。实现一个计算下一个工作日的 $TemporalAdjuster$ 的例子如下:

date = date.with(temporal -> {
    DayOfWeek dow = DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK));
    int dayToAdd = 1;
    if (dow == DayOfWeek.FRIDAY) dayToAdd = 3;
    else if (dow == DayOfWeek.SATURDAY) dayToAdd = 2;
    return temporal.plus(dayToAdd, ChronoField.DAYS);
});

        $TemporalAdjusters.ofDateAdjuster$ 方法接收一个 $UnaryOperator$<$LocalDate$> 类型的参数,返回一个 $TemporalAdjuster$ 。我们也可以使用该方法利用Lambda表达式创建 $TemporalAdjuster$ 。

6. DateTimeFormatter

        $java.time.format$ 专门用于格式化以及解析日期-时间对象,而 $DateTimeFormatter$ 类是其中最重要的类。所有的 $DateTimeFormatter$ 实例都能用于以一定的格式创建代表特定日期或时间的字符串。此外,在解析过程中也可以通过指定格式的形式使用不同的字符串创建日期。

LocalDate date = LocalDate.of(2014, 3, 18);
String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE); // 20140318
String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE); // 2014-03-18

LocalDate date1 = LocalDate.parse("20140318", DateTimeFormatter.BASIC_ISO_DATE);
LocalDate date2 = LocalDate.parse("2014-03-18", DateTimeFormatter.ISO_LOCAL_DATE);

        除了使用已经定义好的格式外,通过 $ofPattern$ 方法,我们也可以自定义格式。$ofPattern$ 还提供了一个重载版本,允许我们创建某个 $Locale$ 的格式器。

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
DateTimeFormatter italianFormatter = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.ITALIAN);

        如果需要更加细粒度的控制,$DateTimeFormatter$ 还提供了更复杂的格式器。

DateTimeFormatter italianFormatter = new DateTimeFormatterBuilder()
                                        .appendText(ChronoField.DAY_OF_MONTH)
                                        .appendLiteral(". ")
                                        .appendText(ChronoField.MONTH_OF_YEAR)
                                        .appendLiteral(" ")
                                        .appendText(ChronoField.YEAR)
                                        .parseCaseInsensitive()
                                        .toFormatter(Locale.ITALIAN);

        相较于 $java.util.DateFormat$ ,所有的 $DateTimeFormatter$ 都是线程安全的,所以可以以单例模式创建格式器实例,并在多个线程之间共享。

7. 时区和历法

        $java.time.ZoneId$ 类是老版的 $java.util.TimeZone$ 的替代品,与其他日期和时间类一样,它也是无法修改的。$ZoneRules$ 类中包含了 $40$ 个实例,可以通过 $ZoneId.getRules(\ )$ 可以获取指定时区的规则。每个特定的 $ZoneId$ 对象都由一个ID标识。

ZoneId romeZone = ZoneId.of("Europe/Rome");

        Java 8中为 $TimeZone$ 提供了一个新方法 $toZoneId$ ,通过该方法,我们可以将一个老时区对象转换为 $ZoneId$ 。

ZoneId zoneId = TimeZone.getDefault().toZoneId();

        一旦得到 $ZoneId$ ,我们就可以将其与 $LocalDate$ 、$LocalDateTime$ 或者是 $Instant$ 结合,构造为一个 $ZonedDateTime$ 实例。通过 $ZoneId$ ,我们还可以实现 $LocalDateTime$ 和 $Instant$ 之间的互相转换。

LocalDate date = LocalDate.of(2014, Month.MARCH, 18);
ZonedDateTime zdt1 = date.atStartOfDay(romeZone);

LocalDateTime dateTime = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45);
ZonedDateTime zdt2 = dateTime.atZone(romeZone);

Instant instant = Instant.now();
ZonedDateTime zdt3 = instant.atZone(romeZone);

Instant instantFromDateTime = dateTime.toInstant(romeZone);
LocalDateTime timeFromInstant = LocalDateTime.ofInstant(instant, romeZone);

        另一种比较通用的表示时区的方式是利用当前时区和UTC/格林尼治的固定偏差,我们可以通过 $of$ 方式创建。要注意,使用这种方式定义的 $ZoneOffset$ 并未考虑任何日光时的影响,所以在大部分情况下不推荐使用。

ZoneOffset newYorkOffset = ZoneOffset.of("-05:00");

        虽然ISO-8601日历系统是世界文明日历系统的事实标准。但是Java 8中另外还提供了 $4$ 种其他的日历系统,这些日历系统中的每一个都有一个对应的日志类,分别是 $ThaiBuddhistDate$ 、$MinguoDate$ 、$JapaneseDate$ 以及 $HijrahDate$ 。所有这些类以及 $LocalDate$ 都实现了 $ChronoLocalDate$ 接口,能够对公历的日期进行建模。我们可以通过 $from$ 方法从 $LocalDate$ 中创建,或者先创建一个日历系统,接着再创建该日期实例。

LocalDate date = LocalDate.of(2014, Month.MARCH, 18);
JapaneseDate japaneseDate = JapaneseDate.from(date);

Chronology japaneseChronology = Chronology.ofLocale(Locale.JAPAN);
ChronoLocalDate now = japaneseChronology.dateNow();

        日期及时间API的设计者建议使用第一种方式进行创建,因为开发者在代码中可能会进行一些假设,但这些假设在不同的日历系统中有可能不成立,比如一个月最多有 $31$ 天,一年有 $12$ 个月等。除非需要将程序的输入或者输出本地化,否则不要使用 $ChronoLocalDate$ 类。

Java 8实战:日期和时间API