Java OO & Functional
9/38
Bài 9 / 38~25 phútKế thừa & Đa hìnhLộ trình · chặng 03/21Miễn phí… lượt xem

Mini-challenge: Thư viện đa phương tiện với sealed và record

Thực hành thiết kế hệ thống ấn phẩm thư viện đa phương tiện với sealed interface LibraryAsset, record cho từng ấn phẩm và switch pattern matching exhaustive.

TL;DR: Thử thách này tổng hợp toàn bộ kiến thức của Module 01: kế thừa, đa hình, interface, sealed types, record và nguyên tắc composition. Bạn sẽ thiết kế hệ thống quản lý danh mục ấn phẩm thư viện đa phương tiện (LibraryCatalog), tận dụng tối đa sức mạnh của Java 21 để đạt được tính an toàn kiểu dữ liệu tuyệt đối (Type Safety).

Ở bài trước, bạn đã học nguyên tắc vàng: ưu tiên cấu thành hơn kế thừa. Trong bài thử thách này, lớp quản lý danh mục thư viện sẽ chứa một danh sách các ấn phẩm (has-a) thay vì kế thừa từ ArrayList.

🎯 Đề bài

Hệ thống quản lý thư viện hiện đại không chỉ có sách in truyền thống mà còn lưu trữ nhiều dạng thức ấn phẩm khác nhau: sách in (PrintedBook), sách nói (AudioBook), tài liệu số tải về (DigitalEbook), và tạp chí nghiên cứu chuyên ngành (ReferenceJournal).

Mỗi loại ấn phẩm có dữ liệu đặc thù và cách tính chi phí lưu trữ/bảo quản định kỳ hoàn toàn khác nhau. Yêu cầu của bạn là dùng Sealed Interface + Record + Switch Pattern Matching (Java 21) để xây dựng mô hình dữ liệu đóng kín với kiểm tra vét cạn.

LibraryCatalog sở hữu danh sách LibraryAsset qua quan hệ has-a, LibraryAsset là sealed interface niêm phong bốn record con

1. sealed interface LibraryAsset

  • Khai báo niêm phong: permits PrintedBook, AudioBook, DigitalEbook, ReferenceJournal.
  • Có hai abstract accessor method: String id() và String title().
  • Có một default method String assetType() trả về tên lớp giản lược (getClass().getSimpleName()).

2. Bốn Record implement LibraryAsset

  • record PrintedBook(String id, String title, int pageCount, double weightGrams)
  • record AudioBook(String id, String title, int durationMinutes, double bitrateKbps)
  • record DigitalEbook(String id, String title, long fileSizeBytes, String format)
  • record ReferenceJournal(String id, String title, int issueNumber, boolean isRestricted)
  • Yêu cầu validation: Sử dụng compact constructor trong mỗi record để kiểm tra tính hợp lệ:
    • id và title không được null hoặc rỗng (isBlank()).
    • pageCount, weightGrams, durationMinutes, bitrateKbps, fileSizeBytes, issueNumber phải có giá trị dương (> 0).

3. class LibraryCatalog quản lý danh mục (Composition)

  • Trường dữ liệu: private final List<LibraryAsset> assets = new ArrayList<>().
  • void addAsset(LibraryAsset asset): Thêm ấn phẩm vào danh mục (ném ngoại lệ NullPointerException nếu truyền null).
  • int totalAssets(): Trả về tổng số lượng ấn phẩm.
  • List<LibraryAsset> assets(): Trả về một bản sao bất biến (List.copyOf(assets)) để bảo vệ tính đóng gói.
  • Map<String, Long> countByType(): Đếm số lượng ấn phẩm theo từng loại.

4. Các Utility Method xử lý nghiệp vụ

  • static double calculateMonthlyMaintenanceCost(LibraryAsset asset): Tính chi phí lưu trữ/bảo quản hàng tháng (VND):

    • PrintedBook: Chi phí thuê kệ kho vật lý = weightGrams * 2.0 (2 VND mỗi gram giấy).
    • AudioBook: Chi phí băng thông máy chủ phát thanh = durationMinutes * 15.0.
    • DigitalEbook: Chi phí lưu trữ Cloud = (fileSizeBytes / (1024.0 * 1024.0)) * 500.0 (500 VND mỗi MB).
    • ReferenceJournal: Chi phí bảo quản phòng lạnh đặc biệt = isRestricted ? 100000.0 : 30000.0.
    • Quy tắc: Bắt buộc dùng Switch Pattern Matching không có nhánh default để tận dụng exhaustiveness check.
  • static String describeAsset(LibraryAsset asset): Trả về chuỗi mô tả chi tiết từng loại ấn phẩm bằng pattern matching.

Kết quả đầu ra mong đợi:

Plain Text
=== HE THONG QUAN LY AN PHAM THU VIEN ===
Da nap: 4 an pham vao danh muc.
Tong so an pham: 4
Chi phi luu tru hang thang:
  [B-01] Clean Code: 1200.00 VND
  [A-01] Design Patterns Audio: 3600.00 VND
  [E-01] Java Concurrency in Practice: 1500.00 VND
  [J-01] ACM Computing Surveys: 100000.00 VND

Thong ke theo loai an pham: {AudioBook=1, DigitalEbook=1, PrintedBook=1, ReferenceJournal=1}

📦 Kiến thức trọng tâm áp dụng

Khái niệmBài học liên quanCách áp dụng trong thử thách
Interface & default methodBài 06LibraryAsset định nghĩa hợp đồng và phương thức mặc định
Sealed class & permitsBài 07Khai báo tập subtype đóng kín cho 4 loại ấn phẩm
Record & compact constructorBài 07Mô hình hoá dữ liệu bất biến và validate dữ liệu đầu vào
Switch Pattern MatchingBài 07Kiểm tra vét cạn khi tính chi phí và tạo chuỗi mô tả
Composition over InheritanceBài 08LibraryCatalog bọc List<LibraryAsset> thay vì kế thừa ArrayList

▶️ Khung mã khởi động (Starter Code)

Java
import java.util.*;
import java.util.stream.Collectors;

public class LibraryCatalogApp {

    // TODO 1: Khai bao sealed interface LibraryAsset permits 4 records
    // public sealed interface LibraryAsset permits ...

    // TODO 2: Khai bao 4 record PrintedBook, AudioBook, DigitalEbook, ReferenceJournal
    // Validate du lieu trong compact constructor

    // TODO 3: Xay dung class LibraryCatalog dung Composition voi List<LibraryAsset>

    // TODO 4: Ham tinh chi phi dung switch pattern matching (khong dung default)
    // public static double calculateMonthlyMaintenanceCost(LibraryAsset asset) { ... }

    // TODO 5: Ham mo ta an pham dung switch pattern matching
    // public static String describeAsset(LibraryAsset asset) { ... }

    public static void main(String[] args) {
        LibraryCatalog catalog = new LibraryCatalog();

        PrintedBook book = new PrintedBook("B-01", "Clean Code", 464, 600.0);
        AudioBook audio = new AudioBook("A-01", "Design Patterns Audio", 240, 320.0);
        DigitalEbook ebook = new DigitalEbook("E-01", "Java Concurrency in Practice", 3 * 1024 * 1024L, "PDF");
        ReferenceJournal journal = new ReferenceJournal("J-01", "ACM Computing Surveys", 108, true);

        catalog.addAsset(book);
        catalog.addAsset(audio);
        catalog.addAsset(ebook);
        catalog.addAsset(journal);

        System.out.println("=== HE THONG QUAN LY AN PHAM THU VIEN ===");
        System.out.println("Da nap: " + catalog.totalAssets() + " an pham vao danh muc.");
        System.out.println("Tong so an pham: " + catalog.totalAssets());

        System.out.println("Chi phi luu tru hang thang:");
        for (LibraryAsset asset : catalog.assets()) {
            double cost = calculateMonthlyMaintenanceCost(asset);
            System.out.println("  [" + asset.id() + "] " + asset.title() + ": " + String.format("%.2f VND", cost));
        }

        System.out.println("\nThong ke theo loai an pham: " + catalog.countByType());
    }
}

Bạn hãy tự làm thử thách này trong khoảng 25 đến 30 phút trước khi mở phần lời giải dưới đây.

💡 Gợi ý giải pháp

Gợi ý khi gặp khó khăn
  1. Kiểm tra hợp lệ trong Compact Constructor:
    Java
    public record PrintedBook(String id, String title, int pageCount, double weightGrams) implements LibraryAsset {
        public PrintedBook {
            if (id == null || id.isBlank()) throw new IllegalArgumentException("id must not be blank");
            if (pageCount <= 0) throw new IllegalArgumentException("pageCount must be positive");
        }
    }
    
  2. Switch Pattern Matching không cần default:
    Java
    public static double calculateMonthlyMaintenanceCost(LibraryAsset asset) {
        return switch (asset) {
            case PrintedBook b -> b.weightGrams() * 2.0;
            case AudioBook a -> a.durationMinutes() * 15.0;
            case DigitalEbook e -> (e.fileSizeBytes() / (1024.0 * 1024.0)) * 500.0;
            case ReferenceJournal j -> j.isRestricted() ? 100000.0 : 30000.0;
        };
    }
    
  3. Thống kê bằng Stream API:
    Java
    public Map<String, Long> countByType() {
        return assets.stream()
            .collect(Collectors.groupingBy(LibraryAsset::assetType, Collectors.counting()));
    }
    

✅ Lời giải hoàn chỉnh

Lời giải mã nguồn đầy đủ
Java
import java.util.*;
import java.util.stream.Collectors;

public class LibraryCatalogApp {

    public sealed interface LibraryAsset permits PrintedBook, AudioBook, DigitalEbook, ReferenceJournal {
        String id();
        String title();

        default String assetType() {
            return getClass().getSimpleName();
        }
    }

    public record PrintedBook(String id, String title, int pageCount, double weightGrams) implements LibraryAsset {
        public PrintedBook {
            validateIdAndTitle(id, title);
            if (pageCount <= 0) throw new IllegalArgumentException("pageCount must be positive, got: " + pageCount);
            if (weightGrams <= 0) throw new IllegalArgumentException("weightGrams must be positive, got: " + weightGrams);
        }
    }

    public record AudioBook(String id, String title, int durationMinutes, double bitrateKbps) implements LibraryAsset {
        public AudioBook {
            validateIdAndTitle(id, title);
            if (durationMinutes <= 0) throw new IllegalArgumentException("durationMinutes must be positive, got: " + durationMinutes);
            if (bitrateKbps <= 0) throw new IllegalArgumentException("bitrateKbps must be positive, got: " + bitrateKbps);
        }
    }

    public record DigitalEbook(String id, String title, long fileSizeBytes, String format) implements LibraryAsset {
        public DigitalEbook {
            validateIdAndTitle(id, title);
            if (fileSizeBytes <= 0) throw new IllegalArgumentException("fileSizeBytes must be positive, got: " + fileSizeBytes);
            Objects.requireNonNull(format, "format must not be null");
        }
    }

    public record ReferenceJournal(String id, String title, int issueNumber, boolean isRestricted) implements LibraryAsset {
        public ReferenceJournal {
            validateIdAndTitle(id, title);
            if (issueNumber <= 0) throw new IllegalArgumentException("issueNumber must be positive, got: " + issueNumber);
        }
    }

    private static void validateIdAndTitle(String id, String title) {
        Objects.requireNonNull(id, "id must not be null");
        if (id.isBlank()) throw new IllegalArgumentException("id must not be blank");
        Objects.requireNonNull(title, "title must not be null");
        if (title.isBlank()) throw new IllegalArgumentException("title must not be blank");
    }

    public static class LibraryCatalog {
        private final List<LibraryAsset> assets = new ArrayList<>();

        public void addAsset(LibraryAsset asset) {
            Objects.requireNonNull(asset, "asset must not be null");
            assets.add(asset);
        }

        public int totalAssets() {
            return assets.size();
        }

        public List<LibraryAsset> assets() {
            return List.copyOf(assets); // Tra ve immutable copy
        }

        public Map<String, Long> countByType() {
            return assets.stream()
                .collect(Collectors.groupingBy(LibraryAsset::assetType, Collectors.counting()));
        }
    }

    public static double calculateMonthlyMaintenanceCost(LibraryAsset asset) {
        return switch (asset) {
            case PrintedBook b -> b.weightGrams() * 2.0;
            case AudioBook a -> a.durationMinutes() * 15.0;
            case DigitalEbook e -> (e.fileSizeBytes() / (1024.0 * 1024.0)) * 500.0;
            case ReferenceJournal j -> j.isRestricted() ? 100000.0 : 30000.0;
        };
    }

    public static String describeAsset(LibraryAsset asset) {
        return switch (asset) {
            case PrintedBook b -> String.format("Sach in: '%s' (%d trang, %.1fg)", b.title(), b.pageCount(), b.weightGrams());
            case AudioBook a -> String.format("Sach noi: '%s' (%d phut, %.0f kbps)", a.title(), a.durationMinutes(), a.bitrateKbps());
            case DigitalEbook e -> String.format("Ebook: '%s' (Dinh dang %s, %d bytes)", e.title(), e.format(), e.fileSizeBytes());
            case ReferenceJournal j -> String.format("Tap chi: '%s' (Ky so %d, %s)",
                j.title(), j.issueNumber(), j.isRestricted() ? "Phong doc kin" : "Pho thong");
        };
    }

    public static void main(String[] args) {
        LibraryCatalog catalog = new LibraryCatalog();

        PrintedBook book = new PrintedBook("B-01", "Clean Code", 464, 600.0);
        AudioBook audio = new AudioBook("A-01", "Design Patterns Audio", 240, 320.0);
        DigitalEbook ebook = new DigitalEbook("E-01", "Java Concurrency in Practice", 3 * 1024 * 1024L, "PDF");
        ReferenceJournal journal = new ReferenceJournal("J-01", "ACM Computing Surveys", 108, true);

        catalog.addAsset(book);
        catalog.addAsset(audio);
        catalog.addAsset(ebook);
        catalog.addAsset(journal);

        System.out.println("=== HE THONG QUAN LY AN PHAM THU VIEN ===");
        System.out.println("Da nap: " + catalog.totalAssets() + " an pham vao danh muc.");
        System.out.println("Tong so an pham: " + catalog.totalAssets());

        System.out.println("Chi phi luu tru hang thang:");
        for (LibraryAsset asset : catalog.assets()) {
            double cost = calculateMonthlyMaintenanceCost(asset);
            System.out.println("  [" + asset.id() + "] " + asset.title() + ": " + String.format("%.2f VND", cost));
        }

        System.out.println("\nThong ke theo loai an pham: " + catalog.countByType());
    }
}

Phân tích kiến trúc giải pháp

  1. Tính vét cạn (Exhaustiveness): Nếu ngày mai thư viện bổ sung thêm MicrofilmArchive vào mệnh đề permits, hàm calculateMonthlyMaintenanceCost và describeAsset sẽ lập tức báo lỗi biên dịch, không bao giờ xảy ra lỗi bỏ quên nhánh xử lý lúc chạy.
  2. Bảo vệ tính đóng gói bằng Composition: Lớp LibraryCatalog không kế thừa từ ArrayList mà giữ một danh sách nội bộ private final. Hàm assets() trả về List.copyOf(assets), ngăn ngừa hoàn toàn nguy cơ bên ngoài gọi .clear() hoặc thay đổi danh mục trái phép.
  3. Data-Oriented Programming (DOP): Dữ liệu (các record LibraryAsset) được tách rời khỏi các hàm xử lý tính toán chi phí (calculateMonthlyMaintenanceCost). Khi chính sách giá bảo quản thay đổi, cấu trúc dữ liệu của ấn phẩm hoàn toàn không bị ảnh hưởng.

🎓 Bài kiểm thử tự động với JUnit 5

Bạn có thể viết kiểm thử tự động để kiểm chứng các điều kiện bất biến của chương trình:

Java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Map;

class LibraryCatalogAppTest {

    @Test
    void testPrintedBookStorageCost() {
        var book = new LibraryCatalogApp.PrintedBook("B-01", "Refactoring", 400, 500.0);
        assertEquals(1000.0, LibraryCatalogApp.calculateMonthlyMaintenanceCost(book), 0.001);
    }

    @Test
    void testRestrictedJournalCost() {
        var restricted = new LibraryCatalogApp.ReferenceJournal("J-01", "Science", 1, true);
        var open = new LibraryCatalogApp.ReferenceJournal("J-02", "Tech Mag", 2, false);
        assertEquals(100000.0, LibraryCatalogApp.calculateMonthlyMaintenanceCost(restricted), 0.001);
        assertEquals(30000.0, LibraryCatalogApp.calculateMonthlyMaintenanceCost(open), 0.001);
    }

    @Test
    void testConstructorValidation() {
        assertThrows(IllegalArgumentException.class, () ->
            new LibraryCatalogApp.PrintedBook("", "Title", 100, 200.0));
        assertThrows(IllegalArgumentException.class, () ->
            new LibraryCatalogApp.PrintedBook("B-01", "Title", -5, 200.0));
        assertThrows(NullPointerException.class, () ->
            new LibraryCatalogApp.PrintedBook(null, "Title", 100, 200.0));
    }

    @Test
    void testCatalogEncapsulation() {
        var catalog = new LibraryCatalogApp.LibraryCatalog();
        catalog.addAsset(new LibraryCatalogApp.PrintedBook("B-01", "Book", 100, 150.0));
        
        var list = catalog.assets();
        assertThrows(UnsupportedOperationException.class, () -> list.clear());
    }
}

✨ Tổng kết Module 01: Kế thừa & Đa hình

Xin chúc mừng! Bạn đã hoàn thành toàn bộ Module 01. Nhìn lại chặng đường vừa qua, bạn đã làm chủ:

  • Tư duy trừu tượng hoá và Is-a: Nhận diện đúng bản chất đối tượng và tránh lạm dụng kế thừa.
  • Vòng đời khởi tạo super(...): Cơ chế nạp constructor trong bộ nhớ JVM.
  • Dynamic Dispatch & vtable: Cách JVM phân giải phương thức lúc chạy và tối ưu hoá HotSpot.
  • Abstract Class vs Interface: Bản chất chia sẻ khung sườn state (Is-a) đối chiếu với hợp đồng năng lực (Can-do).
  • Default Method & Skeletal Implementation: Tiến hoá thư viện an toàn mà không phá vỡ tính tương thích ngược.
  • Sealed Types & Pattern Matching: Kỹ thuật lập trình Algebraic Data Type (ADT) và Data-Oriented Programming hiện đại trong Java 21.
  • Composition over Inheritance: Quy tắc vàng giữ cho hệ thống luôn mềm dẻo và bền bỉ trước thay đổi.

Bài tiếp theo: Ngoại lệ trong Java — cơ chế throw và catch

Bài này có giúp bạn hiểu bản chất không?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Lộ trình Junior Java Backend Developer

✓ Bạn vừa đi hết chặng OOP: class, kế thừa, đa hình, abstraction (3/21)

Chặng tiếp: Exception handling — “try, catch, finally — cây Throwable và cơ chế throw”

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

try, catch, finally — cây Throwable và cơ chế throw