使用服务、事件、作业、操作等重构 Laravel 控制器

发布时间 作者

Restructuring a Laravel Controller using Services, Events, Jobs, Actions, and more image

我听到的最常见的 Laravel 问题之一是“如何构建项目”。如果我们缩小范围,其中很大一部分听起来像“如果逻辑不应该放在控制器中,那么应该放在哪里?”

问题在于,这些问题没有单一的正确答案。Laravel 允许您自由选择结构,这是一件好事也是一件坏事。您不会在官方 Laravel 文档中找到任何建议,所以让我们尝试根据一个具体的例子来讨论各种选项。

注意:由于没有一种构建项目的方法,因此本文将充满旁注、“如果”以及类似的段落。我建议您不要跳过它们,并完整阅读文章,以了解最佳实践的所有例外情况。

假设您有一个用于注册用户的控制器方法,该方法执行很多操作

public function store(Request $request)
{
// 1. Validation
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
 
// 2. Create user
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
 
// 3. Upload the avatar file and update the user
if ($request->hasFile('avatar')) {
$avatar = $request->file('avatar')->store('avatars');
$user->update(['avatar' => $avatar]);
}
 
// 4. Login
Auth::login($user);
 
// 5. Generate a personal voucher
$voucher = Voucher::create([
'code' => Str::random(8),
'discount_percent' => 10,
'user_id' => $user->id
]);
 
// 6. Send that voucher with a welcome email
$user->notify(new NewUserWelcomeNotification($voucher->code));
 
// 7. Notify administrators about the new user
foreach (config('app.admin_emails') as $adminEmail) {
Notification::route('mail', $adminEmail)
->notify(new NewUserAdminNotification($user));
}
 
return redirect()->route('dashboard');
}

准确地说,是七个操作。您可能都同意,对于一个控制器方法来说,这太多了,我们需要分离逻辑并将部分内容移到其他地方。但究竟应该移到哪里呢?

  • 服务?
  • 作业?
  • 事件/监听器?
  • 操作类?
  • 其他东西?

最棘手的部分是,以上所有选项都是正确的答案。这可能也是您应该从本文中吸取的主要信息。我会用加粗和大写的方式再次强调它。

您可以根据自己的意愿构建项目。

我说过了。换句话说,如果您在某个地方看到一些推荐的结构,并不意味着您必须立即在所有地方应用它。选择权始终掌握在您手中。您需要选择一个结构,这样您自己和未来的团队在之后维护代码时都能感到舒适。

就这样,我甚至可以现在就结束文章了。但您可能想要一些“内容”,对吧?好吧,让我们来处理一下上面的代码。


通用重构策略

首先,一个“免责声明”,这样您就可以清楚地了解我们在这里做的事情以及原因。我们的总体目标是使控制器方法更短,这样它就不会包含任何逻辑。

控制器方法需要做三件事

  • 从路由或其他输入中接受参数
  • 调用一些逻辑类/方法,并传递这些参数
  • 返回结果:视图、重定向、JSON 返回等

因此,控制器调用方法,而不是在控制器本身内实现逻辑。

另外,请记住,我建议的更改只是其中的一种,还有数十种其他可行的方法。我将根据个人经验为您提供建议。


1. 验证:表单请求类

这是我的个人偏好,但我喜欢将验证规则单独保留,而 Laravel 提供了一个很好的解决方案:表单请求

因此,我们生成

php artisan make:request StoreUserRequest

我们将验证规则从控制器移到该类中。此外,我们需要在顶部添加 Password 类并将 authorize() 方法更改为返回 true

use Illuminate\Validation\Rules\Password;
 
class StoreUserRequest extends FormRequest
{
public function authorize()
{
return true;
}
 
public function rules()
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'confirmed', Password::defaults()],
];
}
}

最后,在我们的控制器方法中,我们将 Request $request 替换为 StoreUserRequest $request 并从控制器中删除验证逻辑

use App\Http\Requests\StoreUserRequest;
 
class RegisteredUserController extends Controller
{
public function store(StoreUserRequest $request)
{
// No $request->validate needed here
 
// Create user
$user = User::create([...]) // ...
}
}

好了,控制器缩短的第一步已经完成。让我们继续下一步。


2. 创建用户:服务类

接下来,我们需要创建一个用户并为他们上传头像

// Create user
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
 
// Avatar upload and update user
if ($request->hasFile('avatar')) {
$avatar = $request->file('avatar')->store('avatars');
$user->update(['avatar' => $avatar]);
}

如果我们遵循建议,该逻辑不应该放在控制器中。控制器不应该知道用户数据库结构或存储头像的位置。它只需要调用一些类方法,该方法可以处理所有事情。

一个相当常见的位置是围绕一个模型的操作创建一个单独的 PHP 类。它被称为服务类,但这只是一个“花哨”的官方名称,用于表示为控制器“提供服务”的 PHP 类。

这就是为什么没有像 php artisan make:service 这样的命令,因为它只是一个 PHP 类,具有您想要的任何结构,因此您可以在 IDE 中手动创建它,并放在您想要的任何文件夹中。

通常,当围绕同一个实体或模型有多个方法时,就会创建服务。因此,通过在这里创建 UserService,我们假设将来会有更多方法,而不仅仅是创建用户。

此外,服务通常具有返回某些内容的方法(因此,“提供服务”)。相比之下,操作或作业通常在没有预期任何返回结果的情况下被调用。

在我的例子中,我将创建 app/Services/UserService.php 类,目前只包含一个方法。

namespace App\Services;
 
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
 
class UserService
{
public function createUser(Request $request): User
{
// Create user
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
 
// Avatar upload and update user
if ($request->hasFile('avatar')) {
$avatar = $request->file('avatar')->store('avatars');
$user->update(['avatar' => $avatar]);
}
 
return $user;
}
}

然后,在控制器中,我们只需将此服务类作为方法的参数进行类型提示,并在其中调用该方法。

use App\Services\UserService;
 
class RegisteredUserController extends Controller
{
public function store(StoreUserRequest $request, UserService $userService)
{
$user = $userService->createUser($request);
 
// Login and other operations...

是的,我们不需要在任何地方调用 new UserService()。Laravel 允许您在控制器中以这种方式对任何类进行类型提示,您可以在文档中阅读更多关于方法注入的信息这里.

2.1. 遵循单一职责原则的服务类

现在,控制器短了很多,但这种简单的复制粘贴代码分离方法存在一些问题。

第一个问题是,服务方法应该像一个“黑盒子”,只接受参数,而不关心这些参数来自哪里。因此,该方法将来可以从控制器、Artisan 命令或作业中调用。

另一个问题是,服务方法违反了单一职责原则:它创建用户并上传文件。

因此,我们需要另外两层:一层用于文件上传,另一层用于将 $request 转换为函数的参数。而且,一如既往,有许多方法可以实现它。

在我的例子中,我将创建一个第二个服务方法,用于上传文件。

app/Services/UserService.php:

class UserService
{
public function uploadAvatar(Request $request): ?string
{
return ($request->hasFile('avatar'))
? $request->file('avatar')->store('avatars')
: NULL;
}
 
public function createUser(array $userData): User
{
return User::create([
'name' => $userData['name'],
'email' => $userData['email'],
'password' => Hash::make($userData['password']),
'avatar' => $userData['avatar']
]);
}
}

RegisteredUserController.php:

public function store(StoreUserRequest $request, UserService $userService)
{
$avatar = $userService->uploadAvatar($request);
$user = $userService->createUser($request->validated() + ['avatar' => $avatar]);
 
// ...

我再次强调:这只是分离事物的一种方法,您可以采用其他方式。

但我的逻辑是这样的

  1. createUser() 方法现在不知道任何关于 Request 的信息,我们可以在任何 Artisan 命令或其他地方调用它
  2. 头像上传与用户创建操作分离

您可能认为服务方法太小,没有必要将它们分离,但这只是一个非常简化的例子:在实际项目中,文件上传方法以及用户创建逻辑可能会复杂得多。

在这种情况下,我们稍微偏离了“使控制器更短”的神圣规则,并添加了第二行代码,但在我看来,这是有充分理由的。


3. 也许使用操作而不是服务?

近年来,操作类概念在 Laravel 社区中流行起来。逻辑是这样的:您有一个单独的类,只包含一个操作。在我们这个例子中,操作类可以是

  • CreateNewUser
  • UpdateUserPassword
  • UpdateUserProfile
  • 等等

所以,正如您所见,对用户的相同多个操作,只是不在一个 UserService 类中,而是被分成多个 Action 类。从单一职责原则的角度来看,这可能是有意义的,但我更喜欢将方法分组到类中,而不是拥有很多单独的类。再次强调,这只是个人偏好。

现在,让我们看看 Action 类中的代码是什么样子的。

同样,没有 php artisan make:action,您只需创建一个 PHP 类。例如,我将创建 app/Actions/CreateNewUser.php

namespace App\Actions;
 
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
 
class CreateNewUser
{
public function handle(Request $request)
{
$avatar = ($request->hasFile('avatar'))
? $request->file('avatar')->store('avatars')
: NULL;
 
return User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'avatar' => $avatar
]);
}
}

您可以自由地选择 Action 类的​​方法名称,我喜欢 handle()

RegisteredUserController:

public function store(StoreUserRequest $request, CreateNewUser $createNewUser)
{
$user = $createNewUser->handle($request);
 
// ...

换句话说,我们将所有逻辑都卸载到 action 类中,该类随后负责处理围绕文件上传和用户创建的所有事项。说实话,我不确定这是否是说明 Action 类的最佳示例,因为我个人并不喜欢它们,而且很少使用它们。作为另一个示例来源,您可以查看 Laravel Fortify 的代码


4. 代金券创建:相同或不同的服务?

接下来,在 Controller 方法中,我们发现三个操作

Auth::login($user);
 
$voucher = Voucher::create([
'code' => Str::random(8),
'discount_percent' => 10,
'user_id' => $user->id
]);
 
$user->notify(new NewUserWelcomeNotification($voucher->code));

登录操作将保持不变,因为这里调用的是外部类 Auth,类似于一个服务,我们不需要了解底层发生了什么。

但是对于代金券,在这种情况下,Controller 包含如何创建代金券并将其与欢迎电子邮件一起发送给用户的逻辑。

首先,我们需要将代金券创建移到一个单独的类中:我犹豫是否要创建一个 VoucherService 并将其作为 UserService 中的一个方法。这几乎是一个哲学上的争论:此方法与代金券系统、用户系统,还是两者都相关?

由于服务的特点之一是包含多个方法,我决定不创建一个只有一个方法的“孤单”的 VoucherService。我们将在 UserService 中进行。

use App\Models\Voucher;
use Illuminate\Support\Str;
 
class UserService
{
// public function uploadAvatar() ...
// public function createUser() ...
 
public function createVoucherForUser(int $userId): string
{
$voucher = Voucher::create([
'code' => Str::random(8),
'discount_percent' => 10,
'user_id' => $userId
]);
 
return $voucher->code;
}
}

然后,在 Controller 中,我们像这样调用它

public function store(StoreUserRequest $request, UserService $userService)
{
// ...
 
Auth::login($user);
 
$voucherCode = $userService->createVoucherForUser($user->id);
$user->notify(new NewUserWelcomeNotification($voucherCode));

这里还需要考虑的一点是:也许我们应该将这两行代码移到 UserService 的一个单独方法中,该方法负责发送欢迎电子邮件,然后会调用代金券方法?

像这样

class UserService
{
public function sendWelcomeEmail(User $user)
{
$voucherCode = $this->createVoucherForUser($user->id);
$user->notify(new NewUserWelcomeNotification($voucherCode));
}

然后,Controller 只需一行代码就可以完成此操作

$userService->sendWelcomeEmail($user);

5. 通知管理员:可排队作业

最后,我们在 Controller 中看到了这段代码

foreach (config('app.admin_emails') as $adminEmail) {
Notification::route('mail', $adminEmail)
->notify(new NewUserAdminNotification($user));
}

它可能发送多封电子邮件,这可能需要时间,因此我们需要将其放入队列中,以便在后台运行。这就是我们需要作业的地方。

Laravel 通知类 可能是可排队的,但对于此示例,让我们假设可能比简单地发送通知电子邮件更复杂。因此,让我们为此创建一个作业。

在这种情况下,Laravel 为我们提供了 Artisan 命令

php artisan make:job NewUserNotifyAdminsJob

app/Jobs/NewUserNotifyAdminsJob.php:

class NewUserNotifyAdminsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
private User $user;
 
public function __construct(User $user)
{
$this->user = $user;
}
 
public function handle()
{
foreach (config('app.admin_emails') as $adminEmail) {
Notification::route('mail', $adminEmail)
->notify(new NewUserAdminNotification($this->user));
}
}
}

然后,在 Controller 中,我们需要使用参数调用该作业

use App\Jobs\NewUserNotifyAdminsJob;
 
class RegisteredUserController extends Controller
{
public function store(StoreUserRequest $request, UserService $userService)
{
// ...
 
NewUserNotifyAdminsJob::dispatch($user);

所以,现在,我们将所有逻辑从 Controller 移到了其他地方,让我们回顾一下我们拥有什么

public function store(StoreUserRequest $request, UserService $userService)
{
$avatar = $userService->uploadAvatar($request);
$user = $userService->createUser($request->validated() + ['avatar' => $avatar]);
Auth::login($user);
$userService->sendWelcomeEmail($user);
NewUserNotifyAdminsJob::dispatch($user);
 
return redirect(RouteServiceProvider::HOME);
}

更短、更分离到不同的文件,而且仍然可读,对吧?再说一遍,这只是一种完成任务的方法,您可以决定以其他方式进行结构化。

但这并非全部。让我们也讨论一下“被动”的方式。


6. 事件/监听器

从哲学上讲,我们可以将 Controller 方法中的所有操作分成两种类型:主动和被动。

  1. 我们主动创建用户并将其登录
  2. 然后,使用该用户的某些操作可能会(也可能不会)在后台发生。因此,我们被动地等待其他操作:发送欢迎电子邮件和通知管理员。

所以,作为代码分离的一种方式,它不应该在 Controller 中调用,而应该在发生某些事件时自动触发。

您可以使用 事件和监听器 的组合来实现它

php artisan make:event NewUserRegistered
php artisan make:listener NewUserWelcomeEmailListener --event=NewUserRegistered
php artisan make:listener NewUserNotifyAdminsListener --event=NewUserRegistered

事件类应该接受 User 模型,然后将其传递给该事件的任何监听器。

app/Events/NewUserRegistered.php

use App\Models\User;
 
class NewUserRegistered
{
use Dispatchable, InteractsWithSockets, SerializesModels;
 
public User $user;
 
public function __construct(User $user)
{
$this->user = $user;
}
}

然后,事件从 Controller 派发,如下所示

public function store(StoreUserRequest $request, UserService $userService)
{
$avatar = $userService->uploadAvatar($request);
$user = $userService->createUser($request->validated() + ['avatar' => $avatar]);
Auth::login($user);
 
NewUserRegistered::dispatch($user);
 
return redirect(RouteServiceProvider::HOME);
}

并且,在 Listener 类中,我们重复相同的逻辑

use App\Events\NewUserRegistered;
use App\Services\UserService;
 
class NewUserWelcomeEmailListener
{
public function handle(NewUserRegistered $event, UserService $userService)
{
$userService->sendWelcomeEmail($event->user);
}
}

再一个

use App\Events\NewUserRegistered;
use App\Notifications\NewUserAdminNotification;
use Illuminate\Support\Facades\Notification;
 
class NewUserNotifyAdminsListener
{
public function handle(NewUserRegistered $event)
{
foreach (config('app.admin_emails') as $adminEmail) {
Notification::route('mail', $adminEmail)
->notify(new NewUserAdminNotification($event->user));
}
}
}

使用事件和监听器的方法有什么优势?它们用作代码中的“钩子”,任何其他人在未来都可以使用该钩子。换句话说,您在告诉未来的开发人员:“嘿,用户已注册,事件发生了,现在如果您想添加一些其他操作,只需为此创建您的监听器即可”。


7. 观察者:“静默”事件/监听器

在这种情况,也可以使用 模型观察者 实现非常类似的“被动”方法。

php artisan make:observer UserObserver --model=User

app/Observers/UserObserver.php:

use App\Models\User;
use App\Notifications\NewUserAdminNotification;
use App\Services\UserService;
use Illuminate\Support\Facades\Notification;
 
class UserObserver
{
public function created(User $user, UserService $userService)
{
$userService->sendWelcomeEmail($event->user);
 
foreach (config('app.admin_emails') as $adminEmail) {
Notification::route('mail', $adminEmail)
->notify(new NewUserAdminNotification($event->user));
}
}
}

在这种情况下,您不需要在 Controller 中分派任何事件,观察者会在 Eloquent 模型创建后立即触发。

方便,对吧?

但是,在我个人看来,这是一个有点危险的模式。不仅实现逻辑隐藏在 Controller 中,而且这些操作的存在也不清楚。想象一下,一年后新开发人员加入团队,他们会在维护用户注册时检查所有可能的观察者方法吗?

当然,可以弄清楚,但这仍然不明显。我们的目标是使代码更易于维护,因此“惊喜”越少越好。所以,我不是很喜欢观察者。


结论

现在看看这篇文章,我意识到我只触及了可能代码分离的皮毛,而且是在一个非常简单的示例上。

事实上,在这个简单的例子中,似乎我们使应用程序更加复杂,创建了更多的 PHP 类,而不是只有一个类。

但是,在这个示例中,这些单独的代码部分很短。在现实生活中,它们可能要复杂得多,通过将它们分离,我们使它们更容易管理,因此每个部分都可以由不同的开发人员处理,例如。

总的来说,我将最后一次重复:您负责您的应用程序,只有您决定将代码放在哪里。目标是您或您的团队成员将来能够理解它,并且在添加新功能以及维护/修复现有功能时不会遇到麻烦。

PovilasKorop photo

课程和教程创建者,来自 Laravel Daily

归档于
Cube

Laravel 新闻

加入 40,000 多名其他开发人员,永远不要错过新的提示、教程等。

Laravel Forge logo

Laravel Forge

轻松创建和管理您的服务器,并在几秒钟内部署您的 Laravel 应用程序。

Laravel Forge
Tinkerwell logo

Tinkerwell

Laravel 开发人员必备的代码运行器。使用 AI、自动完成和对本地和生产环境的即时反馈进行微调。

Tinkerwell
No Compromises logo

不妥协

Joel 和 Aaron,来自不妥协播客的两名经验丰富的开发者,现在可以为您的 Laravel 项目聘请。 ⬧ 每月固定费用 7500 美元。 ⬧ 无需冗长的销售流程。 ⬧ 无需合同。 ⬧ 100% 退款保证。

不妥协
Kirschbaum logo

Kirschbaum

提供创新和稳定性,以确保您的 Web 应用程序成功。

Kirschbaum
Shift logo

Shift

正在运行旧版本的 Laravel 吗?即时、自动化的 Laravel 升级和代码现代化,以保持您的应用程序新鲜。

Shift
Bacancy logo

Bacancy

让您经验丰富的 Laravel 开发人员(具有 4-6 年的经验)以每月 2500 美元的价格为您的项目加油。获得 160 小时的专用专业知识和 15 天的无风险试用。立即安排通话!

Bacancy
Lucky Media logo

Lucky Media

现在就幸运 - Laravel 开发的理想选择,拥有十多年的经验!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel 电子商务

Laravel 的电子商务。一个开源软件包,将现代无头电子商务功能的力量带入 Laravel。

Lunar: Laravel 电子商务
LaraJobs logo

LaraJobs

官方 Laravel 工作板

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS 启动工具包

SaaSykit 是一款 Laravel SaaS 启动工具包,它具有运行现代 SaaS 所需的所有功能。付款、精美结账、管理面板、用户仪表板、身份验证、现成组件、统计信息、博客、文档等等。

SaaSykit: Laravel SaaS 启动工具包
Rector logo

Rector

您无缝 Laravel 升级、降低成本和加速创新,帮助成功企业的合作伙伴

Rector
MongoDB logo

MongoDB

通过 MongoDB 和 Laravel 的强大集成增强您的 PHP 应用程序,使开发人员能够轻松高效地构建应用程序。支持事务性、搜索、分析和移动用例,同时使用熟悉的 Eloquent API。了解 MongoDB 的灵活、现代数据库如何改变您的 Laravel 应用程序。

MongoDB
Maska is a Simple Zero-dependency Input Mask Library image

Maska 是一个简单的无依赖输入掩码库

阅读文章
Add Swagger UI to Your Laravel Application image

将 Swagger UI 添加到您的 Laravel 应用程序

阅读文章
Assert the Exact JSON Structure of a Response in Laravel 11.19 image

在 Laravel 11.19 中断言响应的精确 JSON 结构

阅读文章
Build SSH Apps with PHP and Laravel Prompts image

使用 PHP 和 Laravel 提示构建 SSH 应用程序

阅读文章
Building fast, fuzzy site search with Laravel and Typesense image

使用 Laravel 和 Typesense 构建快速、模糊的网站搜索

阅读文章
Add Comments to your Laravel Application with the Commenter Package image

使用 Commenter 包向您的 Laravel 应用程序添加评论

阅读文章