Laravel: Рефакторинг контроллера

Источник: «Restructuring a Laravel Controller using Services, Events, Jobs, Actions, and more»
В этом руководстве вы узнаете о некоторых способах рефакторинга контроллера и использовании сервисов, событий, action классов и многое другое.

Один из часто задаваемых вопросов — Как структурировать проект. Если мы сузим его, большая часть прозвучит как Если логика не должна быть в контроллерах, то куда мы должны её переместить?.

Проблема в том, что на такие вопросы нет единственного правильного ответа. 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');
}

Семь разных вещей, если быть точным. Вы все, наверное, согласитесь, что это слишком много для одного метода контроллера, нужно разделить логику и куда-то переместить части. Но куда именно?

Самое сложное в том, что всё вышеперечисленное будет правильным ответом. Это, вероятно, главное сообщение, которое вы должны вынести из этой статьи. Я выделю это для вас, жирным шрифтом и заглавными буквами.

ВЫ МОЖЕТЕ СТРУКТУРИРОВАТЬ СВОЙ ПРОЕКТ КАК ХОТИТЕ

Вот, я сказал это. Другими словами, если вы где-то видите рекомендованную структуру, это не значит, что вам нужно применять её везде. Выбор всегда за вами. Вам нужно выбрать структуру, которая была бы удобной для вас и вашей команды, что бы потом поддерживать код.

На этом я, наверное, даже мог бы закончить статью прямо сейчас. Но вы, наверное, хотите немного мяса, верно? Хорошо, давайте поиграем с кодом размещённым выше.

Общая стратегия рефакторинга

Во-первых, disclaimer, чтобы было понятно, что мы здесь делаем и зачем. Наша общая цель — сделать метод Контроллера короче, чтобы он не содержал никакой логики.

Метод Контроллера должен делать три вещи:

Итак, контроллеры вызывают методы, а не реализуют логику внутри самого контроллера.

Кроме того, имейте в виду, что предложенные мной изменения — это только ОДИН способ сделать это, есть десятки других способов, которые тоже будут работать. Я просто предоставляю вам свои предложения, основанные на личном опыте.

1. Валидация: класс Запроса Формы

Это личное предпочтение, но мне нравится хранить правила валидации отдельно, и у Laravel есть отличное решение для этого: Запрос Формы.

Итак, генерируем:

php artisan make:request StoreUserRequest

Перемещаем правила валидации из контроллера в этот класс. Кроме того, нужно добавить класс Illuminate\Validation\Rules\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)
{
// Здесь не требуется $request->validate

// Создание пользователя
$user = User::create([...]) // ...
}
}

Ok, первое сокращение контроллера сделано. Давайте двигаться дальше.

2. Создание пользователя: Сервисный класс

Далее, нам нужно создать пользователя и загрузить для него аватар:

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

Если следовать рекомендациям, такой логики не должно быть в контроллере. Контроллеры не должны ничего знать о структуре БД пользователя или о том, где хранить аватары. Нужно просто вызвать какой-нибудь метод класса, который позаботится обо всём.

Довольно распространённым местом для размещения такой логики является создание отдельного PHP класса вокруг одной операции Модели. Он называется Сервисным Классом, но это просто причудливое официальное название PHP класса, который предоставляет сервис для контроллера.

Вот почему нет такой команды, как php artisan make:service — потому что это просто PHP класс с любой структурой, которую вы захотите. Поэтому вы можете создать его вручную в своей IDE или текстовом редакторе, в любой папке, где захотите.

Как правило, сервисные классы создаются, когда существует более одного метода для одной и той же сущности или модели. Таким образом, создавая здесь UserService, мы предполагаем, что в будущем в нём будет больше методов, а не только создание пользователя.

Кроме того, сервисные классы обычно содержат методы, которые что-то возвращают (то есть предоставляют сервис). Для сравнения Action или Задачи обычно вызываются, не ожидая ответа.

В моём случае, я пока создам класс 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
{
// Создание пользователя
$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;
}
}

Затем, в контроллере мы можем просто ввести этот сервисный класс в качестве параметра метода и вызвать внутри.

use App\Services\UserService;

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

// Вход и другие операции

Да, нам не нужно нигде вызывать new UserService(). Laravel позволяет указать любой класс, подобно этому, в контроллерах. Больше о Внедрение Методов можно узнать из официальной документации.

2.1. Сервисный класс с SRP

Теперь контроллер намного короче, но такой простой копи-паст перенос кода несколько проблематичный.

Первая проблема в том, что метод сервисного класса должен действовать как чёрный ящик, который просто принимает параметры и не знает откуда они берутся. Таким образом, этот метод можно вызвать из контроллера, из команды 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() теперь ничего не знает о Request, и мы можем вызвать его из любой команды Artisan или откуда-то ещё.
  2. Загрузка аватара отделена от операции создания пользователя.

Вам может показаться, что методы Сервисного класса слишком малы, чтобы разделять их, но это очень упрощённый пример: в реальных проектах метод загрузки файла может быть намного сложнее, как логика создания пользователя.

В данном случае мы немного отошли от правила сделать контроллер короче и добавили вторую строчку кода, но, на мой взгляд, по правильным причинам.

3. Может быть, Action класс, вместо Сервисного класса?

В последние годы концепция Action классов стала популярной в Laravel сообществе. Логика такова: у вас есть отдельный класс только для ОДНОГО действия (action). В нашем случае Action классы могут быть:

Итак, вы видите, те же самые множественные операции вокруг пользователей, только не в одном классе 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 классов. Так как я не большой их поклонник и мало ими пользовался. В качестве другого источника примеров вы можете взглянуть на код Laravel Fortify.

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 классы Notification могут быть поставлены в очередь, но для этого примера давайте представим, что может быть что-то более сложное, чем просто отправка уведомлений по электронной почте. Итак, давайте создадим для него Задание.

В этом случае 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;
}
}

Затем Событие отправляется из контроллера, например:

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 классов вместо одного.

Но в этом примере, эти отдельные части кода короткие. В реальной жизни они могут быть намного сложнее, и, разделив, мы сделали их более управляемыми. Поэтому, например, каждую часть может обрабатывать отдельный разработчик.

В общем, повторюсь в последний раз: вы отвечаете за своё приложение, и только вам решать, где разместить код. Цель состоит в том, чтобы вы или ваши товарищи по команде понимали это в будущем и не испытывали проблем с добавлением новых функций и поддержанием/исправлением существующих.

Дополнительные материалы

Предыдущая Статья

TypeScript: Выведение типа

Следующая Статья

Laravel: Эффективный Eloquent