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 语句。

当然,上面的例子很简单,我们来看一个更复杂一点的。我们试试重构 createstore 方法:

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']);

我不会向你展示一个确切的控制器,因为它们有和上面的例子一样,有两个方法,只不过把showFormschedule 重新命名为更能表达它们干了啥的名字。即使这个不是单行为控制器,但是方法论是一样的:把你应用中的专用行为(方法)和它对应的控制器拆分到一起。

好了,现在你已经看了单行为控制器的例子了。你可能会想,这会导致越来越多的文件。但事实上,这个根本就不是问题。文件多又没啥。有更多、更小、更容易维护的文件比有更大、更难分析的要好。你可以打开一个单行为控制器的文件,然后快速扫描代码,马上就能知道这是干嘛的。

我经常把他们分组到不同的目录,这些目录负责领域的各个部分。这让你从文件结构的角度看控制器时,更加容易。

拆分控制器也让你跟容易找到特定的一个控制器。想象一下,你要寻找那个可以安排事件的控制器时。现在你只需要按照文件名搜索编辑器,而不是一个通用的 EventController

其他情况

我也被问到是否要对所有控制器执行此操作。不总是。在命名控制器时,我倾向于严谨且简洁,但我也会像你一样适应各种情况。

当然,有时候你还是想用 resourceful 控制器。比如在你构建RESTful API时。这样做是很有意义,因为你经常直接与数据本身交互,而没有经常与领域或任何进程进行交互。CMS(内容管理系统)或Laravel Nova等应用程序就是最好的例子。

但是在需要的时候,您最好问问自己的方案是否更接近领域和处理过程。在需要根据领域执行操作的时候,比如 GraphQL 之类的或 API 之类的 RPC ,这样做可能更适合。

结论

我希望这有一点见地,你现在能更理解我为什么如此喜欢单行为控制器了吧。我相信,结合小的 classes,再使用无处不在的语言、显式地命名,会带来更可维护的代码,甚至是控制器,不仅仅是领域对象。但是正如我开头所说,选择能帮助你的部分,好好分辨哪些适用于你,哪些不行。

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://driesvints.com/blog/the-beauty-o...

译文地址:https://learnku.com/laravel/t/43185

本帖已被设为精华帖!
本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 17

并不喜欢这种方式...

3年前 评论

简单的可以用用,负责的嘛,还是看情况

3年前 评论

API接口就是使用这种单行为控制器,很好用,每个API一个控制器,行为隔离,更加专注。

3年前 评论

还是算了

3年前 评论
Marrigan

正在使用,确实不错

3年前 评论

最直观的感受是太费接口了 :joy:

3年前 评论

这样做,对前端对接人员不友好,同样的功能,需要请求不同的接口。我觉得过于追求单一职责也不好,适当的冗余为了大家都高效,这种单行为控制器只处理一件事情,在理论上能保证接口质量,但在开发过程中不一定效率,一个功能往往都是N个单行为的接口配合完成的。

3年前 评论

属实感觉文件多不是很好的方式! :joy:

3年前 评论

对词汇量少的人员不友好

3年前 评论

我怕前端打我!!!

3年前 评论
自由与温暖是遥不可及的梦想

api可以用这种。 但是pc使用这样的不太好,pc轻量级的页面可以,复杂的业务逻辑太多 就不宜了

3年前 评论
//以下是多行为路由
Route::get('events', 'EventController@index');
Route::get('events/past', 'EventController@past');
Route::get('events/upcoming', 'EventController@upcoming');

//以下是多行为控制器
class EventController
{
    public function index() {
        // ...
    }
    public function past() {
        // ...
    }
    public function upcoming() {
        // ...
    }
    .
    .
}

讨厌的 if…else… 一样不见了,这仍然是多行为控制器可以解决的(在资源路由的前面先添加past路由及upcoming路由)。

也可以混用,直接在资源控制器里面添加一个 __invoke()方法。如果某些函数的代码量非常大,可以考虑使用单行为控制器将其拆分出来。

单行为控制器的缺点很明显,文件太多。10个if..else得拆分成10个文件?改成一个文件里面放10个函数的做法或许更好。

3年前 评论

如果接口太多的话 ,是不是要好多个文件

3年前 评论

做云函数这种api才有必要。最大的问题是方法的互相调用,你把private这些都吃了吗?如果你要做一个云函数,比如说登录过程,你要有校验,查询,加解密,初始化乱七八糟的东西。这不是c语言,也不是python,随便就把这些东西解耦出去成一个新控制器或者全局函数,就是重构火葬场和频繁的new object。不解耦出去就是大量的重复代码。

2年前 评论

可以试试,说实话我一直搞不懂为啥要把路由都聚集到一个控制器,大部分控制器的行为是没有什么共同点的,很少在控制器中写通用方法,传统的通过model来封装控制器的玩法业务量大了有的路由都不知道放哪,还不如直接单控制器然后根据业务分成文件夹来区分控制器行为

2年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!