本章介紹**電影票訂票系統(Movie Ticket Booking System)**的設計。這類題目用來評估你建模真實世界系統與運用 OOP 原則的能力。我們會仔細定義代表電影、影廳與場次等實體的類別,建構出清晰、可運作且可擴充的結構。
需求蒐集#
題目情境#
想像你想在熱門檔期訂票。登入系統、瀏覽場次、選座、下訂,幾秒後就收到電子票券。背景中,系統管理座位狀態、追蹤場次並計算金額。請設計能流暢處理這一切的訂票系統。
需求釐清對話#
Candidate:系統是否支援跨多家影城(cinema)與多個影廳(room)的搜尋與訂票?
Interviewer:是的,使用者可跨多家影城搜尋。
Candidate:同一部電影是否可以在不同影廳、不同時段排映多場?
Interviewer:可以。
Candidate:同一場次內座位是否有不同的票價層級?
Interviewer:是。座位可有 normal、premium、VIP 等定價策略。
Candidate:使用者能否一次訂多張票?金額怎麼算?
Interviewer:可以。同一場次可合併下單,金額為所有座位票價總和。
Candidate:是否需要處理金流?
Interviewer:本題不討論金流,聚焦在瀏覽、排映、選座、訂票即可。
Candidate:訂票時系統的行為?
Interviewer:建立一張包含 screening、seat 與計算後票價的 ticket,加入該場次的票券清單,並標記座位為已訂。
需求整理#
功能性需求#
電影與場次管理
- 每家影城位於特定地點,包含多個影廳
- 每部電影可在不同影廳、影城、時段排映多場
座位與票價
- 每個影廳有座位網格供訂票
- 座位可設不同定價策略(normal、premium、VIP)
搜尋與訂票流程
- 使用者可搜尋並訂購可用票券
- Ticket 代表「在某時段、某影廳觀看某電影的某座位」
- 一張訂單可包含多張票券,總價為所有座位票價之和
非功能性需求#
- 場次搜尋必須快速以提供順暢體驗
- 基本的錯誤處理需防止重複訂位(double booking)
辨識核心物件#
- Movie:電影本身,包含 title、duration 等靜態資訊
- Cinema:實體影城,包含多個 Room
- Room:影城中的單一影廳,連結獨特的座位配置
- Layout:以網格組織 Room 內的座位
- Seat:單一座位,連結定價策略
- Screening:結合 Movie、Room、時段,定義一場具體放映
- Ticket:代表使用者購買的座位 + 場次組合,含購買時的票價
- Order:將多張一起購買的 Ticket 彙整為單一交易
Design Choice:將 Movie 與 Screening 分離,可區分電影靜態資料與動態排程,提升重用性與清晰度。將 Room 與 Layout 分離,則讓多個影廳能共享或客製化座位配置。
面試技巧:呈現核心物件時,務必說明選擇理由與如何符合需求,並提及替代方案(例如合併類別)以展現你已考慮過取捨。
類別圖設計#
Movie#
Movie 類別封裝靜態資訊——title、genre、duration——這些屬性在所有場次間保持一致。它與 Screening 不同,後者把電影綁定到特定影廳與時段。
Design Choice:Movie 設計為獨立實體,與影城或排程脈絡無關,因此同一部 Movie 能被不同影城與場次重用而不需重複資料。
Seat#
Seat 類別封裝座位編號與定價邏輯,運用 Strategy Pattern 透過 PricingStrategy 介面(具體類別 NormalRate、PremiumRate、VIPRate)彈性管理價格。
Strategy Pattern 帶來兩大好處:
- 可擴充性:輕鬆新增新的票價類別
- 減少冗餘:以單一 Seat 類別處理所有定價變化
替代方案:將定價邏輯直接內嵌在 Seat 中能簡化結構,但定價規則變動時彈性差。Strategy Pattern 雖較複雜,但支援未來擴充。
想了解更多 Strategy Pattern,請參考 Parking Lot 章節。
Layout#
Layout 是個別 Seat 與 Room 之間的橋樑,以網格組織座位。它使用巢狀 map(Map<Integer, Map<Integer, Seat>>):
- 外層 map 的 key 是列號(row)
- 內層 map 的 key 是欄號(column)
- 值是該位置的 Seat 物件
同時維護一個依座位編號查找的索引(Map<String, Seat>),快速以編號定位座位。
替代方案:Layout 使用巢狀 map 而非 2D array,是為了支援動態建立列(透過
computeIfAbsent)與不規則大小。2D array 適用於固定大小,但缺乏彈性。
面試技巧:說明資料結構選擇(map vs. array)的理由,以及它如何支撐你的設計目標。
Cinema 與 Room#
Cinema 與 Room 類別建構整個影城結構。一家 Cinema 包含多個 Room(組合關係),每個 Room 透過 Layout 定義座位排列。
Design Choice:以組合(composition)關係模擬真實的多廳影城。每個 Room 可獨立運作其布局與排程,Cinema 則提供統一脈絡。
Screening#
Screening 將 Movie、Room 與時間組合為單一實體,定義「在哪個影廳、何時放映何片」。
Design Choice:Screening 集中管理排程細節,讓跨影城的場次管理更容易,並維持清晰的職責分離。
Ticket#
Ticket 代表為某 Screening 購買的座位,結合一個 Screening 與一個 Seat。
它包含 price 屬性,紀錄購買當下的價格。即使 Seat 的 PricingStrategy 之後改變,Ticket 的價格仍保持固定。
Order#
Order 將多張票券彙整為單一交易,記錄下單時間並計算總額。
ScreeningManager#
僅有 Movie、Seat、Ticket 等基礎類別不足以構成完整系統。我們設計 ScreeningManager 來管理:
- 搜尋特定電影的場次
- 識別可用座位
- 儲存已售出票券
Design Choice:替代方案是把這些操作放入 Cinema 類別,讓每家影城自管場次與票券。但這會把靜態的影城屬性與排程/訂票邏輯耦合在一起,降低模組化與可維護性。
MovieBookingSystem#
MovieBookingSystem 是把所有元件整合在一起的最後一塊拼圖。它整合電影清單、影城清單與 ScreeningManager,作為 facade 提供統一入口:
- 新增電影或影城
- 搜尋電影的場次
- 查詢可用座位
- 訂購票券
替代方案:讓客戶端直接與 ScreeningManager 或 Cinema 互動。但這會增加耦合並讓互動變脆弱。Facade 模式提升維護性並簡化系統互動。
完整類別圖#
程式實作#
Movie#
Movie 類別不可變(immutable),沒有 setter 方法,確保建立後資料不被修改。
public class Movie {
private final String title;
private final String genre;
private final int durationInMinutes;
public Movie(String title, String genre, int durationInMinutes) {
this.title = title;
this.genre = genre;
this.durationInMinutes = durationInMinutes;
}
public Duration getDuration() {
return Duration.ofMinutes(durationInMinutes);
}
// getter methods are omitted for brevity
}getDuration() 將 durationInMinutes 轉為 Duration 物件,提供標準化的時長表示。
Cinema#
public class Cinema {
private final String name;
private final String location;
private final List<Room> rooms;
public Cinema(String name, String location) {
this.name = name;
this.location = location;
this.rooms = new ArrayList<>();
}
public void addRoom(Room room) {
rooms.add(room);
}
// getter and setter methods are omitted for brevity
}Room#
public class Room {
private final String roomNumber;
private final Layout layout;
public Room(String roomNumber, Layout layout) {
this.roomNumber = roomNumber;
this.layout = layout;
}
// getter and setter methods are omitted for brevity
}Layout#
// Represents the seating layout of a cinema room.
public class Layout {
private final int rows;
private final int columns;
// Maps seat numbers (e.g., "0-0") to Seat objects for direct access
private final Map<String, Seat> seatsByNumber;
// Nested map for position-based access (row -> column -> seat)
private final Map<Integer, Map<Integer, Seat>> seatsByPosition;
public Layout(int rows, int columns) {
this.rows = rows;
this.columns = columns;
this.seatsByNumber = new HashMap<>();
this.seatsByPosition = new HashMap<>();
initializeLayout();
}
// Creates seats for all positions with default null pricing
private void initializeLayout() {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < columns; j++) {
String seatNumber = i + "-" + j;
addSeat(seatNumber, i, j, new Seat(seatNumber, null));
}
}
}
public void addSeat(String seatNumber, int row, int column, Seat seat) {
// Store seat in number-based lookup map
seatsByNumber.put(seatNumber, seat);
// Store seat in position-based lookup map
seatsByPosition.computeIfAbsent(row, k -> new HashMap<>()).put(column, seat);
}
public Seat getSeatByNumber(String seatNumber) {
return seatsByNumber.get(seatNumber);
}
// Gets a seat by its row and column position
public Seat getSeatByPosition(int row, int column) {
Map<Integer, Seat> rowSeats = seatsByPosition.get(row);
return (rowSeats != null) ? rowSeats.get(column) : null;
}
public List<Seat> getAllSeats() {
return List.copyOf(seatsByNumber.values());
}
}關鍵方法:
- addSeat:透過
computeIfAbsent動態建立新列 - getSeatByNumber:以唯一字串 ID 高效查找座位
- getSeatByPosition:以列欄座標精確存取
- getAllSeats:回傳不可修改的座位清單
Implementation Choice:巢狀
Map<Integer, Map<Integer, Seat>>與Map<String, Seat>都提供 O(1) 存取,並支援動態擴充。替代方案Seat[][]適合固定大小,但缺乏彈性。
Seat#
public class Seat {
private final String seatNumber;
private PricingStrategy pricingStrategy;
public Seat(String seatNumber, PricingStrategy pricingStrategy) {
this.seatNumber = seatNumber;
this.pricingStrategy = pricingStrategy;
}
// getter and setter methods are omitted for brevity
}替代方案:直接在 Seat 中以 enum(NORMAL、PREMIUM)儲存類型與價格。但這會讓定價邏輯緊耦合於 Seat,難以擴充新規則。
PricingStrategy#
PricingStrategy 介面要求實作 getPrice()。具體類別封裝固定價格,遵循開放/封閉原則——可新增策略而不修改既有程式碼。
public interface PricingStrategy {
BigDecimal getPrice();
}
public class NormalRate implements PricingStrategy {
private final BigDecimal price;
public NormalRate(BigDecimal price) {
this.price = price;
}
@Override
public BigDecimal getPrice() {
return price;
}
}
public class PremiumRate implements PricingStrategy {
private final BigDecimal price;
public PremiumRate(BigDecimal price) {
this.price = price;
}
@Override
public BigDecimal getPrice() {
return price;
}
}
public class VIPRate implements PricingStrategy {
private final BigDecimal price;
public VIPRate(BigDecimal price) {
this.price = price;
}
@Override
public BigDecimal getPrice() {
return price;
}
}Screening#
// Represents a scheduled screening of a movie in a specific cinema room.
public class Screening {
private final Movie movie;
private final Room room;
private final LocalDateTime startTime;
private final LocalDateTime endTime;
public Screening(Movie movie, Room room, LocalDateTime startTime, LocalDateTime endTime) {
this.movie = movie;
this.room = room;
this.startTime = startTime;
this.endTime = endTime;
}
public Duration getDuration() {
return Duration.between(startTime, endTime);
} // getter and setter methods are omitted for brevity
}Ticket#
public class Ticket {
private final Screening screening;
private final Seat seat;
private final BigDecimal price;
public Ticket(Screening screening, Seat seat, BigDecimal price) {
this.screening = screening;
this.seat = seat;
this.price = price;
}
// getter and setter methods are omitted for brevity
}Order#
public class Order {
private final List<Ticket> tickets;
private final LocalDateTime orderDate;
public Order(LocalDateTime orderDate) {
this.tickets = new ArrayList<>();
this.orderDate = orderDate;
}
public void addTicket(Ticket ticket) {
tickets.add(ticket);
}
// Calculates the total price of all tickets in the order
public BigDecimal calculateTotalPrice() {
return tickets.stream().map(Ticket::getPrice).reduce(BigDecimal.ZERO, BigDecimal::add);
}
// getter and setter methods are omitted for brevity
}ScreeningManager#
// Manages the relationships between movies, screenings, and tickets in the booking system
public class ScreeningManager {
// Maps movies to their scheduled screenings
private final Map<Movie, List<Screening>> screeningsByMovie;
// Maps screenings to tickets sold for that screening
private final Map<Screening, List<Ticket>> ticketsByScreening;
public ScreeningManager() {
this.screeningsByMovie = new HashMap<>();
this.ticketsByScreening = new HashMap<>();
}
public void addScreening(Movie movie, Screening screening) {
screeningsByMovie.computeIfAbsent(movie, k -> new ArrayList<>()).add(screening);
}
// Returns all screenings for a specific movie
public List<Screening> getScreeningsForMovie(Movie movie) {
return screeningsByMovie.getOrDefault(movie, new ArrayList<>());
}
public void addTicket(Screening screening, Ticket ticket) {
ticketsByScreening.computeIfAbsent(screening, k -> new ArrayList<>()).add(ticket);
}
// Returns all tickets sold for a specific screening
public List<Ticket> getTicketsForScreening(Screening screening) {
return ticketsByScreening.getOrDefault(screening, new ArrayList<>());
}
// Calculates which seats are still available for a screening
public List<Seat> getAvailableSeats(Screening screening) {
List<Seat> allSeats = screening.getRoom().getLayout().getAllSeats();
List<Ticket> bookedTickets = getTicketsForScreening(screening);
List<Seat> availableSeats = new ArrayList<>(allSeats);
for (Ticket ticket : bookedTickets) {
availableSeats.remove(ticket.getSeat());
}
return availableSeats;
}
}addScreening與addTicket用computeIfAbsent動態初始化清單getAvailableSeats從 Layout 取得全部座位後扣除已售出的,回傳剩餘可用座位
Implementation Choice:使用
Map<Movie, List<Screening>>與Map<Screening, List<Ticket>>提供 O(1) 查找。替代方案是用 List 加手動過濾,但會變成 O(n)。
MovieBookingSystem#
// Manages the complete movie booking system operations
public class MovieBookingSystem {
private final List<Movie> movies;
private final List<Cinema> cinemas;
private final ScreeningManager screeningManager;
public MovieBookingSystem() {
this.movies = new ArrayList<>();
this.cinemas = new ArrayList<>();
this.screeningManager = new ScreeningManager();
}
public void addMovie(Movie movie) {
movies.add(movie);
}
public void addCinema(Cinema cinema) {
cinemas.add(cinema);
}
public void addScreening(Movie movie, Screening screening) {
screeningManager.addScreening(movie, screening);
}
// Books a ticket for a specific seat at a screening
public void bookTicket(Screening screening, Seat seat) {
BigDecimal price = seat.getPricingStrategy().getPrice();
Ticket ticket = new Ticket(screening, seat, price);
screeningManager.addTicket(screening, ticket);
}
// Returns all screenings for a specific movie
public List<Screening> getScreeningsForMovie(Movie movie) {
return screeningManager.getScreeningsForMovie(movie);
}
// Returns all available seats for a screening
public List<Seat> getAvailableSeats(Screening screening) {
return screeningManager.getAvailableSeats(screening);
}
// Returns the number of tickets sold for a screening
public int getTicketCount(Screening screening) {
return screeningManager.getTicketsForScreening(screening).size();
}
// Returns the list of tickets for a screening
public List<Ticket> getTicketsForScreening(Screening screening) {
return screeningManager.getTicketsForScreening(screening);
} // getter and setter methods are omitted for brevity
}bookTicket 動態取得座位的 PricingStrategy 計算票價,建立 Ticket 並交由 ScreeningManager 儲存。
深度討論#
處理並行訂票(Concurrent Bookings)#
OOD 面試中,並行(concurrency)常被用來討論訂票系統等多人同時使用的情境。
問題:Race Condition#
兩位使用者同時嘗試訂同一座位,可能都收到確認,造成重複訂位(double-booking),破壞系統可靠性。
解法:悲觀鎖與樂觀鎖#
可使用鎖機制將平行操作序列化。常見兩種:
悲觀鎖(Pessimistic Locking)
- 訂票流程一開始就取得獨佔鎖
- 在鎖釋放前阻擋其他使用者
- 整個交易期間持有鎖,並設逾時機制(如 30 秒)自動釋放
- 適用:高度競爭情境,多人頻繁搶同一座位
- 缺點:取鎖與釋鎖造成延遲
樂觀鎖(Optimistic Locking)
- 訂票過程中不鎖定座位
- 在交易最後階段才驗證座位是否仍可用
- 若已被別人訂走則交易失敗,使用者需重試
- 仰賴原子操作(如
synchronized或 DB transaction)確保一致性 - 適用:低度競爭情境
- 缺點:高度競爭時頻繁衝突會導致大量重試
實作:悲觀鎖#
引入 SeatLockManager 類別,使用 Java 並行特性管理暫時鎖。以 ConcurrentHashMap 確保執行緒安全,並用 synchronized 防止建立/清理時的競態。
public class SeatLockManager {
private final Map<String, SeatLock> lockedSeats = new ConcurrentHashMap<>();
private final Duration lockDuration;
public SeatLockManager(Duration lockDuration) {
this.lockDuration = lockDuration;
}
public synchronized boolean lockSeat(Screening screening, Seat seat, String userId) {
String lockKey = generateLockKey(screening, seat);
// Clean up lock if expired (on-demand cleanup when another process attempts to lock)
cleanupLockIfExpired(lockKey);
// Check if a seat is already locked
if (isLocked(screening, seat)) {
return false;
}
// Create a new lock with expiration time
SeatLock lock = new SeatLock(userId, LocalDateTime.now().plus(lockDuration));
lockedSeats.put(lockKey, lock);
return true;
}
public synchronized boolean isLocked(Screening screening, Seat seat) {
String lockKey = generateLockKey(screening, seat);
// Clean up lock if expired (on-demand cleanup)
cleanupLockIfExpired(lockKey);
// If we reach here, either no lock exists or it's valid
return lockedSeats.containsKey(lockKey);
}
private void cleanupLockIfExpired(String lockKey) {
SeatLock lock = lockedSeats.get(lockKey);
if (lock != null && lock.isExpired()) {
lockedSeats.remove(lockKey);
}
}
private String generateLockKey(Screening screening, Seat seat) {
return screening.getId() + "-" + seat.getSeatNumber();
}
// SeatLock inner class
private static class SeatLock {
private final String userId;
private final LocalDateTime expirationTime;
public SeatLock(String userId, LocalDateTime expirationTime) {
this.userId = userId;
this.expirationTime = expirationTime;
}
public boolean isExpired() {
return LocalDateTime.now().isAfter(expirationTime);
}
public String getUserId() {
return userId;
}
}
}實作:樂觀鎖#
更新 ScreeningManager,在訂票最後一刻才檢查座位狀態,無需持久鎖。synchronized 確保檢查與訂票原子執行。
// Simplified optimistic locking in ScreeningManager
public synchronized Ticket bookSeatOptimistically(Screening screening, Seat seat) {
// First check if a seat is available (optimistic)
if (isSeatBooked(screening, seat)) {
throw new IllegalStateException("Seat is already booked");
}
// Create ticket - at this point, we're optimistically assuming
// the seat is still available
BigDecimal price = seat.getPricingStrategy().getPrice();
Ticket ticket = new Ticket(screening, seat, price);
// Add to booking system - this effectively "reserves" the seat
ticketsByScreening.computeIfAbsent(screening, k -> new ArrayList<>()).add(ticket);
return ticket;
}
// Helper method to check if a seat is already booked
private boolean isSeatBooked(Screening screening, Seat seat) {
List<Ticket> tickets = getTicketsForScreening(screening);
return tickets.stream().anyMatch(ticket -> ticket.getSeat().equals(seat));
}面試技巧:討論並行能展現你處理進階情境的能力。熱門場次競爭高時偏好悲觀鎖,簡單情境則偏好樂觀鎖。記得先詢問面試官的期待。
本章小結#
主要收穫是模組化與遵循單一職責原則。Movie、ScreeningManager、Seat、Order 等元件各司其職,使系統易維護、易擴展。
設計選擇如分離 Movie 與 Screening、用 strategy pattern 處理票價,皆強調彈性與可擴展性。替代方案(如合併 Screening 與 Ticket)雖能簡化模型,但會讓座位管理變複雜。在面試中能反思並解釋這些決策,正是展現批判思維的好機會。