如何使用 Presenter 模式 ?
使用 Presenter 輔助 View
若將顯示邏輯都寫在 view,會造成 view 肥大而難以維護,基於 SOLID 原則,我們應該使用 Presenter 模式輔助 view,將相關的顯示邏輯封裝在不同的 presenter,方便中大型專案的維護。
Version
Laravel 5.1.22
顯示邏輯
顯示邏輯中,常見的如 :
- 將資料顯示不同資料 : 如
性別欄位為M,就顯示Mr.,若性別欄位為F,就顯示Mrs.
。 - 是否顯示某些資料 : 如
根據欄位值是否為Y,要不要顯示該欄位
。 - 依需求顯示不同格式 : 如
依照不同的語系,顯示不同的日期格式
。
Presenter
將資料顯示不同資料
如性別欄位為M,就顯示Mr.,若性別欄位為F,就顯示Mrs.
,初學者常會直接用 blade 寫在 view。
在中大型專案,會有幾個問題 :
- 由於 blade 與 HTML 夾雜,不太適合寫太複雜的程式,只適合做一些簡單的 binding,否則很容易流於傳統 PHP 的義大利麵程式。
- 無法對顯示邏輯做重構與物件導向。
比較好的方式是使用 presenter :
- 將相依物件注入到 presenter。
- 在 presenter 內寫格式轉換。
- 將 presenter 注入到 view。
UserPresenter.php
app/Presenters/UserPresenter.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | namespace App\Presenters; class UserPresenter { /** * 性別欄位為M,就顯示Mr.,若性別欄位為F,就顯示Mrs. * @param string $gender * @param string $name * @return string */ public function getFullName($gender, $name) { if ($gender == 'M') $fullName = 'Mr. ' . $name; else $fullName = 'Mrs. ' . $name; return $fullName; } } |
將原本在 blade 用 @if...@else...@endif
寫的邏輯,改寫在 presenter。
使用 @inject()
注入 UserPresenter
,讓 view 也可以如 controller 一樣使用注入的物件。
將來無論顯示邏輯怎麼修改,都不用改到 blade,直接在 presenter 內修改。
改用這種寫法,有幾個優點 :
將資料顯示不同格式
的顯示邏輯改寫在 presenter,解決寫在 blade 不容易維護的問題。- 可對顯示邏輯做重構與物件導向。
是否顯示某些資料
如根據欄位值是否為Y,要不要顯示該欄位
,初學者常會直接用 blade 寫在 view。
在中大型專案,會有幾個問題 :
- 由於 blade 與 HTML 夾雜,不太適合寫太複雜的程式,只適合做一些簡單的 binding,否則很容易流於傳統 PHP 的義大利麵程式。
- 無法對顯示邏輯做重構與物件導向。
比較好的方式是使用 presenter :
- 將相依物件注入到 presenter。
- 在 presenter 內寫格式轉換。
- 將 presenter 注入到 view。
UserPresenter.php
app/Presenters/UserPresenter.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | namespace App\Presenters; use App\User; class UserPresenter { /** * 是否顯示email * @param User $user * @return string */ public function showEmail(User $user) { if ($user->show_email == 'Y') return '<h2>' . $user->email . '</h2>'; else return ''; } } |
將 @if()
的 boolean 判斷,封裝在 presenter 內。
改由 presenter 負責送出 HTML。
使用 @inject()
注入 UserPresenter
,讓 view 也可以如 controller 一樣使用注入的物件。
{!! !!!}
會保有原來 HTML 格式。
將來無論顯示邏輯怎麼修改,都不用改到 blade,直接在 presenter 內修改。
改用這種寫法,有幾個優點 :
是否顯示某些資料
的顯示邏輯改寫在 presenter,解決寫在 blade 不容易維護的問題。- 可對顯示邏輯做重構與物件導向。
依需求顯示不同格式
如依照不同的語系,顯示不同的日期格式
,初學者常會直接用 blade 寫在 view。11blade、mutator 與 presenter 的比較,詳細請參考如何依各種語言顯示不同日期格式?
在中大型專案,會有幾個問題 :
- 由於 blade 與 HTML 夾雜,不太適合寫太複雜的程式,只適合做一些簡單的 binding,否則很容易流於傳統 PHP 的義大利麵程式。
- 無法對顯示邏輯做重構與物件導向。
- 違反SOLID的開放封閉原則 : 若將來要支援新的語系,只能不斷地在 blade 新增
if...else
。22開放封閉原則 : 軟體中的類別、函式對於擴展是開放的,對於修改是封閉的。
比較好的方式是使用 presenter :
- 將相依物件注入到 presenter。
- 在 presenter 內寫不同的日期格式轉換邏輯。
- 將 presenter 注入到 view。
DateFormatPresenterInterface.php
app/Presenters/DateFormatPresenterInterface.php
1 2 3 4 5 6 7 8 9 10 11 12 13 | namespace App\Presenters; use Carbon\Carbon; interface DateFormatPresenterInterface { /** * 顯示日期格式 * @param Carbon $date * @return string */ public function showDateFormat(Carbon $date) : string; } |
定義了 showDateFormat()
,各語言必須在 showDateFormat()
使用 Carbon 的 format()
去轉換日期格式。
DateFormatPresenter_uk.php
app/Presenters/DateFormatPresenter_uk.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | namespace App\Presenters; use Carbon\Carbon; class DateFormatPresenter_uk implements DateFormatPresenterInterface { /** * 顯示日期格式 * @param Carbon $date * @return string */ public function showDateFormat(Carbon $date) : string { return $date->format('d M, Y'); } } |
DateFormatPresenter_uk
實現了 DateFormatPresenterInterface
,並將轉換成英國日期格式的 Carbon 的 format()
寫在 showDateFormat()
內。
DateFormatPresenter_tw.php
app/Presenters/DateFormatPresenter_tw.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | namespace App\Presenters; use Carbon\Carbon; class DateFormatPresenter_tw implements DateFormatPresenterInterface { /** * 顯示日期格式 * @param Carbon $date * @return string */ public function showDateFormat(Carbon $date) : string { return $date->format('Y/m/d'); } } |
DateFormatPresenter_tw
實現了 DateFormatPresenterInterface
,並將轉換成台灣日期格式的 Carbon 的 format()
寫在 showDateFormat()
內。
DateFormatPresenter_us.php
app/Presenters/DateFormatPresenter_us.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | namespace App\Presenters; use Carbon\Carbon; class DateFormatPresenter_us implements DateFormatPresenterInterface { /** * 顯示日期格式 * @param Carbon $date * @return string */ public function showDateFormat(Carbon $date) : string { return $date->format('M d, Y'); } } |
DateFormatPresenter_us
實現了 DateFormatPresenterInterface
,並將轉換成美國日期格式的 Carbon 的 format()
寫在 showDateFormat()
內。
Presenter 工廠
由於每個語言的日期格式都是一個 presenter 物件,那勢必遇到一個最基本的問題 : 我們必須根據不同的語言去 new 不同的 presenter 物件
,直覺我們可能會在 controller 去 new presenter。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | public function index(Request $request) { $users = $this->userRepository->getAgeLargerThan(10); $locale = $request['lang']; if ($locale === 'uk') { $presenter = new DateFormatPresenter_uk(); } elseif ($locale === 'tw') { $presenter = new DateFormatPresenter_tw(); } else { $presenter = new DateFormatPresenter_us(); } return view('users.index', compact('users')); } |
這種寫法雖然可行,但有幾個問題 :
- 違反 SOLID 的開放封閉原則 : 若將來有新的語言需求,只能不斷去修改
index()
,然後不斷的新增elseif
,就算改用switch
也是一樣。 - 違反 SOLID 的依賴反轉原則 : controller 直接根據語言去 new 相對應的 class,高層直接相依於低層,直接將實作寫死在程式中。33依賴反轉原則 : 高層不應該依賴於低層,兩者都應該要依賴抽象;抽象不要依賴細節,細節要依賴抽象。
- 無法單元測試 : 由於 presenter 直接 new 在 controller,因此要測試時,無法對 presenter 做 mock。
比較好的方式是使用 Factory Pattern。
DataFormatPresenterFactory.php
app/Presenters/DateFormatPresenterFactory.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | namespace App\Presenters; use Illuminate\Support\Facades\App; class DateFormatPresenterFactory { /** * @param string $locale */ public static function bind(string $locale) { App::bind(DateFormatPresenterInterface::class, 'MyBlog\Presenters\DateFormatPresenter_' . $locale); } } |
使用 Presenter Factory
的 create()
去取代 new 建立物件。
這裡當然可以在 create()
去寫 if...elseif
去建立 presenter 物件,不過這樣會違反 SOLID的開放封閉原則,比較好的方式是改用 App::bind()
,直接根據 $locale
去 binding 相對應的 class,這樣無論在怎麼新增語言與日期格式,controller 與 Presenter Factory 都不用做任何修改,完全符合開放封閉原則。
Controller
UserController.php
app/Http/Controllers/UserController.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | namespace App\Http\Controllers; use App\Http\Requests; use Illuminate\Http\Request; use Illuminate\Support\Facades\App; use MyBlog\Presenters\DateFormatPresenterFactory; use MyBlog\Repositories\UserRepository; class UserController extends Controller { /** @var UserRepository 注入的UserRepository */ protected $userRepository; /** * UserController constructor. * @param UserRepository $userRepository */ public function __construct(UserRepository $userRepository) { $this->userRepository = $userRepository; } /** * Display a listing of the resource. * @param Request $request * @param DateFormatPresenterFactory $dateFormatPresenterFactory * @return \Illuminate\Http\Response */ public function index(Request $request) { $users = $this->userRepository->getAgeLargerThan(10); $locale = ($request['lang']) ? $request['lang'] : 'us'; $dateFormatPresenterFactory::bind($locale); return view('users.index', compact('users')); } } |
11 行
1 2 3 4 5 6 7 8 9 10 11 | /** @var UserRepository 注入的UserRepository */ protected $userRepository; /** * UserController constructor. * @param UserRepository $userRepository */ public function __construct(UserRepository $userRepository) { $this->userRepository = $userRepository; } |
將相依的 UserRepository
注入到 UserController
。
23 行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | /** * Display a listing of the resource. * @param Request $request * @param DateFormatPresenterFactory $dateFormatPresenterFactory * @return \Illuminate\Http\Response */ public function index(Request $request) { $users = $this->userRepository->getAgeLargerThan(10); $locale = ($request['lang']) ? $request['lang'] : 'us'; $dateFormatPresenterFactory::bind($locale); return view('users.index', compact('users')); } |
使用 $dateFormatPresenterFactory::bind()
切換 App::bind()
的 presenter 物件,如此 controller 將開放封閉,將來有新的語言需求,也不用修改 controller。
我們可以發現改用 factory pattern 之後,controller 有了以下的優點 :
- 符合 SOLID 的開放封閉 原則: 若將來有新的語言需求,controller 完全不用做任何修改。
- 符合SOLID 的依賴反轉原則 : controller 不再直接相依於 presenter,而是改由 factory 去建立 presenter。
- 可以做單元測試 : 可直接對各 presenter 做單元測試,不需要跑驗收測試就可以測試顯示邏輯。
Blade
使用 @inject
注入 presenter,讓 view 也可以如 controller 一樣使用注入的物件。
使用 presenter 的 showDateFormat()
將日期轉成想要的格式。
改用這種寫法,有幾個優點 :
- 將
依需求顯示不同格式
的顯示邏輯改寫在 presenter,解決寫在 blade 不容易維護的問題。 - 可對顯示邏輯做重構與物件導向。
- 符合 SOLID 的開放封閉原則: 將來若有新的語言,對於擴展是開放的,只要新增 class 實踐
DateFormatPresenterInterface
即可;對於修改是封閉的,controller、factory interface、factory 與 view 都不用做任何修改。 - 不單只有 PHP 可以使用 service container,連 blade 也可以使用 service container,甚至搭配 service provider。
- 可單獨對 presenter 的顯示邏輯做單元測試。
View
若使用了 presenter 輔助 blade,再搭配 @inject()
注入到 view,view 就會非常乾淨,可專心處理將資料binding到HTML
的職責。
將來只有 layout 改變才會動到 blade,若是顯示邏輯改變都是修改 presenter。
Conclusion
- Presenter 使得顯示邏輯從 blade 中解放,不僅更容易維護、更容易擴展、更容易重複使用,且更容易測試。