使用 Services, Events, Jobs, Actions, 等重构 Laravel 控制器(一)
关于 Laravel 我们常有这样一个疑惑,该如何组织项目结构。如果我们再具体一点,这个问题更像是在说“如果逻辑不该放在控制器中,那应该放哪呢?”
问题在于,对这样的疑问并没有一个单一的标准答案。Laravel 给了你自己选择组织代码架构的灵活性,这既是福音也是诅咒。在 Laravel 官方文档中,你看不到任何建议。接下来,让我们尝试总金额根据一个的具体的案例对多种可能进行探讨吧。
注意:由于并非只有一种方式来组织这些代码结构,本文将充满附注,假设和类似的片段。建议不要跳过,通读全文,这样可以了解最佳实践的所有例外。
假设你有一个控制器方法,用来实现注册用户的相关事项:
public function store(Request $request)
{
// 1. 验证表单
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// 2. 新建用户
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
// 3. 上传头像,更新用户
if ($request->hasFile('avatar')) {
$avatar = $request->file('avatar')->store('avatars');
$user->update(['avatar' => $avatar]);
}
// 4. 登录
Auth::login($user);
// 5. 生成一张代金券
$voucher = Voucher::create([
'code' => Str::random(8),
'discount_percent' => 10,
'user_id' => $user->id
]);
// 6. 通过欢迎邮件发送代金券
$user->notify(new NewUserWelcomeNotification($voucher->code));
// 7. 通知管理员新用户注册
foreach (config('app.admin_emails') as $adminEmail) {
Notification::route('mail', $adminEmail)
->notify(new NewUserAdminNotification($user));
}
return redirect()->route('dashboard');
}
准确地说,要做 7 件事。你可能也同意对于一个控制器而言有点太多了,因此我们需要将其中的逻辑分离并移到其他地方。但是具体该放到哪里呢?
- 服务(Services)?
- 队列(Jobs)?
- 事件(Events)/监听(listeners)?
- 动作(Action)类?
- 其他?
棘手的是,似乎以上所述都是正确答案。这可能是你应该从这篇文章中了解的主要信息了。我们将重点内容将会加粗标准。
你完全可以用任何你希望的方式组织你的项目结构
换言之,如果你在某个地方看到了一些推荐的组织方式,并不意味着你一定要在所有地方都采用。选择权总是在你手上。你需要选择让你自己和你的团队将来代码维护的时候舒服的结构。
至此,我甚至可以就此结束这篇文章了。但我想你还是希望多些”干货“,我们就对上面的代码尝试改造一下吧。
通用重构策略
首先需要做下声明,这样我们才能明确我们在做什么以及为什么这么做。我们的目标是,让控制器方法尽量简短,而不包含任何逻辑。
控制器方法要做三件事:
- 从路由或者其他输入中接收参数
- 调用逻辑类或方法,传入这些参数
- 返回结果: 试图,重定向,JSON等
因此,控制器是调用方法而非在它内部自己实现逻辑。
同时,请记住,这里推荐的仅仅是其中的一种实现,此外还会有很多种有效的实现方式。此处只是基于个人经验提供的一种实现。
1. 验证:表单 Request 类
这属于个人偏好,我倾向于将验证规则分离开来,Laravel 提供了一个很好的实现方案:表单验证类 Form Requests
因此,我们可以生成:
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()],
];
}
}
最后,在控制器方法中,我们用 StoreUserRequest $request
替换了 Request $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 类,可以以任何你希望的结构出现,因此你可以在任意文件夹内手动创建。
一般来说,服务类会在同一个实体或者模型有不止一个方法时创建。因此,此处新建 UserServices
类,我们假定了将来会有更多方法,而不只是新建用户。
同时,服务方法需要返回一些东西(因此,才叫提供服务)。相比而言,调用动作(Actions) 和 队列(Jobs) 则不期待任何返回结果。
以我为例,我会创建 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()
方法不必关心请求数据,我们就可以从 Artisan 命令或者其他地方调用它了 - 头像上传与用户创建操作分离了
你可能会觉得服务方法太小而不好分离,不过这只是一个非常简化的例子:在真实的项目中,文件上传和用户创建逻辑都可能更加复杂。
此处,我们稍微有点偏离了”让控制器精简“的上帝法则,多添加了一行代码。不过在我看来,这是出于正当理由的。
3. 或者可以用动作(Action)代替服务(Service)?
最近几年,Action类的概念在Laravel社区流行起来了。它的逻辑是:一个 Action 只使用一个单独的类。此例,可以有几个 Action 类:
- 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 类的最好例子,因为我个人不是很喜欢 Action,用的也不多。其他的例子,你可以看看 Laravel Fortify 的代码。