使用服务、事件、作业、操作等重构 Laravel 控制器
发布时间 作者 PovilasKorop
我听到的最常见的 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 userif ($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]); // ...
我再次强调:这只是分离事物的一种方法,您可以采用其他方式。
但我的逻辑是这样的
createUser()
方法现在不知道任何关于 Request 的信息,我们可以在任何 Artisan 命令或其他地方调用它- 头像上传与用户创建操作分离
您可能认为服务方法太小,没有必要将它们分离,但这只是一个非常简化的例子:在实际项目中,文件上传方法以及用户创建逻辑可能会复杂得多。
在这种情况下,我们稍微偏离了“使控制器更短”的神圣规则,并添加了第二行代码,但在我看来,这是有充分理由的。
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 方法中的所有操作分成两种类型:主动和被动。
- 我们主动创建用户并将其登录
- 然后,使用该用户的某些操作可能会(也可能不会)在后台发生。因此,我们被动地等待其他操作:发送欢迎电子邮件和通知管理员。
所以,作为代码分离的一种方式,它不应该在 Controller 中调用,而应该在发生某些事件时自动触发。
您可以使用 事件和监听器 的组合来实现它
php artisan make:event NewUserRegisteredphp artisan make:listener NewUserWelcomeEmailListener --event=NewUserRegisteredphp 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 类,而不是只有一个类。
但是,在这个示例中,这些单独的代码部分很短。在现实生活中,它们可能要复杂得多,通过将它们分离,我们使它们更容易管理,因此每个部分都可以由不同的开发人员处理,例如。
总的来说,我将最后一次重复:您负责您的应用程序,只有您决定将代码放在哪里。目标是您或您的团队成员将来能够理解它,并且在添加新功能以及维护/修复现有功能时不会遇到麻烦。