编程

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

1031 2022-05-29 00:07:22

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. 事件/监听

理论上讲,我们可以将这个控制器方法里面的操作分成两种:主动的和被动的。

  1. 我们主动地创建用户并让他们登录
  2. 然后在后台用户可能(也可能没有)涉及一些事项。因此,我们被动地等待这些其他操作:发送欢迎邮件及通知管理员。

因此,其中一种分离解耦代码的方式是,不在控制器中调用它,而是应该在有事件发生的时候自动触发。

你可以这样联合使用事件和监听器:

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

事件类应该接收一个用户模型,之后将其传入事件对应的监听器中。

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

然后,事件像这样在控制器中分发:

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

在监听器类中,我们重复相同的业务逻辑:

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

这样,你就不需要在控制器中派发任何事件,模型观察者会在 Eloquent 模型创建之后立即触发观察者。

很方便,是吧?

不过,个人觉得,这种模式有点危险。不只是因为在控制器中隐藏了业务逻辑的实现,同时这些操作的存在并不清晰。想象一下,一个新加入团队的开发者,他们会在维护用户注册的时候去检查所有可能的观察者方法吗?

当然,可能还是可以搞定,不过它仍然是不明显的。我们的目的是让代码具有更高的可维护性,因此少点"惊喜"会更好。因此,我也不是太喜欢观察者。

结语

回看这篇文章,我发现我只是非常肤浅地说明了代码分离的可能,用了非常简单的例子。

事实上,在这个简单地例子中,看起来更像是我们通过新建许多不同的PHP类,使应用变得更加复杂。不过,例子中这些分离的代码都比较短小。真实的开发中,可能远比此复杂,通过分离代码,我们可以更好的管理代码,每一部分可能会有由不同的开发者处理实现。我最后重申一次:你自己的应用最终还是要由你自己决定将其放在何处。目的在于让你和你的团队在将来还能理解这些代码,并且在添加新特性、维护代码修复漏洞时不会产生疑惑。