99 Ngày Java — Ngày 50: Checkpoint — xử lý file dữ liệu
Mốc 50/99 — nửa chặng đường. Mười ngày vừa rồi ta học rời rạc: ngoại lệ, try-with-resources, I/O, NIO.2, JSON, logging, debug. Hôm nay ghép tất cả lại thành một việc thật: đọc một file CSV doanh thu, thống kê, xuất báo cáo. Nghe tầm thường, nhưng mỗi bước có một quyết định thật — và một quyết định trong đó chính là ranh giới giữa code chạy được và code dùng được trong production.

Mô hình dữ liệu và lỗi của riêng bài toán
Trước khi đọc một dòng nào của file, hãy quyết hai chuyện: dữ liệu trông ra sao, và một dòng hỏng thì gọi nó là gì. Hai quyết định này định hình toàn bộ phần còn lại — và cả hai đều đã có sẵn công cụ trong mười bài vừa học.
// ĐỀ BÀI: đọc doanh-thu.csv, ra một bản báo cáo. Nghe tầm thường —
// nhưng nó chạm vào đúng mười bài vừa học, và mỗi bước có một quyết định thật.
// ① MÔ HÌNH DỮ LIỆU — record (Ngày 20), không phải class có setter.
record DongBanHang(String maDon, String khuVuc, LocalDate ngay,
String sanPham, int soLuong, BigDecimal donGia) {
// ⭐ VÌ SAO BigDecimal chứ KHÔNG PHẢI double (Ngày 27):
// 0.1 + 0.2 trong double ra 0.30000000000000004.
// Cộng một triệu dòng doanh thu bằng double thì con số cuối SAI,
// và kế toán sẽ tìm ra chỗ sai đó trước bạn.
BigDecimal thanhTien() { return donGia.multiply(BigDecimal.valueOf(soLuong)); }
// Kiểm tra ngay trong constructor gọn (Ngày 20) — dữ liệu bẩn dừng TẠI ĐÂY,
// không lọt xuống tầng thống kê rồi mới nổ ở một chỗ chẳng liên quan.
DongBanHang {
if (soLuong <= 0) throw new IllegalArgumentException("Số lượng phải dương: " + soLuong);
if (donGia.signum() < 0) throw new IllegalArgumentException("Đơn giá âm: " + donGia);
Objects.requireNonNull(khuVuc, "Thiếu khu vực");
}
}
// ② LỖI CỦA RIÊNG BÀI TOÁN — checked, vì người gọi PHẢI có kế hoạch (Ngày 41–43)
class LoiDongDuLieu extends Exception {
private final int soDong;
LoiDongDuLieu(int soDong, String thongDiep, Throwable nguyenNhan) {
super("Dòng " + soDong + ": " + thongDiep, nguyenNhan); // ⭐ truyền nguyên nhân
this.soDong = soDong; // (Ngày 49)
}
int soDong() { return soDong; }
}
// ❗ Số dòng nằm TRONG thông điệp. Một file 200.000 dòng mà báo lỗi không kèm số dòng
// thì bạn có một câu đố, không phải một thông báo lỗi.Tiền thì dùng
BigDecimal, không dùngdouble(Ngày 27) — cộng một triệu dòng bằngdoublelà con số cuối sai, và kế toán sẽ tìm ra trước bạn.Kiểm tra ngay trong constructor gọn của record (Ngày 20) — dữ liệu bẩn dừng tại đó, không lọt xuống tầng thống kê rồi nổ ở chỗ chẳng liên quan.
Số dòng phải nằm trong thông điệp lỗi: một file 200.000 dòng mà báo lỗi không kèm số dòng thì bạn có một câu đố, không phải một thông báo lỗi.
Ngoại lệ này checked (Ngày 41) vì người gọi buộc phải có kế hoạch — bỏ dòng hay dừng cả file, không thể lờ đi.
Đọc file — và quyết định quan trọng nhất của bài
Phần đọc file dùng lại đúng ba thứ của Ngày 44–46: Files.lines để đọc lười, try-with-resources vì nó là tài nguyên, và UTF_8 ghi rõ. Nhưng thứ đáng nghĩ nhất không phải cú pháp — mà là câu hỏi: một dòng hỏng thì bỏ dòng đó hay dừng cả file?
// ĐỌC FILE — mọi quyết định ở đây đã học trong mười bài vừa rồi.
record KetQuaDoc(List<DongBanHang> hopLe, List<LoiDongDuLieu> loi) {}
KetQuaDoc doc(Path duongDan) throws IOException {
var hopLe = new ArrayList<DongBanHang>();
var loi = new ArrayList<LoiDongDuLieu>();
// ⭐ Files.lines: đọc LƯỜI từng dòng (Ngày 45) — file 2GB vẫn chạy.
// try-with-resources vì nó LÀ tài nguyên (Ngày 44).
// UTF_8 ghi rõ, không phó mặc mặc định của máy (Ngày 45).
try (var dong = Files.lines(duongDan, StandardCharsets.UTF_8)) {
var so = new AtomicInteger(0);
dong.skip(1) // bỏ dòng tiêu đề
.forEach(d -> {
int n = so.incrementAndGet() + 1;
try {
hopLe.add(phanTich(d, n));
} catch (LoiDongDuLieu e) {
loi.add(e); // ⭐ THU LẠI, không dừng cả file
}
});
}
return new KetQuaDoc(hopLe, loi);
}
// ⭐ QUYẾT ĐỊNH THIẾT KẾ QUAN TRỌNG NHẤT CỦA BÀI:
// một dòng hỏng thì BỎ DÒNG ĐÓ hay DỪNG CẢ FILE?
// · Nhập liệu tay, 200.000 dòng → thu lỗi lại, báo cáo cuối. Dừng vì 1 dòng là tàn nhẫn.
// · Đối soát giao dịch ngân hàng → DỪNG NGAY. Báo cáo thiếu một dòng còn tệ hơn không có.
// Không có câu trả lời đúng cho mọi trường hợp — nhưng phải CHỌN CÓ Ý THỨC,
// và ghi lựa chọn đó vào tài liệu. Đây chính là chỗ phân biệt code chạy được
// với code dùng được trong production.
DongBanHang phanTich(String dong, int soDong) throws LoiDongDuLieu {
String[] o = dong.split(",", -1); // ❗ -1: GIỮ các ô rỗng ở cuối
if (o.length != 6) throw new LoiDongDuLieu(soDong, "Cần 6 cột, có " + o.length, null);
try {
return new DongBanHang(o[0].trim(), o[1].trim(), LocalDate.parse(o[2].trim()),
o[3].trim(), Integer.parseInt(o[4].trim()), new BigDecimal(o[5].trim()));
} catch (RuntimeException e) {
throw new LoiDongDuLieu(soDong, "Sai định dạng: " + dong, e); // giữ chuỗi
}
}
// ❗ split(",") KHÔNG đủ cho CSV thật: ô có dấu phẩy trong ngoặc kép sẽ vỡ.
// Dữ liệu thật thì dùng thư viện (OpenCSV, Commons CSV). Ở đây tự viết để thấy rõ luồng.Dữ liệu nhập tay 200.000 dòng thì thu lỗi lại rồi báo cáo cuối — dừng cả file vì một dòng là tàn nhẫn với người dùng.
Đối soát giao dịch ngân hàng thì dừng ngay — một báo cáo thiếu một dòng còn tệ hơn không có báo cáo nào.
Không có câu trả lời đúng cho mọi trường hợp, nhưng phải chọn có ý thức và ghi lựa chọn đó vào tài liệu.
split(",", -1)với tham số-1để giữ các ô rỗng ở cuối; còn CSV thật có dấu phẩy trong ngoặc kép thì dùng thư viện, đừng tự viết.
Thống kê, ghi báo cáo, và báo cáo cả lỗi
Map.merge là ngôi sao của phần này: nó thay cả cụm "có chưa, chưa thì đặt, rồi cộng" bằng đúng một dòng. Còn việc chọn TreeMap, HashMap hay LinkedHashMap thì đã học ở Ngày 35 — mỗi loại cho một nhu cầu thứ tự khác nhau.
// THỐNG KÊ — collections và Map, đúng công cụ cho đúng việc.
// ① TỔNG DOANH THU THEO KHU VỰC — TreeMap để báo cáo ra theo THỨ TỰ (Ngày 35)
Map<String, BigDecimal> theoKhuVuc = new TreeMap<>();
for (var d : hopLe) {
theoKhuVuc.merge(d.khuVuc(), d.thanhTien(), BigDecimal::add);
}
// ⭐ merge() thay cho "có chưa, chưa thì đặt, rồi cộng" — một dòng, không null check.
// ② TOP 5 SẢN PHẨM — sắp bằng Comparator (Ngày 38), lấy đầu bằng limit (Ngày 55 sẽ nói kỹ)
Map<String, Integer> theoSanPham = new HashMap<>();
for (var d : hopLe) theoSanPham.merge(d.sanPham(), d.soLuong(), Integer::sum);
var top5 = theoSanPham.entrySet().stream()
.sorted(Map.Entry.<String,Integer>comparingByValue().reversed())
.limit(5)
.toList();
// ③ NGÀY CAO NHẤT — LinkedHashMap giữ thứ tự CHÈN, hợp cho chuỗi thời gian (Ngày 35)
Map<LocalDate, BigDecimal> theoNgay = new LinkedHashMap<>();
for (var d : hopLe) theoNgay.merge(d.ngay(), d.thanhTien(), BigDecimal::add);
// ④ GHI BÁO CÁO — ghi an toàn bằng file tạm rồi ATOMIC_MOVE (Ngày 46)
Path tam = dich.resolveSibling(dich.getFileName() + ".tmp");
try (var w = Files.newBufferedWriter(tam, StandardCharsets.UTF_8)) {
w.write("Khu vực,Doanh thu\n");
for (var e : theoKhuVuc.entrySet())
w.write(e.getKey() + "," + e.getValue().setScale(0, RoundingMode.HALF_UP) + "\n");
}
Files.move(tam, dich, ATOMIC_MOVE, REPLACE_EXISTING);
// → không ai đọc phải một báo cáo đang ghi dở.
// ⑤ VÀ ĐỪNG QUÊN BÁO CÁO CHÍNH CÁC LỖI (Ngày 48):
log.info("Đọc xong: {} dòng hợp lệ, {} dòng lỗi", hopLe.size(), loi.size());
loi.stream().limit(20).forEach(e -> log.warn("Bỏ qua {}", e.getMessage()));
if (loi.size() > 20) log.warn("... và {} dòng lỗi nữa", loi.size() - 20);
// ❗ Giới hạn số dòng log: 50.000 dòng lỗi in hết ra là làm ngập hệ thống gom log,
// và che mất mọi thứ khác đang xảy ra cùng lúc.TreeMapcho báo cáo cần sắp theo tên,LinkedHashMapcho chuỗi thời gian giữ thứ tự chèn,HashMapkhi thứ tự không quan trọng.Ghi báo cáo bằng file tạm rồi
ATOMIC_MOVE(Ngày 46) — không ai đọc phải một báo cáo đang ghi dở.Phải báo cáo cả các dòng lỗi (Ngày 48) — nhưng giới hạn số dòng log, vì in hết 50.000 lỗi là làm ngập hệ thống gom log và che mất mọi thứ khác.
Bài tập nhỏ
Tự sinh một file CSV 100.000 dòng, cố tình làm hỏng 50 dòng rải rác, rồi chạy chương trình.
Đổi
BigDecimalsangdoublevà so tổng doanh thu của một triệu dòng — nhìn con số lệch đi.Đổi chiến lược từ "bỏ dòng hỏng" sang "dừng cả file" và tự hỏi bài toán của bạn cần cái nào.
Thêm một ô có dấu phẩy trong ngoặc kép để thấy
splittự viết vỡ ra sao.Đo thời gian chạy với
Files.linesvà vớiFiles.readAllLinestrên file 500MB — và xem cái nàoOutOfMemoryError.
Kết lại
Nửa chặng đường rồi. Bốn ý gói lại hôm nay: kiểu dữ liệu là một quyết định — tiền thì BigDecimal, và kiểm tra ngay trong constructor để dữ liệu bẩn dừng tại cửa; đọc lười bằng Files.lines trong try-with-resources để file lớn tới đâu cũng chạy; bỏ dòng hỏng hay dừng cả file là quyết định của bài toán, không phải của ngôn ngữ — và đây chính là chỗ phân biệt code chạy được với code dùng được thật; và báo cáo cả lỗi, nhưng có giới hạn, vì 50.000 dòng log lỗi che mất mọi thứ khác. Ngày 51 mở ra giai đoạn mới và cũng là phần thú vị nhất của Java hiện đại: lambda expression. Hẹn gặp lại!
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.


