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 YYYY thay cho yyyy. YYYY là week-based year. Ngày 30/12/2024 được format với YYYY sẽ 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 Duration vào LocalDate, vì LocalDate.plus(Duration.ofHours(5)) ném UnsupportedTemporalTypeException. LocalDate khô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 LocalDateTime cho timestamp sự kiện. LocalDateTime khô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ùng Instant hoặc OffsetDateTime/ZonedDateTime.
  • Dùng ZonedDateTime cho 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 ZoneId dưới dạng offset. +07:00 không đổi theo mùa, còn Europe/Berlin thì có. Với lịch hẹn trong tương lai, hãy lưu LocalDateTime cùng ZoneId (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ùng ChronoUnit.X.between() hoặc toMinutes().
  • So sánh bằng equals() giữa hai ZonedDateTime khá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ùng isEqual(), isBefore(), isAfter().
  • Gọi now() không truyền Clock trong code nghiệp vụ, khiến test chập chờn theo ngày hoặc giờ chạy.

Lưu ý: java.time khô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. TemporalAdjuster tự 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.

javajava-timedatetimetimezonetesting