Толстые контроллеры и модели — неизбежная проблема всех средних и крупных проектов, основанных на MVC-фреймворках таких как Yii и Laravel. Главная причина возникновения толстых контроллеров и моделей это “Active Record” — мощный и важный компонент таких фреймворков.
“Active Record” — архитектурный паттерн, подход к проектированию доступа к данным в БД. Он был описан Мартином Фаулером в 2003 году в книге “Patterns of Enterprise Application Architecture” и широко применяется в PHP-фреймворках.
Несмотря на всю мощь Active Record (AR), этот паттерн нарушает принцип единой ответственности (SRP) потому что AR-модель:
Такое нарушение SRP удобно для быстрой разработки, когда нужно создать прототип приложения как можно скорее, но крайне вредно для разросшихся приложений. Божественные модели и толстые контроллеры сложно тестировать и поддерживать. Использование моделей повсюду в коде ведет к огромному количеству проблем, когда становится нужно что-то изменить в структуре БД (в любом проекте изменения неизбежны).
Решение проблемы простое: поделить ответственность на несколько слоев, с использованием инъекций зависимости. Такой подход также упрощает тестирование, потому что позволяет мокать те слои, которые в данном тесте не тестируются.
Для реализации слоистой архитектуры нам пригодится ‘dependency injection container’ (DIC) — объект который знает как создавать и конфигурировать объекты. В Yii и Laravel всю магию берут на себя фреймворки и нет необходимости самому создавать такой класс контейнера.
class SiteController extends \Illuminate\Routing\Controller
{
protected $userService;
public function __construct(UserService $userService)
{
$this->userService = $userService;
}
public function showUserProfile(Request $request)
{
$user = $this->userService->getUser($request->id);
return view('user.profile', compact('user'));
}
}
class UserService
{
protected $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
public function getUser($id)
{
$user = $this->userRepository->getUserById($id);
$this->userRepository->logSession($user);
return $user;
}
}
class UserRepository
{
protected $userModel, $logModel;
public function __construct(User $user, Log $log)
{
$this->userModel = $user;
$this->logModel = $log;
}
public function getUserById($id)
{
return $this->userModel->findOrFail($id);
}
public function logSession($user)
{
$this->logModel->user = $user->id;
$this->logModel->save();
}
}
В этом примере `UserService` внедряется в `SiteController`, `UserRepository` внедряется в `UserService` и AR модели `User` and `Logs` внедряются в `UserRepository` class.
Современные MVC-фреймворки, такие как Laravel и Yii берут на себя такие обязанности контроллеров как обработка входных данных, правила роутинга и HTTP-запросов, тогда как пре-процессинг данных в таких фреймворках вынесен в отдельные компоненты, такие как middleware в Laravel и behavior в Yii. Поэтому программисту остается дописать в контроллер всего пару строчек кода.
Обязанность контроллера заключается в получении запроса от пользователя и отправке пользователю результатов. Контроллер не должен содержать никакой бизнес логики, иначе ее будет невозможно использовать повторно, что приведет к дублированию кода. Также это добавит сложностей при изменении типа ответа контроллера, к примеру для API. Если в контроллере нет ничего лишнего, то поменять формат вывода с отображения view на ответ API не составит никакого труда.
Слишком тонкий слой контроллера кажется неправильным и так как контроллер — это точка входа приложения, многие разработчики просто помещают весь код именно туда не задумываясь об архитектуре. В результате в контроллер добавляются такие зоны ответственности как:
Пример контроллера, выполняющего слишком много:
//A bad example of a controller
public function user(Request $request)
{
$user = User::where('id', '=', $request->id)
->leftjoin('posts', function ($join) {
$join->on('posts.user_id', '=', 'user.id')
->where('posts.status', '=', Post::STATUS_APPROVED);
})
->first();
if (!empty($user)) {
$user->last_login = date('Y-m-d H:i:s');
} else {
$user = new User();
$user->is_new = true;
$user->save();
}
return view('user.index', compact('user'));
}
Контроллер должен быть простой. Все, что он должен делать — это получать запрос и отдавать ответ. Например, такой:
//A good example of a controller
public function user (Request $request)
{
$user = $this->userService->getUserById($request->id);
return view('user.index', compact('user'));
}
Куда же деть все остальное? В более низкие слои.
Слой сервисов — это слой бизнес логики. Здесь и только здесь должна быть информация о бизнес процессах и взаимосвязях между бизнес моделями. Это абстрактный слой и он будет разным для разных приложений, но в целом общим остается независимость от типа входных данных (это ответственность контроллера) и типа хранения данных (это ответственность более низких слоев).
На этом этапе возникает одна из самых опасных проблем. Часто в контроллер возвращают AR модель. И как результат представление (или контроллер, если речь идет об API) знает об атрибутах и отношении модели. Это приводит к необходимости множественных правок, если нужно поменять поле в БД.
Вот пример использования AR модели в представлении:
<h1>{{$user->first_name}} {{$user->last_name}}</h1>
<ul>
@foreach($user->posts as $post)
<li>{{$post->title}}</li>
@endforeach
</ul>
Выглядит просто, но если нужно переименовать поле `first_name` менять придется все представления, что чревато ошибками, т.к что-то можно пропустить или забыть. Простое решение — использовать DTO — Data Transfer Objects.
Данные из сервисного слоя нужно обернуть в простой неизменяемый объект. Он не может быть изменен, после того как создан, поэтому сеттеры в таком классе не нужны. Более того, DTO класс должен быть независимым, он не должен наследоваться от Active Record модели. Часто бизнес модель может не совпадать с AR-моделью.
Представьте себе приложение по доставке товаров. По логике вещей заказ должен включать в себя доставку, но в БД мы храним заказы, прикрепленные к пользователю и адреса доставки, прикрепленные к юзеру. В этом примере бизнес модель заказ содержит в себе несколько AR-моделей, но верхнии слои не должны ничего об этом знать. Тогда если мы меняем AR-модель, относящуюся к данной бизнес модели (к примеру перенесем информацию об адресе доставки в таблице заказов), то нам нужно поменять только наш DTO-объект бизнес модели, а не сотни мест использования AR-модели повсюду.
Использование DTO позволяет исключить возможность использования AR-модели в контроллере или представлении. Также DTO-объект позволяет отделить физическое хранение данных от логического представления бизнес модели. Если что-то меняется на уровне БД, менять придется только DTO-объект, а не все контроллеры и представления.
Пример простого DTO-класса:
//Example of simple DTO class. You can add any logic of conversion from an Active Record object to business model here
class DTO
{
private $entity;
public static function make($model)
{
return new self($model);
}
public function __construct($model)
{
$this->entity = (object) $model->toArray();
}
public function __get($name)
{
return $this->entity->{$name};
}
}
//usage example
public function user (Request $request)
{
$user = $this->userService->getUserById($request->id);
$user = DTO::make($user);
return view('user.index', compact('user'));
}
Для отделения логики представлений (к примеру, цвет кнопки в зависимости от статуса), имеет смысл использовать дополнительный слой декораторов. Декоратор — паттерн, который позволяет приукрасить основной объект, обернув его в измененные методы. Это зачастую нужно для кусочков логики в представлениях.
DTO-объект может частично выполнять роль декоратора, но только для общих методов, таких как форматирование даты. DTO представляет бизнес модель, тогда как декоратор лишь украшает данные с помощью HTML для специфических страниц.
Так выглядит представление без декоратора:
<div class="status">
@if($user->status == \App\Models\User::STATUS_ONLINE)
<label class="text-primary">Online</label>
@else
<label class="text-danger">Offline</label>
@endif
</div>
<div class="info"> {{date('F j, Y', strtotime($user->lastOnline))}} </div>
Данный пример простой, но в более сложной логике запросто можно запутаться. На помощь приходит декоратор:
class UserProfileDecorator
{
private $entity;
public static function decorate($model)
{
return new self($model);
}
public function __construct($model)
{
$this->entity = $model;
}
public function __get($name)
{
$methodName = 'get' . $name;
if (method_exists(self::class, $methodName)) {
return $this->$methodName();
} else {
return $this->entity->{$name};
}
}
public function __call($name, $arguments)
{
return $this->entity->$name($arguments);
}
public function getStatus()
{
if($this->entity->status == \App\Models\User::STATUS_ONLINE) {
return '<label class="text-primary">Online</label>';
} else {
return '<label class="text-danger">Offline</label>';
}
}
public function getLastOnline()
{
return date('F j, Y', strtotime($this->entity->lastOnline));
}
}
public function user (Request $request)
{
$user = $this->userService->getUserById($request->id);
$user = DTO::make($user);
$user = UserProfileDecorator::decorate($user);
return view('user.index', compact('user'));
}
Теперь можно использовать атрибуты модели в представлении без лишних условий и логики, что делает представление читабельнее.
<div class="status"> {{$user->status}} </div>
<div class="info"> {{$user->lastOnline}} </div>
Декораторы также можно комбинировать друг с другом:
public function user (Request $request)
{
$user = $this->userService->getUserById($request->id);
$user = DTO::make($user);
$user = UserDecorator::decorate($user);
$user = UserProfileDecorator::decorate($user);
return view('user.index', compact('user'));
}
Каждый декоратор выполнит свою работу и преобразует только ту часть, за которую он отвечает. Такая динамическая вложенность декораторов позволяет комбинировать возможности декораторов без создания новых классов.
Репозиторий работает с конкретным способом хранения данных. Лучше всего внедрять репозиторий через интерфейс, тогда его легко будет подменить в случае смены типа БД. При изменении хранилища данных достаточно создать новый класс репозитория, реализующий такой интерфейс и не придется менять весь код.
Репозиторий получает данные из БД и управляет работой нескольких AR-моделей. AR-модель в данном контексте выполняет роль Entity. Entities — это примитивный объект системы, который хранит в себе информацию, но не знает ничего о том, как он появился (только создан или получен из БД) или как сохранить или обновить свое состояние. Сохранение и обновление AR моделей — это ответственность репозитория. Такой подход помогает разделить ответственность — управление AR-моделями лежит на репозитории, а сами AR-модели — примитивные хранилища данных.
Пример метода репозитория:
public function getUsers()
{
return User::leftjoin('posts', function ($join) {
$join->on('posts.user_id', '=', 'user.id')
->where('posts.status', '=', Post::STATUS_APPROVED);
})
->leftjoin('orders', 'orders.user_id', '=', 'user.id')
->where('user.status', '=', User::STATUS_ACTIVE)
->where('orders.price', '>', 100)
->orderBy('orders.date')
->with('info')
->get();
}
В только что созданном Yii или Laravel приложении есть только папки сontrollers, models, и views. Ни Yii, ни Laravel не добавляют дополнительные слои в пример приложения. Простая и интуитивно понятная даже для новичков MVC структура упрощает работу с фреймворком, но важно понимать, что такая архитектура — это только пример, а не стандарт или стиль, насаживаемый фреймворком.
Разделив задачу на слои с единой ответственностью, мы получаем гибкую и расширяемую архитектуру, которую проще поддерживать:
Если вы только начинаете проект, который имеет шанс разрастись в будущем, подумайте заранее о разделении ответственности.
Оригинал статьи на английском тут.
К сожалению совсем не упомянуто про выделение валидации, примеры из Laravel — там валидация на Request с возможностью FormRequest завязана, в YII классы для валидации можно создавать наследуясь от \yii\base\Model и стоит обязательно отметить что в репозитории данные необходимо передавать уже валидными, в них никаких проверок быть не должно.
Ну и на самом деле такие статьи без примеров для новичков — дорога в ад.
Да, валидацию упустила из вида. Все правильно, валидация должна быть на Request, контроллер не должен пропускать в более низкие слои невалидные данные.
И в общем-то подкидываю идею — создать гит-страничку с подборкой реп где такие подходы грамотно используются.
Я хочу переписать base app для yii в таком стиле, как будет свободное время
А «слои» могут работать с ответом приложения (response) или только контроллер может? Тогда как слои должны сказать контроллеру что что-то пошло не так? Вернуть false, а проблемы в getErros() или кидать исключения? Дальше появляется какой-то франкенштейн, который содержит и логику и данные и верстку. =)
Нет, работать с response может только контроллер. Представьте что каждый слой у вас физически находится на разных машинах (этакая распределенная система). Как бы вы организовали обработку ошибок?
Во-первых можно возвращать заранее определенные коды ошибок (все слои знают о соглашении и кодах). Так же сообщение об ошибки можно передавать в одном из полей ответа.
Во-вторых если слой не уверен в своем подчиненном слое — можно сделать try catch до вызова сервиса/репозитория и обработать ошибку, подготовив правильный формат для более выского слоя.
А вы встречали на гитхабе какое нибудь расширение на Yii2, объема уровня модуля, написанное в указанном в статье стиле, чтобы точнее понять о чем речь.
Как говорится лучше один раз увидеть, чем сто раз услышать, а ваши объяснения можно совсем по разному интерпретировать.
Нет, не встречала. Такая архитектура — необходимость в крупных проектах, а такие проекты в паблик не выкладываются. Для мелких проектов такая архитектура требует много времени и сил, а так как мелкий модуль не будет особо разрастаться, большого смысла в этом нет.
>> Да, валидацию упустила из вида. Все правильно, валидация должна быть на Request, контроллер не должен пропускать в более низкие слои невалидные данные.
В первом Yii (со вторым не работал) была возможность валидацию сделать в AR и это уменьшало дублирование кода. Одна и та же модель валидировалась всегда одинаково в разных контроллерах. Имхо, такой подход тоже имеет право на жизнь.
Конечно имеет, как и в целом существующий «стиль». Он полезен для быстрой разработки прототипа или разработки мелких проектов. Для крупного проекта он мещает, т.к сложнее поддерживать и менять код, когда его много и все в перемешку. Валидацию AR модели полностью не исключить, так как такие вещи как уникальность поля, к примеру, по другому и не проверить. Но при правильном разделении задач — явные ошибки пользовательского ввода — не уровень AR модели.
to Михаил:
https://github.com/dektrium/yii2-user
Отличный модуль, рекомендую в том числе и в плане подсмотреть архитектурные решения. Но разделение на слои в нем более классически DDD-шное.
У вас UserRepository не наследует никакого интерфейса и жестко зависит от ORM. Получается ваш репозиторий железно зависит от реализации доступа к данным, в нашем случае — БД
Да, в статье говорится, что хорошо бы делать интерфейс, но я лично считаю что интерфейсы для всего на свете (как это принято в Java) — излищняя кодовая база, особенно для PHP проекта. Вероятность того, что у вас будет более 1 реализации этого интерфейса, как и вероятность того что вам придется менять тип БД/хранилища — ничтожна мала. А вот струткрута БД меняется по моему опыту достаточно часто, поэтому всю завязку к таблицам и структуре БД лучше где-нибудь инкапсулировать, в моем примере это репозиторий.
Подобную архитектуру я реализовал на базе advanced (https://github.com/phantom-d/yii2-app-services), правда давненько не обновлял.
Данная архитектура, только в более строгом варианте, применяется в достаточно крупном проекте.
Просьба не по теме, но все не смог найти как с вами связаться. Не могли бы вы добавить RSS в свой блог, чтобы другие пользователи могли читать вас не заходя периодично на ваш сайт и проверяя наличии новых записей?
Спасибо, добавила RSS, ссылку можно найти в футере, рядом с ссылками на профили в github и linkedin 😉
В случае DTO не вполне понятна выгода. У вас это такой Proxy который читает из AR и никак не решает вопрос изменения полей.
То есть view -> dto -> ar. При изменении поля в AR, можно сделать геттер в DTO, но это уже будет условно legacy и все равно во всех местах где используется DTO вместо AR надо будет менять обращение к свойству.
View -> AR — в случаи изменения поля в AR нужно менять все вызовы во View
View -> DTO -> AR — в случаи изменения поля в AR нужно менять геттер в DTO, менять во View ничего не нужно.
Насчет легаси не согласна. DTO в моем понимании — это представление бизнес модели, которое инкапсулирует в себе то, как именно эта бизнес модель распределена по AR и хранилищам. Хранение может (и будет по моему опыту) меняться, так же как может меняться и требование к бизнес модели. Но в случаи с AR -> DTO есть четкое разделение мух от котлет и понятно что именно менять в зависимости о того, меняется ли суть бизнес модели или только внутренняя кухня как эта модель хранится в БД.
А разве сервисы это не слой инфраструктуры приложения, которой является входной точкой в доменную модель ?то есть готовит данный и вызывает методы модели.
Я предлагаю добавлять слой репозитория между сервисом и моделью, чтобы сервис не вызывал методы модели напрямую.
А если я хочу вывести список всех пользователей с декораторами, это значит, что надо в контроллере пройтись циклом по всей найденым моделям пользователей, и к каждому применить $user = UserProfileDecorator::decorate($user) прежде чем передать их во view?
Декоратор не несет в себе логики работы модели. Он просто «украшатель». Не стоит создавать декоратор для каждой модели. Создайте один декоратор и через него выводите данные во view, передавая ему модель на которой эти украшения нужно сделать.
public function renderAsLabel($model) { if ($model->status == 1) return '<span class="my class">Active</span>'; else return '<span class="another class">Disabled</span>'; }Зачем? Во view не должо быть if-ов, т.к любой if — это уже логика. Декоратор знает отчего зависит вид кусочка view. Если поменяется зависимость — поменяется декоратор, а не все view.
А как вам такой вариант обращения к декоратору, на примере «последнего поcещения»: $user->decorator->lastVisit?
Тут decorator — это функция getDecorator() модели $user, в которой возвращается UserDecorator с переданным им моделью $user.
///// в User public function getDecorator() { if ($this->_decorator === nulll) { $this->_decorator = new Decorator::($this); } return $this->_decorator; } ///// class UserDecorator { public $user; public function __construct ($user) { $this->user = $user; } public function getLastVisit() { return ''.$this->user->last_visit.''; } }Нормальное решение, главное удержаться от соблазна и не использовать user без декоратора, когда он вот уже тут бери и пользуйся 😉
Добрый день! Скажите, а как по-вашему лучше быть с отношениями? Например у нас есть связь между пользователями и задачами? Я хочу вывести все задачи пользователя, при этом перед выводом в шаблон я должен воспользоваться переводом данных в json-формат. Для этого нужно воспользоваться DTO? То есть именно в DTO определить метод tasks и вывести оттуда уже отформатированный вид?
Да, все верно. Представьте что в какой-то момент вы поменяете отношения. Например задачи станут общими для компании, в которой работает юзер (user->company->task). Если отношения у вас размазаны по всему коду, менять придется очень многое. Идеально: из метода в DTO user возвращать DTO task. Тогда DTO task не зависит от того, как именно он получил данные. И если что-то нужно будет менять — то поменяется только тот метод, который создает DTO task.
Добрый день! Не понял, почему у вас метод репозитория getUsers() возвращает AR объекты, а не DTO? И какой тогда класс будет заниматься формированием DTO на основе полученых из репозитория AR объектов?
Репозиторий — это уровень работы с БД, поэтому на этом уровне работа идет с AR, а в DTO данные преобразует контроллер, прежде чем передать их во view. Можно сделать класс UserDTO в котором будет зашита логика преобразования AR -> DTO для класса User. Если вы размажете эту логику по всему репозиторию, то в случаи изменения структуры БД будет сложно сохранить не изменным DTO объект и придется менять код на всех уровнях.