Laravel框架中如何使用 Presenter 模式?
若将显示逻辑都写在view,会造成view肥大而难以维护,基于SOLID原则,我们应该使用Presenter模式辅助view,将相关的显示逻辑封装在不同的presenter,方便中大型项目的维护。
若将显示逻辑都写在view
,会造成view
肥大而难以维护,基于SOLID
原则,我们应该使用Presenter
模式辅助view
,将相关的显示逻辑封装在不同的presenter,方便中大型项目的维护。
显示逻辑中,常见的如:
- 将数据显示不同数据:如性别字段为
M
,就显示Mr.
,若性别字段为F
,就显示Mrs.
。 - 是否显示某些数据:如根据字段值是否为
Y
,要不要显示该字段。 - 依需求显示不同格式:如依照不同的语系,显示不同的日期格式。
Presenter
将数据显示不同数据
如性别字段为M
,就显示Mr.
,若性别字段为F
,就显示Mrs.
,初学者常会直接用blade
写在view
。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Users</title>
</head>
<body>
<div>
@foreach($users as $user)
<div>
<h2>@if('M' == $user->gender) {{'Mr.'}} @else {{'Mrs.'}} @endif {{$user->name}}</h2>
<h2>{{$user->email}}</h2>
</div>
@endforeach
</div>
</body>
</html>
在中大型项目,会有几个问题:
- 由于blade与HTML夹杂,不太适合写太复杂的程序,只适合做一些简单的binding。
- 无法对显示逻辑做重构与面向对象。
比较好的方式是使用presenter:
- 将相依物件注入到presenter。
- 在presenter内写格式转换。
- 将presenter注入到view。
例如:下面是app/Presenters/UserPresenter.php
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
。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Users</title>
</head>
<body>
<div>
@inject('userPresenter', 'MyBlog\Presenters\UserPresenter')
@foreach($users as $user)
<div>
<h2>{{$userPresenter->getFullName($user->gender, $user->name)}}</h2>
<h2>{{$user->email}}</h2>
</div>
@endforeach
</div>
</body>
</html>
使用@inject()
注入UserPresenter
,让view
也可以如controller
一样使用注入的物件。
将来无论显示逻辑怎么修改,都不用改到 blade
,直接在 presenter
内修改。
改用这种写法,有几个优点:
- 将数据显示不同格式的显示逻辑改写在
presenter
,解决写在blade
不容易维护的问题。 - 可对显示逻辑做重构与面向对象。
是否显示某些数据
如根据字段值是否为Y
,要不要显示该字段,初学者常会直接用blade
写在view
。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Users</title>
</head>
<body>
<div>
<?php $locale = App::getLocale(); ?>
@foreach($users as $user)
<div>
<h2>{{$user->name}}</h2>
<h2>{{$user->email}}</h2>
@if('uk' == $locale)
<h2>{{$user->created_at->format('d M, Y')}}</h2>
@elseif('tw' == $locale)
<h2>{{$user->created_at->format('Y/m/d')}}</h2>
@else
<h2>{{$user->created_at->format('M d, Y')}}</h2>
@endif
</div>
@endforeach
</div>
</body>
</html>
在中大型项目,会有几个问题:
- 由于blade与HTML夹杂,不太适合写太复杂的程序,只适合做一些简单的binding,否则很容易流于传统PHP的意大利面程序。
- 无法对显示逻辑做重构与面向对象。
- 违反
SOLID
的开放封闭原则:若将来要支持新的语系,只能不断地在blade
新增if…else
。(开放封闭原则:软件中的类别、函式对于扩展是开放的,对于修改是封闭的。)
比较好的方式是使用presenter
:
- 将相依物件注入到
presenter
。 - 在
presenter
内写不同的日期格式转换逻辑。 - 将
presenter
注入到view
。
例如,下面是app/Presenters/DateFormatPresenterInterface.php
:
namespace App\Presenters;
use Carbon\Carbon;
interface DateFormatPresenterInterface
{
/**
* 显示日期格式
* @param Carbon $date
* @return string
*/
public function showDateFormat(Carbon $date) : string;
}
定义了showDateFormat()
,各语言必须在showDateFormat()
使用Carbon
的format()
去转换日期格式。
app/Presenters/DateFormatPresenter_uk.php
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()
内。
app/Presenters/DateFormatPresenter_tw.php
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()
内。
app/Presenters/DateFormatPresenter_us.php
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
去newpresenter
。
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
,高层直接相依于低层,直接将实作写死在程序中。(依赖反转原则:高层不应该依赖于低层,两者都应该要依赖抽象;抽象不要依赖细节,细节要依赖抽象) - 无法单元测试:由于
presenter
直接new在controller
,因此要测试时,无法对presenter
做mock
。
比较好的方式是使用Factory Pattern。
app/Presenters/DateFormatPresenterFactory.php
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都不用做任何修改,完全符合开放封闭原则。
app/Http/Controllers/UserController.php
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'));
}
}
使用$dateFormatPresenterFactory::bind()
切换App::bind()
的presenter
物件,如此controller
将开放封闭,将来有新的语言需求,也不用修改controller
。
我们可以发现改用factory pattern之后,controller
有了以下的优点:
- 符合
SOLID
的开放封闭原则:若将来有新的语言需求,controller
完全不用做任何修改。 - 符合
SOLID
的依赖反转原则:controller
不再直接相依于presenter
,而是改由factory去建立presenter
。 - 可以做单元测试:可直接对各
presenter
做单元测试,不需要跑验收测试就可以测试显示逻辑。
Blade
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Users</title>
</head>
<body>
<div>
@inject('dateFormatPresenter', 'MyBlog\Presenters\DateFormatPresenterInterface')
@foreach($users as $user)
<div>
<h2>{{$user->name}}</h2>
<h2>{{$user->email}}</h2>
<h2>{{$dateFormatPresenter->showDateFormat($user->created_at)}}</h2>
</div>
@endforeach
</div>
</body>
</html>
使用@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中解放,不仅更容易维护、更容易扩展、更容易重复使用,且更容易测试。