Laravel 项目架构

初学者学习Laravel时分两种,一种是乖乖的将程序填入MVC构架内,导致controller与model异常的肥大,日后一样很难维护;一种是常常不知道程序该写在哪一个class内而犹豫不决,毕竟传统PHP都是一个页面一个档案。本文整理出最适合Laravel的中大型项目构架,兼具容易维护、容易扩充与容易重复使用的特点,并且容易测试。

Controller过于肥大

受RoR的影响,初学者常认为MVC构架就是model,view,controller:

Model就是数据库。
Controller负责与HTTP沟通,调用model与view。
View就是HTML。
假如依照这个定义,以下这些需求该写在哪里呢?

  1. 发送Email,使用外部API。
  2. 使用PHP写的逻辑。
  3. 依需求将显示格式作转换。
  4. 依需求是否显示某些数据。
  5. 依需求显示不同数据。

其中1,2属于商业逻辑,而3,4,5属于显示逻辑,若依照一般人对MVC的定义,model是数据库,而view又是HTML,以上这些需求都不能写在model与view,只能勉强写在controller。
因此初学者开始将大量程序写在controller,造成controller的肥大难以维护。

Model过于肥大

既然逻辑写在controller不方便维护,那我将逻辑都写在model就好了?

当你将逻辑从controller搬到model后,虽然controller变瘦了,但却肥了model,model从原本代表数据库,现在变成还要负担商业逻辑与显示逻辑,结果更惨。
Model代表数据库吗?把它想成是Eloquent class就好,数据库逻辑应该写在repository里,这也是为什么Laravel 5已经没有models目录,Eloquent class仅仅是放在app根目录下而已。

中大型项目构架

那我们该怎么写呢?别将我们的思维局限在MVC内:

  • Model:仅当成Eloquent class。
  • Repository:辅助model,处理数据库逻辑,然后注入到service。
  • Service:辅助controller,处理商业逻辑,然后注入到controller。
  • Controller:接收HTTP request,调用其他service。
  • Presenter:处理显示逻辑,然后注入到view。
  • View:使用blade将数据binding到HTML。

picture

其中蓝色为原本的MVC,而紫色为本文要介绍的的重点:Repository模式,Service模式与Presenter模式。
箭头表示物件依赖注入的方向。
我们可以发现MVC构架还在,由于SOLID的单一职责原则与依赖反转原则:

  1. 我们将数据库逻辑从model分离出来,由repository辅助model,将model依赖注入进repository。
  2. 我们将商业逻辑从controller分离出来,由service辅助controller,将service依赖注入进controller。
  3. 我们将显示逻辑从view分离出来,由presenter辅助view,将presenter依赖注入进view。

建立目录

在 app 目录建立 Repositories,Services 与 Presenters 目录。

别害怕在Laravel预设目录以外建立的其他目录,根据SOLID的单一职责原则,class功能越多,责任也越多,因此越违反单一职责原则,所以你应该将你的程序分割成更小的部分,每个部分都有它专属的功能,而不是一个class功能包山包海,也就是所谓的万能类别,所以整个项目不应该只有MVC三个部分,放手根据你的需求建立适当的目录,并将适当的class放到该目录下,只要我们的class有namespace帮我们分类即可。

Repository

若将数据库逻辑都写在model,会造成model的肥大而难以维护,基于SOLID原则,我们应该使用Repository模式辅助model,将相关的数据库逻辑封装在不同的repository,方便中大型项目的维护。

数据库逻辑

在CRUD中,CUD比较稳定,但R的部分则千变万化,大部分的数据库逻辑都在描述R的部分,若将数据库逻辑写在controller或model都不适当,会造成controller与model肥大,造成日后难以维护。

Model

使用repository之后,model仅当成Eloquent class即可,不要包含数据库逻辑,仅保留以下部分:

Property:如$table$fillable

Mutator:包括mutator与accessor.

Method:relation类的method,如使用hasMany()belongsTo().

注释:因为Eloquent会根据数据库字段动态产生propertymethod,等。若使用Laravel IDE Helper,会直接在model加上@property@method描述model的动态propertymethod

  • User.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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
app/User.php
namespace MyBlog;
use Illuminate\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Foundation\Auth\Access\Authorizable;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
/**
* MyBlog\User
*
* @property integer $id
* @property string $name
* @property string $email
* @property string $password
* @property string $remember_token
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereId($value)
* @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereName($value)
* @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereEmail($value)
* @method static \Illuminate\Database\Query\Builder|\MyBlog\User wherePassword($value)
* @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereRememberToken($value)
* @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereCreatedAt($value)
* @method static \Illuminate\Database\Query\Builder|\MyBlog\User whereUpdatedAt($value)
*/
class User extends Model implements AuthenticatableContract,
AuthorizableContract,
CanResetPasswordContract
{
use Authenticatable, Authorizable, CanResetPassword;

/**
* The database table used by the model.
*
* @var string
*/
protected $table = 'users';

/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = ['name', 'email', 'password'];

/**
* The attributes excluded from the model's JSON form.
*
* @var array
*/
protected $hidden = ['password', 'remember_token'];
}

Repository

初学者常会在controller直接调用model写数据库逻辑:

1
2
3
4
5
6
7
8
public function index()
{
$users = User::where('age', '>', 20)
->orderBy('age')
->get();

return view('users.index', compact('users'));
}

数据库逻辑是要抓20岁以上的数据。在中大型项目,会有几个问题:

  1. 将数据库逻辑写在controller,造成controller的肥大难以维护。
  2. 违反SOLID的单一职责原则:数据库逻辑不应该写在controller。
  3. controller直接相依于model,使得我们无法对controller做单元测试。
  4. 比较好的方式是使用repository:
  5. 将model依赖注入到repository。
  6. 将数据库逻辑写在repository。
  7. 将repository依赖注入到service。
  • UserRepository.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
app/Repositories/UserRepository.php
namespace MyBlog\Repositories;
use Doctrine\Common\Collections\Collection;
use MyBlog\User;
class UserRepository
{
/** @var User 注入的User model */
protected $user;
/**
* UserRepository constructor.
* @param User $user
*/
//将相依的User model依赖注入到UserRepository。
public function __construct(User $user)
{
$this->user = $user;
}
/**
* 回传大于?年纪的数据
* @param integer $age
* @return Collection
*/
public function getAgeLargerThan($age)
{
return $this->user
->where('age', '>', $age)
->orderBy('age')
->get();
}
}
  • 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
namespace App\Http\Controllers;
use App\Http\Requests;
use MyBlog\Repositories\UserRepository;
class UserController extends Controller
{
/** @var UserRepository 注入的UserRepository */
protected $userRepository;

/**
* UserController constructor.
*
* @param UserRepository $userRepository
*/
// 将相依的UserRepository依赖注入到UserController。
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}

/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
//从原本直接相依的User model,改成依赖注入的UserRepository。
$users = $this->userRepository
->getAgeLargerThan(20);

return view('users.index', compact('users'));
}
}

用这种写法,有几个优点:

  1. 将数据库逻辑写在repository,解决controller肥大问题。
  2. 符合SOLID的单一职责原则:数据库逻辑写在repository,没写在controller。
  3. 符合SOLID的依赖反转原则:controller并非直接相依于repository,而是将repository依赖注入进controller。

实务上建议repository仅依赖注入于service,而不要直接注入在controller,本示例因为还没介绍到servie模式,为了简化起见,所以直接注入于controller。

是否该建立Repository Interface?

理论上使用依赖注入时,应该使用interface,不过interface目的在于抽象化方便抽换,让代码达到开放封闭的要求,但是实务上要抽换repository的机会不高,除非你有抽换数据库的需求,如从MySQL抽换到MongoDB,此时就该建立repository interface。
不过由于我们使用了依赖注入,将来要从class改成interface也很方便,只要在constructor的type hint改成interface即可,维护成本很低,所以在此大可使用repository class即可,不一定得用interface而造成over design,等真正需求来时再重构成interface即可。

Conclusion

事实上可以一开始1个repository对应1个model,但不用太执着于1个repository一定要对应1个model,可将repository视为逻辑上的数据库逻辑类别即可,可以横跨多个model处理,也可以1个model拆成多个repository,端看需求而定。
Repository使得数据库逻辑从controller或model中解放,不仅更容易维护、更容易扩展、更容易重复使用,且更容易测试。
Sample Code

Service

若将商业逻辑都写在controller,会造成controller肥大而难以维护,基于SOLID原则,我们应该使用Service模式辅助controller,将相关的商业逻辑封装在不同的service,方便中大型项目的维护。

商业逻辑

商业逻辑中,常见的如:

  • 牵涉到外部行为:如发送Email,使用外部API…。
  • 使用PHP写的逻辑:如根据购买的件数,有不同的折扣。若将商业逻辑写在controller,会造成controller肥大,日后难以维护。

Service

牵涉到外部行为
如发送Email,初学者常会在controller直接调用Mail::queue():

1
2
3
4
5
6
7
8
public function store(Request $request)
{
Mail::queue('email.index', $request->all(), function (Message $message) {
$message->sender(env('MAIL_USERNAME'));
$message->subject(env('MAIL_SUBJECT'));
$message->to(env('MAIL_TO_ADDR'));
});
}

在中大型项目,会有几个问题

  1. 将牵涉到外部行为的商业逻辑写在controller,造成controller的肥大难以维护。
  2. 违反SOLID的单一职责原则:外部行为不应该写在controller。
  3. controller直接相依于外部行为,使得我们无法对controller做单元测试。

比较好的方式是使用service

  1. 将外部行为注入到service。
  2. 在service使用外部行为。
  3. 将service注入到controller。
  • EmailService.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
app/Services/EmailService.php
namespace App\Services;
use Illuminate\Mail\Mailer;
use Illuminate\Mail\Message;
class EmailService
{
/** @var Mailer */
private $mail;

/**
* EmailService constructor.
* @param Mailer $mail
*/
public function __construct(Mailer $mail)
{
//将相依的Mailer注入到EmailService。
$this->mail = $mail;
}

/**
* 發送Email
* @param array $request
*/
public function send(array $request)
{
//将发送Emai的商业逻辑写在send()。不是使用Mail facade,而是使用注入的$this->mail
$this->mail->queue('email.index', $request, function (Message $message) {
$message->sender(env('MAIL_USERNAME'));
$message->subject(env('MAIL_SUBJECT'));
$message->to(env('MAIL_TO_ADDR'));
});
}
}
  • 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
app/Http/Controllers/UserController.php
namespace App\Http\Controllers;
use App\Http\Requests;
use Illuminate\Http\Request;
use MyBlog\Services\EmailService;
class UserController extends Controller
{
/** @var EmailService */
protected $emailService;

/**
* UserController constructor.
* @param EmailService $emailService
*/
public function __construct(EmailService $emailService)
{
#将相依的EmailService注入到UserController。
$this->emailService = $emailService;
}

/**
* Store a newly created resource in storage.
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
#从原本直接相依于Mail facade,改成相依于注入的EmailService
$this->emailService->send($request->all());
}
}

用这种写法,有几个优点

  • 将外部行为写在service,解决controller肥大问题。
  • 符合SOLID的单一职责原则:外部行为写在service,没写在controller。
  • 符合SOLID的依赖反转原则:controller并非直接相依于service,而是将service依赖注入进controller。

使用PHP写的逻辑

如根据购买的件数,有不同的折扣,初学者常会在controller直接写if…else逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function store(Request $request)
{
$qty = $request->input('qty');
$price = 500;
if ($qty == 1) {
$discount = 1.0;
}
elseif ($qty == 2) {
$discount = 0.9;
}
elseif ($qty == 3) {
$discount = 0.8;
}
else {
$discount = 0.7;
}
$total = $price * $qty * $discount;
echo($total);
}

在中大型项目,会有几个问题:

  1. 将PHP写的商业逻辑直接写在controller,造成controller的肥大难以维护。
  2. 违反SOLID的单一职责原则:商业逻辑不应该写在controller。
  3. 违反SOLID的单一职责原则:若未来想要改变折扣与加总的算法,都需要改到此method,也就是说,此method同时包含了计算折扣与计算加总的职责,因此违反SOLID的单一职责原则。
  4. 直接写在controller的逻辑无法被其他controller使用。

比较好的方式是使用service。

  1. 将相依物件注入到service。
  2. 在service写PHP逻辑使用相依物件。
  3. 将service注入到controller。
  • OrderService.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
app/Services/OrderService.php
namespace App\Services;
class OrderService
{
/**为了符合SOLID的单一职责原则,将计算折扣独立成getDiscount(),将PHP写的判断逻辑写在里面。
* 計算折扣
* @param int $qty
* @return float
*/
public function getDiscount($qty)
{
if ($qty == 1) {
return 1.0;
} elseif ($qty == 2) {
return 0.9;
} elseif ($qty == 3) {
return 0.8;
} else {
return 0.7;
}
}

/**
* 計算最後價錢
* @param integer $qty
* @param float $discount
* @return float
*/
public function getTotal($qty, $discount)
{
#为了符合SOLID的单一职责原则,将计算加总独立成getTotal(),将PHP写的计算逻辑写在里面。
return 500 * $qty * $discount;
}
}
  • OrderController.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
app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\Http\Requests;
use App\MyBlog\Services\OrderService;
use Illuminate\Http\Request;

class OrderController extends Controller
{
/** @var OrderService */
protected $orderService;

/**
* OrderController constructor.
* @param OrderService $orderService
*/
public function __construct(OrderService $orderService)
{
#将相依的OrderService注入到UserController。
$this->orderService = $orderService;
}

/**
* Store a newly created resource in storage.
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
#将原本的if…else逻辑改成呼叫OrderService,controller变得非常干净,也达成原本controller接收HTTP request,调用其他class的责任。
$qty = $request->input('qty');

$discount = $this->orderService->getDiscount($qty);
$total = $this->orderService->getTotal($qty, $discount);

echo($total);
}
}

这种写法,有几个优点:

  1. 将PHP写的商业逻辑写在service,解决controller肥大问题。
  2. 符合SOLID的单一职责原则:商业逻辑写在service,没写在controller。
  3. 符合SOLID的单一职责原则:计算折扣与计算加总分开在不同method,且归属于OrderService,而非OrderController。
  4. 符合SOLID的依赖反转原则:controller并非直接相依于service,而是将service依赖注入进controller。
  5. 其他controller也可以重复使用此段商业逻辑。

若使用了service辅助controller,再搭配依赖注入与service container,则controller就非常干净,能专心处理接收HTTP request,调用其他class的职责了。

Conclusion

实务上会有很多service,须自行依照SOLID原则去判断是否该建立service。
Service使得商业逻辑从controller中解放,不仅更容易维护、更容易扩展、更容易重复使用,且更容易测试。

Presenter

若将显示逻辑都写在view,会造成view肥大而难以维护,基于SOLID原则,我们应该使用Presenter模式辅助view,将相关的显示逻辑封装在不同的presenter,方便中大型项目的维护。

显示逻辑

显示逻辑中,常见的如:

将数据显示不同数据:如性别字段为M,就显示Mr.,若性别字段为F,就显示Mrs.。

是否显示某些数据:如根据字段值是否为Y,要不要显示该字段。

依需求显示不同格式:如依照不同的语系,显示不同的日期格式。

Presenter

如性别字段为M,就显示Mr.,若性别字段为F,就显示Mrs.,初学者常会直接用blade写在view。

1
<h2>@if($user->gender == 'M') {{ 'Mr.'}} @else {{ 'Mrs.' }} @endif {{ $user->name }}</h2>

在中大型项目,会有几个问题:

  1. 由于blade与HTML夹杂,不太适合写太复杂的程序,只适合做一些简单的binding,否则很容易流于传统PHP的义大利面程序。

  2. 无法对显示逻辑做重构与面向对象。

比较好的方式是使用presenter:

  1. 将相依物件注入到presenter。
  2. 在presenter内写格式转换。
  3. 将presenter注入到view。
  • UserPresenter.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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。

1
2
3
4
5
6
7
8
9
10
11
12
<html>
<body>
<div>
@inject('UserPresenter', 'Project\Your\UserPresenter')
@foreach($users as $user)
<div>
<h2>{{ $UserPresenter->getFullName($user->gender, $user->name) }}</h2>
</div>
@endforeach
</div>
</body>
</html>

使用@inject()注入UserPresenter,让view也可以如controller一样使用注入的物件。

将来无论显示逻辑怎么修改,都不用改到blade,直接在presenter内修改。

改用这种写法,有几个优点:

  1. 将数据显示不同格式的显示逻辑改写在presenter,解决写在blade不容易维护的问题。
  2. 可对显示逻辑做重构与面向对象。

是否显示某些数据

如根据字段值是否为Y,要不要显示该字段,初学者常会直接用blade写在view。

在中大型项目,会有几个问题

  • 由于blade与HTML夹杂,不太适合写太复杂的程序,只适合做一些简单的binding,否则很容易流于传统PHP的义大利面程序。
  • 无法对显示逻辑做重构与面向对象。
  • 违反SOLID的开放封闭原则:若将来要支持新的语系,只能不断地在blade新增if…else。(开放封闭原则:软件中的类别、函式对于扩展是开放的,对于修改是封闭的。)

比较好的方式是使用presenter

  1. 将相依物件注入到presenter。
  2. 在presenter内写不同的日期格式转换逻辑。
  3. 将presenter注入到view。
  • DateFormatPresenterInterface.php
1
2
3
4
5
6
7
8
9
10
11
12
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()使用Carbonformat()去转换日期格式。

  • DateFormatPresenter_uk.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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,并将转换成英国日期格式的Carbonformat()写在showDateFormat()内。

  • DateFormatPresenter_tw.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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,并将转换成湾湾日期格式的Carbonformat()写在showDateFormat()内。

Presenter工厂

由于每个语言的日期格式都是一个presenter物件,那势必遇到一个最基本的问题:我们必须根据不同的语言去new不同的presenter物件,直觉我们可能会在controllernew 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'));
}

这种写法虽然可行,但有几个问题:

  1. 违反SOLID的开放封闭原则:若将来有新的语言需求,只能不断去修改index(),然后不断的新增elseif,就算改用switch也是一样。
  2. 违反SOLID的依赖反转原则:controller直接根据语言去new相对应的class,高层直接相依于低层,直接将实作写死在程序中。(依赖反转原则:高层不应该依赖于低层,两者都应该要依赖抽象;抽象不要依赖细节,细节要依赖抽象)
  3. 无法单元测试:由于presenter直接new在controller,因此要测试时,无法对presenter做mock。

比较好的方式是使用Factory Pattern

  • DataFormatPresenterFactory.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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(),直接根据$localebinding相对应的class,这样无论在怎么新增语言与日期格式,controller与Presenter Factory都不用做任何修改,完全符合开放封闭原则。

Controller

  • 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
38
39
40
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)
{
#将相依的UserRepository注入到UserController。
$this->userRepository = $userRepository;
}

/**
* Display a listing of the resource.
* @param Request $request
* @param DateFormatPresenterFactory $dateFormatPresenterFactory
* @return \Illuminate\Http\Response
*/
public function index(Request $request)
{
#使用$dateFormatPresenterFactory::bind()切换App::bind()的presenter物件,如此controller将开放封闭,将来有新的语言需求,也不用修改controller。
$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

1
2
3
4
5
6
7
8
9
10
11
12
<html>
<body>
<div>
@inject('DateFormatPresenter', 'Project\Your\DateFormatPresenter')
@foreach($users as $user)
<div>
<h2>{{ $dataFormatPresenter->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中解放,不仅更容易维护、更容易扩展、更容易重复使用,且更容易测试。

单元测试

由于现在model、view、controller的相依物件都已经拆开,也都使用依赖注入,因此每个部分都可以单独的做单元测试,如要测试service,就将repository加以mock,也可以将其他service加以mock。
Presenter也可以单独跑单元测试,将其他service加以mock,不一定要跑验收测试才能测显示逻辑。

Conclusion

本文谈到的构架只是开始,你可以依照实际需求增加更多的目录与class,当你发现你的MVC违反SOLID原则时,就大胆的将class从MVC拆开重构,然后依照以下手法:

  1. 建立新的class或interface。
  2. 将相依物件依赖注入到class。
  3. 在class内处理他的职责。
  4. 将class或interface注入到controller或view。

最后搭配单元测试,测试重构后的构架是否与原来的需求结果相同。

转载自原作者博文