學完本章你將能夠:

  • 從需求清單出發,規劃套件結構與類別職責,完成一個分層的主控台專案。

  • 把中級篇八章的知識(封裝、介面、例外、集合、泛型、I/O、enum 與日期)整合進同一個系統。

  • 用自訂例外表達商業規則,讓錯誤處理集中且友善。

  • 以「對介面寫程式」設計儲存層,讓記憶體版與檔案版可以互換。

  • 循「照打就能跑」的完整程式碼,獨立完成建置、執行與驗證。

前七章的知識點都是單兵訓練,本章進行合成演練:打造一個可以實際操作的圖書管理系統——新增書籍、註冊會員、借書還書、資料存檔,全部在主控台完成。麻雀雖小,五臟俱全:這個專案的分層方式(model、exception、repository、service、app)正是業界後端系統的標準縮影,進階篇的資料庫與網路章節會在同一副骨架上繼續長大。

本章請開著編輯器逐檔輸入:每個檔案一個程式碼框,照著套件路徑建立目錄、照打就能執行;打完一層,再回頭讀該層的設計說明。

需求分析

功能需求(系統要做什麼):

  • 書籍管理:新增書籍(ISBN、書名、作者)、列出全部書籍與借閱狀態。

  • 會員管理:註冊會員(編號、姓名)。

  • 借書:書必須存在且未被借出;會員必須存在且未達借閱上限(2 本);借期 14 天。

  • 還書:找到對應的借閱記錄結案,回報是否逾期與逾期天數。

  • 持久化:把書目存進文字檔,下次啟動可載回。

非功能需求(系統要做得多好):

  • 分層:畫面、商業規則、資料存取徹底分離,各層只依賴下一層的介面。

  • 可測試:商業規則不依賴 |Scanner| 與檔案,可以在測試中用記憶體儲存庫直接驗證。

  • 錯誤友善:任何規則違反都以自訂例外回報,使用者看到的是清楚的中文訊息,程式不崩潰。

系統設計

套件結構(目錄即套件,一層一責):

  • |library.model|:資料模型——|Book|、|Member|、|Loan|。

  • |library.exception|:領域例外——|LibraryException| 家族。

  • |library.repository|:資料存取——|LibraryRepository| 介面與兩個實作。

  • |library.service|:商業規則——|LibraryService|。

  • |library.app|:使用者介面——|LibraryApp| 主程式。

各類別職責一覽:

類別 職責 主要知識點
|Book| 書籍資料與借閱狀態 封裝、equals 與 hashCode
|Member| 會員資料與借閱額度 封裝、類別常數
|Loan| 借閱記錄與逾期計算 不可變欄位、java.time
例外家族 表達違反的商業規則 繼承、自訂例外
|LibraryRepository| 儲存層合約 介面、多型
|InMemoryLibraryRepository| 記憶體儲存 Map、集合操作
|FileLibraryRepository| 文字檔持久化 繼承、檔案 I/O
|LibraryService| 借還書規則的權威來源 例外、防禦式檢查
|LibraryApp| 選單迴圈與訊息呈現 Scanner、switch、try-catch

依賴方向嚴格單向:|app| 依賴 |service|,|service| 依賴 |repository| 介面與 |model|,誰都不回頭依賴上層。

為什麼要分層?因為變化的理由不同:畫面想從主控台換成網頁、儲存想從檔案換成資料庫、規則想把上限改成 5 本——三種變化互不相干。分層讓每種變化只衝擊一層;|service| 依賴 |repository| 介面而非實作(第 2 章的開閉原則),更讓「換儲存方式」變成換一行 |new|。單機小專案就養成這個習慣,將來接手大系統時才不會水土不服。

model 層:三個資料類別

|Book| 以 ISBN 作為唯一識別:|equals| 與 |hashCode| 都只看 ISBN(第 1 章的標準範本),將來放進任何集合都行為正確。

package library.model;

import java.util.Objects;

/** 書籍:ISBN 為唯一識別;基本資料不可變,只有借閱狀態會變動 */ public class Book

private final String isbn; // 唯一識別,永不改變 private final String title; // 書名 private final String author; // 作者 private boolean available = true; // 新書進館預設可借

public Book(String isbn, String title, String author) this.isbn = isbn; this.title = title; this.author = author;

public String getIsbn() return isbn;

public String getTitle() return title;

public String getAuthor() return author;

public boolean isAvailable() return available;

/** 出借與歸還都經由這裡改變狀態(唯一的寫入口) */ public void setAvailable(boolean available) this.available = available;

/** 邏輯相等:ISBN 相同即同一本書(第 1 章範本) */ @Override public boolean equals(Object obj) if (this == obj) return true; if (!(obj instanceof Book)) return false; return isbn.equals(((Book) obj).isbn);

@Override public int hashCode() return Objects.hash(isbn);

@Override public String toString() return "《" + title + "》(" + author + ")";

|Member| 管理借閱額度:上限做成類別常數,額度的加減提供具名方法而不是裸露的 setter——呼叫端讀起來是「借出一本」而不是「把某個數字加一」:

package library.model;

/** 會員:個人資料與借閱額度 */ public class Member

/** 借閱上限:每人同時最多 2 本(規則改這裡就好) */ public static final int MAX_LOANS = 2;

private final String id; // 會員編號 private final String name; // 姓名 private int activeLoans; // 目前借閱中的本數

public Member(String id, String name) this.id = id; this.name = name;

public String getId() return id;

public String getName() return name;

/** 是否還有借閱額度 */ public boolean canBorrow() return activeLoans < MAX_LOANS;

/** 借出一本:額度佔用加一 */ public void incrementLoans() activeLoans++;

/** 歸還一本:額度釋放,防呆不讓它變負數 */ public void decrementLoans() if (activeLoans > 0) activeLoans–;

|Loan| 是借閱記錄:借出當下就推算出應還日(|LocalDate| 不可變,|plusDays| 回傳新物件——第 7 章),歸還時計算逾期天數:

package library.model;

import java.time.LocalDate; import java.time.temporal.ChronoUnit;

/** 借閱記錄:一次借書行為的完整生命週期 */ public class Loan

/** 借期規則:14 天 */ public static final int LOAN_DAYS = 14;

private final String isbn; // 借了哪本書 private final String memberId; // 誰借的 private final LocalDate borrowedOn; // 借出日 private final LocalDate dueOn; // 應還日 private LocalDate returnedOn; // 實際還書日(未還為 null)

public Loan(String isbn, String memberId, LocalDate borrowedOn) this.isbn = isbn; this.memberId = memberId; this.borrowedOn = borrowedOn; // 應還日由借出日推算:plusDays 回傳新物件 this.dueOn = borrowedOn.plusDays(LOAN_DAYS);

/** 尚未歸還的記錄才是「進行中」 */ public boolean isActive() return returnedOn == null;

/** 結案:記下還書日並回傳逾期天數(準時或提早為 0) */ public long close(LocalDate returnDate) this.returnedOn = returnDate; long overdue = ChronoUnit.DAYS.between(dueOn, returnDate); return Math.max(0, overdue); // 提早還書不是負逾期

public String getIsbn() return isbn;

public String getMemberId() return memberId;

public LocalDate getDueOn() return dueOn;

exception 層:讓錯誤會說話

所有領域例外繼承共同基底 |LibraryException|(checked——這些都是營運中必然發生的狀況,呼叫端必須面對,第 3 章的選擇準則)。基底提供例外鏈建構子,供包裝底層 I/O 例外使用:

package library.exception;

/** 領域例外基底:checked,強迫呼叫端面對可預期的營運狀況 */ public class LibraryException extends Exception

public LibraryException(String message) super(message);

/** 例外鏈版本:包裝底層例外(如 IOException)時使用 */ public LibraryException(String message, Throwable cause) super(message, cause);

三個具體例外各對應一條核心商業規則,訊息在建構子裡組好,呼叫端拿到就能直接顯示。注意不必為每種錯誤都開一個類別:像「查無此書」這種呼叫端不需要特別區分的情況,直接用基底例外帶清楚訊息即可:

package library.exception;

/** 規則:已被借走的書不能再借 */ public class BookNotAvailableException extends LibraryException

public BookNotAvailableException(String title) super("《" + title + "》目前出借中,請改借其他書");

package library.exception;

/** 規則:借書者必須是已註冊的會員 */ public class MemberNotFoundException extends LibraryException

public MemberNotFoundException(String memberId) super("查無會員,編號:" + memberId);

package library.exception;

/** 規則:會員同時借閱的數量有上限 */ public class LoanLimitExceededException extends LibraryException

public LoanLimitExceededException(String name, int max) super("會員 " + name + " 已達借閱上限(" + max + " 本),請先還書");

repository 層:可抽換的儲存

先立合約。|service| 只認識這個介面,永遠不知道資料實際放在哪裡:

package library.repository;

import java.util.Collection;

import library.model.Book; import library.model.Member;

/** 儲存層合約:底層用記憶體、檔案還是資料庫,實作自己決定 */ public interface LibraryRepository

void addBook(Book book);

/** 依 ISBN 查書;找不到回傳 null(由服務層轉成例外) */ Book findBook(String isbn);

Collection<Book> allBooks();

void addMember(Member member);

/** 依編號查會員;找不到回傳 null */ Member findMember(String memberId);

記憶體版用兩個 |Map| 實作「以鍵查值」,查詢時間接近常數(第 4 章)。選 |LinkedHashMap| 是刻意的:列表輸出維持加入順序,畫面穩定好對照:

package library.repository;

import java.util.Collection; import java.util.LinkedHashMap; import java.util.Map;

import library.model.Book; import library.model.Member;

/** 記憶體版儲存庫:LinkedHashMap 讓遍歷順序等於加入順序 */ public class InMemoryLibraryRepository implements LibraryRepository

private final Map<String, Book> books = new LinkedHashMap<>(); private final Map<String, Member> members = new LinkedHashMap<>();

@Override public void addBook(Book book) // 同 ISBN 再加入視為覆蓋(Map 的天然行為) books.put(book.getIsbn(), book);

@Override public Book findBook(String isbn) return books.get(isbn); // 查無此鍵回傳 null

@Override public Collection<Book> allBooks() return books.values();

@Override public void addMember(Member member) members.put(member.getId(), member);

@Override public Member findMember(String memberId) return members.get(memberId);

檔案版繼承記憶體版:日常操作全部沿用(is-a 成立:檔案版儲存庫「是一種」記憶體儲存庫,只是多了持久化能力),額外提供存檔與載入。存檔格式刻意簡單——一本書一行、欄位用直線符號分隔:

package library.repository;

import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List;

import library.exception.LibraryException; import library.model.Book;

/** 檔案版儲存庫:沿用記憶體版行為,追加文字檔存讀能力 */ public class FileLibraryRepository extends InMemoryLibraryRepository

private final Path file; // 存檔位置

public FileLibraryRepository(Path file) this.file = file;

/** 存檔:一本書一行,欄位以 | 分隔,回傳筆數 */ public int save() throws LibraryException List<String> lines = new ArrayList<>(); for (Book b : allBooks()) lines.add(b.getIsbn() + "|" + b.getTitle() + "|" + b.getAuthor() + "|" + b.isAvailable()); try Files.write(file, lines, StandardCharsets.UTF_8); catch (IOException e) // 例外鏈(第 3 章):把底層 IO 例外包成領域例外 throw new LibraryException("存檔失敗:" + file, e); return lines.size();

/** 載入:檔案不存在視為第一次啟動,不算錯誤 */ public int load() throws LibraryException if (!Files.exists(file)) return 0; try List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8); for (String line : lines) // split 的參數是正規表示式,| 須跳脫成
| String[] cols = line.split("
|"); Book book = new Book(cols[0], cols[1], cols[2]); book.setAvailable(Boolean.parseBoolean(cols[3])); addBook(book); // 沿用父類別的加入邏輯
return lines.size();
catch (IOException e) throw new LibraryException("載入失敗:" + file, e);

service 層:商業規則的權威

所有借還書規則只住在這裡:畫面層不重複檢查,儲存層也不管規則。留意 |borrowBook| 的結構——先完成全部檢查,才開始改動狀態,永遠不會出現「額度扣了、書卻沒借到」的半套結果:

package library.service;

import java.time.LocalDate; import java.util.ArrayList; import java.util.List;

import library.exception.BookNotAvailableException; import library.exception.LibraryException; import library.exception.LoanLimitExceededException; import library.exception.MemberNotFoundException; import library.model.Book; import library.model.Loan; import library.model.Member; import library.repository.LibraryRepository;

/** 服務層:借還書規則集中地;規則違反一律拋自訂例外 */ public class LibraryService

private final LibraryRepository repo; // 只認識介面 private final List<Loan> loans = new ArrayList<>();

/** 建構子注入:測試時可以塞進記憶體版儲存庫 */ public LibraryService(LibraryRepository repo) this.repo = repo;

/** 借書:四道檢查全過,才建立借閱記錄 */ public Loan borrowBook(String isbn, String memberId, LocalDate date) throws LibraryException

Book book = repo.findBook(isbn); if (book == null) throw new LibraryException("查無此書,ISBN:" + isbn); Member member = repo.findMember(memberId); if (member == null) throw new MemberNotFoundException(memberId); if (!book.isAvailable()) throw new BookNotAvailableException(book.getTitle()); if (!member.canBorrow()) throw new LoanLimitExceededException( member.getName(), Member.MAX_LOANS);

// 檢查全數通過,才真正改動狀態 book.setAvailable(false); member.incrementLoans(); Loan loan = new Loan(isbn, memberId, date); loans.add(loan); return loan;

/** 還書:結案借閱記錄,回傳逾期天數(0 表示準時) */ public long returnBook(String isbn, String memberId, LocalDate date) throws LibraryException

// 找「這位會員、這本書、進行中」的那筆記錄 Loan target = null; for (Loan loan : loans) if (loan.isActive() && loan.getIsbn().equals(isbn) && loan.getMemberId().equals(memberId)) target = loan; break; if (target == null) throw new LibraryException( "查無進行中的借閱記錄:" + memberId + " / " + isbn);

long overdueDays = target.close(date); repo.findBook(isbn).setAvailable(true); // 書變回可借 repo.findMember(memberId).decrementLoans(); // 額度釋放 return overdueDays;

檢查的順序是有意設計的:先確認「東西存在」(書、會員),再檢查「狀態允許」(可借、有額度)。反過來寫,可能對不存在的物件呼叫方法而引爆 |NullPointerException|——那是程式臭蟲,不是商業例外。另外切記「先檢查、後改動」:只要有任何一道檢查可能失敗,就不要先動手改狀態,否則失敗時得寫復原邏輯,複雜度立刻翻倍。

app 層:選單迴圈

最外層負責人機互動:印選單、讀輸入、呼叫服務、把例外翻譯成友善訊息。每一輪操作都包在 try-catch 裡——任何商業規則失敗都只影響那一輪,系統繼續服務(第 3 章 ATM 範例的放大版):

package library.app;

import java.nio.file.Path; import java.time.LocalDate; import java.util.Scanner;

import library.exception.LibraryException; import library.model.Book; import library.model.Loan; import library.model.Member; import library.repository.FileLibraryRepository; import library.service.LibraryService;

/** 主控台進入點:只做三件事——讀輸入、呼叫 service、顯示結果 */ public class LibraryApp

// 教學用固定營業日,讓輸出可重現;實務改用 LocalDate.now() private static final LocalDate TODAY = LocalDate.of(2026, 8, 14);

private final Scanner scanner = new Scanner(System.in); private final FileLibraryRepository repo = new FileLibraryRepository(Path.of("books.txt")); private final LibraryService service = new LibraryService(repo);

public static void main(String[] args) LibraryApp app = new LibraryApp(); app.seedData(); app.run();

/** 種子資料:讓系統一啟動就有東西可操作 */ private void seedData() repo.addBook(new Book("978-1", "Java 核心技術", "王小凡")); repo.addBook(new Book("978-2", "演算法圖解", "林大同")); repo.addBook(new Book("978-3", "資料庫實戰", "陳美心")); repo.addMember(new Member("M001", "小明"));

/** 主迴圈:直到使用者選擇離開 */ private void run() System.out.println("=== 圖書管理系統(營業日 " + TODAY + ")==="); boolean running = true; while (running) printMenu(); String choice = scanner.nextLine().trim(); try switch (choice) case "1" -> addBook(); case "2" -> listBooks(); case "3" -> registerMember(); case "4" -> borrowBook(); case "5" -> returnBook(); case "6" -> saveData(); case "0" -> running = false; default -> System.out.println("無效選項,請重新輸入"); catch (LibraryException e) // 統一的錯誤出口:訊息友善,程式不中斷 System.out.println("[操作失敗] " + e.getMessage()); System.out.println("感謝使用,再見!");

private void printMenu() System.out.println(); System.out.println("[1]新增書籍 [2]書籍列表 [3]註冊會員 " + "[4]借書 [5]還書 [6]存檔 [0]離開"); System.out.print("請選擇:");

private void addBook() System.out.print("ISBN:"); String isbn = scanner.nextLine().trim(); System.out.print("書名:"); String title = scanner.nextLine().trim(); System.out.print("作者:"); String author = scanner.nextLine().trim(); repo.addBook(new Book(isbn, title, author)); System.out.println("已新增《" + title + "》");

private void listBooks() System.out.println("— 書籍列表(共 " + repo.allBooks().size() + " 本)—"); for (Book b : repo.allBooks()) String state = b.isAvailable() ? "可借" : "出借中"; System.out.println("[" + state + "] " + b.getIsbn() + " " + b.getTitle() + "/" + b.getAuthor());

private void registerMember() System.out.print("會員編號:"); String id = scanner.nextLine().trim(); System.out.print("姓名:"); String name = scanner.nextLine().trim(); repo.addMember(new Member(id, name)); System.out.println("已註冊會員 " + name + "(" + id + ")");

private void borrowBook() throws LibraryException System.out.print("會員編號:"); String memberId = scanner.nextLine().trim(); System.out.print("書籍 ISBN:"); String isbn = scanner.nextLine().trim(); Loan loan = service.borrowBook(isbn, memberId, TODAY); System.out.println("借閱成功,應還日期:" + loan.getDueOn());

private void returnBook() throws LibraryException System.out.print("會員編號:"); String memberId = scanner.nextLine().trim(); System.out.print("書籍 ISBN:"); String isbn = scanner.nextLine().trim(); long overdue = service.returnBook(isbn, memberId, TODAY); if (overdue > 0) System.out.println("還書成功,逾期 " + overdue + " 天,請補繳逾期費"); else System.out.println("還書成功,未逾期");

private void saveData() throws LibraryException int count = repo.save(); System.out.println("書目已寫入 books.txt(共 " + count + " 筆)");

輸入一律用 |nextLine()| 再自行轉換,是 |Scanner| 的保命守則:混用 |nextInt()| 與 |nextLine()| 時,|nextInt| 會把換行符留在緩衝區,下一個 |nextLine| 直接吃到空字串——這是初學者論壇的月經題。統一 |nextLine| 加 |trim|,世界清靜。

建置與執行:在 |library| 目錄的上層執行 |javac -d out library/app/LibraryApp.java|——編譯器會沿著 |import| 自動把其餘檔案一併編譯進 |out|;接著用 |java -cp out library.app.LibraryApp| 啟動。

完整執行示範

以下是一次完整操作:新增第 4 本書、註冊會員小美、連借兩本、第三本被上限擋下、還書、存檔、離開。提示文字(如「請選擇:」「ISBN:」)後面緊接的內容,是使用者輸入的回顯:

=== 圖書管理系統(營業日 2026-08-14)===

[1]新增書籍 [2]書籍列表 [3]註冊會員 [4]借書 [5]還書 [6]存檔 [0]離開 請選擇:1 ISBN:978-4 書名:設計模式入門 作者:張以芯 已新增《設計模式入門》

[1]新增書籍 [2]書籍列表 [3]註冊會員 [4]借書 [5]還書 [6]存檔 [0]離開 請選擇:3 會員編號:M002 姓名:小美 已註冊會員 小美(M002)

[1]新增書籍 [2]書籍列表 [3]註冊會員 [4]借書 [5]還書 [6]存檔 [0]離開 請選擇:4 會員編號:M002 書籍 ISBN:978-1 借閱成功,應還日期:2026-08-28

[1]新增書籍 [2]書籍列表 [3]註冊會員 [4]借書 [5]還書 [6]存檔 [0]離開 請選擇:4 會員編號:M002 書籍 ISBN:978-2 借閱成功,應還日期:2026-08-28

[1]新增書籍 [2]書籍列表 [3]註冊會員 [4]借書 [5]還書 [6]存檔 [0]離開 請選擇:4 會員編號:M002 書籍 ISBN:978-3 [操作失敗] 會員 小美 已達借閱上限(2 本),請先還書

[1]新增書籍 [2]書籍列表 [3]註冊會員 [4]借書 [5]還書 [6]存檔 [0]離開 請選擇:5 會員編號:M002 書籍 ISBN:978-2 還書成功,未逾期

[1]新增書籍 [2]書籍列表 [3]註冊會員 [4]借書 [5]還書 [6]存檔 [0]離開 請選擇:6 書目已寫入 books.txt(共 4 筆)

[1]新增書籍 [2]書籍列表 [3]註冊會員 [4]借書 [5]還書 [6]存檔 [0]離開 請選擇:0 感謝使用,再見!

驗證存檔成果:工作目錄出現 |books.txt|,每行一本書,例如第二行是 !978-2|演算法圖解|林大同|true!(歸還後回到可借),而 |978-1| 的最後一欄是 |false|(仍在小美手上)。想體驗載入,可在 |seedData| 後加一行呼叫 |repo.load()| 再觀察書籍列表(選項 2)的變化。

知識點回顧與延伸挑戰

這個專案把中級篇各章的知識用在哪裡?對照如下:

章 知識點 在本專案的落點
第 1 章 封裝、equals 與 hashCode |Book| 以 ISBN 定義相等
第 1 章 繼承與 super |FileLibraryRepository| 繼承記憶體版
第 2 章 介面與多型 |service| 依賴 |LibraryRepository| 介面
第 3 章 自訂例外、例外鏈 |LibraryException| 家族、包裝 IOException
第 4 章 Map 與集合選型 |LinkedHashMap| 存書與會員
第 5 章 泛型 |Map<String, Book>|、|List<Loan>|
第 6 章 檔案 I/O |Files.write| 與 |readAllLines| 持久化
第 7 章 java.time、switch 箭頭 借期與逾期計算、選單分派

延伸挑戰(依難度遞增):

  • 搜尋:新增「依書名關鍵字搜尋」選項(|title.contains(keyword)| 掃一遍 |allBooks|)。

  • 序列化存檔:仿照第 6 章,把整個館藏(書加會員加借閱記錄)改用 |ObjectOutputStream| 一次存讀,比較與文字檔方案的優缺點。

  • 統計報表:用 |Map| 統計每本書的歷史借閱次數,排序印出排行榜——進階篇學完 Stream API 之後,回來把這段改寫成一行管線,體會演進。

本章小結

  • 分層架構:model 放資料、exception 放錯誤語意、repository 放存取、service 放規則、app 放互動;依賴方向嚴格由外而內單向。

  • 商業規則集中在 service 層,且「先檢查、後改動」,避免半套狀態。

  • 儲存層以介面立約,記憶體版與檔案版可互換;檔案版繼承記憶體版,只追加持久化能力。

  • 自訂例外家族讓每條規則違反都有明確型別與友善訊息;底層 |IOException| 以例外鏈包裝上拋。

  • app 層把每輪操作包進 try-catch:單次失敗不中斷服務;|Scanner| 統一用 |nextLine| 讀取。

  • |LinkedHashMap| 讓輸出順序穩定;|Book| 覆寫 |equals| 與 |hashCode| 後可安心放進任何集合。

  • 借期與逾期全靠 |LocalDate| 與 |ChronoUnit| 計算,不可變物件讓日期邏輯無副作用。

  • 這副骨架就是實務系統的縮影:進階篇會在同樣的分層上換上資料庫、多執行緒與網路介面。

本章練習

  1. 實作題:實作「列出某會員借閱中書單」:輸入會員編號,顯示每本書的書名與應還日期。(提示:需要讓 |LibraryService| 提供查詢 |loans| 的方法,回傳該會員的進行中記錄。)

  2. 實作題:加入逾期罰金規則:每逾期一天 5 元,還書時一併顯示罰金。(提示:規則放 service 層或 |Loan| 內,app 層只負責顯示。)

  3. 實作題:讓 |FileLibraryRepository| 連會員也一起持久化(另存 |members.txt|),啟動時自動載入。(提示:儲存庫要先補上「列出全部會員」與讀取 |activeLoans| 的方法,再仿照 |save| 與 |load| 寫一對方法。)

  4. 觀念題:如果要把主控台介面換成網頁介面,本專案哪些類別完全不用改?這說明了分層的什麼價值?(提示:想想依賴方向——誰依賴誰,誰就會被改動波及。)