Laravel 应用设计:单行为控制器的魅力
昨天,Jeffrey Way 发布了一条推文,他问大家更愿意将其控制器命名为单数还是复数。 我回答我两种方案都不选,我使用单动作控制器。随后发生的是,有的人同意,有的不同意,有的甚至做出了最奇怪的事情。
由于十分强烈的反映,我想写一篇文章来解释为什么我爱单行为控制器、还有我为什么觉得它们很美妙。
首先在开始文章之前,我想要说这个东西并不是只有单一的真相。与往常一样,我想指出的是,一切都归结于你的个人喜好。我只能教、建议和指出一些事情,由你来决定是否同意、不同意、接受、学习和/或调整。或者都不是。从这篇博客中获得你想要的,随心所欲地做让自己感到舒适的事情吧。
对比 CRUD 和 Domain Modelling
开始前,我们先来想想我们倾向于写resourceful的CRUD控制器。我相信很多人会坚持使用这种做法,因为这是Laravel中的一个标准做法,文档中的大多数示例也是使用这种方法。另外,这或许也是你在各类博客或app代码中经常看到的。
但是,如果你停下来思考一下,这是编写它们的最佳方法吗?是软件行业的一般性做法吗?最近几年,我在“领域驱动设计”(Domain Driven Design)等领域投入了大量时间,并且思考软件如何应用于你工作的领域(Domian)以及它转化的过程。当您开始考虑模仿您领域中无处不在的语言的术语和措辞时,您会发现您的代码将变得更加清晰明了,更加戳到点子上。(最后这一句话仍值得斟酌、改进)
最后,我相信编写软件的本质是尽可能地应用domain processes来让你的代码更加易读和更加可维护。
Resourceful 控制器在这两个方面做得并不好。首先,它们不易读,因为您倾向于根据数据来构建它们,而不是根据领域来构建它们。这样的话,你就会丢失上下文对照。你表现了数据的处理方式,但却没有说明到底发生什么了,也没有说明你使用哪个过程进行处理。
第二,你没有针对可维护性进行优化。由于你是根据数据结构来构建的,因此你也会跟着耦合进去。实际上,您的领域模型在不断发展,数据结构也在不断发展。如果你的数据结构处理着多个过程或领域的多个部分,那你将很难进行调整。
一个实际的例子
因为理论很无聊,上代码更加容易解释,所以我们来看一个实际的例子。
假设您正在构建一个应用,它允许用户去组织事件。您想提供一种创建,更新和删除这些事件的方法。这是一种非常典型的例子,你会用CRUD的方式来考虑实现它。那么,让我们看看就这样一个resourceful控制器是如何被转换的。
首先我们来看看路由:
Route::get('events', [EventController::class, 'index']);
Route::get('events/create', [EventController::class, 'create']);
Route::post('events', [EventController::class, 'store']);
Route::get('event/{event}', [EventController::class, 'show']);
Route::get('events/{event}/edit', [EventController::class, 'edit']);
Route::put('events/{event}', [EventController::class, 'update']);
Route::destroy('events/{event}', [EventController::class, 'destroy']);
现在对应的控制器:
<?php
namespace App\Http\Controllers;
use App\Models\Event;
final class EventController
{
public function index()
{
// ...
}
public function create()
{
// ...
}
public function store()
{
// ...
}
public function show(Event $event)
{
// ...
}
public function edit(Event $event)
{
// ...
}
public function update(Event $event)
{
// ...
}
public function destroy(Event $event)
{
// ...
}
}
这个EventController
处理所有的 CRUD 请求,展示事件列表,展示指定的事件,创建一个事件,更新一个现存的事件和删除一个事件。
来看看index
方法的细节:
public function index()
{
$events = Event::paginate(10);
return view('events.index', compact('events'));
}
在这个方法中,我们检索出事件们,然后交给视图让它去展示到一个分页列表中。 到目前为止都还好。但是你现在想实现一个方法,用不同的页面去查看过去和即将来的事件。让我们看看如何在index
方法中实现它:
public function index(Request $request)
{
if ($request->boolean('past')) {
$events = Event::past()->paginate(10);
} elseif ($request->boolean('upcoming')) {
$events = Event::upcoming()->paginate(10);
} else {
$events = Event::paginate(10);
}
return view('events.index', compact('events'));
}
呃啊!看起来好乱啊。尽管我们已经用Eloquent scopes来隐藏查询逻辑,但是还是有很丑的链式语句。我们来看看如何用单行为控制器来代替它。
每个单行为控制器只执行一件事情,仅仅一件事情。
首先,我们不使用查询参数去获得不同的事件列表,而是使用专用路由去实现它。
Route::get('events', ShowAllEventsController::class);
Route::get('events/past', ShowPastEventsController::class);
Route::get('events/upcoming', ShowUpcomingEventsController::class);
这个路由比之前的要长一些,但是这个比之前的要更有表达力。你可以一下子辨识出哪一个控制器处理哪一个特定的逻辑。如果你对比一下URL,你会看到在可读性上改进了一些:
# Before
/events
/events?past=true
/events?upcoming=true
# After
/events
/events/past
/events/upcoming
现在来看其中一个控制器。就看 ShowUpcomingEventsController
这个控制器:
<?php
namespace App\Http\Controllers;
use App\Models\Event;
final class ShowUpcomingEventsController
{
public function __invoke()
{
$events = Event::upcoming()->paginate(10);
return view('events.index', compact('events'));
}
}
丑陋的 if
语句没了,我们还可以将同样的方式应用到第一个 CRUD 控制器示例中。现在我们有了一个单一行为的专门控制器,代替了之前包含所有其他的 CRUD 操作的复杂控制器。
简单,易读,便于维护。
你可能会问自己,这样做值么,毕竟之前的 if
语句也没那么坏吧?但是我想向你展示的是你正在为未来的改进做优化,并改进维护性。下次你想要对这三个页面做任何指定改变的时候,你会知道在哪里改,并且不需要艰难地更新一个 if
语句。
当然,上面的例子很简单,我们来看一个更复杂一点的。我们试试重构 create
和 store
方法:
public function create()
{
return view('events.create');
}
public function store(Request $request)
{
$data = $request->validate([
'name' => 'required',
'start' => 'required',
'end' => 'required|after:start',
])
$event = Event::create($data);
return redirect()->route('event.show', $event);
}
我们要做的就是把这两个方法移到专用的控制器,这样更好地解释了这些方法做了啥。这些方法更好地服务于你,比起把它们放在一个叫做ScheduleNewEventController
的控制器中。我们接着更新这个控制器的路由:
Route::get('events/schedule', [ScheduleNewEventController::class, 'showForm']);
Route::post('events/schedule', [ScheduleNewEventController::class, 'schedule']);
我不会向你展示一个确切的控制器,因为它们有和上面的例子一样,有两个方法,只不过把showForm
和 schedule
重新命名为更能表达它们干了啥的名字。即使这个不是单行为控制器,但是方法论是一样的:把你应用中的专用行为(方法)和它对应的控制器拆分到一起。
好了,现在你已经看了单行为控制器的例子了。你可能会想,这会导致越来越多的文件。但事实上,这个根本就不是问题。文件多又没啥。有更多、更小、更容易维护的文件比有更大、更难分析的要好。你可以打开一个单行为控制器的文件,然后快速扫描代码,马上就能知道这是干嘛的。
我经常把他们分组到不同的目录,这些目录负责领域的各个部分。这让你从文件结构的角度看控制器时,更加容易。
拆分控制器也让你跟容易找到特定的一个控制器。想象一下,你要寻找那个可以安排事件的控制器时。现在你只需要按照文件名搜索编辑器,而不是一个通用的 EventController
。
其他情况
我也被问到是否要对所有控制器执行此操作。不总是。在命名控制器时,我倾向于严谨且简洁,但我也会像你一样适应各种情况。
当然,有时候你还是想用 resourceful 控制器。比如在你构建RESTful API时。这样做是很有意义,因为你经常直接与数据本身交互,而没有经常与领域或任何进程进行交互。CMS(内容管理系统)或Laravel Nova等应用程序就是最好的例子。
但是在需要的时候,您最好问问自己的方案是否更接近领域和处理过程。在需要根据领域执行操作的时候,比如 GraphQL 之类的或 API 之类的 RPC ,这样做可能更适合。
结论
我希望这有一点见地,你现在能更理解我为什么如此喜欢单行为控制器了吧。我相信,结合小的 classes,再使用无处不在的语言、显式地命名,会带来更可维护的代码,甚至是控制器,不仅仅是领域对象。但是正如我开头所说,选择能帮助你的部分,好好分辨哪些适用于你,哪些不行。
本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
意味深长...
并不喜欢这种方式...
简单的可以用用,负责的嘛,还是看情况
API接口就是使用这种单行为控制器,很好用,每个API一个控制器,行为隔离,更加专注。
还是算了
正在使用,确实不错
最直观的感受是太费接口了 :joy:
这样做,对前端对接人员不友好,同样的功能,需要请求不同的接口。我觉得过于追求单一职责也不好,适当的冗余为了大家都高效,这种单行为控制器只处理一件事情,在理论上能保证接口质量,但在开发过程中不一定效率,一个功能往往都是N个单行为的接口配合完成的。
脱裤子放屁。。
属实感觉文件多不是很好的方式! :joy:
对词汇量少的人员不友好
我怕前端打我!!!
api可以用这种。 但是pc使用这样的不太好,pc轻量级的页面可以,复杂的业务逻辑太多 就不宜了
讨厌的 if…else… 一样不见了,这仍然是多行为控制器可以解决的(在资源路由的前面先添加past路由及upcoming路由)。
也可以混用,直接在资源控制器里面添加一个
__invoke()
方法。如果某些函数的代码量非常大,可以考虑使用单行为控制器将其拆分出来。单行为控制器的缺点很明显,文件太多。10个if..else得拆分成10个文件?改成一个文件里面放10个函数的做法或许更好。
如果接口太多的话 ,是不是要好多个文件
做云函数这种api才有必要。最大的问题是方法的互相调用,你把private这些都吃了吗?如果你要做一个云函数,比如说登录过程,你要有校验,查询,加解密,初始化乱七八糟的东西。这不是c语言,也不是python,随便就把这些东西解耦出去成一个新控制器或者全局函数,就是重构火葬场和频繁的new object。不解耦出去就是大量的重复代码。
可以试试,说实话我一直搞不懂为啥要把路由都聚集到一个控制器,大部分控制器的行为是没有什么共同点的,很少在控制器中写通用方法,传统的通过model来封装控制器的玩法业务量大了有的路由都不知道放哪,还不如直接单控制器然后根据业务分成文件夹来区分控制器行为