SOLID
SOLID — принципы программирования, следуя которым можно добиться легко-масштабируемого и легко-поддерживаемого кода.
Controller Service Repository
Controller Service Repository — архитектурный паттерн, необходимый для разделения ответственности и помогающий соблюдать принципы SOLID в фреймворке Laravel
Контроллеры
Контроллеры — классы, отвечающие за обработку запросов. Таким образом ответственность контроллера — это формирование ответа на пользовательские запросы.
1. Принять запрос (request)
2. Запустить метод сервиса
3. Обработать исключения или возвращенное сервисом значение
4. Ответить (response) в нужном формате
<?php
namespace App\Http\Controllers;
use App\Exceptions\UserNotFoundException;
use App\Http\Controllers\Controller;
use App\Http\Requests\ShowUserRequest;
use App\Services\UserService;
use Illuminate\View\View;
final class UserController extends Controller
{
public function show(ShowUserRequest $request, UserService $service): View
{
try {
$user = $service->getUser($request->getUserId());
} catch (UserNotFoundException $exception) {
return view('user.not_found', ['user_id' => $request->getUserId()]);
}
return view('user.profile', ['user' => $user]);
}
}
Laravel дает возможность конвертировать исключения в Http ответы, добавив метод render, но это нарушает принципы SOLID, как минимум S — принцип единственной ответственности и не рекомендуется к применению.
<?php
namespace App\Exceptions;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class UserNotFoundException extends Exception
{
public function report(): ?bool
{
//
}
public function render(Request $request): View
{
return view('user.not_found', ['user_id' => $request->getUserId()]);
}
}
Исключение не должно определять какой контент получит пользователь. Негативные последствия такой реализации в том, что при необходимости получения разных ответов в разных методах контроллеров, одно и то же исключение может быть преобразовано по разному.
В таких случаях:
1. Метод render будет содержать различные условия, проверяющие из какого именно контроллера было выброшено исключение
2. Будет не очевидно, какие ответы какой контроллер возвращает в той или иной ситуации
То же самое справедливо и для преобразования исключений в методе Handler::register()
Сервисы
Сервисы — классы, отвечающие за бизнес логику. В Laravel по умолчанию не создана директория сервисов. Вы должны создать её самостоятельно.
<?php
namespace App\Services;
final class UserService
{
public function __construct(public readonly UserRepository $userRepository)
{
}
public function getUser(int $userId): User
{
//Выполняем различные бизнес проверки
...
$user = $this->userRepository->find($userId);
if ($user === null) {
throw new UserNotFoundException("User {$userId} not found.');
}
return $user;
}
}
В теле метода сервиса не выполняются запросы к базе данных. Только бизнесовая логика.
Репозитории
Репозитории — классы, отвечающие за сохранение и извлечение некоторого набора данных. В репозиториях нет сложной и тем более бизнесовой логики. В методах репозитория должны лишь формироваться и выполняться запросы. В Laravel по умолчанию не создана директория репозиториев. Вы должны создать её самостоятельно.
<?php
namespace App\Repositories;
final class UserRepository
{
public function find(int $userId): ?User
{
return User::find($userId);
}
}
В идеале репозиторий не должен возвращать или принимать объекты класса Model. Лучше создать собственные entity или dto.
Такой код может показаться излишне сложным. Но с опытом вы поймете, что разделение ответственности, делает работу с таким кодом и его поддержку значительно легче.
Комментарии (5)
CSR — одно из самых неудачных архитектурных решений со времен Б4.
Предлагается для каждой модели написать свой репозиторий и сервис к нему. Но, простите, а что делать, если, например, одной модели оказывается недостаточно? К примеру пользователю понадобился адрес, его куда прикажете запихивать? В UserRepository? Или уже в AddressRepository?
Если в последний, то как передать пользователя? Через id? Тогда будет N+1.
Через ids? Тогда что делать, если сам UserRepository используется в каком-нибудь CustomerService который, не к ночи будет сказано, подтягивает еще какой-нибудь OrderRepositiory?
Т.е. дальше одного шага проблема не решается принципиально, придется городить какие-то обходы выборок, сбор ids, передача всего этого в репы... и всё ради чего???
try {
$user = $service->getUser($request->getUserId());
}
Изюмительно. Я прямо сижу.. и старческие слезы умиления наворачиваются на глазах. Простите, а что Вы собираетесь делать, когда в запрос нужно будет передать новый параметр, кроме id? Ну, например, СНИЛС или что-то еще?
Давайте я Вам помогу. Небольшой реверс-энжинеринг.
Так как getUserId — это какой то малопонятный огород, вангую, что это обычная обертка над Request за каким-то бесом засунутая в виде метода в ShowUserRequest
protected int $userId;
public function __construct()
{
$this->userId = (int) $this->id;
}
public function getUserId(): int
{
return $this->userId
}
Зачем? Да просто, ради писанины. Ведь теперь, чтобы добавить новый параметр и передать его в сервис нужно еще методов в ShowUserRequest закинуть.
Почему нельзя просто сделать (int) $request->get('id', 0) ? Вопрос чисто риторический.
Ну оке.. с ид понятно, добавление всего одного поля в такую систему приводит к каскаду правок в
1. реквесте
2. контроллере
3. сервисе(он же получает не реквест, а параметры отдельно)
4. репозитории(его же нужно как то обработать)
Несколько многовато для такого простого действия.
catch (UserNotFoundException $exception) {
return view('user.not_found', ['user_id' => $request->getUserId()]);
}
тут еще интереснее. Вместо совершенно логичного и общего для любых моделей ModelNotFound требуется на каждый реп описать свой эксепшен. За ради что?
Т.е. в репе User::find($userId) заменить на User::findOrFail($userId) — уже не православно, а наогородить
if ($user === null) {
throw new UserNotFoundException("User {$userId} not found.');
}
— это феншуй. Ню... токооое.
«Негативные последствия такой реализации в том, что при необходимости получения разных ответов в разных методах контроллеров, одно и то же исключение может быть преобразовано по разному.»
Это в каком мире 404 страница должна быть преобразована по разному? Нет модели — пульнул одну для всех 404 и забыл. Я так понимаю, что разные ответы нужны чтобы программисты не скучали.
«В теле метода сервиса не выполняются запросы к базе данных. Только бизнесовая логика»
и это приводит к тому, что запросы выполняются катастрофически долго, а логика превращается в спагетти-стайл с вызовом сервисов внутри других сервисов обернутое циклами внутри других циклов со вложенностью 5 и более и нытьём что ой у нас все как то сложно получилось давайте перейдем на DDD там всё еще сложнее.
«Репозитории — классы, отвечающие за сохранение и извлечение некоторого набора данных»
Нет конечно. Ничего подобного репозитории НЕ ДЕЛАЮТ. Этим занимается DAO! Запомни, а лучше запиши и никогда в дальнейшем не путай!!!
Репозиторий — это высокоуровневая абстракция доступа к данным. Никаких изменений он делать не может и не умеет.
«В идеале репозиторий не должен возвращать или принимать объекты класса Model»
В сферическом вакууме, надо понимать. Там, где сферические кони.
Паттерн был придуман задолго до того, как появилась эта ваша богомерзкая DDD и предназначался, в первую очередь, для быстрого доступа к уже полученным данным. Возможно ты в курсе, что есть языки, которые не выгружаются из памяти всякий раз на каждом запросе, так вот в них(например в яве) репы очень комфортно себя чувствуют, так как являются эдаким кэшем справочника в памяти, ну и, разумеется, хранят они самые что ни на есть Models, а не эти ваши «энтити», простихоспиди.
«Лучше создать собственные entity или dto.»
Ага.. давай еще один огород сверху прикостылим. А то там абстракций чет маловато, ОРМ уже не хватает. Теперь для добавления одного поля мало того, что нужно будет сделать миграцию и добавить логику в сервис, так еще написать пару-десятков методов гетеров и сетеров в entity, dto, valueObject commandBus и всё заверте... но это не точно.
«Но с опытом вы поймете...»
что простое лучше чем сложное, что код, который никогда не ломается — это код, которого нет. Его проще сопровождать, проще добавлять новые фичи, проще разбираться с багами. YAGNI + KISS.
А нет простых решений. Идея-то в том, чтобы разделить инфраструктуру (база данных, кэш, файлы), бизнес логику и ввод-вывод (веб-сайт, API, событие, отправка почты ну или принтер). У автора статьи просто нелепый пример односложного CRUD, и к сожалению такие примеры в большинстве статей и даже книг. В Вашем примере, видимо,
UserRepository
будет иметь методыaddAddress($userId, $address)
,getAddress ($userId)
,saveUser($user, $address, $photos)
илиgetUser($id, $fields)
— чтобы получить пользователя с зависимыми сущностями. Исходить можно из того, что адрес принадлежит пользователю. Но разделение слоев минимально необходимо. В определенном смысле оно уже выполняется ORM, и можно пренебречь созданием отдельной сущности, если есть уверенность, что существенных изменений вноситься не будет (и дело не в отказе от ORM, а, например, во внедрении кэша, в централизованном ведении лога, отправке событий и так далее, тут уже нужно смотреть по обстоятельствам). Думаю, если не существует бизнес логики, можно пренебречь созданием спагетти-сервиса и, действительно, вернуть напрямую из репозитория, в данном случае представленном ORM. Так что код контроллераreturn User::findOrFail($id)
в принципе допустим. Тем более задача №1 создать прототип, а завернуть его в цепочку не составит труда, это не является тяжелым изменением. И в этом смысле Вы совершенно правы, просто разработчику стоит держать в уме разделение ввода-вывода вверх и вниз: в инфраструктуру приложения и внешние взаимодействия с клиентом, по возможности выделяя непосредственно логику в независимую и портируемую. Держаться именно за паттерн стоит разве что библиотечному приложению (вроде самого фреймворка, предполагающего максимальную универсальность и заменяемость компонент, как то делает ORM, абстрагируя от хранилища)..
«В Вашем примере, видимо, UserRepository будет иметь методы addAddress($userId, $address), getAddress ($userId), saveUser($user, $address, $photos) или getUser($id, $fields) — чтобы получить пользователя с зависимыми сущностями»
ок. Допустим. Я лишь хочу напомнить, что Пользователь — это актор. На него примерно 99.9% всего завязано, при таком подходе в UserRepository будет чуть менее чем всё, что касается пользователя(а его касается вообще всё) и поверьте, я видел такой трындец, получался классический God-class который назывался или UserController или UserRepository, не важно.
И Вы правильно подметили, что ActiveRecord — это уже абстракция которая уже содержит необходимое разделение, если так уж хочется иметь слои, а не модели — всегда можно вынести запросы в билдер, при этом остается гибкость композиции и нет мешанины методов. Тогда логику работы с моделью/билдером можно вынести в сервис, связи таблиц, если не требуется запись, можно сделать в релейшенах модели и использовать with, а вот уже для записи создать отдельный сервис.
Все так, о чем и речь, нет смысла делать разделение ради разделения, архитектору нужно взвесить возможное развитие проекта. К примеру, кэш вероятно придется однажды заменить с Redis на Memcached — вероятный сценарий, но вряд ли кто-то и когда-то будет значительно менять поток получения данных пользователя
user($id)
в контроллере, настолько, что придется подменять репозиторий или сервис, скорее всего смысл выносить логику в обычной жизни придется лишь тогда, когда захочется избежать повторения (скажем, если данные пользователя получаются разными действиями контроллера, нет смысла дублировать код, можно реализовать один методgetUser
). При этом, бизнес-логика (логика домена) как правило уже значительно разделена на слои фреймворком: валидация, проверка прав доступа — это все относится к слоям логики, наиболее частые операции. Ровно так же в некоторых сценариях может понадобиться, скажем, отделить DTO, если вводимые данные отличаются от сохраняемых (из головы пример, если для удобства пользователя адрес вводится с помощью JS-плагина с дополнением адресов, на сервер попадают строка и код реестра, а в базу идет расшифрованный адрес, и тут может понадобиться сервис, что выполнит такое преобразование).