MVC + Scenario Против Толстых Контроллеров
Современные PHP фреймворки (Symphony, Laravel, далее везде) убедительно показывают, что реализовать паттерн Model-View-Controller не так уж просто. Все реализации почему-то склонны к Толстым Контроллерам (fat controllers
), осуждаемыми всеми, и разработчиками, и самими фреймворками.
Почему все так? И можно ли с этим как-то справиться? Давайте разбираться.
Терминология
- Model — модель (формирователь запрошенных данных)
- View — представление (оформитель данных модели)
- Controller — контроллер (координатор модель-представление согласно запросу)
- Template — шаблон представления
- Rendering — рендеринг (формирование, оформление образа представления)
- Renderer — рендерер (формирователь, оформитель образа представления)
Толстый контроллер
Вот типичный Толстый Контроллер:
class UserController
{
/**
* Действие контроллера
* Возвращает приветствие юзеру с заданным ID
*/
public function actionUserHello($userId)
{
// Получаем имя и фамилию юзера из модели юзера (База Данных)
$user = UserModel::find($userId);
// Шаблону представления нужно полное имя юзера - делаем его
$name = $user->firstName.' '.$user->lastName;
// Создаем представление с нужным шаблоном и полным именем
$view = new View('hello', ['name' => $name]);
// Рендерим (создаем образ) представление и возвращаем приветствие
return $view->render();
}
}
Что мы видим? Мы видим винегрет! В контроллере намешано все, что можно — и модель, и представление, и, собственно, сам контроллер!
Мы видим имена модели и шаблона, намертво зашитые в контроллер. Это не гуд. Мы видим манипуляции с данными модели в контроллере — формирование полного имени из имени и фамилии. И это не гуд.
И еще: мы не видим этого примере явно, но неявно оно есть. А именно: есть только один способ рендеринга (формирования образа)! Только один: по шаблону в php файле! А если я хочу pdf? А если я хочу не в файле, а в php строке? У меня были проекты с вычурным дизайном на сотне маленьких шаблончиков. Приходилось самому ляпать рендерер для строковых шаблонов. Не перегревался конечно, но дело ведь в принципе.
Краткое резюме:
Современные фреймворки имеют общие для всех недостатки в реализации MVC:
- Узкая трактовка MVC-представления (View) только как "Представление с шаблоном в PHP файле" вместо "Представление с любым рендерером".
- Узкая трактовка MVC-модели только как как "Модель домена Базы Данных" вместо "Любой компилятор данных для представления".
- Провоцируют использование так называемых "Толстых Контроллеров" содержащих одновременно все логики: бизнеса, представления и взаимодействия. Это полностью разрушает основную цель MVC — разделение ответственностей между компонентами триады.
Для устранения этих недостатков неплохо бы взглянуть на компоненты MVC повнимательнее.
Представление — это рендерер
Глянем на первый недостаток:
- Узкая трактовка MVC-представления (View) только как "Представление с шаблоном в PHP файле" вместо "Представление с любым рендерером".
Здесь все довольно просто — решение проблемы уже указано в самой формулировке проблемы. Мы должны просто сказать, что представление может использовать любой рендерер. Для реализации этого достаточно просто добавить новое свойство renderer
к классу View:
class View {
public $template, $data, $renderer;
public function __costruct($template, $data, $renderer = NULL) {}
}
Итак, мы определили новое свойство renderer
для представления. В самом общем случае значением этого свойства может быть любая callable
функция, которая формирует образ переданных ей данных используя переданный шаблон.
Большинство приложений используют только один рендерер и даже если используют несколько, то один из них — предпочтительный. Поэтому аргумент renderer
определен как необязательный, предполагая наличие некоторого дефолтного рендерера.
Просто? Просто. На самом деле не так уж и просто. Дело в том, что тот View
, который в MVC — это не совсем тот View
, который в фреймворках. Тот View
, который в фреймворках, не может жить без шаблона. А вот тот View
, который в MVC, почему-то ничего не знает про эти самые шаблоны. Почему? Да потому, что для MVC View
— это любой преобразователь данных модели в образ, а не только и исключительно шаблонизатор. Когда мы в обработчике запроса пишем что-нибудь типа:
$name = 'дядя Ваня';
return "Hello, {$name}!";
или даже:
$return json_encode($name); // Ajax response
то мы реально определяем тот View
, который в MVC, не трогая при этом никаких тех View
, которые в фреймворках!
А вот теперь все на самом деле просто: те View
, которые в фреймворках — это подмножество тех View
, которые в MVC. Причем очень узкое подмножество, а именно, это только шаблонизаторы на основе PHP файлов.
Резюме: именно рендерер
, т.е. любой оформитель образа данных и является тем View
, который в MVC. А те View
, которые в фреймворках, это только разновидность рендереров
.
Модель домена / Модель представления (ViewModel / DomainModel)
А теперь глянем на второй недостаток:
- Узкая трактовка MVC-модели только как как "Модель домена Базы Данных" вместо "Любой компилятор данных для представления".
Всем очевидно, что MVC-модель — это сложная штука, которая состоит из других штук. В сообществе есть согласие по декомпозиции модели на две компоненты: доменная модель (DomainModel) и модель представления (ViewModel).
Доменная модель — это то, что хранится в базах данных, т.е. нормализованные данные модели. Типа, 'имя' и 'фамилия' в разных полях. Фреймворки заняты именно этой частью модели просто потому, что хранение данных — это своя вселенная, неплохо изученная.
Однако приложению нужны агрегированные, а не нормализованные данные. Доменные данные надо компилировать в образы типа: 'Привет, Иван!', или 'Дорогой Иван Петров!', или даже 'Для Ивана Петрова!'. Вот эти преобразованные данные и относят к другой модели — модели представления. Так вот именно эта часть модели пока игнорируется современными фреймворками. Игнорируется потому, что нет согласия как с ней быть. А если фреймворки решения не дают, то программисты идут по самому простому пути — кидают модель представления в контроллер. И получают ненавистные, но неизбежные Толстые Контроллеры!
Итого: чтобы реализовать MVC необходимо реализовать модель представления. Других опций нет. Учитывая, что представления и их данные могут быть любыми, констатируем, что имеем проблему.
Сценарий против Толстых Контроллеров
Остался последний недостаток фреймворков:
- Провоцируют использование так называемых "Толстых Контроллеров" содержащих одновременно все логики: бизнеса, представления и взаимодействия. Это полностью разрушает основную цель MVC — разделение ответственностей между компонентами триады.
Здесь мы подобрались к основам MVC. Давайте наведем ясность. Итак, MVC предполагает такое распределение ответственностей между компонентами триады:
- Контроллер — логика взаимодействия, т.е. взаимодействия с как с внешним миром (запрос — ответ), так и с внутренним (Модель — Представление),
- Модель — бизнес-логика, т.е. формирование данных для конкретного запроса,
- Представление — логика представления, т.е. декорация данных, сформированных Моделью.
Идем дальше. Ясно просматриваются два уровня ответственностей:
- Организационный уровень — это Контроллер,
- Исполнительный уровень — это Модель и Представление.
Говоря по-простому, Контроллер рулит, Модель и Представление пашут. Это если по-простому. А если не по-простому, а поконкретнее? Как именно рулит Контроллер? И как именно пашут Модель и Представление?
Контроллер рулит так:
- Получает запрос от приложения,
- Решает, какую Модель и какое Представление использовать для этого запроса,
- Вызывает выбранную Модель и получает от нее данные,
- Вызывает выбранное Представление с полученными от Модели данными,
- Возвращает декорированные Представлением данные обратно приложению.
Вот как-то так. Существенным в этой схеме является то, что Модель и Представление оказываются звеньями цепи исполнения запроса. Причем последовательными звеньями: сначала Модель преобразует запрос в некоторые данные, затем эти данные Модели преобразуются Представлением в ответ, декорированный так, как надо конкретному запросу. Типа, запрос гуманоида декорируется визуально шаблонизаторами, запрос андроида декорируется кодировщиками JSON.
Теперь попробуем разобраться, как конкретно пашут исполнители — Модель и Представление. Выше мы говорили, что есть консенсус насчет декомпозиции Модели на две под-компоненты: Модель домена и Модель представления. Это значит, что исполнителей может быть больше — не два, а три. Вместо цепочки исполнения
Модель
>>Представление
вполне может быть цепочка
Модель домена
>>Модель представления
>>Представление
Сам собой напрашивается вопрос: а почему только два или три? А если надо больше? Естественный ответ — да ради бога, сколько надо, столько и берите!
Сходу просматриваются иные полезные исполнители: валидаторы, редиректоры, разнообразные рендереры и вообще все, что непредсказуемо, но угодно.
Давайте резюмируем:
- Исполнительный уровень MVC (
Модель
—Представление
) может быть реализован как цепочка звеньев, где каждое звено преобразует выход предшествующего звена во вход для последующего.- Входом первого звена является запрос приложения.
- Выход последнего звена является ответом приложения на запрос.
Я назвал эту цепочку Сценарием
(Scenario
), а для звеньев цепочки с названием пока не определился. Текущие варианты — сцена (как часть сценария), фильтр (как преобразователь данных), действие сценария. Вообще говоря, название для звена не столь важно, есть более существенная вещь.
Существенным является последствия появления Сценария. А именно: Сценарий взял на себя основную ответственность Контроллера — определять нужные для запроса Модель и Представление и запускать их. Тем самым у контроллера остаются только две ответственности: взаимодействие с внешним миром (запрос-ответ) и запуск сценария. И это хорошо в том плане, что все компоненты триады MVC последовательно декомпозируются и становятся более конкретными и управляемыми. И еще хорошо в другом плане — контроллер MVCS становится чисто внутренним неизменяемым классом, и поэтому даже в принципе не может стать толстым.
Использование Сценариев приводит к очередной вариации паттерна MVC, эту вариацию я обозвал как MVCS
— Model-View-Controller-Scenario
.
И еще пару строк насчет декомпозиции MVC. Современные фреймворки, где все типовые функции декомпозированы до предела, вполне себе естественным образом забрали у концептуальной MVC часть ответственностей по взаимодействию с внешним миром. Так, обработкой запроса пользователя занимаются специально обученные классы типа HTTP запрос
и Роутер
. В результате Контроллер получает не исходный запрос пользователя, а некоторое рафинированное действие
, и это позволяет изолировать контроллер от специфики запроса. Аналогичным образом делается изоляция от специфики HTTP ответа, позволяя модулю MVC определять свой собственный тип ответа. Кроме того, фреймворки полностью реализовали две компоненты MVC — Модель домена и Шаблонизатор представления, впрочем, это мы уже обсуждали. Я все это к тому, что уточнение и конкретизация MVC идет постоянно и непрерывно, и это гуд.
Пример использования MVCS
А теперь давайте посмотрим, как пример "Толстого Кортроллера" в начале этой статьи может быть реализован в MVCS.
Начинаем с создания контроллера MVCS:
$mvcs = new MvcsController();
Контроллер MVCS получает запрос от внешнего роутера. Пусть роутер преобразует URI вида 'user/hello/XXX' в такие действие и параметры запроса:
$requestAction = 'user/hello'; // Действие запроса
$requestParams = ['XXX']; // Параметры действия - ИД юзера
Учитывая, что контроллер MVCS принимает не URI, а сценарии, нам надо сопоставить действию запроса некоторый сценарий. Лучше всего это делать в контейнере MVCS:
// Определяем сценарий MVCS для URI запроса
$mvcs->set('scenarios', [
'user/hello' => 'UserModel > UserViewModel > view, hello',
...,
]);
Давайте глянем на этот сценарий повнимательнее. Это цепь из трех преобразователей данных, разделенных знаком '>':
- 'UserModel' — это имя Модели домена 'User', входом модели будут параметры запроса, выходом — собственно данные модели,
- 'UserViewModel' — это имя Модели Представления, которая преобразует доменные данные в данные представления,
- 'view, hello' — это системный шаблонизатор 'view' для PHP шаблона с именем 'hello'.
Теперь нам осталось только добавить в контейнер MVCS два задействованных в сценарии преобразователя как функции-замыкания:
// Модель домена UserModel
$mvcs->set('UserModel', function($id) {
$users = [
1 => ['first' => 'Иван', 'last' => 'Петров'],
2 => ['first' => 'Петр', 'last' => 'Иванов'],
];
return isset($users[$id]) ? $users[$id] : NULL;
});
// Модель представления UserViewModel
$mvcs->set('UserViewModel', function($user) {
// Слепить данные для PHP шаблона типа: 'echo "Hello, $name!"';
return ['name' => $user['first'].' '.$user['last']];
});
И это все! Для каждого запроса надо определить соответствующий сценарий и все его сцены (кроме системных, таких, как 'view'). И ничего более.
А теперь мы готовы протестировать MVCS для разных запросов:
// Получить из контейнера сценарий для текущего запроса
$scenarios = $mvcs->get('scenarios');
$scenario = $scenarios[$requestAction];
// Исполнить сценарий с параметрами текущего запроса...
// Для запроса 'user/hello/1' получим 'Иван Петров' декорированный шаблоном 'hello'
$requestParams = ['1'];
$response = $mvcs->play($scenario, $requestParams);
// Для запроса 'user/hello/2' получим 'Петр Иванов' декорированный шаблоном 'hello'
$requestParams = ['2'];
$response = $mvcs->play($scenario, $requestParams);
PHP реализация MVCS размещена на github.com.
Этот пример находится в директорииexample
MVCS.
Комментарии (27)
FanatPHP
27.09.2018 12:55Самый простой сценарий для проверки контроллера на излишний вес я нашел здесь:
Попробуйте написать консольную команду, которая выполняет ту же самую функцию, что и экшен в контроллере. Создание нового пользователя к примеру. Если не пришлось дублировать код из контроллера — всё нормально, контроллер выполняет только свою работу, и не пытается быть моделью. Если код пришлось дублировать — это именно тот код, который должен быть вынесен из контроллера в модель.
godzie
27.09.2018 14:27Краткий пересказ статьи (для себя): в начале symfony назвали mvc фреймворком, в конце переизобрели pipeline. Или я что то не понял?
Fantyk
27.09.2018 15:04+1На любом фреймворке ваш код можно переписать так:
public function actionUserHello($userId) { $user = $this->userRepository->get($userId); return $this->renderView('hello', ['name' => $user->getFullName()]); }
И решение озвученных вами проблем:
Современные фреймворки имеют общие для всех недостатки в реализации MVC:
1. Узкая трактовка MVC-представления (View) только как «Представление с шаблоном в PHP файле» вместо «Представление с любым рендерером».
Как раз наоборот, сейчас все более распространяется json формат, и современные фреймворки дают инструменты вернуть ответ в любом формате (json, xml, csv и даже pdf!).
Узкая трактовка MVC-модели только как как «Модель домена Базы Данных» вместо «Любой компилятор данных для представления».
Как слова «модель домена» и «база данных» вообще могут стоять рядом? То, что некоторые люди олицетворяют модель с табличкой в БД это их проблемы.
Провоцируют использование так называемых «Толстых Контроллеров» содержащих одновременно все логики: бизнеса, представления и взаимодействия.
Современные фреймворки ни на что не провоцируют, если вы бизнес логику пишете в контроллере — это ваши проблемы. По хорошему они содержат только логику взаимодействия.svratenkov Автор
27.09.2018 15:53Современные фреймворки ни на что не провоцируют, если вы бизнес логику пишете в контроллере — это ваши проблемы. По хорошему они содержат только логику взаимодействия.
Такие слова мне писали из команды Laravel. В статье я старался подчеркнуть, что Толстые Контроллеры возникают из-за того, что фреймворки реализуют только часть Модели MVC, а именно — только модель домена.
В остальном согласен с Вами.
svratenkov Автор
27.09.2018 19:03Как раз наоборот, сейчас все более распространяется json формат, и современные фреймворки дают инструменты вернуть ответ в любом формате (json, xml, csv и даже pdf!).
Согласен, есть такие. Но монстры типа Symphony & Laravel не дают.
Как слова «модель домена» и «база данных» вообще могут стоять рядом?
Типа согласен, но по жизни они рядом.
Современные фреймворки ни на что не провоцируют, если вы бизнес логику пишете в контроллере — это ваши проблемы. По хорошему они содержат только логику взаимодействия.
Они провоцируют. С этим глаголом согласились разработчики Laravel. Где-то выше в комментах про это уже отвечал.Fantyk
27.09.2018 20:05Согласен, есть такие. Но монстры типа Symphony & Laravel не дают.
Да ладно
Они провоцируют. С этим глаголом согласились разработчики Laravel.
Вам так господин Тейлор сказал? То, что в доке приведены самые простые примеры уже обсуждалось не раз. В доке городить сервисный слой оверхед, она решает другую задачу. А вот в проекте больше шести человеко-месяцев выделить бизнес логику в отдельный слой уже необходимость.svratenkov Автор
27.09.2018 21:22Саппорт сказал: github.com/laravel/framework/issues/18786.
Fantyk
27.09.2018 23:39Вам в issue ответили, что фреймворк никак не ограничивает вас структурировать проект так, как вы хотите. И толстые контроллеры у вас получаются только потому, то вы так пишете код. И никаких согласий что «Laravel провоцирует писать толстые контроллеры» не заметил (можете тыкнуть в конкретный ответ если я пропустил).
Вы, кстати, сами обратили внимание на то, что вы сделали?
Вы взяли фреймворк, придумали новый паттерн и воплотили его в жизнь на этом же самом фреймворке! То есть он достаточно гибок, чтобы воплотить другой архитектурный паттерн. А теперь задумайтесь, не в этом ли благо? Каждый может реализовать логику так, как он хочет. Только теперь я вообще не понимаю ваших претензий к фреймворку.ACherabaev
28.09.2018 11:00Ответ был достаточно красноречив — У вас будет только «толстый» контроллер, если вы их так пишете. В самом деле, если прицепиться к конкретному примеру, то на ларавэл это делается вообще одной строчкой:
class UserController extends Controller { public function info(User $user) { return view('info', ['name' => $user->getFullName()]); } }
в контроллере и:
class User extends Model { public function getFullName() { return $this->first_name . ' ' . $this->last_name; } }
в самой модели. Зачем писать развесисто, если можно не писать развесисто.
Если говорить о единообразии — напишите response()->view() вместо view() и все встанет на свои места. Никакого разброда и шатания.svratenkov Автор
28.09.2018 12:57Проблема принципиальная. У вас в контроллере сидит модель:
return view('info', ['name' => $user->getFullName()]);
Вам придется переписывать контроллер всякий раз, когда меняется представление, скажем, вместо полного имени захочется просто имя.
Лучше полная независимость контроллера от моделей и представлений, согласитесь. Контроллер тогда просто вызывает нужные для запроса модели и представления (это делается в роутере). И контроллера как-бы вообще нет. Он есть, конечно. Но вся специфика приложения строго в модели.ACherabaev
28.09.2018 21:07Окей, можно сделать прям в роутере:
function(User $user) { return view('info', [$user => 'user']; }
а в модель добавить:
public function getFullNameAttribute() { return $this->first_name . ' ' . $this->last_name; }
чтобы во вьюхе вызвать $user->full_name
Никто не мешает. Зачем городить огород вокруг колхоза?
svratenkov Автор
28.09.2018 09:10Fantyk 27.09.18 в 20:05
Согласен, есть такие. Но монстры типа Symphony & Laravel не дают.
Да ладно
Уточнить можете? Или вы про то, что в фреймворке можете сами все сделать. Если про это, то ответ один: PHP — лучший фреймворк.
svratenkov Автор
28.09.2018 09:13Вам так господин Тейлор сказал? То, что в доке приведены самые простые примеры уже обсуждалось не раз. В доке городить сервисный слой оверхед, она решает другую задачу. А вот в проекте больше шести человеко-месяцев выделить бизнес логику в отдельный слой уже необходимость.
Это тоже обсуждалось: github.com/laravel/framework/issues/18786
XanderBass
27.09.2018 16:21И чем это проще «толстого контроллера»?..
Поделюсь вкратце своим архитектурным опытом:
— Запрос принимается небольшим объектом класса Request, который определяет, какой контроллер грузить, и какое действие в нём вызвать;
— Внутри контроллера может создаваться модель, может не вызываться. В конечном итоге совершается некое действие, результатом которого станет ассоциативный массив — Response;
— Всё! Получившийся массив можно скормить любой вменяемой системе шаблонизации.
По маршруту определяем, какой шаблон использовать. Нужна выборка юзеров? Пусть маршрут будет таким — users/index. Значит у берём шаблон из файла, скажем, templates/users/index. Контроллер у нас будет называться controllerUsers, метод — actionIndex…
Логика прямолинейная. Что же касается дополнения данных, то система хуков при описанной мною выше архитектуре — задача тривиальнейшая. Тупо пропускаем итоговый массив через цепочку Data-хуков, которые при необходимости модифицируют этот самый массив.svratenkov Автор
27.09.2018 18:22Внутри контроллера может создаваться модель, может не вызываться
,По маршруту определяем, какой шаблон использовать.
…
— согласно MVC, контроллер определяет, какие модель и представления использовать и создает-вызывает их. Но если в контроллере есть код формирования или представления данных — то это смешение ответственности. Как я понял, у Вас смешения нет и все у Вас гуд.XanderBass
28.09.2018 12:29У меня вызывается модель (например, users), которая делает нужные запросы к источнику данных, формирует данные, которые затем пропускаются через pipeline (надеюсь, я правильно понял общераспространённое название паттерна) обработчиков хука вызванного действия (например, usersIndex), которые могут чем-то дополнить (например, присоединить дополнительные данные пользователей из других модулей) или преобразовать (например, скорректировать ACL-таблицы) получившиеся данные. Затем итоговый ассоциативный массив тупо скармливается используемому шаблонизатору, который в свою очередь сам уже определит как эти данные выводить согласно выбранному для конкретного запроса к системе шаблону. Или же превращается в JSON одной строчкой и просто отдаётся в браузер.
Хотя некоторые простейшие действия у меня всё таки делаются без модели. Например, пометка сущности удалённой или снятие таковой пометки — это один запрос к БД. Совершенно незачем для этого грузить целую модель. Приведу пример такового запроса:
"update `[+prefix+]fields` set `flags` = `flags` | {$f} where (`id` in ({$ids})) and (`table` = 'users')"
Где $f — целочисленное значение флага, $ids — массив ID полей из запроса (тупо пробегаемся по нужному полю запроса array_walk с функцией intval, потом объединяем функцией implode). Совершенно очевидно, что грузить для таких целей модель нецелесообразно.
BoShurik
27.09.2018 18:31А можете проиллюстрировать как в вашем подходе будет выглядеть создание/редактирование?
svratenkov Автор
27.09.2018 18:48Могу. Если кратко, то это ВСЕГДА цепочка действий, одна — для создания, другая — для редактирования. Каждая цепочка начинается с модели домена (БД), далее действия валидации, редиректы, преобразования данных для требований Представления (вьюха) и завершится цепочка собственно рендерингом.
Смысл вот в чем: базовая MVC цепочка Модель -> Представления просто делится на составные части. Типа, возьмите любой Толстый Контроллер и разбейте его на части.
Главное — вытащить все это из контроллера и облегчить жизнь путем декомпозиции и разделения ответственностей.BoShurik
27.09.2018 18:55Хотелось бы увидеть код. Основной вопрос: как быть с условиями? К примеру, если валидация прошла успешно, то создаем пользователя и редиректим на страницу просмотра, если нет — то рендерим форму с ошибками. В ваших примерах все линейно:
'UserModel > UserViewModel > view, hello'
svratenkov Автор
27.09.2018 19:19Код есть в примере, ссылка в конце статьи.
Насчет условий — вы прям в точку попали. Что делать с ветвлениями, циклами, — мне пока неясно.
Сам я пока условия и редиректы пишу в одном действии. Я понимаю, что это вне концепции. Пока я вижу только одно решение условий — некий условный ветвитель, который переключает разные сценарии. Сценарии в смысле этой статьи — последовательность действий по преобразованию запроса в ответ.
Буду признателен любым предложениям как можно условия вписать в цепь Модель-Представление.XanderBass
28.09.2018 13:43Синтаксис тернарного оператора:
UserModel ? UserViewModel : WrongUserViewModel > view, hello
Ну, или что-то подобное.
Fortop
29.09.2018 01:07+1Попробуйте не изобретать велосипед
docs.zendframework.com/zend-expressive/v3/getting-started/quick-start/#piping
avraam_linkoln
Автор пишет так как будто фреймворк заставляет его писать логику в констроллере. Для логики создают классы-сервисы, для базы данных — репозитории или менеджеры.
— так сделано, потому что для большинства задач этого достаточно. Если недостаточно, то при использовании Data mapper можно использовать все что угодно, хоть текстовый файл.svratenkov Автор
Не заставляет. Фреймворк просто не реализует Модель MVC полностью, а только ту часть, которая связана с первичными данными модели.
avraam_linkoln
Правильно, как раз таки дает возможность расширить реализацию