# Laravel Beyond CRUD 中文翻譯 ## 推薦閱讀 * [DDD系列第五讲:聊聊如何避免写流水账代码](https://juejin.cn/post/6953141151931039758) ## 概要 * 關於作者 * 位於比利時的軟體公司,專注於設計符合 `SOLID` 原則的 Laravel 網站及應用程式 * 推出了多個眾所周知的 Laravel Package,例如 `laravel-permission`、`Laravel-Excel` * 關於教學 * 在 Laravel 專案中實踐 領域驅動設計 (Domain-Driven Design) 及 六邊形架構 (Hexagonal architectures) * 區分 領域 及 應用程式 程式碼 * 可以使 Eloquent Model 保持簡潔及輕量 ## 以領域為導向的 Laravel * 人類以類別的角度思考,我們的程式碼也應如此 * 如何定義「領域」? * 簡而言之:特定的活動或知識領域 (knowledge) * 領域描述了一系列應當被解決的業務問題 * 可能被稱為 模組 (module)、服務 (service) 或是 群組 (groups),但都指向前兩點所描述的 * 舉例:一個 Hotel Booking 應用程式,包含 顧客管理、預約管理、發票開立 及 庫存管理 * 以領域為導向的專案架構 * 重要的是,依照「領域概念」而非「技術概念」進行分類 * 「領域」可能包含了 Model, Query Builder, Validation Rules, ... etc * 「應用程式」可以使用所有「領域」,但通常應用程式間不會互相通信 * 「領域」和「應用程式」部分的專案架構 ``` { // … "autoload" : { "psr-4" : { "App\\" : "app/App/", "Domain\\" : "app/Domain/", "Support\\" : "app/Support/" } } } ``` ``` One specific domain folder per business concept app/Domain/Invoices/ ├── Actions ├── QueryBuilders ├── Collections ├── DataTransferObjects ├── Events ├── Exceptions ├── Listeners ├── Models ├── Rules └── States app/Domain/Customers/ // … ``` ``` The admin HTTP application app/App/Admin/ ├── Controllers ├── Middlewares ├── Requests ├── Resources └── ViewModels The REST API application app/App/Api/ ├── Controllers ├── Middlewares ├── Requests └── Resources The console application app/App/Console/ └── Commands ``` ## 建立資料傳輸物件 (Data Transfer Object) * 重點:以結構化、可預期、安全的方式處理 Input Data * 因 PHP 屬於弱型別,開發人員無法在撰寫程式碼時預期資料型別 (只能在 Runtime 確定) * 承上論述,對於陣列更是如此,無法預期鍵值是否存在?鍵值型別、內容是否如預期 * (不過 PHP 7.4 之後提供了 `Typed properties`,可以稍微改善此情況) * 範例:更新客戶資料的 Controller Method ``` function store(CustomerRequest $request, Customer $customer) { $validated = $request->validated(); $customer->name = $validated['name']; $customer->email = $validated['email']; // … } ``` * 要怎麼知道 `$validated` 裡面包了些什麼?: * 看看 code 吧 (程式碼不會說謊) * 閱讀文件 (如果有) * `var_dump($validated)` 或 `dd($validated)` * 用 debugger 來 inspect (XDebug ?) * 五個月後,重新 review 程式碼時可能就忘記當初的用意了,因此本章節建議如下 ``` class CustomerData { public string $name; public string $email; public Carbon $birth_date; } ``` ``` function store(CustomerRequest $request, Customer $customer) { $validated = CustomerData::fromRequest($request); $customer->name = $validated->name; $customer->email = $validated->email; $customer->birth_date = $validated->birth_date; // … } ``` * 這種模式特性如下: * 透過非結構化數據包裝在類別中,讓我們能夠以可靠的方式使用我們的數據,稱為“數據傳輸對象” (Data Transfrer Object) * IDE 就能透過靜態分析在我們寫作時給予協助 * 使用 DTO 是有代價的,但長期來看值得這麼做 * 那麼,如何將 Request 中的 Input 傳遞給 DTO? * 使用專用工廠 (相對乾淨,但在當前 PHP 版本不支援 `named arguments` 的前提下,不建議這麼做) * 建立 `CustomerData::fromRequest` 靜態方法,使其能夠傳入 Request 來建構 DTO ``` use Spatie\DataTransferObject\DataTransferObject; class CustomerData extends DataTransferObject { // … public static function fromRequest( CustomerRequest $request ): self { return new self([ 'name' => $request->get('name'), 'email' => $request->get('email'), 'birth_date' => Carbon::make( $request->get('birth_date') ), ]); } } ``` * 譯者註:在其他專案中,我們見到了類似的作法 * 在 Controller 中產生 DTO (其稱為 Payload),再透過 setters 注入資料 * 產生完 DTO 後轉交給 服務 (service) 處理,藉此將 reuqest 與 service 解構 * 為什麼 Controller 要知道這些事情?是否違反了`單一職責原則`? ## 建立行動 (Action) * 行動 (Action) 定義如下: * 行動 (Action) 住在各自的領域中,而非應用程式 * 它們不鳥抽象或是接口,就是一個簡單的類別 * 通常只有一個方法 (method),接收輸入、做某事、最後吐出輸出 * 如果有需要,還會有自己的建構元 (constructor) * 兩種呼叫動作的方法:使用 `__invoke` 或統一定義 `execute`、`handle` ... * 帶來的好處: * 行動 允許開發人員以更貼近現實的方式進行思考,而非單純技術層面 * 行動 被分割成足夠小的部分,使其可以被複用 * 減少了開發人員的認知負荷,可以明確地知道該從 行動 類別開始 * 行動 幾乎是獨立存在的少許程式碼,因此很容易進行單元測試,也易於斷言 * 若以傳統方式開發,邏輯會分散在控制器、監聽器或模型中,按照技術屬性而非領域來進行分組 * 一個簡單的 User Story: * 管理員建立了一張發票 * 計算發票中每個項目的價格及總金額 * 將發票儲存到資料庫中 * 透過 Payment Provider 發起支付金流 * 建立一個包含了發票資訊的 PDF * 將 PDF 發送給使用者 * 付諸實踐 * 目標:計算發票中每個項目的價格及總金額,同時必須考慮營業稅 * 行動名稱:`CreateInvoiceAction` * 發現 `CreateInvoiceAction` 應專注於計算總額,因此新增 `CreateInvoiceLineAction` 專門計算發票中的細項 * 又注意到`CreateInvoiceLineAction` 可能不關心計算稅額,因此轉交給 `VatCalculator` * 注意到了嗎?我們將多個行動或外部類別進行組合,以達到依賴注入及控制反轉的效果,在保持單個行動簡潔的前提下,組合出複雜的業務功能 ``` class CreateInvoiceAction { private $createInvoiceLineAction; public function __construct( CreateInvoiceLineAction $createInvoiceLineAction ) { /* … */ } public function __invoke(InvoiceData $invoiceData): Invoice { foreach ($invoiceData->lines as $lineData) { $invoice->addLine( ($this->createInvoiceLineAction)($lineData) ); } } } ``` ``` // CreateInvoiceLineAction 將會被注入到 CreateInvoiceAction // 而這個也可具有其他依賴項,例如 CreatePdfActionand 或是 SendMailAction class CreateInvoiceLineAction { private $vatCalculator; public function __construct(VatCalculator $vatCalculator) { $this->vatCalculator = $vatCalculator; } public function execute( InvoiceLineData $invoiceLineData ): InvoiceLine { $item = $invoiceLineData->item; if ($item->vatIncluded()) { [$priceIncVat, $priceExclVat] = $this->vatCalculator->vatIncluded( $item->getPrice(), $item->getVatPercentage() ); } else { [$priceIncVat, $priceExclVat] = $this->vatCalculator->vatExcluded( $item->getPrice(), $item->getVatPercentage() ); } $amount = $invoiceLineData->item_amount; $invoiceLine = new InvoiceLine([ 'item_price' => $item->getPrice(), 'total_price' => $amount * $priceIncVat, 'total_price_excluding_vat' => $amount * $priceExclVat, ]); } } ``` ## Eloquent 模型 * 只保留 `getter`、`setter`、`accessor`、`mutator`、`cast`、`relation` * 模型 不等於 業務邏輯 * attribute 只應該讀取持久化儲存中的資料,而非進行任何計算 * `$invoice->send()` 或 `$invoice->toPdf()` 聽起來很酷,但長期會是災難 * 具有數百行代碼的模型類別無法保持可維護性 * 將模型及其目的視為僅為您提供數據,讓其他事情來確保正確計算數據。 ### 擁抱 Laravel * 擁抱框架的意思:你不需要引入新的模式,你可以在 Laravel 提供的基礎上進行構建 #### Query Builder * Eloquent Model 提供了局部作用域,讓你可以把約束條件封裝到模型中 * 我們在使用框架提供的機制並成功地避免程式碼在特定地方增長過大 ``` namespace Domain\Invoices\Models; use Domain\Invoices\States\Paid; use Illuminate\Database\Eloquent\Model; class Invoice extends Model { public function scopePaid($query) { return $query->where('status', Paid::class); } } ``` * 我們使用 Laravel 提供的 Query Builder 來完成擴展,同時使得模型保持輕量 ``` namespace Domain\Invoices\QueryBuilders; use Domain\Invoices\States\Paid; use Illuminate\Database\Eloquent\Builder; class InvoiceQueryBuilder extends Builder { public function wherePaid(): self { return $this->whereState('status', Paid::class); } } ``` ``` namespace Domain\Invoices\Models; use Domain\Invoices\QueryBuilders\InvoiceQueryBuilder; class Invoice extends Model { public function newEloquentBuilder($query): InvoiceQueryBuilder { return new InvoiceQueryBuilder($query); } } ``` #### Collection * 將商業邏輯放到自定義 Collection 中,並提供一些方法來方便使用 * 注意:我們複寫了 Eloquent 模型的 newCollection 方法,以便返回我們自定義的集合類 ``` namespace Domain\Invoices\Collections; use Domain\Invoices\Models\InvoiceLines; use Illuminate\Database\Eloquent\Collection; class InvoiceLineCollection extends Collection { public function creditLines(): self { return $this->filter(function (InvoiceLine $invoiceLine) { return $invoiceLine->isCreditLine(); }); } } ``` ``` namespace Domain\Invoices\Models; use Domain\Invoices\Collection\InvoiceLineCollection; class InvoiceLine extends Model { public function newCollection(array $models = []): InvoiceLineCollection { return new InvoiceLineCollection($models); } public function isCreditLine(): bool { return $this->price < 0.0; } } ``` * 現在,我們可以用乾淨簡潔的方式來處理關聯,而不是讓模型提供商業邏輯 ``` $invoice ->invoiceLines ->creditLines() ->map(function (InvoiceLine $invoiceLine) { // … }); ``` ## 狀態模式 * 假設發票的狀態可以對應到不同的顏色 * 待處理 => 橙色 * 已付款 => 綠色 * 如果我們讓 Eloquent 模型承擔了太多職責,看起來會像這樣 ``` class Invoice extends Model { // … public function getStateColour(): string { if ($this->state->equals(InvoiceState::PENDING())) { return 'orange'; } if ($this->state->equals(InvoiceState::PAID())) { return 'green'; } return 'gray'; } } ``` * 由於 `$this->state` 是某種 enum 類別,我們可以做些改進 ``` class Invoice extends Model { // … public function getStateColour(): string { return $this->state->getColour(); } } ``` ``` /** * @method static self PENDING() * @method static self PAID() */ class InvoiceState extends Enum { private const PENDING = 'pending'; private const PAID = 'paid'; public function getColour(): string { if ($this->value === self::PENDING) { return 'orange'; } if ($this->value === self::PAID) { return 'green' } return 'gray'; } } ``` * 無論如何整理程式碼,上述的邏輯本質上就是 *一個巨大的 if-else 語句* * 在這裡,我們試著將 *每個狀態都用一個單獨的類別來表示* * 首先,我們建立抽象類別 `InvoiceState` 並期望它可以為每個狀態提供一種顏色 ``` abstract class InvoiceState { abstract public function colour(): string; } ``` * 接著,基於剛才的抽象類別建立所需的類別及對應的單元測試 ``` class PendingInvoiceState extends InvoiceState { public function colour(): string { return 'orange'; } } ``` ``` class PaidInvoiceState extends InvoiceState { public function colour(): string { return 'green'; } } ``` ``` class InvoiceStateTest extends TestCase { /** @test */ public function the_colour_of_pending_is_orange { $state = new PendingInvoiceState(); $this->assertEquals('orange', $state->colour()); } } ``` * 我們希望在此之上多提供一些功能,比如:*這張發票需要被支付嗎?* * 為了這個需求,我們需要在最初的抽象類別以及派生類別進行一些小調整 * 同時,我們注意到它沒有辦法取得 Eloquent Model,所以 ... ``` abstract class InvoiceState { /** @var Invoice */ protected $invoice; public function __construct(Invoice $invoice) { /* … */ } abstract public function mustBePaid(): bool; // … } ``` ``` class PendingInvoiceState extends InvoiceState { public function mustBePaid(): bool { return $this->invoice->total_price > 0 && $this->invoice->type->equals(InvoiceType::DEBIT()); } // … } ``` ``` class PaidInvoiceState extends InvoiceState { public function mustBePaid(): bool { return false; } // … } ``` * 完成後,我們準備著手調整發票模型的 `accessor` 及 `getter` ``` class Invoice extends Model { public function getStateAttribute(): InvoiceState { return new $this->state_class($this); } public function mustBePaid(): bool { return $this->state->mustBePaid(); } } ``` * 如此一來,我們就將發票狀態及發票模型解構了,同時也符合開放封閉原則 * 同樣的想法也能運用於狀態轉換,同時不影響到我們剛才加入的程式碼 ``` class PendingToPaidTransition { public function __invoke(Invoice $invoice): Invoice { if (! $invoice->mustBePaid()) { throw new InvalidTransitionException(self::class, $invoice); } $invoice->status_class = PaidInvoiceState::class; $invoice->save(); History::log($invoice, "Pending to Paid"); } } ``` * 運用這樣的模式,你可以: * 定義模型上所有允許的轉換 * 直接使用狀態轉換類,而非手動操作屬性 * 根據參數自動決定要轉換到哪個狀態 * 簡單的類別、單純的動作,易於測試 ## 管理領域 * 重點: * 你該如何開始使用領域? * 如何辨識領域? * 如何 *長期* 管理領域? * 團隊合作 * 幫助開發人員團隊多年來保持大型 Laravel 應用程式的可維護性 * 魔法和間接性越少,混淆的空間就越小 * 讓大型代碼庫更易於導航,盡可能減少混亂的空間,並使項目長時間保持健康 * 辨識領域 * 開發人員的主要目標也是了解業務問題並將其轉化為代碼 * 代碼本身只是達到目的的一種手段,始終將注意力集中在正在解決的問題上 * 如果你要從事一個長期運行的項目,您不僅需要編寫代碼 - 您還需要了解您試圖解決的實際問題 * 不要害怕開始使用領域,因為您可以隨時重構它們 ## 從 領域 進入 應用程式 * 面向領域的 Laravel * 根據程式碼在現實世界中的相似性,而不是目的,對其進行分組 * 領域 和 應用程式 代碼是兩個獨立的東西 * 預設的 Laravel 專案架構如下: ``` App/Admin ├── Http │ ├── Controllers │ ├── Kernel.php │ └── Middleware ├── Requests ├── Resources ├── Rules └── ViewModels ``` * 這種結構在小型項目中很好,但老實說它不能很好地擴展 * 最好將類別們依照實際涵義而非技術屬性進行分組 ``` Admin └── Invoices ├── Controllers │ ├── IgnoreMissedInvoicesController.php │ ├── InvoiceStatusController.php │ ├── InvoicesController.php │ ├── MissedInvoicesController.php │ └── RefreshMissedInvoicesController.php ├── Filters │ ├── InvoiceMonthFilter.php │ ├── InvoiceOfferFilter.php │ ├── InvoiceStatusFilter.php │ └── InvoiceYearFilter.php ├── Middleware │ ├── EnsureValidHabitantInvoiceCollectionSettingsMiddleware.php │ ├── EnsureValidInvoiceDraftSettingsMiddleware.php │ └── EnsureValidOwnerInvoiceCollectionSettingsMiddleware.php ├── Queries │ ├── InvoiceCollectionIndexQuery.php │ └── InvoiceIndexQuery.php ├── Requests │ └── InvoiceRequest.php ├── Resources │ ├── InvoiceCollectionDataResource.php │ ├── InvoiceCollectionResource.php │ ├── InvoiceDataResource.php │ ├── InvoiceDraftResource.php │ ├── InvoiceIndexResource.php │ ├── InvoiceLabelResource.php │ ├── InvoiceLineDataResource.php │ ├── InvoiceLineResource.php │ ├── InvoiceMainOverviewResource.php │ ├── InvoiceResource.php │ └── InvoiceeResource.php └── ViewModels ├── InvoiceCollectionHabitantContractPreviewViewModel.php ├── InvoiceCollectionOwnerContractPreviewViewModel.php ├── InvoiceCollectionPreviewViewModel.php ├── InvoiceDraftViewModel.php ├── InvoiceIndexViewModel.php ├── InvoiceLabelsViewModel.php └── InvoiceStatusViewModel.php ```