Backend

99 Ngày Java — Ngày 26: Date/Time API (java.time)

SSite Admin
22 tháng 08, 2026 7 phút đọc 0 lượt xem
99 Ngày Java — Ngày 26: Date/Time API (java.time)

Ngày 25 khép lại mảng kỹ thuật viết method — hôm nay ta bước vào API thực dụng bậc nhất của JDK: Date/Time API trong gói java.time. Thời gian nghe đơn giản nhưng là nơi chôn nhiều bug nhất của backend: tháng lệch một, múi giờ đọc nhầm, lịch hẹn chạy sai giờ. Bài hôm nay đi qua lý do java.time thay thế hoàn toàn Date/Calendar, bộ ba LocalDate/LocalTime/LocalDateTime, cặp khái niệm quan trọng nhất — giờ máy Instant và giờ người ZonedDateTime — rồi Duration/Period và format/parse.

Sketchnote Ngày 26: Date/Time API — vì sao java.time thay Date/Calendar, bộ ba LocalDate/LocalTime/LocalDateTime, Instant vs ZonedDateTime, Duration vs Period và DateTimeFormatter

Vì sao java.time thay thế Date và Calendar

// Thế hệ cũ — java.util.Date & Calendar — ba tội lớn:
Date d = new Date(126, 7, 22);   // năm 2026 = 126?! tháng 8 = 7?!
d.setTime(0);                    // MUTABLE — ai giữ tham chiếu đều sửa được
// và Date thực chất là... một mốc millisecond, không phải "ngày"

// java.time (Java 8+) sửa cả ba:
LocalDate ngay = LocalDate.of(2026, 8, 22);   // tháng 8 là 8 — hết nhầm
ngay = ngay.plusDays(7);                      // IMMUTABLE — trả về BẢN MỚI
// mỗi kiểu một khái niệm rõ ràng: ngày, giờ, mốc thời gian, múi giờ

// Quy tắc chuyển đổi tư duy:
//   java.util.Date         → Instant / LocalDateTime
//   Calendar               → ZonedDateTime
//   SimpleDateFormat       → DateTimeFormatter (thread-safe!)
  • Ba lỗi thiết kế của thế hệ cũ: năm tính từ 1900 và tháng đánh số từ 0 (nguồn bug kinh điển), đối tượng mutable nên bị sửa lén qua tham chiếu (đúng nỗi đau Ngày 24 với key của HashMap), và một lớp Date gánh mọi khái niệm nên chẳng khái niệm nào rõ.

  • java.time thiết kế theo triết lý ngược lại: immutable toàn bộ (giá trị như record Ngày 20 — chia sẻ thoải mái, thread-safe miễn phí), mỗi lớp một khái niệm, và tên method đọc thành văn: plusDays, isAfter, withDayOfMonth.

  • Gặp API cũ trong code kế thừa? Cầu nối hai chiều có sẵn: date.toInstant()Date.from(instant) — khoanh vùng API cũ ở ranh giới, phần lõi viết mới hoàn toàn bằng java.time.

LocalDate, LocalTime, LocalDateTime — thời gian không múi giờ

LocalDate ngay = LocalDate.now();                 // 2026-08-22
LocalTime gio = LocalTime.of(18, 10);             // 18:10
LocalDateTime lich = LocalDateTime.of(ngay, gio); // 2026-08-22T18:10

// Immutable: mọi phép biến đổi TRẢ VỀ đối tượng mới (bài học Ngày 20)
LocalDate hanNop = ngay.plusWeeks(2).minusDays(1);
ngay.plusDays(30);          // vô nghĩa nếu bỏ kết quả — "ngay" không đổi!

// Truy vấn và điều chỉnh ngữ nghĩa:
lich.getDayOfWeek();                       // SATURDAY (enum — Ngày 23!)
ngay.withDayOfMonth(1);                    // về đầu tháng
ngay.with(TemporalAdjusters.lastDayOfMonth());   // cuối tháng — 28, 30 hay 31 tự lo

// So sánh đúng cách — đừng dùng equals cho "trước/sau":
hanNop.isAfter(ngay);      // true
hanNop.isBefore(ngay);     // false
  • Chữ Local nghĩa là chưa gắn múi giờ: "ngày 22/08" và "18:10" thuần túy — đủ dùng cho ngày sinh, giờ mở cửa, hạn nộp bài; nhưng chưa đủ để chỉ một khoảnh khắc duy nhất trên trái đất.

  • Vì immutable, quên hứng kết quả là bug im lặng: ngay.plusDays(30) đứng một mình không làm gì cả — IDE và compiler đều cảnh báo kết quả bị bỏ rơi, hãy để ý; đổi lại, chuỗi plusWeeks(2).minusDays(1) an toàn tuyệt đối.

  • TemporalAdjusters gói những phép "lịch" dễ sai nhất — cuối tháng, thứ Hai đầu tiên của tháng sau — thành method có tên; getDayOfWeek() trả về enum DayOfWeek nên switch với nó được exhaustive check (Ngày 23).

Instant & ZonedDateTime — giờ máy và giờ người

// Hai loại thời gian — đừng trộn lẫn:
//   Instant       = giờ MÁY:  một mốc tuyệt đối trên trục thời gian (UTC)
//   ZonedDateTime = giờ NGƯỜI: mốc đó nhìn qua lăng kính một múi giờ

Instant moc = Instant.parse("2026-08-22T11:10:00Z");   // chuẩn ISO-8601, hậu tố Z = UTC

ZoneId vn = ZoneId.of("Asia/Ho_Chi_Minh");
ZonedDateTime gioVN = moc.atZone(vn);        // 2026-08-22T18:10+07:00
// đúng bài đăng này: hẹn 18:10 giờ Việt Nam = lưu 11:10 UTC trong DB

// Chiều ngược lại — từ giờ tường sang mốc tuyệt đối:
LocalDateTime tuong = LocalDateTime.of(2026, 8, 22, 18, 10);
Instant luuDb = tuong.atZone(vn).toInstant();

// Quy tắc vàng của backend:
//   LƯU TRỮ và TÍNH TOÁN bằng Instant (UTC)
//   chỉ đổi sang ZonedDateTime ở TẦNG HIỂN THỊ
// vì "18:10" không có múi giờ là mơ hồ — còn Instant thì không bao giờ
  • Instantmốc tuyệt đối — không phụ thuộc ai đang nhìn từ đâu; ZonedDateTime là mốc đó qua lăng kính một múi giờ. Cùng một Instant, Hà Nội thấy 18:10, London thấy 12:10 — mốc không đổi, cách đọc đổi.

  • Ví dụ sống ngay trong bài: series này hẹn giờ đăng 18:10 giờ Việt Nam, nhưng DB lưu 2026-08-22T11:10:00Z — một chuỗi ISO-8601 kết thúc bằng Z (UTC). Máy chủ ở đâu cũng so sánh đúng, còn 18:10 hiển thị chỉ là chuyện của tầng giao diện.

  • Dùng tên vùng Asia/Ho_Chi_Minh thay vì độ lệch +07:00 cứng: nhiều nước có giờ mùa hè (DST) khiến độ lệch thay đổi trong năm — ZoneId tra bảng lịch sử múi giờ và tự xử lý, offset cứng thì không.

Đồng hồ cát trên nền sỏi, dòng cát xanh đang chảy — dòng chảy tuyệt đối của thời gian là Instant, còn giờ đọc trên mặt đồng hồ mỗi nơi mỗi khác là ZonedDateTime; khoảng cát đã chảy chính là Duration

Duration, Period & DateTimeFormatter

// Hai kiểu "khoảng thời gian" — máy đo và người đếm:
Duration d = Duration.between(instantBatDau, instantKetThuc);
d.toMinutes();                       // 90 — chính xác từng giây, hợp với Instant

Period p = Period.between(ngayDangKy, LocalDate.now());
p.getYears(); p.getMonths();         // "2 năm 3 tháng" — lịch của người, hợp LocalDate

// Duration.ofMinutes(30), Period.ofDays(7) — cộng thẳng vào thời gian:
hanThanhToan = donHang.plus(Duration.ofMinutes(15));

// Format & parse — DateTimeFormatter:
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
lich.format(fmt);                             // "22/08/2026 18:10"
LocalDate.parse("22/08/2026", DateTimeFormatter.ofPattern("dd/MM/yyyy"));
// parse sai định dạng → DateTimeParseException — fail-fast, đừng nuốt
// SimpleDateFormat cũ KHÔNG thread-safe — formatter mới dùng chung thoải mái
  • Duration đếm bằng giây — khoảng cách vật lý giữa hai Instant, hợp với timeout, đo thời gian chạy; Period đếm bằng năm-tháng-ngày của lịch — "2 năm 3 tháng" của con người, nơi tháng lúc 28 lúc 31 ngày là chuyện Period tự lo.

  • DateTimeFormatter sửa lỗi chí mạng của SimpleDateFormat: thread-safe, nên khai báo một lần làm hằng số static final (Ngày 18) và dùng chung toàn ứng dụng — đúng mẫu mà mọi codebase Spring vẫn làm.

  • Parse dữ liệu người dùng luôn có thể nổ DateTimeParseException: bắt và trả thông báo rõ ràng ở tầng validation — đừng catch rồi lặng lẽ thay bằng now(), một dạng nuốt lỗi khiến dữ liệu sai không dấu vết.

Bài tập nhỏ

  • Viết method nhận ngày sinh (LocalDate) trả về tuổi chính xác bằng Period — test với người sinh 29/02 năm nhuận.

  • Cho chuỗi "2026-08-22T11:10:00Z": parse thành Instant, đổi sang giờ Việt Nam và giờ Tokyo — giải thích vì sao hai kết quả hiển thị khác nhau nhưng equals trên Instant vẫn là một.

  • Tính "23:59:59 ngày cuối tháng này theo giờ Việt Nam" rồi đổi ra Instant để lưu DB — dùng TemporalAdjusters.lastDayOfMonth().

  • Tạo DateTimeFormatter pattern dd/MM/yyyy HH:mm, parse một chuỗi sai định dạng và quan sát DateTimeParseException — rồi viết bản validation trả thông báo tiếng Việt thân thiện.

Kết luận

Bốn ý mang về: java.time thay thế trọn bộ API cũ bằng thiết kế immutable, mỗi lớp một khái niệm; họ Local là thời gian chưa gắn múi giờ — đủ cho lịch nội bộ, chưa đủ cho một khoảnh khắc toàn cầu; quy tắc vàng lưu và tính bằng Instant (UTC), hiển thị bằng ZonedDateTime với tên vùng thay vì offset cứng; và Duration/Period + DateTimeFormatter thread-safe khép kín bộ công cụ. Ngày 27 ta sang mảng số: Math, Random & BigDecimal — vì sao 0.1 + 0.2 không bằng 0.3, và kiểu nào mới xứng đáng giữ tiền. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Spring — Ngày 26: Quan hệ N-N

Ngày 26 của 99 Ngày Spring: quan hệ N-N luôn sống trong bảng trung gian — @ManyToMany với @JoinTable cho quan hệ thuần túy, owning side và Set với luật cascade cấm REMOVE trên tài nguyên chia sẻ, và entity trung gian tách N-N thành hai lần 1-N khi quan hệ cần cột riêng, truy vấn riêng hay phân trang.

22 thg 8, 20267 phút0
99 Ngày Spring — Ngày 25: Quan hệ 1-1 & 1-N

Ngày 25 của 99 Ngày Spring: ánh xạ quan hệ với @ManyToOne giữ khóa ngoại ở phía nhiều (và vì sao phải LAZY từ đầu), @OneToMany với mappedBy làm tấm gương đọc, owning side — phía duy nhất JPA lắng nghe khi ghi, cùng cascade và orphanRemoval cho quan hệ sở hữu trọn vẹn.

21 thg 8, 20266 phút4
99 Ngày Java — Ngày 25: Varargs & đệ quy

Ngày 25 của 99 Ngày Java: varargs — mảng mặc cú pháp gọn với hai quy tắc cứng, bậc thang overload resolution chính xác → widening → boxing → varargs, cấu trúc base case + bước thu nhỏ của đệ quy, và ranh giới StackOverflowError quyết định khi nào nên đổi sang vòng lặp.

21 thg 8, 20267 phút5