java.time:LocalDate、ZonedDateTime和Duration

1. 概览

处理日期时间是常见的bug来源:账单错了一天、会议日程搞错时区、DST切换时任务跑了两遍。包java.time(Java 8+)可以解决大部分这些问题,条件是你选择正确的数据类型。

本文重点介绍最常用的三种类型:LocalDate、ZonedDateTime和Duration。读完后,你将知道何时使用哪种类型,以及如何处理时区转换和夏令时(DST)。你也会学到如何编写不依赖运行时间的测试。

2. 为什么使用java.time

java.util.Date和Calendar存在多个问题。它们是可变的,所以不是线程安全的。月份从0开始计数,Date这个名字容易误导,因为它实际上代表的是一个时刻。java.time修复了这些问题:

  • 所有对象都是不可变的并且线程安全的。像plusDays()这样的方法总是返回新对象。
  • 每个概念都有自己的类型:
类型 含义 例子
LocalDate 日期,无时间,无时区 出生日期、到期日期
LocalDateTime 日期 + 时间,无时区 日历上的时间,未关联位置
ZonedDateTime 日期 + 时间 + ZoneId 会议时间为河内时间9点
Instant 时间线上的一个点(UTC) 数据库中的时间戳、日志
Duration 以秒/纳秒计的时间间隔 超时30秒
Period 以年/月/日计的时间间隔 3个月的套餐

无需额外依赖,因为所有内容都包含在JDK中。要运行测试示例,你需要JUnit 5和AssertJ(如果使用spring-boot-starter-test,这些已经包含):

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>

3. LocalDate:处理日期

3.1. 创建和解析

LocalDate dueDate = LocalDate.of(2024, 5, 15);
LocalDate parsed = LocalDate.parse("2024-02-29");

DateTimeFormatter vnFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate fromForm = LocalDate.parse("15/05/2024", vnFormat);

System.out.println(dueDate);                     
System.out.println(fromForm.equals(dueDate));    
System.out.println(dueDate.format(vnFormat));    
2024-05-15
true
15/05/2024

LocalDate.of()接收1到12的月份。你也可以传入Month.MAY以增加可读性。不带formatter的parse()使用ISO-8601标准(yyyy-MM-dd)。对于其他格式,创建DateTimeFormatter,并将其声明为static final常量,因为formatter也是线程安全的。

注意:不要用YYYY代替yyyy。YYYY是基于周的年份。日期2024年12月30日用YYYY格式化时会变成2025年。这个错误通常只在年底才会出现。

3.2. 加减和月末行为

LocalDate orderDate = LocalDate.of(2024, 1, 31);

System.out.println(orderDate.plusDays(10));     
System.out.println(orderDate.plusMonths(1));    
System.out.println(orderDate.plusMonths(1).plusMonths(1)); 
System.out.println(orderDate.plusMonths(2));    
2024-02-10
2024-02-29
2024-03-29
2024-03-31

2024年2月没有31号,所以plusMonths(1)会退到该月最后一个有效日期29号。结果是分别加两次一个月不同于一次加两个月。如果你计算按月循环的周期,始终从原始日期计算,例如start.plusMonths(n)。不要从上一个周期的结果累加。

3.3. TemporalAdjusters

诸如“月末”或“下一个星期一”这样的问题有现成的adjuster:

LocalDate today = LocalDate.of(2024, 5, 15); // 星期三

LocalDate endOfMonth = today.with(TemporalAdjusters.lastDayOfMonth());
LocalDate nextMonday = today.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
LocalDate firstOfNextMonth = today.with(TemporalAdjusters.firstDayOfNextMonth());

System.out.println(endOfMonth + " " + nextMonday + " " + firstOfNextMonth);
2024-05-31 2024-05-20 2024-06-01

3.4. 两个日期之间的间隔:Period和ChronoUnit

LocalDate start = LocalDate.of(2024, 1, 15);
LocalDate end = LocalDate.of(2024, 3, 10);

Period period = Period.between(start, end);
long days = ChronoUnit.DAYS.between(start, end);

System.out.println(period);              
System.out.println(period.getDays());    
System.out.println(days);                
P1M24D
24
55

这是非常常见的错误。period.getDays()只返回“1个月24天”中的日部分(24),而不是总天数。要获得总天数,使用ChronoUnit.DAYS.between()。

4. ZonedDateTime:绑定时区的日期时间

4.1. 创建和转换时区

ZoneId hcm = ZoneId.of("Asia/Ho_Chi_Minh");
ZoneId newYork = ZoneId.of("America/New_York");

ZonedDateTime meetingHcm = ZonedDateTime.of(
        LocalDateTime.of(2024, 6, 1, 9, 0), hcm);

ZonedDateTime sameMoment = meetingHcm.withZoneSameInstant(newYork);
ZonedDateTime sameClock = meetingHcm.withZoneSameLocal(newYork);

System.out.println(meetingHcm);
System.out.println(sameMoment);
System.out.println(sameClock);
2024-06-01T09:00+07:00[Asia/Ho_Chi_Minh]
2024-06-01T09:00-04:00[America/New_York]

等等,这还不是完整的输出。正确的输出是:

2024-06-01T09:00+07:00[Asia/Ho_Chi_Minh]
2024-05-31T22:00-04:00[America/New_York]
2024-06-01T09:00-04:00[America/New_York]

这两个方法的区别如下:

  • withZoneSameInstant()保留时刻不变。胡志明市早上9点是纽约前一天晚上10点(正在使用夏令时,UTC-4)。这是95%的情况下你需要的方法,例如向不同时区的人展示会议时间时。
  • withZoneSameLocal()保留钟表上的时间不变并切换到不同的时刻。这个方法只在你故意重新关联时区时使用,例如当输入数据被错误地指派了zone时。

始终使用地区/城市形式的ID,如Europe/Berlin。避免使用EST或ICT这样的缩写,因为它们含糊不清。固定偏移量如+07:00也不了解任何夏令时规则。

4.2. 夏令时:不存在的时间间隔

2024年3月31日在德国,时钟从02:00直接跳到03:00。所以那天的02:30不存在:

ZoneId berlin = ZoneId.of("Europe/Berlin");
ZonedDateTime inGap = ZonedDateTime.of(
        LocalDateTime.of(2024, 3, 31, 2, 30), berlin);

System.out.println(inGap);
2024-03-31T03:30+02:00[Europe/Berlin]

java.time不会抛出异常,而是将时间推后正好是间隔的长度(1小时)。当夏令时结束时,相反的情况会发生,钟表上的一个小时出现两次。默认情况下java.time选择较早的偏移量。你可以用withLaterOffsetAtOverlap()更改。

4.3. Instant:应该保存到数据库的东西

当需要保存“事件何时发生”时,转换为Instant(或数据库中的TIMESTAMP WITH TIME ZONE类型):

Instant createdAt = meetingHcm.toInstant();
System.out.println(createdAt);                      
System.out.println(createdAt.atZone(newYork));      
2024-06-01T02:00:00Z
2024-05-31T22:00-04:00[America/New_York]

一般规则是保存Instant,只在显示给用户时转换为ZonedDateTime(使用他们所在时区)。

5. Duration:精确测量时间间隔

5.1. 创建和读取Duration

Duration timeout = Duration.ofSeconds(30);
Duration delivery = Duration.ofMinutes(135);

System.out.println(delivery);                   
System.out.println(delivery.toMinutes());       
System.out.printf("%d小时%d分钟%n",
        delivery.toHoursPart(), delivery.toMinutesPart());

Instant start = Instant.parse("2024-05-15T08:00:00Z");
Instant end = Instant.parse("2024-05-15T08:00:42.5Z");
System.out.println(Duration.between(start, end).toMillis()); 
PT2H15M
135
2小时15分钟
42500

toMinutes()返回总的分钟数。而toHoursPart()和toMinutesPart()(Java 9+)分别提取每个部分用于显示。这与第3.4节中Period.getDays()的陷阱相同。

5.2. Duration和Period:“1天”并非总是24小时

这是本文最重要的区别点:

ZoneId berlin = ZoneId.of("Europe/Berlin");
ZonedDateTime beforeDst = ZonedDateTime.of(
        LocalDateTime.of(2024, 3, 30, 12, 0), berlin);

ZonedDateTime calendarDay = beforeDst.plusDays(1);              
ZonedDateTime exact24h = beforeDst.plus(Duration.ofDays(1));    

System.out.println(calendarDay);
System.out.println(exact24h);
System.out.println(Duration.between(beforeDst, calendarDay));
2024-03-31T12:00+02:00[Europe/Berlin]
2024-03-31T13:00+02:00[Europe/Berlin]
PT23H

上面的结果表明三点:

  • plusDays(1)按日历加法,所以保持第二天中午12:00。实际上两个时刻之间只有23小时。
  • plus(Duration.ofDays(1))加正好24小时,所以落在13:00。
  • Duration.between()确认实际间隔是23小时。

选择类型的规则非常简单。如果用户按日历思考(“每天提醒我9点”、“1个月的套餐”),使用Period或plusDays/plusMonths。如果系统测量流逝的时间(超时、24小时SLA、速率限制),使用Duration。

**注意:**不能将Duration加到LocalDate中,因为LocalDate.plus(Duration.ofHours(5))会抛出UnsupportedTemporalTypeException。LocalDate没有时间,所以小于天的单位没有意义。

6. 用Clock编写易于测试的代码

直接在业务逻辑中调用LocalDate.now()会使测试依赖于运行日期。正确的方法是注入Clock:

public class SubscriptionService {

    private static final int TRIAL_DAYS = 14;
    private final Clock clock;

    public SubscriptionService(Clock clock) {
        this.clock = clock;
    }

    public LocalDate trialEndDate() {
        return LocalDate.now(clock).plusDays(TRIAL_DAYS);
    }

    public boolean isExpired(LocalDate expiryDate) {
        return LocalDate.now(clock).isAfter(expiryDate);
    }

    public Duration timeUntil(Instant deadline) {
        return Duration.between(Instant.now(clock), deadline);
    }
}

java.time中的每个now()方法都有接收Clock的重载。在Spring Boot 3.x中,你声明一个bean:

@Configuration
public class TimeConfig {

    @Bean
    public Clock clock() {
        return Clock.system(ZoneId.of("Asia/Ho_Chi_Minh"));
    }
}

使用带显式时区的Clock.system(...)而不是systemDefaultZone()。这样即使服务器运行在UTC而开发机运行在越南时间,结果也不会改变。

在测试中,用Clock.fixed()固定时间:

class SubscriptionServiceTest {

    private static final ZoneId HCM = ZoneId.of("Asia/Ho_Chi_Minh");
    // UTC 03:00 = 2024年5月15日胡志明市上午10:00
    private final Clock fixedClock =
            Clock.fixed(Instant.parse("2024-05-15T03:00:00Z"), HCM);
    private final SubscriptionService service = new SubscriptionService(fixedClock);

    @Test
    void trialEndsAfterFourteenDays() {
        assertThat(service.trialEndDate()).isEqualTo(LocalDate.of(2024, 5, 29));
    }

    @Test
    void expiredOnlyAfterExpiryDate() {
        assertThat(service.isExpired(LocalDate.of(2024, 5, 15))).isFalse();
        assertThat(service.isExpired(LocalDate.of(2024, 5, 14))).isTrue();
    }

    @Test
    void calculatesRemainingTime() {
        Instant deadline = Instant.parse("2024-05-15T05:30:00Z");
        assertThat(service.timeUntil(deadline)).isEqualTo(Duration.ofMinutes(150));
    }

    @Test
    void calendarDayAcrossDstIsTwentyThreeHours() {
        ZonedDateTime start = ZonedDateTime.of(
                LocalDateTime.of(2024, 3, 30, 12, 0), ZoneId.of("Europe/Berlin"));
        assertThat(Duration.between(start, start.plusDays(1))).isEqualTo(Duration.ofHours(23));
    }
}

第二个测试检查边界情况:套餐在5月15日到期仍在5月15日当天有效。最后一个测试“锁定”了第5.2节的DST行为。这样,如果有人将plusDays改成plus(Duration.ofDays(1)),测试会立即报告。

还有一个容易漏掉的细节。如果你用Instant.parse("2024-05-14T18:00:00Z")作为固定时刻,在胡志明市LocalDate.now(clock)会是5月15日(凌晨1点)。在UTC上它仍然是14号。因此Clock的时区决定了“今天”是哪一天。

7. 常见错误和不应该使用的场景

  • 为事件时间戳使用LocalDateTime。 LocalDateTime不知道时区,所以当你再次读取它时,你不知道“10:00”是哪里的10:00。对于事件,使用Instant或OffsetDateTime/ZonedDateTime。
  • 为纯日期使用ZonedDateTime。 出生日期或节假日是LocalDate。如果附加时区,转换zone时日期可能“漂移”到前一天。
  • 将ZoneId作为偏移量保存。 +07:00不会随夏令时改变,但Europe/Berlin会。对于将来的约定,保存LocalDateTime加ZoneId(字符串)。DST规则可能在约定日期前改变,这种保存方式允许重新计算。
  • 混淆getDays()/toMinutesPart()与总数。 使用ChronoUnit.X.between()或toMinutes()。
  • 在不同时区的两个ZonedDateTime之间用equals()比较。 equals()也比较时区,所以胡志明市9点和纽约22点尽管是同一时刻仍返回false。要比较时刻,使用isEqual()、isBefore()、isAfter()。
  • 在业务逻辑中调用不传Clock的now(),使测试随运行日期或时间波动。

注意: java.time不替代业务日历库。工作日、越南节假日或农历需要自定义逻辑或专用库。自定义TemporalAdjuster是封装这些逻辑的合适位置。

8. 结论

选择正确的数据类型是最重要的一步。对日历上的日期使用LocalDate,对关联特定地点的时间使用ZonedDateTime,对保存时刻使用Instant。对于时间间隔,Period按日历进行,而Duration按钟表进行,在第5.2节你已经看到它们遇到夏令时时给出不同的结果。

你也应该把Clock注入服务中并显式声明时区。为边界情况编写测试,比如月底、夏令时切换和UTC午夜。做到这些,大多数日期时间bug都会被挡在生产环境之外。

javajava-timedatetimetimezonetesting