從零到可上線:打造一個企業級的 Laravel 模組化停車管理系統
嗨,各位 Laravel 的老手與架構師們!今天想跟大家分享一個我最近打造的專案骨架:一個基於 Laravel 11 的模組化停車場管理系統 (Parking Management System)。這個專案不僅僅是 CRUD 範例,它更是一個考慮了高並發、可擴展性與可維護性的「生產就緒 (Production-ready)」專案模板。
如果你厭倦了傳統的巨石型 (Monolithic) MVC 架構,想知道如何將 Laravel 專案拆分成獨立的業務模組,並應用各種設計模式來解決現實世界的挑戰,那麼這篇文章就是為你準備的。
為什麼選擇模組化架構?傳統 MVC 的痛點
在 Laravel 中,我們習慣將所有的邏輯都放在 app/Http/Controllers
、app/Models
和 app/Services
中。對於小型專案這很方便,但當業務邏輯變得複雜時,會出現以下問題:
- 職責不清:一個控制器可能同時處理用戶、停車、計費等多種業務,程式碼耦合度高。
- 難以維護:專案規模變大,檔案暴增,找程式碼就像大海撈針。
- 團隊協作困難:多個開發者可能同時修改同一個 Controller 或 Service 檔案,容易產生衝突。
為了解決這些痛點,我決定採用模組化架構。這個專案將系統拆分為三個核心模組:
- Parking 模組:專注於車輛進出、車位分配。
- Billing 模組:專門處理停車費用的計算邏輯。
- User 模組:負責用戶和角色的權限管理。
這種架構符合 SOLID 原則中的「單一職責原則」和「開閉原則」,讓每個模組只專注於自己的業務,大幅提升了程式碼的內聚性 (Cohesion) 和可維護性。
你可以在專案的
app/Modules
目錄下看到這三個模組,每個模組都有自己的Models
,Services
,Providers
等子目錄,形成一個獨立的、可插拔的單元。
核心設計模式:高並發與可擴展性的基石
一個好的架構,必須能應對真實世界的挑戰。在這個專案中,我特別針對兩個關鍵問題進行了設計:高並發的車位分配和靈活的費率計算。
1. 悲觀鎖與資料庫事務:如何確保車位「不超賣」?
在停車場這種高並發場景下,如果兩輛車幾乎同時入場,系統該如何確保它們不會被分配到同一個車位?
傳統的檢查方式會產生「Race Condition」。為了解決這個問題,我在 ParkingService
中使用了悲觀鎖 (Pessimistic Locking),確保高流量下不出錯。
以下是 ParkingService.php
中的核心程式碼片段:
public function recordVehicleEntry(string $licensePlate, int $parkingLotId): EntryExitRecord
{
// 用資料庫事務確保操作原子性
return DB::transaction(function () use ($licensePlate, $parkingLotId) {
// 檢查或創建車輛記錄,確保車輛存在
$vehicle = Vehicle::firstOrCreate([
'license_plate_number' => $licensePlate,
'parking_lot_id' => $parkingLotId
]);
// 查可用車位,用悲觀鎖避免並發衝突
$availableSpace = ParkingSpace::where('parking_lot_id', $parkingLotId)
->where('status', 'available')
->lockForUpdate() // <--- 悲觀鎖!
->first();
// 沒車位就丟異常
if (!$availableSpace) {
throw new NoAvailableSpaceException("No available spaces found in parking lot ID: {$parkingLotId}");
}
// 更新車位狀態為已占用
$availableSpace->update(['status' => 'occupied']);
// 創建進場記錄
$record = EntryExitRecord::create([
'parking_lot_id' => $parkingLotId,
'vehicle_id' => $vehicle->id,
'parking_space_id' => $availableSpace->id,
'entry_time' => now(),
]);
// 觸發進場事件,給異步處理(像更新看板)
event(new \App\Modules\Parking\Events\VehicleEntered($record));
return $record;
});
}
```lockForUpdate()` 會在 SQL 查詢中加上 `FOR UPDATE` 子句,當這筆資料被查詢時,會加上一個排他鎖,直到事務結束。這能確保在事務提交前,沒有其他請求能修改或鎖定這筆資料,完美解決了並發問題。同時,`DB::transaction` 確保車位更新和記錄創建是**原子性**的。
**2. 策略模式 (Strategy Pattern):讓費率計算更靈活**
停車場的計費方式千變萬化,從簡單的時租、日租上限到複雜的階梯費率。如果用一堆 `if/else` 或 `switch` 判斷,程式碼將變得難以維護。
為此,我在 `Billing` 模組中導入了**策略模式**。我定義了一個介面 `RateCalculationStrategyInterface`。然後,我為每種計費方式實現一個具體的策略類別,例如 `HourlyRateStrategy`。`FeeCalculationService` 只負責根據 `RatePlan` 的類型來「選擇」正確的策略,而無需知道具體的計算細節。
這種設計讓系統**極具擴展性**。未來想增加日租、夜間優惠或月租費率,只需新增一個策略類別,並在 `FeeCalculationService` 中新增一個 `case`,**無需修改任何現有策略的程式碼**。這符合開閉原則。
#### **非同步處理:事件驅動架構**
在車輛出場時,我們需要執行多個任務:計算費用、更新看板上的車位資訊、記錄日誌等。如果這些操作都同步執行,API 回應時間會變長。
為了解決這個問題,我使用了 Laravel 的**事件驅動架構**。車輛進出觸發事件,並將異步處理(像更新看板或費用計算)放進佇列,這樣可以提升效率且能即時處理。當車輛出場時,`ParkingService` 會觸發一個 `VehicleExited` 事件。
```php
// In ParkingService.php
// 觸發進場事件,給異步處理(像更新看板)
event(new \App\Modules\Parking\Events\VehicleEntered($record));
然後,EventServiceProvider
會將這個事件分發給多個異步監聽器,例如 CalculateFeeListener
和 UpdateDashboard
。
CalculateFeeListener
:在後台異步計算費用並生成費用記錄。UpdateDashboard
:異步更新前端看板上的車位數量,並釋放被佔用的車位。
這些監聽器都實現了 ShouldQueue
介面,可以將任務推送到 Redis 佇列中,由佇列工作者 (Queue Worker) 在後台處理。這樣一來,parking/exit
API 可以在費用計算完成前就立即回應,大幅提升了使用者體驗。
完善的異常處理與 API 回應
為了讓前端和外部系統能夠輕鬆整合,API 錯誤的回應格式必須統一。
- 我自定義了
NoAvailableSpaceException
和RecordNotFoundException
等業務異常。 - 在
app/Exceptions/Handler.php
中,我統一捕獲這些自定義異常和 Laravel 內建的驗證異常,並回傳格式一致的 JSON 回應。 - 專案也搭配了統一的 API 回應,方便前端整合。
自訂異常讓錯誤原因一目了然。在 Handler.php
中統一處理,將業務邏輯和錯誤處理分開,控制器就不用寫一堆 try-catch
。
API 端點與資料庫結構
API 路由都位於 /api/v1
前綴下。並且需要認證,使用 Sanctum 中間件。
- 車輛進場:
POST /api/v1/parking/entry
。 - 車輛出場:
POST /api/v1/parking/exit
。
核心資料表包括:parking_lots
、parking_spaces
、vehicles
、entry_exit_records
、rate_plans
、users
和 roles
。其中 rate_plans
的規則是使用 JSON 格式儲存。entry_exit_records
的 exit_time
和 total_fee
欄位設為可空,這是因為車輛進場時,這兩個欄位還沒有值。
總結
這個專案骨架是一個高起點的起手式,它包含了所有核心業務邏輯、模組化架構和設計模式。它能作為一個接近生產環境的專案模板。
希望這篇文章能幫助你在下一個專案中,打造出更具彈性和可維護性的 Laravel 應用程式!
沒有留言:
張貼留言