Backend

99 Ngày Java — Ngày 50: Checkpoint — xử lý file dữ liệu

SSite Admin
15 tháng 09, 2026 9 phút đọc 0 lượt xem
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.

Sketchnote Ngày 50: checkpoint xử lý file dữ liệu — record với BigDecimal và kiểm tra trong constructor, Files.lines đọc lười và thu lỗi theo dòng, merge để thống kê, ghi báo cáo bằng ATOMIC_MOVE

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ùng double (Ngày 27) — cộng một triệu dòng bằng double là 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.
  • TreeMap cho báo cáo cần sắp theo tên, LinkedHashMap cho chuỗi thời gian giữ thứ tự chèn, HashMap khi 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 BigDecimal sang double và 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 split tự viết vỡ ra sao.

  • Đo thời gian chạy với Files.lines và với Files.readAllLines trên file 500MB — và xem cái nào OutOfMemoryError.

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!

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 50: Security best practices

CSRF chỉ phụ thuộc một câu hỏi — trình duyệt có tự gửi thông tin đăng nhập không, CORS bảo vệ người dùng chứ không bảo vệ server, secret lỡ commit thì phải đổi — và rate limiting, thứ Spring Security không có sẵn.

15 thg 9, 20268 phút0
99 Ngày Spring — Ngày 49: Resource server

Một dòng issuer-uri là đủ, JWKS cho phép hai mươi service verify token mà không service nào giữ bí mật, vì sao bỏ kiểm iss là lỗ hổng chết người — và scope khác role ở chỗ nào.

14 thg 9, 20267 phút6
99 Ngày Java — Ngày 49: Debug & đọc lỗi hiệu quả

Hai dòng đầu stack trace nói gần hết câu chuyện, chuỗi Caused by phải đọc từ dưới lên, breakpoint có điều kiện và theo ngoại lệ — cùng ranh giới giữa lúc dùng debugger và lúc chỉ còn log.

14 thg 9, 20267 phút6