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都会被挡在生产环境之外。