Толстые контроллеры и модели — неизбежная проблема всех средних и крупных проектов, основанных на 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, передавая ему модель на которой эти украшения нужно сделать.
Зачем? Во view не должо быть if-ов, т.к любой if — это уже логика. Декоратор знает отчего зависит вид кусочка view. Если поменяется зависимость — поменяется декоратор, а не все view.
А как вам такой вариант обращения к декоратору, на примере «последнего поcещения»: $user->decorator->lastVisit?
Тут decorator — это функция getDecorator() модели $user, в которой возвращается UserDecorator с переданным им моделью $user.
Нормальное решение, главное удержаться от соблазна и не использовать 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 объект и придется менять код на всех уровнях.