Backend

99 Ngày Java — Ngày 58: Optional

SSite Admin
23 tháng 09, 2026 9 phút đọc 9 lượt xem
99 Ngày Java — Ngày 58: Optional

Ngày 56 kết thúc với một lời dặn: findFirst trả Optional, đừng .get() mù quáng. Hôm nay là bài về cái hộp đó — vì sao nó tồn tại, dùng ở đâu (chỉ kiểu trả về), và bộ ba map/flatMap/orElse để đi qua chuỗi có-thể-null mà không một chữ if. Kèm theo là danh sách anti-pattern: isPresent + get chỉ là null check mặc áo mới, orElse gọi DB dù cache đã có, và Optional ở field hay tham số.

Sketchnote Ngày 58: Optional — cái hộp có thể rỗng chỉ dành cho kiểu trả về, chuỗi map và flatMap thay kim tự tháp if, orElse luôn tính tham số còn orElseGet lười, orElseThrow cho lỗi nghiệp vụ, và các anti-pattern isPresent get, orElse null, Optional ở field

Optional là gì — và không phải là gì

Optional<T> là một cái hộp có thể rỗng: có giá trị hoặc không, và kiểu dữ liệu nói điều đó ra thay vì một dòng Javadoc "có thể trả về null" không ai đọc. Đó là toàn bộ lý do nó tồn tại — cho kiểu trả về. Đưa nó vào field, tham số, hay bọc quanh một collection là dùng sai công cụ: người thiết kế JDK cố tình không cho nó Serializable, và JPA, Jackson, equals đều khó chịu với nó.

// OPTIONAL — một cái HỘP có thể rỗng, thay cho việc trả về null.

// Ngày 56: findFirst / findAny trả Optional, vì có thể KHÔNG ai thỏa.
Optional<DonHang> dau = donHang.stream().filter(DonHang::quaHan).findFirst();

// ① BA CÁCH TẠO — và một cái bẫy:
Optional<String> a = Optional.of("An");          // giá trị CHẮC CHẮN khác null
Optional<String> b = Optional.ofNullable(ten);   // ten có thể null → hộp rỗng
Optional<String> c = Optional.empty();           // hộp rỗng tường minh
Optional.of(null);                                // 💥 NullPointerException NGAY ĐÂY
                                                  //    of() dành cho thứ bạn dám cam đoan

// ② ĐÂY LÀ NULL CHECK MẶC ÁO MỚI — anti-pattern số 1:
if (dau.isPresent()) {                            // ❌ y hệt if (x != null)
    return dau.get().ma();                        //    get() vẫn có thể 💥 NoSuchElementException
}
return "-";
return dau.map(DonHang::ma).orElse("-");          // ✅ một dòng, không get()

// ③ OPTIONAL LÀ CÔNG CỤ CHO KIỂU TRẢ VỀ — không phải cho mọi chỗ có thể null:
record KhachHang(String ten, Optional<String> email) {}   // ❌ field: không Serializable,
                                                          //    JPA/Jackson khó chịu, equals rối
void gui(Optional<String> email) {}                       // ❌ tham số: người gọi phải bọc,
                                                          //    mà vẫn truyền null được
Optional<List<DonHang>> timTatCa() {}                     // ❌ collection: trả List RỖNG là đủ
Optional<KhachHang> timTheoEmail(String email) {}         // ✅ "có thể không thấy" nói bằng KIỂU

// ⭐ Ý ĐỊNH CỦA NGƯỜI THIẾT KẾ JDK: Optional sinh ra để một method trả về "không có kết quả"
//    mà không cần null — và người gọi KHÔNG THỂ quên xử lý, vì kiểu trả về nhắc họ.
//    Không phải để thay null ở khắp nơi. Nó cũng KHÔNG implements Serializable — cố ý.

// ④ Số nguyên thủy có hộp riêng — tránh boxing:
OptionalInt max = IntStream.of(3, 7, 5).max();   // OptionalInt, OptionalLong, OptionalDouble
int m = max.orElse(0);                            // getAsInt() thay cho get()
  • of cho giá trị chắc chắn khác null (null là nổ ngay), ofNullable cho thứ có thể null, empty cho hộp rỗng tường minh.

  • isPresent() rồi get()null check mặc áo mới — mọi chỗ như vậy đều viết lại được bằng map/orElse/ifPresent trong một dòng.

  • Chỉ dùng cho kiểu trả về — không field, không tham số, không Optional<List> (trả list rỗng); và OptionalInt/OptionalLong cho số nguyên thủy để khỏi boxing.

map, flatMap, filter — chuỗi không null

Sức mạnh thật của Optional không phải ở chỗ thay null, mà ở chỗ xâu chuỗi: map biến đổi giá trị nếu có, và một bước rỗng thì mọi bước sau tự bỏ qua. Kim tự tháp bốn tầng if (x != null) thành bốn dòng đọc từ trên xuống. Câu hỏi duy nhất cần trả lời ở mỗi bước là hàm này trả giá trị thường hay trả Optional — cái trước là map, cái sau là flatMap, y hệt Stream ở Ngày 55.

// MAP, FLATMAP, FILTER — đi qua chuỗi có-thể-null mà không một chữ if.

// Bài toán quen: lấy thành phố của khách đặt đơn — mỗi mắt xích đều có thể null.
// ❌ Kim tự tháp if:
String thanhPho = null;
if (don != null) {
    KhachHang kh = don.khachHang();
    if (kh != null) {
        DiaChi dc = kh.diaChi();
        if (dc != null) thanhPho = dc.thanhPho();
    }
}
if (thanhPho == null) thanhPho = "Không rõ";

// ✅ Chuỗi Optional — bước nào rỗng thì các bước sau BỎ QUA, không nổ:
String thanhPho = Optional.ofNullable(don)
        .map(DonHang::khachHang)      // Optional<KhachHang> — trả null → tự thành rỗng
        .map(KhachHang::diaChi)       // Optional<DiaChi>
        .map(DiaChi::thanhPho)        // Optional<String>
        .orElse("Không rõ");

// ① MAP hay FLATMAP? Nhìn KIỂU TRẢ VỀ của hàm bạn truyền vào:
Optional<KhachHang> kh = timKhach(id);
kh.map(KhachHang::timEmail);          // timEmail trả Optional<String>
                                      // ❌ → Optional<Optional<String>> — hộp lồng hộp
kh.flatMap(KhachHang::timEmail);      // ✅ → Optional<String> — flatMap "làm phẳng"
// ⭐ Hàm trả giá trị thường → map. Hàm trả Optional → flatMap. Y hệt Stream ở Ngày 55.

// ② FILTER — điều kiện không thỏa thì hộp thành rỗng:
Optional<DonHang> donLon = Optional.ofNullable(don)
        .filter(d -> d.tong() > 1_000_000);   // đơn nhỏ → Optional.empty()

// ③ OR (Java 9) — phương án dự phòng cũng là một Optional, và LƯỜI:
Optional<KhachHang> kq = timTrongCache(id)
        .or(() -> timTrongDb(id));            // chỉ chạy khi cache rỗng

// ④ STREAM (Java 9) — lọc bỏ các Optional rỗng trong một Stream:
List<String> emails = khachHangs.stream()
        .map(KhachHang::timEmail)             // Stream<Optional<String>>
        .flatMap(Optional::stream)            // ⭐ rỗng biến mất, có thì mở hộp
        .toList();
// Thay cho .filter(Optional::isPresent).map(Optional::get) — hai bước, và vẫn có get()

// ❗ Đừng "mở hộp rồi đóng lại": Optional.ofNullable(x.orElse(null)) là mùi code —
//    thường vì đã lấy giá trị ra quá sớm. Giữ chuỗi cho tới bước cuối rồi mới mở.
  • map với hàm trả null thì tự thành rỗng — đó là lý do chuỗi không bao giờ ném NPE giữa chừng.

  • map với hàm trả Optional cho ra hộp lồng hộp Optional<Optional<T>> — dấu hiệu bạn cần flatMap.

  • or() là phương án dự phòng lười (chỉ chạy khi rỗng); Optional::stream trong flatMap lọc bỏ hộp rỗng khỏi Stream mà không cần get.

  • filter biến điều kiện sai thành hộp rỗng — hợp cho "có khách, khách đủ điều kiện" trong một chuỗi.

orElse, orElseGet, orElseThrow — và các anti-pattern

Mở hộp có ba cách, và cách phổ biến nhất có bẫy: orElse(x) luôn tính x trước, kể cả khi hộp có giá trị — nên orElse(db.tai(id)) gọi DB ở mọi lần cache hit. orElseGet nhận Supplierchỉ chạy khi rỗng. orElseThrow dành cho trường hợp "không có là lỗi" — và đó chính là idiom findById(id).orElseThrow(...) bạn sẽ gặp trong mọi service Spring.

// ORELSE, ORELSEGET, ORELSETHROW — mở hộp đúng cách, và các anti-pattern hay gặp.

// ① BẪY LỚN NHẤT: orElse(...) LUÔN TÍNH tham số, kể cả khi hộp CÓ giá trị.
KhachHang kh = cache.tim(id)
        .orElse(db.taiKhachHang(id));             // ❌ gọi DB MỖI LẦN — dù cache đã có!
KhachHang kh = cache.tim(id)
        .orElseGet(() -> db.taiKhachHang(id));    // ✅ Supplier — chỉ chạy khi rỗng
// ⭐ Quy tắc: orElse cho HẰNG SỐ rẻ tiền ("-", 0, List.of()). Có tính toán → orElseGet.

// ② ORELSETHROW — "không có" là LỖI, không phải giá trị mặc định:
KhachHang kh = repo.findById(id)                              // Spring Data trả Optional
        .orElseThrow(() -> new KhongTimThayException(id));    // ✅ ngoại lệ nghiệp vụ (Ngày 43)
KhachHang kh = repo.findById(id).orElseThrow();               // Java 10+: NoSuchElementException
// ➜ orElseThrow() không tham số đọc rõ hơn get(): "tôi biết có thể nổ, và tôi chấp nhận".

// ③ IFPRESENT / IFPRESENTORELSE (Java 9) — tác dụng phụ, không cần lấy giá trị ra:
timKhach(id).ifPresent(this::guiEmail);
timKhach(id).ifPresentOrElse(this::guiEmail,
                             () -> log.warn("Không có khách {}", id));

// ④ CÁC ANTI-PATTERN — nhận ra là sửa được ngay:
if (opt.isPresent()) { dung(opt.get()); }          // ❌ null check mặc áo mới → ifPresent / map
opt.orElse(null);                                   // ❌ trả null ra ngoài — mất hết ý nghĩa
                                                    //    (chỉ chấp nhận ở biên với API cũ)
Optional.ofNullable(x).orElse(y);                   // ❌ chỉ là x != null ? x : y — dùng ba ngôi
opt.isEmpty() ? macDinh : opt.get();               // ❌ cũng vậy — orElse(macDinh)
Optional<List<T>> timTatCa();                       // ❌ trả List RỖNG
private Optional<String> email;                     // ❌ field — không Serializable
Map<Optional<String>, Integer> dem;                 // ❌ Optional làm khóa — mùi code
Optional.of(x).equals(Optional.of(y));              // ⚠ đúng là so theo giá trị bên trong,
                                                    //    nhưng có Optional hai bên thì đã sai từ trước

// ⭐ CHỖ ĐÁNG DÙNG NHẤT: lớp service — repo.findById(...).orElseThrow(...) rồi làm việc
//    với đối tượng THẬT. Optional sống ngắn: sinh ra ở kiểu trả về, tiêu thụ ngay ở người gọi.
//    Nó không phải kiểu dữ liệu để "mang đi khắp nơi".
  • orElse cho hằng số rẻ, orElseGet cho bất cứ thứ gì phải tính, orElseThrow khi vắng mặt là lỗi — ba câu hỏi, ba method.

  • orElseThrow() không tham số (Java 10) thay cho get(): cùng hành vi, nhưng tên nói rõ "tôi chấp nhận ngoại lệ".

  • orElse(null) trả null ra ngoài và xóa sạch lợi ích — chỉ chấp nhận ở biên với API cũ; Optional.ofNullable(x).orElse(y) chỉ là toán tử ba ngôi viết dài.

  • Optional sống ngắn: sinh ở kiểu trả về, tiêu thụ ngay ở người gọi — service gọi orElseThrow rồi làm việc với đối tượng thật, không truyền hộp đi tiếp.

Bài tập nhỏ

  • Tìm trong dự án của bạn mọi chỗ isPresent() đi kèm get() — viết lại từng chỗ bằng map, ifPresent hoặc orElseThrow.

  • Viết chuỗi Optional.ofNullable(don).map(...).map(...).orElse(...) cho một cấu trúc ba tầng, rồi thử với don = null và với tầng giữa null.

  • Đặt System.out.println trong hàm truyền cho orElse và cho orElseGet, gọi trên một Optional có giá trị — đếm số lần in.

  • Cho List<Optional<String>>, lấy ra List<String> chỉ gồm giá trị có mặt bằng flatMap(Optional::stream).

  • Thử Optional.of(null), Optional.empty().get()Optional.empty().orElseThrow() — đọc ba thông điệp lỗi và thấy cái nào rõ nhất.

Kết lại

Bốn ý gói lại hôm nay: Optionalcông cụ cho kiểu trả về — không field, không tham số, không bọc collection; map/flatMap/filter xâu chuỗi các bước có-thể-null và một bước rỗng thì mọi bước sau tự bỏ qua, với flatMap khi hàm trả Optional; orElse luôn tính tham số nên việc tốn kém phải nằm trong orElseGet, và orElseThrow là idiom cho "không có là lỗi"; cuối cùng, isPresent + getorElse(null) là hai anti-pattern xóa sạch lợi ích của cái hộp. Ngày 59 chạm vào cái nút ai cũng từng bấm thử: parallelStreamForkJoinPool chung, khi nào nên và không nên, và cái bẫy lambda có trạng thái. 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 58: Spring Boot Actuator

Boot 3 mặc định chỉ lộ health; bản đồ endpoint an toàn và nguy hiểm; HealthIndicator tự viết với liveness tách khỏi readiness cho Kubernetes, info từ build và git, metrics Micrometer với tag hữu hạn — và ba lớp khóa: cổng riêng, SecurityFilterChain với EndpointRequest, exclude env/heapdump/threaddump.

23 thg 9, 202610 phút5
So sánh EasyExcel và Apache Fesod: khác biệt thật nằm ở đâu?

Fesod là hậu duệ của EasyExcel, cùng engine SAX/SXSSF. Khác biệt kiểm chứng được: vòng đời dự án, POI 5.2.5 vs 5.5.1, các API mới (numRows, csv(), headerMergeStrategy) và độ bền với file xấu — không phải hiệu năng gấp nhiều lần.

23 thg 9, 20268 phút1
99 Ngày Spring — Ngày 57: Test data & fixtures

Builder với mặc định hợp lệ để test chỉ nói điều nó quan tâm, @TestComponent gói builder với repository, @Sql cho dữ liệu nhiều hay legacy với bẫy method ghi đè class, ba bẫy script SQL — và chiến lược dọn dẹp: rollback, TRUNCATE trước mỗi test, dữ liệu duy nhất, còn @DirtiesContext thì không.

22 thg 9, 202612 phút1