java.time: LocalDate, ZonedDateTime và Duration
1. Tổng quan
Xử lý ngày giờ là nguồn bug quen thuộc: hóa đơn lệch một ngày, lịch họp sai múi giờ, job chạy hai lần khi đổi giờ mùa hè. Package java.time (Java 8+) giải quyết phần lớn các vấn đề này, với điều kiện bạn chọn đúng kiểu dữ liệu.
Bài này tập trung vào ba kiểu hay dùng nhất là LocalDate, ZonedDateTime và Duration. Đọc xong, bạn sẽ biết khi nào dùng kiểu nào và cách xử lý chuyển múi giờ lẫn giờ mùa hè (DST). Bạn cũng sẽ biết cách viết test không phụ thuộc vào thời điểm chạy.
2. Vì sao dùng java.time
java.util.Date và Calendar có nhiều vấn đề. Chúng mutable, nên không thread-safe. Tháng đếm từ 0, và tên Date gây hiểu lầm vì nó thực chất là một thời điểm. java.time sửa những điểm đó:
- Mọi đối tượng đều immutable và thread-safe. Các method như
plusDays()luôn trả về đối tượng mới. - Mỗi khái niệm có một kiểu riêng:
| Kiểu | Ý nghĩa | Ví dụ |
|---|---|---|
LocalDate |
Ngày, không giờ, không múi giờ | Ngày sinh, ngày đáo hạn |
LocalDateTime |
Ngày + giờ, không múi giờ | Giờ “trên lịch” chưa gắn địa điểm |
ZonedDateTime |
Ngày + giờ + ZoneId |
Cuộc họp lúc 9h ở Hà Nội |
Instant |
Một điểm trên trục thời gian (UTC) | Timestamp lưu DB, log |
Duration |
Khoảng thời gian tính bằng giây/nano | Timeout 30 giây |
Period |
Khoảng thời gian tính bằng năm/tháng/ngày | Gói cước 3 tháng |
Không cần thêm dependency nào vì tất cả đều có sẵn trong JDK. Để chạy các ví dụ test, bạn cần JUnit 5 và AssertJ (đã có sẵn nếu dùng 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: làm việc với ngày
3.1. Tạo và parse
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() nhận tháng từ 1 đến 12. Bạn cũng có thể truyền Month.MAY cho dễ đọc. parse() không có formatter sẽ dùng chuẩn ISO-8601 (yyyy-MM-dd). Với định dạng khác, bạn tạo DateTimeFormatter và nên khai báo nó là hằng số static final, vì formatter cũng thread-safe.
Lưu ý: Đừng viết
YYYYthay choyyyy.YYYYlà week-based year. Ngày 30/12/2024 được format vớiYYYYsẽ thành năm 2025. Lỗi này thường chỉ lộ ra vào cuối năm.
3.2. Cộng trừ và hành vi cuối tháng
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
Tháng 2/2024 không có ngày 31, nên plusMonths(1) lùi về ngày hợp lệ cuối cùng là 29. Hệ quả là cộng hai lần một tháng khác với cộng một lần hai tháng. Nếu bạn tính chu kỳ thanh toán hàng tháng, hãy luôn tính từ ngày gốc, ví dụ start.plusMonths(n). Đừng cộng dồn từ kết quả của kỳ trước.
3.3. TemporalAdjusters
Các câu hỏi như “ngày cuối tháng” hay “thứ Hai kế tiếp” có sẵn adjuster:
LocalDate today = LocalDate.of(2024, 5, 15); // thứ Tư
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. Khoảng cách giữa hai ngày: Period và 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
Đây là lỗi rất hay gặp. period.getDays() chỉ trả về phần ngày (24) của “1 tháng 24 ngày”, chứ không phải tổng số ngày. Muốn tổng số ngày, bạn dùng ChronoUnit.DAYS.between().
4. ZonedDateTime: ngày giờ gắn với múi giờ
4.1. Tạo và chuyển múi giờ
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]
Khoan, đó chưa phải output đầy đủ. Output đúng là:
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]
Hai method này khác nhau ở điểm sau:
withZoneSameInstant()giữ nguyên thời điểm. 9h sáng ở TP.HCM là 22h tối hôm trước ở New York (đang dùng giờ mùa hè, UTC-4). Đây là method bạn cần trong 95% trường hợp, chẳng hạn khi hiển thị cuộc họp cho người ở múi giờ khác.withZoneSameLocal()giữ nguyên giờ trên đồng hồ và đổi sang thời điểm khác. Method này chỉ dùng khi bạn cố ý gắn lại múi giờ, ví dụ khi dữ liệu nhập vào bị gán nhầm zone.
Luôn dùng ID dạng Khu_vực/Thành_phố như Europe/Berlin. Tránh các chữ viết tắt như EST hay ICT vì chúng mơ hồ. Offset cố định như +07:00 cũng không biết gì về quy tắc giờ mùa hè.
4.2. Giờ mùa hè: khoảng giờ “không tồn tại”
Ngày 31/03/2024 ở Đức, đồng hồ nhảy từ 02:00 lên thẳng 03:00. Vậy 02:30 hôm đó không tồn tại:
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 không ném exception mà đẩy giờ về sau đúng bằng độ dài khoảng trống (1 giờ). Ở chiều ngược lại, khi giờ mùa hè kết thúc, một giờ trên đồng hồ xuất hiện hai lần. Mặc định java.time chọn offset sớm hơn. Bạn có thể đổi bằng withLaterOffsetAtOverlap().
4.3. Instant: thứ nên lưu vào database
Khi cần lưu “sự kiện xảy ra lúc nào”, hãy chuyển về Instant (hoặc kiểu TIMESTAMP WITH TIME ZONE ở DB):
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]
Nguyên tắc chung là lưu Instant và chỉ chuyển sang ZonedDateTime khi hiển thị cho người dùng ở múi giờ của họ.
5. Duration: đo khoảng thời gian chính xác
5.1. Tạo và đọc Duration
Duration timeout = Duration.ofSeconds(30);
Duration delivery = Duration.ofMinutes(135);
System.out.println(delivery);
System.out.println(delivery.toMinutes());
System.out.printf("%d giờ %d phút%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 giờ 15 phút
42500
toMinutes() trả về tổng số phút. Còn toHoursPart() và toMinutesPart() (Java 9+) tách thành từng phần để hiển thị. Đây là cùng kiểu bẫy với Period.getDays() ở mục 3.4.
5.2. Duration và Period: “1 ngày” không phải lúc nào cũng là 24 giờ
Đây là điểm khác biệt quan trọng nhất của bài:
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
Kết quả trên cho thấy ba điều:
plusDays(1)cộng theo lịch, nên giữ nguyên 12:00 trưa hôm sau. Thực tế giữa hai thời điểm chỉ có 23 giờ.plus(Duration.ofDays(1))cộng đúng 24 giờ, nên rơi vào 13:00.Duration.between()xác nhận khoảng cách thực là 23 giờ.
Quy tắc chọn kiểu rất đơn giản. Nếu người dùng nghĩ theo lịch (“nhắc tôi mỗi ngày lúc 9h”, “gói 1 tháng”), bạn dùng Period hoặc plusDays/plusMonths. Nếu hệ thống đo thời gian trôi qua (timeout, SLA 24 giờ, rate limit), bạn dùng Duration.
Lưu ý: Không thể cộng
DurationvàoLocalDate, vìLocalDate.plus(Duration.ofHours(5))némUnsupportedTemporalTypeException.LocalDatekhông có giờ, nên đơn vị nhỏ hơn ngày là vô nghĩa.
6. Viết code dễ test với Clock
Gọi LocalDate.now() trực tiếp trong business logic khiến test phụ thuộc vào ngày chạy. Cách đúng là inject 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);
}
}
Mọi method now() của java.time đều có overload nhận Clock. Trong Spring Boot 3.x, bạn khai báo một bean:
@Configuration
public class TimeConfig {
@Bean
public Clock clock() {
return Clock.system(ZoneId.of("Asia/Ho_Chi_Minh"));
}
}
Dùng Clock.system(...) với zone tường minh thay vì systemDefaultZone(). Như vậy kết quả không đổi khi server chạy ở UTC còn máy dev chạy ở giờ Việt Nam.
Trong test, bạn cố định thời gian bằng Clock.fixed():
class SubscriptionServiceTest {
private static final ZoneId HCM = ZoneId.of("Asia/Ho_Chi_Minh");
// 03:00 UTC = 10:00 sáng 15/05/2024 tại TP.HCM
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));
}
}
Test thứ hai kiểm tra biên: gói hết hạn ngày 15/05 vẫn còn hiệu lực trong chính ngày 15/05. Test cuối “khóa” lại hành vi DST ở mục 5.2. Nhờ vậy, nếu ai đó đổi plusDays thành plus(Duration.ofDays(1)), test sẽ báo ngay.
Thêm một chi tiết dễ sót. Nếu bạn dùng Instant.parse("2024-05-14T18:00:00Z") làm thời điểm cố định, LocalDate.now(clock) ở TP.HCM sẽ là 15/05 (01:00 sáng). Ở UTC nó vẫn là 14/05. Vì vậy zone của Clock quyết định “hôm nay” là ngày nào.
7. Lỗi hay gặp và khi nào không nên dùng
- Dùng
LocalDateTimecho timestamp sự kiện.LocalDateTimekhông biết múi giờ, nên khi đọc lại bạn không biết “10:00” là 10:00 ở đâu. Với sự kiện, hãy dùngInstanthoặcOffsetDateTime/ZonedDateTime. - Dùng
ZonedDateTimecho ngày thuần túy. Ngày sinh hay ngày lễ làLocalDate. Nếu gắn múi giờ vào, khi chuyển zone ngày sinh có thể “trôi” sang hôm trước. - Lưu
ZoneIddưới dạng offset.+07:00không đổi theo mùa, cònEurope/Berlinthì có. Với lịch hẹn trong tương lai, hãy lưuLocalDateTimecùngZoneId(chuỗi). Quy tắc DST có thể thay đổi trước ngày hẹn, và cách lưu này cho phép tính lại đúng. - Nhầm
getDays()/toMinutesPart()với tổng. Hãy dùngChronoUnit.X.between()hoặctoMinutes(). - So sánh bằng
equals()giữa haiZonedDateTimekhác zone.equals()so sánh cả zone, nên 9h TP.HCM và 22h New York cùng thời điểm vẫn trả vềfalse. Muốn so sánh thời điểm, dùngisEqual(),isBefore(),isAfter(). - Gọi
now()không truyềnClocktrong code nghiệp vụ, khiến test chập chờn theo ngày hoặc giờ chạy.
Lưu ý:
java.timekhông thay thế thư viện lịch nghiệp vụ. Ngày làm việc, ngày lễ Việt Nam hay lịch âm cần logic riêng hoặc thư viện chuyên dụng.TemporalAdjustertự viết là chỗ phù hợp để đóng gói logic đó.
8. Kết luận
Chọn đúng kiểu dữ liệu là bước quan trọng nhất. Dùng LocalDate cho ngày trên lịch, ZonedDateTime cho giờ gắn với một địa điểm, và Instant để lưu thời điểm. Với khoảng thời gian, Period đi theo lịch còn Duration đi theo đồng hồ, và ở mục 5.2 bạn đã thấy chúng cho kết quả khác nhau khi gặp giờ mùa hè.
Bạn cũng nên inject Clock vào service và khai báo zone tường minh. Viết test cho các ca biên như cuối tháng, đổi giờ và nửa đêm UTC. Làm được những điều này, phần lớn bug ngày giờ sẽ bị chặn từ trước khi lên production.