Laravel Controller 設計心得
今天跟朋友討論依賴注入,剛好有聊到一點點 Controller 設計,想想這是個不錯的主題!
以下參考 Laravel Framework v8.12.3 與 Laravel v8.4.0 撰寫
官方的預設
Laravel 預設的 Controller 是長像下面這樣:
namespace App\Http\Controllers; |
通常其他 Controller 會繼承這個基礎 Controller 延伸下去實作。
其中三個 trait 就不看了,因為它們只是方便在 Controller 呼叫對應 trait 提供的方法,而不是必須的,BaseController 才是要關注的。
實作主要是在 middleware 的功能,也就是可以在同個 controller 使用相同的 middleware:
class FooController extends BaseController |
官方 Foundation 裡的 AuthorizesRequests::authorizeResource() 裡,有呼叫到 middleware() 方法。在單元測試也可以看到實際用法,是在建構時呼叫:
public function __construct() |
但實際上 Laravel 不一定要繼承 BaseController 類別,才能設定 Route,一般的 class 就能設定了,比方說:
class FooController |
Route 對應的寫法如下:
// 下面兩個寫法都可以 |
這裡可以看到,如果使用 Magic Method __invoke()
的話,Route 設定可以不輸入 @method
參數。
我的設計
以前 Zend Framework 1 Route 調整不容易,通常會嚴格遵守 /:controller/:action
的格式。一開始在寫 Laravel 的時候,會習慣把類似功能的 Action 放在同個 class 裡。但自從發現 Laravel 有這樣的特性後,我現在設計 Controller 會以共用 middleware 為主(通常很少,或直接在 Route 設定解決);若沒共用 middleware 的話,則會採用一個 class 配一個 action 的設計,命名習慣也會以 -Action 結尾來命名。
Route 的設定就會長得像下面這樣:
Route::get('/login', 'LoginPageAction'); |
這樣做最重要的理由是:一個 class 只做一個 action,這符合單一職責原則。
一個 class 做多個 action 最常遇到下面這個問題:
class FooController |
上面這段程式碼最難搞的地方,在於 bar()
和 baz()
同時有兩個相同的依賴:
- 建構子的
Dep
類別的建構方法 - 共用了私有方法
common()
第一點還算好解,可以改成依賴注入即可;第二點我是覺得很煩人,用到抽取共用方法,有一種可能是它有一定的複雜度,因此想把細節移出。但因為有兩個方法共用了它,因此就容易出現改 A 壞 B;更可怕的事是,如果 common 依賴了 Request 或是 Session 等 context 類的物件,那就更難改動了,而且也很難寫測試。
因此我目前是要求團隊在設計 Controller 要遵守以下規則:
- Controller 必須要使用方法注入,禁止使用建構子注入
- Controller 所有方法都必須被 Route 設定到(也就是不準出現 private / protected 方法)
- 若想打破以上規則,請把該方法抽出成獨立的 Action
這些規則很單純,執行起來也非常很有效。改寫 Action 真的能提高維護性,也能無形間促進模組化行為:因為不能採共用方法,只能改採抽取 class。
似曾相識的設計模式
Route 只會對到一個 class,這與 Command Pattern 非常相似。我曾經在公司內部分享 Symfony Console 時,給它下面這些註解:
Controller 能做的事 Symfony Console 都能做
換句話說,Command 其實是另一種形式的 Controller。而 Command Pattern 會有一個介面(interface)需要實作執行指令。以 Symfony Console v5.1.8 來說,它提供的介面是這個:
protected function execute(InputInterface $input, OutputInterface $output) |
它是使用 override 來達成約定實作的效果,與介面類似。
回到 Controller,Route 只對到一個 class 的好處,就會跟 Command Pattern 一樣,即符合單一職責原則,上面有提過了;相對的缺點就是 class 不斷增加,而在設定 Route 的時候,必須指定特定 class,才能運作正常,而無法用抽象(abstract)或介面來隱藏細節,因此開發者必須要清楚每個 class 的功能。
實務上,上面的缺點是可以用目錄結構來管理 class,增加可維護性。