本章設計類似 UPS、FedEx 或 Amazon Locker 的Shipping Locker(取貨櫃)系統。它讓顧客能方便、安全地取得線上訂單。系統管理 locker 可用性、為包裹分派合適的 locker、並確保流暢的取件體驗。
需求蒐集#
題目情境#
想像你收到通知:線上訂單已送達附近的取貨櫃。你前往取貨櫃,輸入安全代碼,櫃門隨即彈開。背景中,系統已管理可用性、分派合適大小的格位,並確保流暢取件。請設計這樣的系統。
需求釐清對話#
Candidate:支援多種 locker 大小嗎?
Interviewer:是的,包裹應分派到能容納的最小可用 locker,最佳化空間使用。
Candidate:包裹送達到取件的流程?
Interviewer:包裹到達時,系統找出合適大小的空 locker 並分派。放入後通知顧客取件位置與獨一存取碼。輸入正確存取碼即可取件並釋放 locker。
Candidate:是否有時間限制或費用?
Interviewer:有 locker policy。提供Free Period——一段免費天數;之後依 locker 大小開始按日收費。若超過Maximum Period,員工會清出包裹。
Candidate:系統會處理付款嗎?
Interviewer:為簡化起見,由外部服務處理金流。
需求整理#
功能性需求#
- 系統追蹤所有 locker 並支援不同大小
- 將包裹分派到能容納的最小可用 locker
- 顧客以獨一存取碼開啟自己的 locker
- 依顧客的 locker policy 監控儲存費用
非功能性需求#
- 系統需在高量操作下維持效能
- 高可用性,全時段可供顧客與員工存取
辨識核心物件#
- Locker:單一物理 locker
- Site:包含多個不同大小 locker 的設施
- ShippingPackage:包裹標準介面,
BasicShippingPackage為具體實作 - Account:顧客帳戶,含 policy 與餘額
類別圖設計#
Locker#
Locker 代表物理儲存單位,屬性包括:
- LockerSize size:locker 大小
- ShippingPackage currentPackage:目前存放的包裹
- Date assignmentDate:包裹放入日期
- String accessCode:取件用的獨一安全碼
提供功能:分派包裹、取件後釋放、依使用時長計算費用、查詢可用性、透過存取碼驗證安全存取。
Design Choice:Locker 為獨立實體,封裝單一 locker 的狀態與行為,保持模組化。
LockerSize#
LockerSize enum 代表預定義的 locker 大小,每個大小附帶:
- String sizeName:標籤(如 Small、Medium、Large)
- BigDecimal dailyCharge:每日費用
- BigDecimal width, height, depth:實體尺寸
Design Choice:用 enum 是因為 locker 大小是執行期不變的固定集合,提供型別安全與簡潔。
Site#
Site 模擬包含多個 locker 的物理位置,依大小組織。
關鍵方法:
findAvailableLocker(LockerSize size):尋找指定大小的空 lockerplacePackage(ShippingPackage pkg, Date date):找到合適 locker、放入、追蹤日期、更新狀態
每個 site 的 lockers 用 map 結構管理:key 為 LockerSize、value 為該大小的 locker 集合,便於快速依大小存取。
ShippingPackage#
ShippingPackage 為介面,定義所有包裹類型的標準。BasicShippingPackage 為標準包裹的具體實作。
ShippingStatus enum 定義生命週期狀態(PENDING、STORED、RETRIEVED 等)。
Design Choice:把
ShippingPackage設為介面提升可擴充性,能支援不同包裹類型(易碎、易腐)而不修改核心邏輯。
Account#
Account 代表使用者,維護費用餘額並關聯 AccountLockerPolicy。
Design Choice:透過關聯
AccountLockerPolicy支援彈性計費規則。將顧客資料與政策資料分離,提升維護性並支援個人化條件。
AccountLockerPolicy#
定義帳戶的 locker 使用規則:
- freePeriodDays:免費使用天數
- maximumPeriodDays:包裹最多可放置的天數
NotificationInterface#
定義發送通知的契約,包含 sendNotification 方法。
最佳實務:在 OOD 面試中,外部系統(如通知)常以介面表示,以維持設計彈性與可擴展性。
LockerManager#
LockerManager 管理特定 site 的儲存與取件,使用:
- Site:物理位置
- NotificationInterface:發送通知
- Map<String, Account>:帳戶 ID 對應使用者
- Map<String, Locker>:存取碼對應 locker,便於取件時快速查找
Design Choice:
LockerManager設為 facade,提供包裹分派與取件的單一控制點。
完整類別圖#
程式實作#
Locker 與 LockerSize#
public class Locker {
// Size of the locker
private final LockerSize size;
// Currently stored package
private ShippingPackage currentPackage;
// Date when the current package was assigned
private Date assignmentDate;
// Access code for retrieving the package
private String accessCode;
public Locker(LockerSize size) {
this.size = size;
}
// Assigns a package to this locker and generates an access code
public void assignPackage(ShippingPackage pkg, Date date) {
this.currentPackage = pkg;
this.assignmentDate = date;
this.accessCode = generateAccessCode();
}
// Releases the locker by removing the current package and its details
public void releaseLocker() {
this.currentPackage = null;
this.assignmentDate = null;
this.accessCode = null;
}
// Calculates storage charges based on usage duration and policy
public BigDecimal calculateStorageCharges() {
if (currentPackage == null || assignmentDate == null) {
return BigDecimal.ZERO;
}
AccountLockerPolicy policy = currentPackage.getUser().getLockerPolicy();
long totalDaysUsed =
(new Date().getTime() - assignmentDate.getTime()) / (1000 * 60 * 60 * 24);
// Check if exceeds maximum period
if (totalDaysUsed > policy.getMaximumPeriodDays()) {
currentPackage.updateShippingStatus(ShippingStatus.EXPIRED);
throw new MaximumStoragePeriodExceededException(
"Package has exceeded maximum allowed storage period of "
+ policy.getMaximumPeriodDays()
+ " days");
}
// Calculate chargeable days (excluding free period)
long chargeableDays = Math.max(0, totalDaysUsed - policy.getFreePeriodDays());
return size.dailyCharge.multiply(new BigDecimal(chargeableDays));
}
// Checks if the locker is available for new packages
public boolean isAvailable() {
return currentPackage == null;
}
// Verifies if the provided access code matches the locker's code
public boolean checkAccessCode(String code) {
return this.accessCode != null && accessCode.equals(code);
}
// getter and setter methods are omitted for brevity
}public enum LockerSize {
// Small locker with 10x10x10 dimensions and $5 daily charge
SMALL(
"Small",
new BigDecimal("5.00"),
new BigDecimal("10.00"),
new BigDecimal("10.00"),
new BigDecimal("10.00")),
// Medium locker with 20x20x20 dimensions and $10 daily charge
MEDIUM(
"Medium",
new BigDecimal("10.00"),
new BigDecimal("20.00"),
new BigDecimal("20.00"),
new BigDecimal("20.00")),
// Large locker with 30x30x30 dimensions and $15 daily charge
LARGE(
"Large",
new BigDecimal("15.00"),
new BigDecimal("30.00"),
new BigDecimal("30.00"),
new BigDecimal("30.00"));
// Name of the locker size
final String sizeName;
// Daily charge for using this size locker
final BigDecimal dailyCharge;
// Width of the locker in inches
final BigDecimal width;
// Height of the locker in inches
final BigDecimal height;
// Depth of the locker in inches
final BigDecimal depth;
// Creates a new locker size with specified dimensions and charges
LockerSize(
String sizeName,
BigDecimal dailyCharge,
BigDecimal width,
BigDecimal height,
BigDecimal depth) {
this.sizeName = sizeName;
this.dailyCharge = dailyCharge;
this.width = width;
this.height = height;
this.depth = depth;
}
// getter methods are omitted for brevity
}Implementation Choice:用
BigDecimal處理dailyCharge與尺寸以確保金額計算精度,避免float/double的捨入錯誤。
Site#
public class Site {
// Map of locker sizes to sets of lockers of that size
final Map<LockerSize, Set<Locker>> lockers = new HashMap<>();
// Creates a new site with specified number of lockers for each size
public Site(Map<LockerSize, Integer> lockers) {
for (Map.Entry<LockerSize, Integer> entry : lockers.entrySet()) {
Set<Locker> lockerSet = new HashSet<>();
for (int i = 0; i < entry.getValue(); i++) {
lockerSet.add(new Locker(entry.getKey()));
}
this.lockers.put(entry.getKey(), lockerSet);
}
}
// Finds an available locker of the specified size
public Locker findAvailableLocker(LockerSize size) {
for (Locker locker : lockers.get(size)) {
if (locker.isAvailable()) {
return locker;
}
}
return null;
}
// Places a package in an available locker of appropriate size
public Locker placePackage(ShippingPackage pkg, Date date) {
// Determine the smallest locker size that can fit this package
LockerSize size = pkg.getLockerSize();
Locker locker = findAvailableLocker(size);
if (locker != null) {
locker.assignPackage(pkg, date);
pkg.updateShippingStatus(ShippingStatus.IN_LOCKER);
return locker;
}
throw new NoLockerAvailableException(
"No locker of size " + size + " is currently available");
}
}Implementation Choice:
placePackage()等輔助方法將「找+放」封裝成單一呼叫,提供類似原子交易的行為,確保執行緒安全並避免不一致狀態。
BasicShippingPackage#
public class BasicShippingPackage implements ShippingPackage {
// Unique identifier for the order
private final String orderId;
// User account associated with this package
private final Account user;
private final BigDecimal width;
private final BigDecimal height;
private final BigDecimal depth;
// Current status of the package
private ShippingStatus status;
// Creates a new shipping package with specified dimensions
public BasicShippingPackage(
String orderId, Account user, BigDecimal width, BigDecimal height, BigDecimal depth) {
this.orderId = orderId;
this.user = user;
this.width = width;
this.height = height;
this.depth = depth;
this.status = ShippingStatus.CREATED;
}
// Returns the current package status
@Override
public ShippingStatus getStatus() {
return status;
}
// Updates the package status
@Override
public void updateShippingStatus(ShippingStatus status) {
this.status = status;
}
// Determines the smallest locker size that can fit this package
@Override
public LockerSize getLockerSize() {
for (LockerSize size : LockerSize.values()) {
if (size.getWidth().compareTo(width) >= 0
&& size.getHeight().compareTo(height) >= 0
&& size.getDepth().compareTo(depth) >= 0) {
return size;
}
}
throw new PackageIncompatibleException("No locker size available for the package");
} // getter methods are omitted for brevity
}關鍵方法 getLockerSize() 評估所有尺寸,回傳能容納包裹的最小合適 locker。
Account 與 AccountLockerPolicy#
public class Account {
private final String accountId;
private final String ownerName;
// Policy defining locker usage rules for this account
private final AccountLockerPolicy lockerPolicy;
// Total charges accumulated for locker usage
private BigDecimal usageCharges = new BigDecimal("0.00");
// Creates a new account with specified details and policy
public Account(String accountId, String ownerName, AccountLockerPolicy lockerPolicy) {
this.accountId = accountId;
this.ownerName = ownerName;
this.lockerPolicy = lockerPolicy;
}
// Adds a charge to the account's total usage charges
public void addUsageCharge(BigDecimal amount) {
usageCharges = usageCharges.add(amount);
}
// getter methods are omitted for brevity
}public class AccountLockerPolicy {
// Number of days of free storage
final int freePeriodDays;
// Maximum number of days a package can be stored
final int maximumPeriodDays;
// Creates a new locker policy with specified free and maximum periods
public AccountLockerPolicy(int freePeriodDays, int maximumPeriodDays) {
this.freePeriodDays = freePeriodDays;
this.maximumPeriodDays = maximumPeriodDays;
}
// getter methods are omitted for brevity
}LockerManager#
public class LockerManager {
// The site being managed
private final Site site;
// Service for sending notifications
private final NotificationInterface notificationService;
// Map of account IDs to account objects
private final Map<String, Account> accounts;
// Map of access codes to lockers
private final Map<String, Locker> accessCodeMap = new HashMap<>();
// Creates a new locker manager for a site
public LockerManager(
Site site, Map<String, Account> accounts, NotificationInterface notificationService) {
this.site = site;
this.accounts = accounts;
this.notificationService = notificationService;
}
// Assigns a package to an available locker
public Locker assignPackage(ShippingPackage pkg, Date date) {
Locker locker = site.placePackage(pkg, date);
if (locker != null) {
accessCodeMap.put(locker.getAccessCode(), locker);
notificationService.sendNotification(
"Package assigned to locker" + locker.getAccessCode(), pkg.getUser());
}
return locker;
}
// Processes package pickup using an access code
public Locker pickUpPackage(String accessCode) {
Locker locker = accessCodeMap.get(accessCode);
if (locker != null && locker.checkAccessCode(accessCode)) {
try {
BigDecimal charge = locker.calculateStorageCharges();
ShippingPackage pkg = locker.getPackage();
locker.releaseLocker();
pkg.getUser().addUsageCharge(charge);
pkg.updateShippingStatus(ShippingStatus.RETRIEVED);
return locker;
} catch (MaximumStoragePeriodExceededException e) {
locker.releaseLocker();
return locker;
}
}
return null;
}
// getter methods are omitted for brevity
}最佳實務:在複雜系統中常用 manager 類別集中操作。但要保持 manager 類別輕量,必須將低階操作(找 locker、儲存、套用 policy)委派給專屬類別(如
Site、Locker)。
深度討論#
設計可擴展的 Locker 建立系統#
目前 locker 直接依預定大小建立,使系統僵化且難擴充。例如新增 XLARGE 大小或溫控 locker 都需修改多處程式碼。
我們可用 Factory 設計模式集中建立邏輯。
class LockerFactory {
// Creates a new locker of the specified size
public static Locker createLocker(LockerSize size) {
return switch (size) {
case SMALL -> new Locker(LockerSize.SMALL);
case MEDIUM -> new Locker(LockerSize.MEDIUM);
case LARGE -> new Locker(LockerSize.LARGE);
case XLARGE -> new Locker(LockerSize.XLARGE);
};
}
}LockerFactory 的優點:
- 集中物件建立:locker 建立的變動侷限於此類別
- 可擴充性:新增 locker 大小或類型不需修改核心業務邏輯
- 可讀性與維護性:將建立與使用邏輯分離,符合單一職責原則
用事件驅動解耦事件處理#
目前 LockerManager 直接呼叫 NotificationInterface.sendNotification(),與通知系統緊密耦合。
想了解 Observer Pattern,請參考 Elevator System 章節。
我們以 Observer 模式讓 locker 系統維護觀察者清單,而非硬編碼通知邏輯。觀察者可以是不同通知服務(email、SMS)或其他系統(分析、指標)。
Subject:LockerManagerChange:
class LockerManagerChange {
// List of observers that will be notified of locker events
private final List<LockerEventObserver> observers = new ArrayList<>();
public void addObserver(LockerEventObserver observer) {
observers.add(observer);
}
public void removeObserver(LockerEventObserver observer) {
observers.remove(observer);
}
private void notifyObservers(String message, Account account) {
for (LockerEventObserver observer : observers) {
observer.update(message, account);
}
}
// Assigns a package to an available locker and notifies observers.
public void assignPackage(ShippingPackage pkg) {
Locker locker = assignLockerToPackage(pkg);
if (locker != null) {
notifyObservers("Package assigned to locker", pkg.getUser());
}
}
private Locker assignLockerToPackage(ShippingPackage pkg) {
return null;
}
}Observer 介面:
// Interface for objects that need to be notified of locker events
public interface LockerEventObserver {
// Updates the observer with a message and the affected account
void update(String message, Account account);
}具體觀察者:
// Implementation of LockerEventObserver that sends email notifications
class EmailNotification implements LockerEventObserver {
// Updates the observer with a message and sends it to the account owner
public void update(String message, Account account) {
// send email to account owner
}
}本章小結#
我們透過結構化問答釐清需求、辨識核心物件、建立類別圖、實作關鍵元件。
深度討論中我們探討:
- Factory Pattern:簡化 locker 實例化以利未來擴展
- Observer Pattern:解耦事件處理以提供彈性通知
延伸閱讀:Factory Design Pattern#
Factory Design Pattern#
Factory Method 是一種創建型設計模式,提供建立類別實例的介面,但允許子類別決定要建立的物件型別。
問題情境:電商支付系統#
初始僅支援信用卡,當需要新增數位錢包或加密貨幣時,須改動多處緊密耦合的程式碼。
解法#
Factory Method 將直接物件建立替換為封裝實例化的工廠方法。新增支付類型只需建立新子類別,不需更動既有程式碼。
何時使用#
- 程式所需物件的特定類型與依賴在執行前未知
- 允許函式庫使用者擴充內部元件而不修改既有程式
- 透過重用既有物件最佳化資源使用