diff --git a/java/date.md b/java/date.md new file mode 100644 index 0000000..52d7d6f --- /dev/null +++ b/java/date.md @@ -0,0 +1,340 @@ +# Java 中的日期和时间处理类:从传统到现代 + +2024-11-07 + +## 1、概览 + +处理 `Date`(日期)和 `Time`(时间)是许多 Java 应用程序的基本组成部分。多年来,Java 在处理日期方面不断发展,引入了更好的解决方案来简化开发者的工作。 + +## 2、传统的日期和时间处理类 + +在 `java.time` 包出现之前,Java 主要使用 `Date` 和 `Calendar` 类来处理日期。尽管它们现在也可以使用,但是有一些缺陷。 + +### 2.1、java.util.Date 类 + +`java.util.Date` 类是 Java 最初处理日期的解决方案,但它有一些缺点: + +- 它是可变的,这意味着可能会遇到 **线程安全** 问题。 +- 不支持时区。 +- 它使用了令人困惑的方法名称和返回值,比如 `getYear()`,它返回的是自 *1900* 年以来的年数。 +- 许多方法已废弃。 + +使用无参数构造函数创建 `Date` 对象,表示当前日期和时间(对象创建时)。 + +如下,实例化一个 `Date` 对象并打印其值: + +```java +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` 操作日期。 + +```java +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。它旨在解决旧版 `Date` 和 `Calendar` 类的许多问题,使日期和时间操作更加直观和友好。 + +受到流行的 [Joda-Time](https://www.joda.org/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` 包提供了多个类来表示日期和时间的不同方面。 + +代码如下,使用 `LocalDate`、`LocalTime` 和 `LocalDateTime` 创建一个基本日期: + +```java +@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()); +} +``` + +还可以通过传递所需的参数来创建特定的日期和时间: + +```java +@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` 类提供了一组预定义的方法来操作日期: + +```java +@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*: + +```java +@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` 类允许我们以线程安全的方式格式化和解析日期时间对象: + +```java +@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` 可以将字符串解析为日期或时间对象: + +```java +@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 处理时区 + +在处理不同时区时,`OffsetDateTime` 和 `OffsetTime` 类对于处理日期和时间或与 UTC 的偏移量非常有用: + +```java +@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()` 创建这两个时区的当前时间。 + +该测试检查巴黎时间的偏移量是否与巴黎时区的预期偏移量相匹配。最后,验证纽约和巴黎之间的时间差,确保它在典型的 *5* 到 *7* 小时范围内,反映了标准时区差异。 + +### 4.6、高级用例:Clock + +`java.time` 软件包中的 `Clock` 类提供了一种灵活的方式来访问当前日期和时间,同时考虑到特定的时区。 + +在我们需要对时间进行更多控制或测试基于时间的逻辑时,该类非常有用。 + +与使用 `LocalDateTime.now()` 获取系统当前时间不同,`Clock` 允许我们获取相对于特定时区的时间,甚至为测试目的模拟时间。通过向 `Clock.system()` 方法传递 `ZoneId`,我们可以获得任何地区的当前时间。例如,在下面的测试用例中,我们使用 `Clock` 类获取 `America/New_York`(美国/纽约)时区的当前时间: + +```java +@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、从传统类到现代类的迁移 + +我们可能仍然需要处理使用 `Date` 或 `Calendar` 的遗留代码或库。幸运的是,我们可以轻松地从旧的日期时间类迁移到新的日期时间类。 + +### 5.1、转换 Date 为 Instant + +使用 `toInstant()` 方法可以轻松地将传统的 `Date` 类转换为 `Instant`。这对我们迁移到 `java.time` 包中的类很有帮助,因为 `Instant` 表示时间轴上的一个点(纪元): + +```java +@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`,它可以同时处理日期和时间以及时区信息: + +```java +@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. 当不需要时区时,可以使用 `LocalDate`、`LocalTime` 或 `LocalDateTime`。 +3. 处理时区或时间戳时,请使用 `ZonedDateTime` 或 `Instant` 代替。 +4. 使用 `DateTimeFormatter` 来解析和格式化日期。 +5. 为避免混淆,应始终明确指定时区。 + +这些最佳实践为在 Java 中处理日期和时间奠定了坚实的基础,确保我们可以在应用程序中高效、准确地处理它们。 + +## 7、总结 + +Java 8 中引入的 `java.time` 包极大地改进了我们处理日期和时间的方式。此外,采用该 API 还能确保代码更简洁、更易于维护。 + +对于旧项目中遗留的 `Date` 或 `Calendar`,我们也可以轻松地迁移到新的 `java.time` API。 \ No newline at end of file