Java 中的日期和时间处理类:从传统到现代

1、概览

处理 Date(日期)和 Time(时间)是许多 Java 应用程序的基本组成部分。多年来,Java 在处理日期方面不断发展,引入了更好的解决方案来简化开发者的工作。

2、传统的日期和时间处理类

java.time 包出现之前,Java 主要使用 DateCalendar 类来处理日期。尽管它们现在也可以使用,但是有一些缺陷。

2.1、java.util.Date 类

java.util.Date 类是 Java 最初处理日期的解决方案,但它有一些缺点:

  • 它是可变的,这意味着可能会遇到 线程安全 问题。
  • 不支持时区。
  • 它使用了令人困惑的方法名称和返回值,比如 getYear(),它返回的是自 1900 年以来的年数。
  • 许多方法已废弃。

使用无参数构造函数创建 Date 对象,表示当前日期和时间(对象创建时)。

如下,实例化一个 Date 对象并打印其值:

Date now = new Date();
logger.info("Current date and time: {}", now);

这将输出当前日期和时间,如 Wed Sep 24 10:30:45 PDT 2024。虽然该构造函数仍然有效,但由于上述原因,这里不再建议新项目使用该构造函数。

2.2、java.util.Calendar 类

由于 Date 的局限性,Java 引入了 Calendar 类,对其进行了改进:

  • 支持各种日历系统。
  • 时区管理。
  • 更加直观的日期操作方法。

我们可以使用 Calendar 操作日期。

Calendar cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_MONTH, 5);
Date fiveDaysLater = cal.getTime();

如上,我们计算当前日期 5 天后的日期,并将其存储在 Date 对象中。

但是,Calendar 也有缺陷:

  • Date 一样,它仍然是可变的,所以不是线程安全的。
  • 其 API 既混乱又复杂,比如月份是从 0 开始的。

3、现代的日期和时间处理类:java.time 包

Java 8 引入了 java.time 包,为处理日期和时间提供了一个现代、强大的 API。它旨在解决旧版 DateCalendar 类的许多问题,使日期和时间操作更加直观和友好。

受到流行的 Joda-Time 库的启发,java.time 现在已成为处理日期和时间的核心 Java 解决方案。

3.1、java.time 包下的关键类

java.time 包提供了几个在实际应用中经常使用的重要类。这些类可分为三大类:

时间容器

  • LocalDate:代表日期,不包含时间或时区。
  • LocalTime:代表时间,不包含日期或时区。
  • LocalDateTime:包括了日期和时间,但不包括时区。
  • ZonedDateTime:包括了日期和时间以及时区。
  • Instant:代表时间轴上的一个特定点,类似于时间戳。

时间操作

  • Duration:表示基于时间的时间量(例如 “5 小时” 或 “30 秒”)。
  • Period:代表基于日期的时间量(如 “2 年 3 个月”)。
  • TemporalAdjusters:提供调整日期的方法(如查找下一个星期一)。
  • Clock:使用时区提供当前日期时间,并可进行时间控制。

格式化和输出

  • DateTimeFormatter:用于格式化和解析日期时间对象。

3.2、java.time 包的优点

与旧的日期和时间类相比,java.time 包带来了多项改进:

  • 不可变:所有类都不可变,确保线程安全。
  • 清晰的 API:方法一致,使 API 更容易理解。
  • 专注的类:每个类都有特定的作用,无论是处理日期存储、操作还是格式化。
  • 格式化和解析:内置方法可轻松格式化和解析日期。

4、java.time 的使用示例

首先从使用 java.time 包创建日期和时间表示的基础知识开始。有了基础后,再了解如何调整日期以及如何格式化和解析日期。

4.1、创建日期表示

java.time 包提供了多个类来表示日期和时间的不同方面。

代码如下,使用 LocalDateLocalTimeLocalDateTime 创建一个基本日期:

@Test
void givenCurrentDateTime_whenUsingLocalDateTime_thenCorrect() {
    LocalDate currentDate = LocalDate.now(); // 当前日期
    LocalTime currentTime = LocalTime.now(); // 当前时间
    LocalDateTime currentDateTime = LocalDateTime.now(); // 当前日期和时间

    assertThat(currentDate).isBeforeOrEqualTo(LocalDate.now());
    assertThat(currentTime).isBeforeOrEqualTo(LocalTime.now());
    assertThat(currentDateTime).isBeforeOrEqualTo(LocalDateTime.now());
}

还可以通过传递所需的参数来创建特定的日期和时间:

@Test
void givenSpecificDateTime_whenUsingLocalDateTime_thenCorrect() {
    LocalDate date = LocalDate.of(2024, Month.SEPTEMBER, 18);
    LocalTime time = LocalTime.of(10, 30);
    LocalDateTime dateTime = LocalDateTime.of(date, time);

    assertEquals("2024-09-18", date.toString());
    assertEquals("10:30", time.toString());
    assertEquals("2024-09-18T10:30", dateTime.toString());
}

4.2、用 TemporalAdjusters 调整日期

有了日期表示后,我们就可以使用 TemporalAdjusters 对其进行调整。

TemporalAdjusters 类提供了一组预定义的方法来操作日期:

@Test
void givenTodaysDate_whenUsingVariousTemporalAdjusters_thenReturnCorrectAdjustedDates() {
    LocalDate today = LocalDate.now();

    LocalDate nextMonday = today.with(TemporalAdjusters.next(DayOfWeek.MONDAY));        // 调整日期为下周一
    assertThat(nextMonday.getDayOfWeek())
        .as("Next Monday should be correctly identified")
        .isEqualTo(DayOfWeek.MONDAY);

    LocalDate firstDayOfMonth = today.with(TemporalAdjusters.firstDayOfMonth()); // 整日期为月初第一天
    assertThat(firstDayOfMonth.getDayOfMonth())
        .as("First day of the month should be 1")
        .isEqualTo(1);
}

除了预定义的 Adjuster(调整器)外,我们还可以根据特定需求创建自定义 Adjuster

@Test
void givenCustomTemporalAdjuster_whenAddingTenDays_thenCorrect() {
    LocalDate specificDate = LocalDate.of(2024, Month.SEPTEMBER, 18);
    TemporalAdjuster addTenDays = temporal -> temporal.plus(10, ChronoUnit.DAYS);
    LocalDate adjustedDate = specificDate.with(addTenDays);

    assertEquals(
      today.plusDays(10),
      adjustedDate,
      "The adjusted date should be 10 days later than September 18, 2024"
    );
}

4.3、格式化日期

java.time.format 包中的 DateTimeFormatter 类允许我们以线程安全的方式格式化和解析日期时间对象:

@Test
void givenDateTimeFormat_whenFormatting_thenVerifyResults() {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm");
    LocalDateTime specificDateTime = LocalDateTime.of(2024, 9, 18, 10, 30);

    String formattedDate = specificDateTime.format(formatter);
    LocalDateTime parsedDateTime = LocalDateTime.parse("18-09-2024 10:30", formatter);

    assertThat(formattedDate).isNotEmpty().isEqualTo("18-09-2024 10:30");
}

我们可以根据需要使用预定义的格式或自定义的格式。

4.4、解析日期

同样,DateTimeFormatter 可以将字符串解析为日期或时间对象:

@Test
void givenDateTimeFormat_whenParsing_thenVerifyResults() {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm");

    LocalDateTime parsedDateTime = LocalDateTime.parse("18-09-2024 10:30", formatter);

    assertThat(parsedDateTime)
            .isNotNull()
            .satisfies(time -> {
                assertThat(time.getYear()).isEqualTo(2024);
                assertThat(time.getMonth()).isEqualTo(Month.SEPTEMBER);
                assertThat(time.getDayOfMonth()).isEqualTo(18);
                assertThat(time.getHour()).isEqualTo(10);
                assertThat(time.getMinute()).isEqualTo(30);
            });
}

4.5、通过 OffsetDateTime 和 OffsetTime 处理时区

在处理不同时区时,OffsetDateTimeOffsetTime 类对于处理日期和时间或与 UTC 的偏移量非常有用:

@Test
void givenVariousTimeZones_whenCreatingOffsetDateTime_thenVerifyOffsets() {
    // 巴黎时区
    ZoneId parisZone = ZoneId.of("Europe/Paris");
    // 纽约时区
    ZoneId nyZone = ZoneId.of("America/New_York");

    OffsetDateTime parisTime = OffsetDateTime.now(parisZone);
    OffsetDateTime nyTime = OffsetDateTime.now(nyZone);

    assertThat(parisTime)
            .isNotNull()
            .satisfies(time -> {
                assertThat(time.getOffset().getTotalSeconds())
                        .isEqualTo(parisZone.getRules().getOffset(Instant.now()).getTotalSeconds());
            });

    // 验证不同地区之间的时差
    assertThat(ChronoUnit.HOURS.between(nyTime, parisTime) % 24)
            .isGreaterThanOrEqualTo(5)  // 纽约一般比巴黎晚 5-6 个小时
            .isLessThanOrEqualTo(7);
}

代码如上,演示了如何为不同时区创建 OffsetDateTime 实例并验证其偏移量。首先,使用 ZoneId 定义巴黎和纽约的时区。然后,使用 OffsetDateTime.now() 创建这两个时区的当前时间。

该测试检查巴黎时间的偏移量是否与巴黎时区的预期偏移量相匹配。最后,验证纽约和巴黎之间的时间差,确保它在典型的 57 小时范围内,反映了标准时区差异。

4.6、高级用例:Clock

java.time 软件包中的 Clock 类提供了一种灵活的方式来访问当前日期和时间,同时考虑到特定的时区。

在我们需要对时间进行更多控制或测试基于时间的逻辑时,该类非常有用。

与使用 LocalDateTime.now() 获取系统当前时间不同,Clock 允许我们获取相对于特定时区的时间,甚至为测试目的模拟时间。通过向 Clock.system() 方法传递 ZoneId,我们可以获得任何地区的当前时间。例如,在下面的测试用例中,我们使用 Clock 类获取 America/New_York(美国/纽约)时区的当前时间:

@Test
void givenSystemClock_whenComparingDifferentTimeZones_thenVerifyRelationships() {
    Clock nyClock = Clock.system(ZoneId.of("America/New_York"));

    LocalDateTime nyTime = LocalDateTime.now(nyClock);

    assertThat(nyTime)
            .isNotNull()
            .satisfies(time -> {
                assertThat(time.getHour()).isBetween(0, 23);
                assertThat(time.getMinute()).isBetween(0, 59);
                // 验证是否在最后一分钟内(最近)
                assertThat(time).isCloseTo(
                        LocalDateTime.now(),
                        within(1, ChronoUnit.MINUTES)
                );
            });
}

这也使得 Clock 对于管理多个时区或控制时间流一致的应用非常有用。

5、从传统类到现代类的迁移

我们可能仍然需要处理使用 DateCalendar 的遗留代码或库。幸运的是,我们可以轻松地从旧的日期时间类迁移到新的日期时间类。

5.1、转换 Date 为 Instant

使用 toInstant() 方法可以轻松地将传统的 Date 类转换为 Instant。这对我们迁移到 java.time 包中的类很有帮助,因为 Instant 表示时间轴上的一个点(纪元):

@Test
void givenSameEpochMillis_whenConvertingDateAndInstant_thenCorrect() {
    long epochMillis = System.currentTimeMillis();
    Date legacyDate = new Date(epochMillis);
    Instant instant = Instant.ofEpochMilli(epochMillis);
    
    assertEquals(
      legacyDate.toInstant(),
      instant,
      "Date and Instant should represent the same moment in time"
    );
}

我们可以将传统的 Date 转换为 Instant,并通过从相同的毫秒纪元创建两者来确保它们代表相同的时间点。

5.2、迁移 Calendar 到 ZonedDateTime

在使用 Calendar 时,我们可以迁移到更现代的 ZonedDateTime,它可以同时处理日期和时间以及时区信息:

@Test
void givenCalendar_whenConvertingToZonedDateTime_thenCorrect() {
    Calendar calendar = Calendar.getInstance();
    calendar.set(2024, Calendar.SEPTEMBER, 18, 10, 30);
    ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(
      calendar.toInstant(),
      calendar.getTimeZone().toZoneId()
    );

    assertEquals(LocalDate.of(2024, 9, 18), zonedDateTime.toLocalDate());
    assertEquals(LocalTime.of(10, 30), zonedDateTime.toLocalTime());
}

如上,我们将 Calendar 实例转换为 ZonedDateTime,并验证它们是否代表相同的日期时间。

6、最佳实践

有一些使用 java.time 类的最佳实践,你可以参考:

  1. 任何新项目都应使用 java.time 类。
  2. 当不需要时区时,可以使用 LocalDateLocalTimeLocalDateTime
  3. 处理时区或时间戳时,请使用 ZonedDateTimeInstant 代替。
  4. 使用 DateTimeFormatter 来解析和格式化日期。
  5. 为避免混淆,应始终明确指定时区。

这些最佳实践为在 Java 中处理日期和时间奠定了坚实的基础,确保我们可以在应用程序中高效、准确地处理它们。

7、总结

Java 8 中引入的 java.time 包极大地改进了我们处理日期和时间的方式。此外,采用该 API 还能确保代码更简洁、更易于维护。

对于旧项目中遗留的 DateCalendar,我们也可以轻松地迁移到新的 java.time API。


Ref:https://www.baeldung.com/java-date-time-history