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

    // ...

再次强调: 这只是一种拆分简化控制器的方法,你可以按你的想法来简化控制器.

这是我的逻辑:

  1. 因为createUser()方法不依赖Request类, 我们可以在Artisan命令中或者其它任何地方调用它。
  2. 头像上传与用户创建操作分开

你可能会认为这个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. 事件/监听

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

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

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

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

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\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 类,而不仅仅是一个。

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

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

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://laravel-news.com/controller-refa...

译文地址:https://learnku.com/laravel/t/68751

本帖已被设为精华帖!
本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
讨论数量: 17
Summer

这是一篇实践性很强的文章。

作为练习,我分享下我的做法:

public function store(StoreUserRequest $request, UserService $userService, VoucherService $voucherService)
{
    // 1. 创建用户,create 里如果有头像就上传,否则用默认
    //     这样在除了控制器的其他地方都可以调用此方法来创建用户,
    //     且,头像这个是创建用户时,必须会有的,封装到 createUser 里保证
    //     团队其他开发者创建用户时不会被遗漏
    $user = $userService->create($request->validated(), $request->file('avatar'));

    //  2. 登录用户
    Auth::login($user);


    // 3. 创建代金券,因为以后说不定创建代金券会有自己的逻辑,这个要看具体的业务逻辑,
    //    例如说创建以后发送短信给用户,如果在系统中这件事是必须的,不论此方法里在
    //    命令行或后台的逻辑中,调用都可以保证调用者不会遗漏。
    //    VoucherService 统一处理 Voucher 的具体逻辑,不能放到 UserService 里,
    //    边界模糊了,你会发现啥都能放到 UserService。
    $voucher = $voucherService->create($user);


    // 4. 发送新用户欢迎邮件,欢迎邮箱不应该跟创建 voucher 的逻辑放到一起
    $userService->sendWelcomeEmail($user);

    // 5. 通知管理员
    NewUserNotifyAdminsJob::dispatch($user);

    // 6. 重定向到首页
    return redirect(RouteServiceProvider::HOME);
}

另外,一般项目中,我不使用 Events/listeners ,包括不使用 Model 事件。

使用事件设计模式的问题是:

  1. 会出现太多的隐性代码,不方便阅读和维护(属于过度设计);
  2. 太灵活,几乎所有的场景『事件模式』都能做,任何一个方法的调用,都是一个模式,边界很难定义,会导致混乱的代码结构。

关于不使用 Model 事件,原因有以下:

  1. 模型的增加,修改和删除,不应该附带 side effect。设想下你会在不同的场景下创建一个模型,控制器、管理员后台、命令行、测试代码、seed 里,很多时候,业务逻辑会不一样。例如说创建用户时,模型监听器 created 里给用户发邮件,这在控制器中听起来没毛病,但是在 seed 或者测试代码中,就很扯。
  2. 隐性代码,不直观,不方便维护,有种代码黑洞的感觉,不知道后面还会藏哪些逻辑;
  3. 模型的附带逻辑,应该在 Service 层写出来。Service 有对应的 create/update/delete 方法,既直观,又方便维护。

最后,如果上面是在一个 API 的控制器方法中,我会把除了 Auth::login($user); 以外的逻辑挪到 UserService 的 signup 方法中。注意有 V2 功能的时候,或者 Web 的注册控制器里需要的话,都可复用。

参考:

2周前 评论
Yoooooo 2周前

我有个问题 service接受一个一个单个的参数好 还是接受一个request对象好

2周前 评论
chowjiawei 2周前
周小云 (作者) 2周前
laravel_peng 2周前
Summer 2周前
周小云 (作者) 2周前
putyy 2周前
周小云 (作者) 2周前
周小云 (作者) 2周前

很棒!个人比较喜欢大道至简的风格,当然大型项目肯定还是要分层合理!

2周前 评论
Mumujin

分享下我的实战



    public function __construct(
        protected Guard $guard,
        protected Application $app
    ) {
    }

    public function create(Request $request, View $view)
    {
        return $view->make('auth.register');
    }

    public function store(RegisteredUserRequest $request, CreatesNewUsersInterface $creator): RegisterResponseInterface
    {
        $this->app['events']->dispatch(
            new Registered(
                $user = $creator->create($request->all())
            )
        );

        $this->guard->login($user);

        return $this->app->make(RegisterResponseInterface::class);
    }
2周前 评论

我觉得还可以继续分,service承接了controller的大部分工作,然后service里又因为业务比较复杂,可以把涉及到数据库操作或者其他操作再细分到具体的repository(数据仓库)。这样更符合单一原则。例如:

// 控制器,省略了构造方法
public function store(Request $request){
    //数据校验完成后有一个新的$data
    ...
    //调用service
    return $this->Usersevice->login($data)
}

// service
class UserService
{
    //省略了构造方法
    public function login($data) {
        $user = $this->uerRopository->create($data);
        if($user){
            ...
        }
        ...
    }
}

//数据仓库
class UserRespositoy {
    public funtion create($data) {
        return User::create([
        'name' => $request->name,
        'email' => $request->email,
        'password' => Hash::make($request->password),
    ]);
    }
}
1周前 评论
周小云 1周前

我是一个功能接口对应一个Service类,比如UserLoginService,这个Service中会有很多function负责不同的环节。 其中涉及到公共可封装的部分,单独提出来并且只能从每个function中调用,比如getUserInfoByAccount(string $account)

1周前 评论

那如果service里面的错误应该怎么样处理呢? 是直接抛出异常还是返回错误? 如果是错误的话控制器里面是不是还需要多一层判断

5小时前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!