Lỗi Gặp Phải
java.time.format.DateTimeParseException: Text '2023-13-01' could not be parsed: Invalid value for MonthOfYear (valid values 1 - 12): 13
Production đang sập, log tràn ngập DateTimeParseException, và đâu đó phía thượng nguồn có một chuỗi ngày tháng với tháng 13 đang làm crash parser của bạn. JVM nói đúng — tháng 13 không tồn tại. Nhưng thủ phạm thực sự hầu như không bao giờ là ngày tháng thực sự không hợp lệ. Đó là sai lệch định dạng: code của bạn đang đọc vị trí ngày như vị trí tháng.
Chuỗi đầu vào 2023-13-01 có cấu trúc theo dạng yyyy-dd-MM (năm-ngày-tháng). Parser mặc định của Java — và hầu hết các pattern DateTimeFormatter — đều kỳ vọng định dạng yyyy-MM-dd. Số 13 rơi vào vị trí tháng, và quá trình parse thất bại ngay lập tức.
Nguyên Nhân Gốc Rễ
- Sai lệch định dạng: Pattern khai báo
yyyy-MM-ddnhưng đầu vào lại làyyyy-dd-MM. Đây là nguyên nhân phổ biến nhất. - Dữ liệu đầu vào lỗi từ upstream: Một API bên ngoài, file CSV import, hoặc cột database legacy serialize ngày tháng theo định dạng không chuẩn ISO — và không ai validate trước khi đưa vào xử lý.
- Thứ tự ngày tháng kiểu châu Âu vs Mỹ:
dd/MM/yyyy(châu Âu) so vớiMM/dd/yyyy(Mỹ). Khi giá trị ngày vượt quá 12, parse thất bại ngay và báo lỗi rõ ràng. - Đầu vào người dùng không được kiểm tra: Ai đó nhập sai và dữ liệu trượt qua parser mà không được kiểm tra.
Cách Sửa Từng Bước
Bước 1: Xác định định dạng thực tế của chuỗi đầu vào
Chưa cần động vào code. Trước tiên, hãy xác định chuỗi của bạn thực sự có định dạng gì. Nếu bạn nhận được 2023-13-01, con số 13 chắc chắn là ngày — ngày có thể lên đến 31, còn tháng chỉ đến 12. Nghĩa là pattern phải là yyyy-dd-MM. Hãy log giá trị raw ngay trước lệnh parse nếu nguồn dữ liệu chưa rõ ràng.
Bước 2: Khớp formatter với cấu trúc đầu vào
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class DateParseExample {
public static void main(String[] args) {
String input = "2023-13-01";
// SAI — ISO parser mặc định kỳ vọng yyyy-MM-dd
// LocalDate.parse(input); // ném DateTimeParseException!
// ĐÚNG — pattern khớp với cấu trúc đầu vào thực tế
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-dd-MM");
LocalDate date = LocalDate.parse(input, formatter);
System.out.println(date); // 2023-01-13
}
}
Bước 3: Bọc parse trong try-catch cho đầu vào không tin cậy
Ngày tháng đến từ người dùng, API, hoặc file không bao giờ nên được parse trực tiếp. Hãy bọc lại:
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
public LocalDate parseDateSafely(String raw, String pattern) {
try {
return LocalDate.parse(raw, DateTimeFormatter.ofPattern(pattern));
} catch (DateTimeParseException e) {
// Log giá trị raw — bạn sẽ cần nó để truy vết nguồn dữ liệu
log.error("Failed to parse date '{}' with pattern '{}': {}", raw, pattern, e.getMessage());
throw new IllegalArgumentException("Invalid date format: " + raw, e);
}
}
Bước 4: Xử lý nhiều định dạng đầu vào có thể xảy ra
Tình huống này phổ biến khi lấy dữ liệu từ nhiều nguồn khác nhau. CSV export, API legacy, và các tích hợp bên thứ ba hiếm khi đồng nhất về định dạng ngày tháng:
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;
public LocalDate parseFlexible(String raw) {
List<String> patterns = List.of(
"yyyy-MM-dd", // ISO 8601 — thử trước tiên
"yyyy-dd-MM", // ngày/tháng đảo ngược
"dd/MM/yyyy", // kiểu châu Âu
"MM/dd/yyyy", // kiểu Mỹ
"dd-MM-yyyy"
);
for (String pattern : patterns) {
try {
return LocalDate.parse(raw, DateTimeFormatter.ofPattern(pattern));
} catch (DateTimeParseException ignored) {
// thử pattern tiếp theo
}
}
throw new IllegalArgumentException("Cannot parse date string: " + raw);
}
Lưu ý: Những ngày như 2023-05-06 là mơ hồ — chúng khớp với cả yyyy-MM-dd (ngày 6 tháng 5) lẫn yyyy-dd-MM (ngày 5 tháng 6). Hãy sắp xếp pattern theo thứ tự từ phổ biến nhất đến ít phổ biến nhất, và ghi chú rõ ràng nguồn dữ liệu nào dùng định dạng nào.
Bước 5: Validate tại API boundary (Spring Boot)
REST endpoint là nơi tốt nhất để chặn đầu vào xấu. Để Spring từ chối trước khi dữ liệu đến tầng service:
@RestController
public class ReportController {
@GetMapping("/reports")
public List<Report> getReports(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to
) {
// Spring tự động trả về 400 Bad Request cho ngày không hợp lệ
// Service của bạn không bao giờ nhận được đầu vào sai định dạng
return reportService.find(from, to);
}
}
Xác Nhận Bản Sửa
Chạy đoạn code sau để xác nhận đầu vào gây lỗi đã được parse đúng:
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class VerifyFix {
public static void main(String[] args) {
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-dd-MM");
LocalDate result = LocalDate.parse("2023-13-01", fmt);
System.out.println("Parsed: " + result); // Parsed: 2023-01-13
System.out.println("Month: " + result.getMonthValue()); // Month: 1
System.out.println("Day: " + result.getDayOfMonth()); // Day: 13
assert result.equals(LocalDate.of(2023, 1, 13)) : "Unexpected result";
System.out.println("Fix confirmed.");
}
}
Kết quả kỳ vọng:
Parsed: 2023-01-13
Month: 1
Day: 13
Fix confirmed.
Mẹo Hữu Ích
Luôn log giá trị raw trước khi parse
Lúc 2 giờ sáng, phần đau đầu nhất của lỗi này là tìm ra chuỗi sai đến từ đâu — chứ không phải sửa nó. Một dòng log trước mỗi lệnh parse trên đầu vào không tin cậy sẽ giúp bạn tránh khỏi nỗi đau đó:
log.debug("Parsing date string: '{}'", rawDateString);
LocalDate date = LocalDate.parse(rawDateString, formatter);
Dùng ResolverStyle.STRICT để kiểm tra chặt chẽ hơn
DateTimeFormatter mặc định chạy ở chế độ SMART. Nghĩa là nó âm thầm kẹp các giá trị ngoài phạm vi thay vì ném exception — ngày 30 tháng 2 lặng lẽ trở thành ngày 28 tháng 2 mà không có cảnh báo nào. Chuyển sang STRICT để bắt những trường hợp như vậy:
import java.time.format.ResolverStyle;
DateTimeFormatter strictFormatter = DateTimeFormatter
.ofPattern("uuuu-MM-dd") // dùng 'u' thay vì 'y' khi dùng chế độ STRICT
.withResolverStyle(ResolverStyle.STRICT);
LocalDate date = LocalDate.parse("2023-02-30", strictFormatter); // ném exception
Debug chuyển đổi timestamp
Khi truy tìm nguồn gốc của một ngày tháng sai — đặc biệt từ Unix timestamp hoặc API bên ngoài — tôi thường giữ ToolCraft's Timestamp Converter mở sẵn trong một tab. Dán vào một Unix epoch hoặc chuỗi ISO, nó sẽ hiển thị ngay phân tích dạng người đọc được mà không cần upload bất cứ thứ gì. Rất hữu ích khi đối chiếu những gì nguồn dữ liệu thực sự gửi so với những gì parser của bạn kỳ vọng.
Chuẩn hóa về ISO 8601 tại các system boundary
Nếu bạn kiểm soát API contract, hãy bắt buộc dùng yyyy-MM-dd trong OpenAPI spec hoặc tài liệu của bạn. Chấp nhận nhiều định dạng có vẻ linh hoạt ở thời điểm hiện tại. Sáu tháng sau, bạn sẽ lại debug một lỗi sai lệch định dạng khác lúc nửa đêm.

