编程

使用 Services, Events, Jobs, Actions, 等重构 Laravel 控制器(一)

1383 2022-05-28 21:34:02

关于 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]);
 
    // ...

再重复一次:不止一种方法可以分离,你可以用其他方式实现。

我的逻辑是:

  1. 现在的 createUser() 方法不必关心请求数据,我们就可以从 Artisan 命令或者其他地方调用它了
  2. 头像上传与用户创建操作分离了

你可能会觉得服务方法太小而不好分离,不过这只是一个非常简化的例子:在真实的项目中,文件上传和用户创建逻辑都可能更加复杂。

此处,我们稍微有点偏离了”让控制器精简“的上帝法则,多添加了一行代码。不过在我看来,这是出于正当理由的。

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 的代码。