Laravel 代码重构:使用 Services, Events, Jobs, Actions 来重构控制器方法

我听到关于 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');
}

确切地说,有七件事。你们可能都会同意,对于一个控制器方法来说,这太多了,我们需要分离逻辑并将各个部分移动到某个地方。但具体在哪里?

Services?
Jobs?
Events/listeners?
Action classes?
Something else?
最棘手的是以上所有内容都是正确答案。这可能是您应该从本文中获得的主要信息。我会用粗体和大写字母为您强调它。

您可以随意构建您的项目。

那里,我已经说过了。换句话说,如果您在某处看到一些推荐结构,并不意味着您必须跳到任何地方去应用它。选择永远是你的。您需要选择适合自己和未来团队的结构,以便以后维护代码。

有了这些,我甚至可以现在就结束这篇文章。但您可能想吃点「干货」,对吧? 好的,让我们来看看上面的代码。

一般重构策略
首先,「免责说明」,这样我们就可以清楚知道我们在这里做什么以及为什么。我们的总体目标是使控制器方法更短,这样它就不会包含任何逻辑。
控制器方法需要做三件事:

接收来自路由或其他输入的参数
调用一些 逻辑 / 方法,传递这些参数
返回结果:视图、重定向、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. 创建用户: Service 类
接下来,我们需要创建一个用户并为他上传头像:

// 创建用户
$user = User::create([
    'name' => $request->name,
    'email' => $request->email,
    'password' => Hash::make($request->password),
]);

// 头像上传并更新用户
if ($request->hasFile('avatar')) {
    $avatar = $request->file('avatar')->store('avatars');
    $user->update(['avatar' => $avatar]);
}

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

放置这种逻辑的一个很常见的地方是围绕一个模型的操作创建一个单独的 PHP 类。它被称为 Service 类,但这只是为控制器「提供服务」的 PHP 类的「花哨」官方名称。

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

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

此外,服务通常具有 返回某些内容 的方法 (因此,「提供服务」)。相比之下, Actions 或 Jobs 通常不需要返回任何内容。

在我的例子中,我将暂时使用一个方法来创建一个类。

namespace App\Services;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;

class UserService
{
    public function createUser(Request $request): User
    {
        // 创建用户
        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);

        // 头像上传并更新用户
        if ($request->hasFile('avatar')) {
            $avatar = $request->file('avatar')->store('avatars');
            $user->update(['avatar' => $avatar]);
        }

        return $user;
    }
}

然后,在 Controller 中,我们可以将这个 Service 类作为方法的参数类型提示,并调用里面的方法。

use App\Services\UserService;

class RegisteredUserController extends Controller
{
    public function store(StoreUserRequest $request, UserService $userService)
    {
        $user = $userService->createUser($request);

        // 登录等操作...

是的,我们不需要在任何地方调用 new UserService() 。 Laravel 允许你在控制器中输入任何类似这样的类,你可以阅读更多关于方法注入的信息 在这个文档中.

2.1. 单一职责原则的服务类
现在,Controller 更短了,但是这种简单的复制粘贴分离代码有点问题

第一个问题是 Service 方法应该像一个「黑匣子」,它只接受参数而不知道这些参数来自哪里。因此,将来可以从 Controller、Artisan 命令或 Job 调用此方法。

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

因此,我们还需要两个「层」:一个用于文件上传,另一个用于从 $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 命令中或者其它任何地方调用它。
头像上传与用户创建操作分开
你可能会认为这个 Service 方法颗粒度太小了,无法将它们分开,但这只是一个非常简单的例子:实际项目中,文件上传方法和用户创建逻辑要复杂的多.

在这种情况下,我们可能稍稍违背了” 让控制器更简短的原则”,并且多加了一行代码,但在我看来,这是合理的.

3. 或者用 Action 替代 Service?
近些年,Action 类的概念在 Laravel 社区中流行起来了。它的逻辑是,为一个 Action 单独创建一个类。此例中,可以有如下 Action 类:

CreateNewUser
UpdateUserPassword
UpdateUserProfile
etc.
因此,你可以看到,围绕用户的多个相同操作,不放在同一个 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 的代码。

4. 4. 代金券创建:放在同一个服务类还是不同的服务类?
接下来,在控制器方法中,还有三个操作:

Auth::login($user);

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

$user->notify(new NewUserWelcomeNotification($voucher->code));

控制器中此处的登录操作会保留不变,因为它已经像服务类一样调用外部的 Auth 类了,我们并不需要了解它的底层实现。

不过对于此例中的代金券,控制器包含了代金券如何创建及发送给用户的逻辑代码。

首先,我们需要将代金券的创建移到一个单独的类中:我在犹豫新建一个代金券服务类是 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;
    }
}

然后,在控制器中,我们这样调用它:

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));
    }

然后,控制器将只有一行代码:

$userService->sendWelcomeEmail($user);

5. 通知管理员:队列任务
最后,我们还有这么一段代码:

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

它用来发送多封邮件,可能需要消耗一些时间,因此我们将其放到队列之中,在后台运行。这里就用到了队列任务。

Laravel 通知类可以是 queueable 的,以此为例,我们可以想象它比单纯的发送通知邮件更为复杂。因此我们为其创建了一个队列。

这种情况下,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));
        }
    }
}

然后,在控制器中,我们就可以传入参数调用队列:

use App\Jobs\NewUserNotifyAdminsJob;

class RegisteredUserController extends Controller
{
    public function store(StoreUserRequest $request, UserService $userService)
    {
        // ...

        NewUserNotifyAdminsJob::dispatch($user);

至此,我们将所有的业务逻辑从控制器移到了其他地方,我们简要回顾一下:

 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. 事件 / 监听
理论上讲,我们可以将这个控制器方法里面的操作分成两种:主动的和被动的。

我们主动地创建用户并让他们登录
然后在后台用户可能 (也可能没有) 涉及一些事项。因此,我们被动地等待这些其他操作:发送欢迎邮件及通知管理员。
因此,其中一种分离解耦代码的方式是,不在控制器中调用它,而是应该在有事件发生的时候自动触发。

你可以为此使用 事件和监听器 的组合:

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 派发 Event,如下所示:

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\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));
        }
    }
}

在这种情况下,你不需要在控制器中发送任何事件,观察者在 Eloquent 模型创建后立即触发。

很方便,对吧?

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

当然,有可能弄清楚,但仍然不明显。而我们的目标是让代码更易于维护,所以「惊喜」越少越好。所以,我不是观察者模式的忠实粉丝。

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

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

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

总而言之,我将最后一次重复:你负责你的应用程序,只有你自己决定将代码放在哪里。目标是让你或你的队友将来能够理解它,并且在添加新功能和维护 / 修复现有功能时不会遇到麻烦。

————————————————
原文作者:haodudecao
转自链接:https://learnku.com/laravel/t/68751
版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请保留以上作者信息和原文链接。

微信公众号
手机浏览(小程序)
0
分享到:
没有账号? 忘记密码?