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.

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ệ:
idvàtitlekhông đượcnullhoặc rỗng (isBlank()).pageCount,weightGrams,durationMinutes,bitrateKbps,fileSizeBytes,issueNumberphả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ệNullPointerExceptionnếu truyềnnull).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:
=== 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ệm | Bài học liên quan | Cách áp dụng trong thử thách |
|---|---|---|
| Interface & default method | Bài 06 | LibraryAsset định nghĩa hợp đồng và phương thức mặc định |
| Sealed class & permits | Bài 07 | Khai báo tập subtype đóng kín cho 4 loại ấn phẩm |
| Record & compact constructor | Bài 07 | Mô hình hoá dữ liệu bất biến và validate dữ liệu đầu vào |
| Switch Pattern Matching | Bài 07 | Kiểm tra vét cạn khi tính chi phí và tạo chuỗi mô tả |
| Composition over Inheritance | Bài 08 | LibraryCatalog bọc List<LibraryAsset> thay vì kế thừa ArrayList |
▶️ Khung mã khởi động (Starter Code)
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
- 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"); } } - 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; }; } - 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
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
- Tính vét cạn (Exhaustiveness): Nếu ngày mai thư viện bổ sung thêm
MicrofilmArchivevào mệnh đềpermits, hàmcalculateMonthlyMaintenanceCostvàdescribeAssetsẽ 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. - Bảo vệ tính đóng gói bằng Composition: Lớp
LibraryCatalogkhông kế thừa từArrayListmà giữ một danh sách nội bộprivate final. Hàmassets()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. - 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:
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.
Composition over inheritance — khi nào KHÔNG kế thừa
Bài tiếp Exception Handlingtry, catch, finally — cây Throwable và cơ chế throw
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