Laravel Controller 設計心得

今天跟朋友討論依賴注入,剛好有聊到一點點 Controller 設計,想想這是個不錯的主題!

以下參考 Laravel Framework v8.12.3Laravel v8.4.0 撰寫

官方的預設

Laravel 預設的 Controller 是長像下面這樣:

namespace App\Http\Controllers;

use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;

class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
}

通常其他 Controller 會繼承這個基礎 Controller 延伸下去實作。

其中三個 trait 就不看了,因為它們只是方便在 Controller 呼叫對應 trait 提供的方法,而不是必須的,BaseController 才是要關注的。

實作主要是在 middleware 的功能,也就是可以在同個 controller 使用相同的 middleware:

class FooController extends BaseController
{
public function __construct()
{
$this->middleware([
SomeMiddleware::class
]);
}
}

官方 Foundation 裡的 AuthorizesRequests::authorizeResource() 裡,有呼叫到 middleware() 方法。在單元測試也可以看到實際用法,是在建構時呼叫:

public function __construct()
{
$this->authorizeResource('App\User', 'user');
}

但實際上 Laravel 不一定要繼承 BaseController 類別,才能設定 Route,一般的 class 就能設定了,比方說:

class FooController
{
public function __invoke()
{
}

public function something()
{
}
}

Route 對應的寫法如下:

// 下面兩個寫法都可以
Route::get('/foo', 'FooController');
// Route::get('/foo', 'FooController@__invoke');

Route::get('/foo/something', 'FooController@something');

這裡可以看到,如果使用 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');
Route::post('/login', 'LoginAction');

這樣做最重要的理由是:一個 class 只做一個 action,這符合單一職責原則

一個 class 做多個 action 最常遇到下面這個問題:

class FooController
{
private $dep;

public function __construct()
{
$this->dep = new Dep();
}

public function bar()
{
$this->common();
$this->dep->bar();
}

public function baz()
{
$this->common();
$this->dep->baz();
}

private function common()
{
// do something common
}
}

上面這段程式碼最難搞的地方,在於 bar()baz() 同時有兩個相同的依賴:

  1. 建構子的 Dep 類別的建構方法
  2. 共用了私有方法 common()

第一點還算好解,可以改成依賴注入即可;第二點我是覺得很煩人,用到抽取共用方法,有一種可能是它有一定的複雜度,因此想把細節移出。但因為有兩個方法共用了它,因此就容易出現改 A 壞 B;更可怕的事是,如果 common 依賴了 Request 或是 Session 等 context 類的物件,那就更難改動了,而且也很難寫測試。

因此我目前是要求團隊在設計 Controller 要遵守以下規則:

  1. Controller 必須要使用方法注入,禁止使用建構子注入
  2. Controller 所有方法都必須被 Route 設定到(也就是不準出現 private / protected 方法)
  3. 若想打破以上規則,請把該方法抽出成獨立的 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)
{
throw new LogicException('You must override the execute() method in the concrete command class.');
}

它是使用 override 來達成約定實作的效果,與介面類似。

回到 Controller,Route 只對到一個 class 的好處,就會跟 Command Pattern 一樣,即符合單一職責原則,上面有提過了;相對的缺點就是 class 不斷增加,而在設定 Route 的時候,必須指定特定 class,才能運作正常,而無法用抽象(abstract)或介面來隱藏細節,因此開發者必須要清楚每個 class 的功能。

實務上,上面的缺點是可以用目錄結構來管理 class,增加可維護性。