Laravel: Чистка контроллеров
Проблема с раздутыми контроллерами
Раздутые контроллеры могут быть источником ряда проблем для разработчиков. Они могут:
- Сделать сложным отслеживание определённого фрагмента кода или функционала. Если вы хотите поработать над конкретным фрагментом кода, который находится в раздутом контроллере. Вам может потребоваться некоторое время для поиска в каком методе контроллера фактически размещается код. При использовании частых контроллеров, которые логически разделены, это сделать намного проще.
- Сделать сложным определение точного местоположения бага. Как мы увидим в примерах кода позже, если мы обрабатываем авторизацию, валидацию, бизнес-логику и построение ответа на запрос в одном месте, довольно сложно определить точное местоположение бага.
- Сделать сложным написание тестов для комплексных запросов. Иногда бывает сложно написать тесты для сложных методов контроллера, которые содержат много строк и выполняют множество разных вещей. Очистка кода значительно упрощает тестирование. Прочтите эту статью, если хотите узнать, как сделать ваше Laravel приложение более тестируемым.
Раздутый контроллер
В этой статье мы будем использовать в качестве примера UserController
:
class UserController extends Controller
{
public function store(Request $request): RedirectResponse
{
$this->authorize('create', User::class);
$request->validate([
'name' => 'string|required|max:50',
'email' => 'email|required|unique:users',
'password' => 'string|required|confirmed',
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => $request->password,
]);
$user->generateAvatar();
$this->dispatch(RegisterUserToNewsletter::class);
return redirect(route('users.index'));
}
public function unsubscribe(User $user): RedirectResponse
{
$user->unsubscribeFromNewsletter();
return redirect(route('users.index'));
}
}
Что бы код был удобным и легко читаемым, я не включил в контроллер методы index()
, create()
, edit()
, update()
и delete()
. Но мы сделаем предположение, что они есть, и что мы так же используем нижеприведённые способы для очистки этих методов. Большую часть статьи мы сосредоточим на оптимизации метода store()
.
1. Перенесите валидацию и авторизацию в запросы формы
Одно из первых действий, которое можно сделать с контроллером — это перенести любую валидацию и авторизацию из контроллера в класс запроса формы. Итак, давайте посмотрим, как мы могли бы сделать это для метода store()
нашего контроллера.
Воспользуемся следующей командой Artisan
для создания нового запроса формы:
php artisan make:request StoreUserRequest
Приведённая выше команда создаст новый класс app/Http/Requests/StoreUserRequest.php
со следующим содержимым:
class StoreUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
//
];
}
}
Мы можем использовать метод authorize()
для определения следует ли разрешать пользователю выполнять запрос. Метод возвращает true
, если пользователю разрешено выполнять запрос и false
, если нет. Мы так же можем использовать метод rules()
для указания любых правил проверки, которые должны выполнятся в теле запроса. Оба этих методов будут выполняться автоматически, до того как мы сможем выполнить какой-либо код внутри методов нашего контроллера без необходимости вызвать любой из них в ручную.
Итак, давайте переместим авторизацию из верхней части метода store()
нашего контроллера в метод authorize()
класса StoreUserRequest
. После того как мы это сделаем, мы можем переместить правила валидации из контроллера в метод rules()
. Теперь наш запрос формы должен выглядеть следующим образом:
class StoreUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return Gate::allows('create', User::class);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [
'name' => 'string|required|max:50',
'email' => 'email|required|unique:users',
'password' => 'string|required|confirmed',
];
}
}
А наш контроллер должен выглядеть так:
class UserController extends Controller
{
public function store(StoreUserRequest $request): RedirectResponse
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => $request->password,
]);
$user->generateAvatar();
$this->dispatch(RegisterUserToNewsletter::class);
return redirect(route('users.index'));
}
public function unsubscribe(User $user): RedirectResponse
{
$user->unsubscribeFromNewsletter();
return redirect(route('users.index'));
}
}
Обратите внимание, как в нашем контроллере мы изменили первый аргумент метода store()
с \Illuminate\Http\Request
на наш новый \App\Http\Requests\StoreUserRequest
. Нам так же удалось уменьшить раздувание метода контроллера перенеся из него авторизация и валидацию в класс запроса.
Примечание: Что бы это работало автоматически, вам необходимо убедиться, что ваш контроллер использует трейты \Illuminate\Foundation\Auth\Access\AuthorizesRequests
и \Illuminate\Foundation\Validation\ValidatesRequests
. Они автоматически выключаются в контроллер, который Laravel предоставляет вам при новой инсталляции. Итак, если вы используете эти трейты в своём контроллере, то всё готово. Если нет, обязательно добавьте их.
2. Переместите общую логику в Actions или Сервисы
Ещё один шаг, который мы можем предпринять для очистки метода store()
, может заключаться в перемещении нашей «бизнес-логики» в отдельный Action или сервис класс.
В этом конкретном случае использования мы видим, что основная функциональность метода store()
заключается в создании пользователя, генерации его аватара и последующей отправке задания в очередь, которое регистрирует пользователя в рассылке. На мой взгляд, для этого примера больше подходит action, чем сервис. Я предпочитаю использовать action для небольших задач, которые выполняют только определённое действие. В то время как для больших объёмов кода, который потенциально может состоять из сотен строк и выполнять несколько задач больше подходит сервис.
Итак, давайте создадим наш action, создав новый каталог Actions
в каталоге приложения и создадим в нём новый класс StoreUserAction.php
. Теперь поместим в action следующий код:
use Illuminate\Foundation\Bus\DispatchesJobs;
class StoreUserAction
{
use DispatchesJobs;
public function execute(Request $request): void
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => $request->password,
]);
$user->generateAvatar();
$this->dispatch(RegisterUserToNewsletter::class);
}
}
Теперь нужно обновить наш контроллер для использования action:
class UserController extends Controller
{
public function store(StoreUserRequest $request, StoreUserAction $storeUserAction): RedirectResponse
{
$storeUserAction->execute($request);
return redirect(route('users.index'));
}
public function unsubscribe(User $user): RedirectResponse
{
$user->unsubscribeFromNewsletter();
return redirect(route('users.index'));
}
}
Как видите, мы перенесли бизнес-логику из метода контроллера в action. Это полезно, потому что, как я упоминал ранее, контроллеры, по сути, являются «клеем» для наших запросов и ответов. Итак, нам удалось снизить когнитивную нагрузку на понимание того, что делает метод, за счёт логического разделения кода. Например, если мы хотим проверить авторизацию или валидацию, мы знаем, что нужно проверить форму запроса. Если мы хотим проверить, что делается с данными запроса, мы можем проверить action.
Ещё одно огромное преимущество абстрагирования кода в эти отдельные классы состоит в том, что может значительно упростить и ускорить тестирование. Я кратко говорил об этом в своей прошлой статье, о том, как сделать ваше приложение Laravel более тестируемым; так что я рекомендую прочитать её, если вы ещё этого не сделали.
Использование DTO в Action
Ещё одно больше преимущество извлечения бизнес-логики в Сервисы и классы заключается в том, что теперь эту бизнес-логику мы можем использовать в разных местах без необходимости дублировать код. Предположим, что у нас есть UserController
, который обрабатывает традиционные веб-запросы и Api\UserController
, который обрабатывает API запросы. В качестве аргумента мы можем сделать предположение, что общая структура методов store()
для этих контроллеров будет одинаковой. Но что бы мы сделали, если бы в нашем API запросе мы не использовали поле email
, а использовали поле email_address
? Мы не сможем передать наш объект запроса в класс StoreUserAction
, потому что он будет ожидать объект запроса с полем email
.
Для решения этой проблемы, мы можем использовать DTO (Data Transfer Object). Это действительно полезный способ разделения данных и передачи их по коду вашей системы без тесной связи с чем-либо (в данном случае с запросом). Чтобы добавить DTO в наш проект, мы воспользуемся пакетом spatie/data-transfer-object
и установим его с помощью следующей команды composer:
composer require spatie/data-transfer-object
После установки пакета создадим новый каталог DataTransferObjects
внутри каталога App
и создадим новый класс StoreUserDTO.php
. Необходимо убедиться, что наш DTO расширяет Spatie\DataTransferObject\DataTransferObject
. Далее, мы можем определить наши три поля следующим образом:
class StoreUserDTO extends DataTransferObject
{
public string $name;
public string $email;
public string $password;
}
Когда мы это сделали, мы можем добавить новый метод к нашему запросу форм StoreUserRequest
для создания и возвращения класса StoreUserDTO
:
class StoreUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return Gate::allows('create', User::class);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [
'name' => 'string|required|max:50',
'email' => 'email|required|unique:users',
'password' => 'string|required|confirmed',
];
}
/**
* Build and return a DTO.
*
* @return StoreUserDTO
*/
public function toDTO(): StoreUserDTO
{
return new StoreUserDTO(
name: $this->name,
email: $this->email,
password: $this->password,
);
}
}
Теперь мы можем обновить наш контроллер, что бы передать DTO Action классу:
class UserController extends Controller
{
public function store(StoreUserRequest $request, StoreUserAction $storeUserAction): RedirectResponse
{
$storeUserAction->execute($request->toDTO());
return redirect(route('users.index'));
}
public function unsubscribe(User $user): RedirectResponse
{
$user->unsubscribeFromNewsletter();
return redirect(route('users.index'));
}
}
Наконец, нам нужно обновить метод Action, что бы он принимал в качестве аргумента DTO, а не объект запроса:
use Illuminate\Foundation\Bus\DispatchesJobs;
class StoreUserAction
{
use DispatchesJobs;
public function execute(StoreUserDTO $storeUserDTO): void
{
$user = User::create([
'name' => $storeUserDTO->name,
'email' => $storeUserDTO->email,
'password' => $storeUserDTO->password,
]);
$user->generateAvatar();
$this->dispatch(RegisterUserToNewsletter::class);
}
}
В результате всего этого мы полностью отделили наш Action от объекта запроса. Это означает, что мы можем повторно использовать этот Action в нескольких местах системы без привязки к определённой структуре запроса. Теперь мы так же сможем использовать этот подход в среде CLI или очереди заданий, которые не привязаны к веб-запросу. Например, если бы в нашем приложении была возможность импортировать пользователей из CSV файла, мы могли бы создать DTO из данных CSV и передать их Action.
Возвращаясь к нашей исходной проблеме наличия API запроса, который использует email_address
, а не email
. Мы можем решить её просто построив DTO и назначив DTO полю email
поле email_address
. Представим, что API запрос имеет собственный отдельный класс формы запроса. Он может выглядеть так:
class StoreUserAPIRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return Gate::allows('create', User::class);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [
'name' => 'string|required|max:50',
'email_address' => 'email|required|unique:users',
'password' => 'string|required|confirmed',
];
}
/**
* Build and return a DTO.
*
* @return StoreUserDTO
*/
public function toDTO(): StoreUserDTO
{
return new StoreUserDTO(
name: $this->name,
email: $this->email_address,
password: $this->password,
);
}
}
3. Используйте Контроллеры Ресурсов или Контроллеры одиночного действия
Отличный способ содержать контроллеры в чистоте — убедиться, что они являются «контроллерами ресурсов» или «контроллерами одиночного действия». Прежде чем идти дальше, давайте попытаемся обновить наш пример контроллера, и рассмотрим, что означают оба этих термина.
Контроллер ресурсов — это контроллер, который предоставляет функциональные возможности на основе определённого ресурса. В нашем случае этим ресурсом является модель User
, и мы хотим иметь возможность выполнять все операции CRUD (create, update, update, delete) с этой моделью. Контроллер ресурсов обычно содержит методы index()
, create()
, store()
, show()
, edit()
, update()
и destroy()
. Он необязательно должен включать все эти методы, но в нём не будет методов, которых нет в этом списке. Используя эти типы контроллеров мы можем сделать нашу маршрутизацию RESTful. Для получения информации о маршрутизации REST и RESTful ознакомьтесь с этой статьёй.
Контроллер одиночного действия — это контроллер, у которого есть только один общедоступный метод __invoke()
. Это действительно полезно, если у вас есть контроллер, который не подходит ни для одного из методов RESTful, которые есть в наших контроллерах ресурсов.
Основываясь на приведённой выше информации, мы видим, что наш контроллер UserController
можно улучшить, переместив метод unsubscribe
в собственный контроллер одиночного действия. Сделав в это мы сделаем UserController
контроллером ресурсов, который включает только методы ресурсов.
Давайте создадим новый контроллер используя следующую команду artisan:
php artisan make:controller UnsubscribeUserController -i
Обратите внимание, что мы передали параметр -i
команде, что бы новый контроллер был вызываемым, контроллером одиночного действия. Теперь у нас должен появиться контроллер, который выглядит так:
class UnsubscribeUserController extends Controller
{
public function __invoke(User $user)
{
//
}
}
Переместим код из нашего метода и удалим метод unsubscribe
из нашего старого контроллера:
class UnsubscribeUserController extends Controller
{
public function __invoke(User $user): RedirectResponse
{
$user->unsubscribeFromNewsletter();
return redirect(route('users.index'));
}
}
Убедитесь, что вы изменили маршрут в файле routes/web.php
и вызываете контроллер UnsubscribeController
, а не удалённый метод unsubscribe
из UserController
.
Вывод
Надеюсь эта статья дала вам представление о различных типах вещей, которые вы можете сделать для очистки контроллеров в своих проектах Laravel. Пожалуйста, помните, что методы, которые я использовал здесь — это моё личное мнение. Я уверен, что среди вас есть разработчики, которые используют совершенно другой подход к созданию своих контроллеров. Сама важная часть — это согласованность и использование подхода, который соответствует вашему (и вашей команды) рабочему процессу.
Я был бы рад услышать какие методы вы используете для написания чистых контроллеров.