Вступление
На самом деле, в заголовке должен стоять знак вопроса. Довольно долго я не кодил как на yii, так и на php в целом. Сейчас, вернувшись, хочется переосмыслить свои принципы разработки, понять куда двигаться дальше. И лучший способ — изложить их и выложить на ревью профессионалам, что я и делаю в этом посте. Несмотря на то, что я преследую чисто корыстные цели, пост будет полезен многим новичкам, и даже не новичкам.
Оформление и понятия
В тексте понятия «контроллер» и «модель» будет встречаться в двух контекстах: MVC и Yii, обратите на это внимание. В неочевидных местах я буду пояснять какой контекст использую.
«Представление» — это представление в контексте MVC.
«Вью» — это файл из папки views.
Паттерны я буду выделять ЗАГЛАВНЫМИ буквами.
Поехали!
Yii — очень гибкий фреймворк. Это дает возможность некоторым разработчикам не заботиться о структуризации своего кода, что всегда ведет к куче багов и сложному рефакторингу. Впрочем, Yii здесь не при чем — довольно часто проблемы начинаются уже с банального недопонимания принципа MVC.
Поэтому в этом посте я рассмотрю основы MVC, и его C и V в контексте Yii. Буква М — это отдельная сложная тема, которая достойна своего поста. Все примеры кода будут банальными, но отражающими сущность принципов.
MVC
MVC — отличный принцип проектирования, который помогает избежать многих проблем. На мой взгляд, необходимо-достаточные знание об этом шаблоне проектирования можно почерпнуть из стать в Википедии.
К сожалению, я не раз видел, когда выражение «Yii — это MVC фреймворк» принимали слишком дословно (то есть М — это CModel
, С — это CController
, V — это вьюхи из папки views
), что уводит в сторону от понимания самого принципа. Это порождает массу ошибок, например, когда в контроллере выбираются все необходимые данные для вьюхи, или когда в контроллер выносятся куски бизнес-логики.
Контроллер («C») — это операционный уровень приложения. Не стоит путать его с классом CContrller
. CContrller
наделен многими обязанностями. В MVC понятие «контроллер» — это прежде всего экшн CController'а
. В случае выполнения какой-либо операции над объектом, контроллер не должен знать как именно выполнять эту операцию — это задача «М». В случае отображения объекта он не должен знать как именно отображать объект — это задача «V». По факту, контроллер должен просто взять нужный объект(ы), и сказать ему(им) что делать.
Модель («М») — это уровень бизнес-логики приложения. Опасно ассоциировать понятие модели в Yii с понятием модели в MVC. Модель — это не только классы сущностей (как правило CModel
). Сюда, например, входят специальные валидаторы CValidator
, или СЛУЖБЫ (если они отображают бизнес-логику), РЕПОЗИТОРИИ, и многое другое. Модель ничего не должна знать об контроллерах или отображениях, использующих ее. Она содержит только бизнес-логику и ничего больше.
Представление («V») — уровень отображения. Не стоит воспринимать его как просто php файл для отображения (хотя, как правило, оно так и есть). У него есть своя, порой, очень сложная, логика. И если для отображения объекта нам нужны какие-то специфичные данные, например список языков или что-то еще, запрашивать их должен именно этот уровень. К сожалению, в Yii нельзя связать вьюху с каким-то определенным классом (разве что с помощью CWidget
и т.п.), который бы содержал логику отображения. Но это легко реализовать самому (редко нужно, но иногда — крайне полезно).
Сам же Yii предоставляет нам шикарную инфраструктуру для всех этих трех уровней.
Типичные ошибки MVC
Приведу пару типичных ошибок. Эти примеры крайне утрированны, но они отображают суть. В масштабах крупного приложения эти ошибки вырастают в катастрофические проблемы.
1. Допустим, нам нужно отобразить пользователя с его постами. Типичный экшн выглядит как-то так:
public function actionUserView($id)
{
$user = User::model()->findByPk($id);
$posts = Post::model()->findAllByAttributes(['user_id' => $user->id]);
$this->render('userWithPosts', [
'user' => $user,
'posts' => $posts
]);
}
Здесь ошибка. Контроллер не должен знать о том, как именно будет отображаться пользователь. Он должен найти пользователя, и сказать ему «отобразись-ка с помощью вот этой вьюхи». Здесь же мы выносим часть логики отображения в контроллер(а именно — знание о том, что ей нужны посты ).
Проблема в том, что если делать как в примере — про повторное использование кода можно забыть и словить повсеместное дублирование.
Везде, где мы захотим использовать эту вьюху, нам придется передавать в нее и список постов, а значит, везде придется заранее выбирать их — дублирование кода.
Так же мы не сможем повторно использовать этот экшн. Если убрать из него выборку постов, а название вьюхи сделать параметром (например, реализовав его в виде CAction
) — мы можем использовать его везде, где нужно отобразить какую-либо вьюху с данными пользователя. Это выглядело бы как-то так:
public function actions()
{
return [
'showWithPost' => [
'class' => UserViewAction::class,
'view' => 'withPost'
],
'showWithoutPost' => [
'class' => UserViewAction::class,
'view' => 'withoutPost'
],
'showAnythingUserView' => [
'class' => UserViewAction::class,
'view' => 'anythingUserView'
]
];
}
Если мешать контроллер и отображение — это не возможно.
Эта ошибка создает лишь дублирование кода. Вторая ошибка имеет куда более катастрофические последствия.
2. Допустим нам нужно перевести новость в архив. Делается это установкой поля status
. Смотрим экшн:
public function actionArchiveNews($id)
{
$news = News::model()->findByPk($id);
$news->status = News::STATUS_ARCHIVE;
$news->save();
}
Ошибка данного примера в том, что мы переносим бизнес-логику в контроллер. Это так же ведет к невозможности повторно использовать код (ниже объясню почему), но это лишь мелочь по сравнению со второй проблемой: что если мы изменим способ перевода в архив? Например, вместо изменения статуса мы будем присваивать true
полю inArchive
? И это действие будет выполняться в нескольких местах приложения? И это не новость, а транзакция на 10млн$?
В примере эти места легко найти — достаточно сделать Find Usage
для константы STATUS_ARCHIVE
. Но если вы сделали это с помощь запроса "status = 'archive'"
— найти гораздо сложнее, ведь даже один лишний пробел — и вы бы не нашли эту строку.
Бизнес логика всегда должна оставаться в модели. Здесь следует выделить отдельный метод в сущности, который переводит новость в архив (или как-то по другому, но именно в слое бизнес-логики). Этот пример — крайне утрирован, немногие допускают подобную ошибку.
Но в примере из первой ошибки тоже есть эта проблема, гораздо менее очевидная:
$posts = Post::model()->findAllByAttributes(['user_id' => $user->id]);
Знания о том, как именно связанны Post
и User
— это тоже бизнес-логика приложения. Поэтому данная строка не должна встречаться ни в контроллере, ни в представлении. Здесь правильным решением было бы использования релейшена для User
, или скоупа для Post
:
// релейшн
$posts = $user->posts;
// скоуп
$posts = Post::model()->forUser($user)->findAll();
Магия CAction
Контроллеры (в терминологии MVC, в терминологии Yii — экшены) — самая реюзабельная часть приложений. Они не несут в себе практически никакой логики приложения. В большинстве случаев их можно спокойно копировать из проекта в проект.
Посмотрим как же можно реализовать UserViewAction
из примеров выше:
class UserViewAction extends CAction
{
/**
* @var string view for render
*/
public $view;
/**
* @param $id string user id
* @throws CHttpException
*/
public function run($id)
{
$user = User::model()->findByPk($id);
if(!$user)
throw new CHttpException(HttpResponse::STATUS_NOT_FOUND, "User not found");
$this->controller->render($this->view, $user);
}
}
Теперь мы можем задавать любую вьюху в конфиге экшена. Это хороший пример реюзабельности кода, но он не идеален. Модифицируем код, чтобы он работал не только с моделью User
, а с любым наследником CActiveRecord
:
class ModelViewAction extends CAction
{
/**
* @var string model class for action
*/
public $modelClass;
/**
* @var string view for render
*/
public $view;
/**
* @param $id string model id
* @throws CHttpException
*/
public function run($id)
{
$model = CActiveRecord::model($this->modelClass)->findByPk($id);
if(!$model)
throw new CHttpException(HttpResponse::STATUS_NOT_FOUND, "{$this->modelClass} not found");
$this->controller->render($this->view, $model);
}
}
По сути мы просто заменили жестко заданный класс User
на конфигурируемое свойство $modelClass
В итоге получился экшн, который можно использовать для вывода любой модели с помощью любой вьюхи.
На первый взгляд он не гибок, но этот всего лишь пример для понимания общего принципа. PHP — очень гибкий язык, и это дает нам простор для творчества:
- в свойство
$view
мы можем передать не строку, а анонимную функцию, которая вернет название вьюхи. В экшене проверять: если во$view
строка — то это и есть вьюха, еслиcallable
— то вызывать его и получать вьюху. - сделать
boolean
свойствоrenderPartial
и рендерить с помощью него, если надо - проверять заголовок на
Accept
: еслиhtml
— рендерим вьюху, если json — отдаемjson
- много много всего другого
Подобные экшны можно написать практически для любого действия: CRUD, валидация, выполнение бизнес-операций, работа с связанными объектами и т.д.
На самом деле, достаточно написать порядка 30-40 подобных экшнов, которые покроют 90% кода контроллеров (естественно, если вы разделяете модель, представление и контроллер). Самым приятным плюсом, конечно, является уменьшение кол-ва багов, ибо гораздо меньше кода + проще писать тесты + когда экшн используется в сотне местах они всплывают гораздо быстрее.
Пример экшна для Update
Приведу еще пару примеров. Вот экшн на update
class ARUpdateAction extends CAction
{
/**
* @var string update view
*/
public $view = 'update';
/**
* @var string model class
*/
public $modelClass;
/**
* @var string model scenario
*/
public $modelScenario = 'update';
/**
* @var string|array url for return after success update
*/
public $returnUrl;
/**
* @param $id string|int|array model id
* @throws CHttpException
*/
public function run($id)
{
$model = CActiveRecord::model($this->modelClass)->findByPk($id);
if($model === null)
throw new CHttpException(HttpResponse::STATUS_NOT_FOUND, "{$this->modelClass} not found");
$model->setScenario($this->modelScenario);
if($data = Yii::app()->request->getDataForModel($model))
{
$model->setAttributes($data);
if($model->save())
Yii::app()->request->redirect($this->returnUrl);
else
Yii::app()->response->setStatus(HttpResponse::STATUS_UNVALIDATE_DATA);
}
$this->controller->render($this->view, $model);
}
}
Его код я взял из CRUD gii, и немного переработал. Помимо того, что введено свойство $modelClass
для реюзабельности, он дополнен еще несколькими важными моментами:
- Установку
scenario
для модели. Это крайне важный момент, о котором многие забывают. Модель должна знать что с ней собираются делать! Подробнее об этом я напишу в следующем посте, посвященный моделям. - Получение данных не из
$_POST
, а с помощьюYii::app()->request->getDataForModel($model)
, ибо данные могут придти вjson
формате, или как-то по другому. Знания о том, в каком формате приходят данные и как их правильно распарсить — это не задача контроллера, это задача инфраструктуры, в данном случае —HttpRequest
. - В случае непрохождения валидации (которая находиться в методе save) устанавливается
http
статусSTATUS_UNVALIDATE_DATA
. Это очень важно. В стандартном варианте код вернул бы статус200
— что означает «все хорошо». Но это же не так! Если, например, клиент определяет успешность выполнения операции поhttp
статусу, то это вызвало проблемы. А так как мы не знаем, как именно будет работать клиент, нужно соблюдать все правила протокола.
Естественно, этот контроллер намного проще реального:
$view
и$retrunUrl
— просто строки (для гибкости их лучше сделатьstring|callable
)- не проверяется заголовок
Accept
чтоб понять в каком виде выводить данные и делать ли редирект или просто выводитьjson
- Жестко задать метод модели для сохранения. Например гибче было бы сделать так:
$model->{$this->updateMethod}()
- многое другое
Еще один важный момент который здесь опущен — приведение входных данных к необходимым типам. Сейчас данные обычно присылаются в json
, что частично облегчает задачу. Но проблема все равно остается, например, если клиент шлет timestamp
, а в модели — MongoDate
. Предоставить модели правильные данные — это определенно задача контроллера. Но информация о том, какие типы у полей — это знания класса модели.
На мой взгляд, наилучшее место выполнения приведения — метод Yii::app()->request->getDataForModel($model)
. Получить типы полей можно несколькими способами, для меня самые привлекательные — это:
- Если у нас
AR
— то мы можем получить эти сведения из схемы таблицы. - Сделать в модели метод
getAttributesTypes
, который вернет информацию о типах. - Рефлексия, а именно — получение с помощью
CModel::getAttributeNames
списка атрибутов, затем обход их рефлексией с целью парсинга комментария к полю и вычисления типа, сохранение это в кэш. К сожалению, нормальных аннотаций в php нет, так что это довольно спорный способ. Но он избавляет от написания рутины.
В любом случае, мы можем сделать интерфейс IAttributesTypes
где определить метод getAttributesTypes
, и объявить метод HttpRequest::getDataForModel
как public getDataForModel(IAttributesTypes $model)
. А каждый класс пусть сам определяет как ему реализовывать интерфейс.
Пример экшна для List
Пожалуй, это самый сложный пример, я приведу его для показа разделения обязанностей между классами:
class MongoListAction extends CAction
{
/**
* @var string view for action
*/
public $view = 'list';
/**
* @var array|EMongoCriteria predefined criteria
*/
public $criteria = [];
/**
* @var string model class
*/
public $modelClass;
/**
* @var string scenario for models
*/
public $modelScenario = 'list';
/**
* @var array dataProvider config
*/
public $dataProviderConfig = [];
/**
* @var string dataProvuder class
*/
public $dataProviderClass = 'EMongoDocumentDataProvider';
/**
* @var string filter class
*/
public $filterClass;
/**
* @var string filter scenario
*/
public $filterScenario = 'search';
/**
*
*/
public function run()
{
// Первым делом создадим фильтр и установим параметры фильтрации из входных данных
/** @var $filter EMongoDocument */
$filterClass = $this->filterClass ? $this->filterClass : $this->modelClass;
$filter = new $filterClass($this->filterScenario);
$filter->unsetAttributes();
if($data = Yii::app()->request->getDataForModel($filter))
$filter->setAttributes($data);
$filter->search(); // Этот метод для того, чтобы критерия модели фильтра стала выбирать по установленным в модели атрибутам
// Теперь смержим критерию фильтра с предустановленной критерией
$filter->getDbCriteria()->mergeWith($this->criteria);
// Теперь создадим дата провайдер. Дата провайдер из расширения yiimongodbsuite может брать критерию из
// переданной ему модели (в нашем случае - фильтра)
/** @var $dataProvider EMongoDocumentDataProvider */
$dataProviderClass = $this->dataProviderClass;
$dataProvider = new $dataProviderClass($filter, $this->dataProviderConfig);
// Теперь установим сценарии для моделей. Этот метод я опущу, он просто обходит модели и ставит каждой сценарий
self::setScenario($dataProvider->getData(), $this->modelScenario);
// И выводим
$this->controller->render($this->view, [
'dataProvider' => $dataProvider,
'filter' => $filter
]);
}
}
И пример его использования, выводящий неактивных юзеров:
public function actions()
{
return [
'unactive' => [
'class' => MongoListAction::class,
'modelClass' => User::class,
'criteria' => ['scope' => User::SCOPE_UNACTIVE],
'dataProviderClass' => UserDataProvider::class
],
];
}
Логика работы проста: получаем критерию фильтрации, делаем дата-провайдер и выводим.
Фильтр:
Для простой фильтрации по значением атрибутов достаточно использовать модель того же класса. Но обычно фильтрация гораздо сложнее — в ней может быть своя очень сложная логика, которая вполне может делать кучу запросов к БД или что-то еще. Поэтому иногда разумно унаследовать класс фильтра от модели, и реализовать эту логику там.
Но единственное назначение фильтра — получение критерии для выборки. Реализация фильтра в примере — не совсем удачная. Дело в том, что несмотря на возможность установить класс фильтра (с помощью $filterClass
), она все равно подразумевает что это будет СModel
. Об этом свидетельствуют вызов методов $filter->unsetAttributes()
и $filter->search()
, которые присуще моделям.
Единственное что фильтру нужно — это получать входные данные и отдавать EMongoCriteria
. Он просто должен реализовывать этот интерфейс:
interface IMongoDataFilter
{
/**
* @param array $data
* @return mixed
*/
public function setFilterAttributes(array $data);
/**
* @return EMongoCriteria
*/
public function getFilterCriteria();
}
Filter
в названиях методов я вставил чтоб не зависеть от декларации методов setAttributes
и getDbCriteria
в имплементирующем классе. Чтобы использовать модель в качестве фильтра, лучше всего написать простенький трейт:
trait MongoDataFilterTrait
{
/**
* @param array $data
* @return mixed
*/
public function setFilterAttributes(array $data)
{
$this->unsetAttributes();
$this->setAttrubites($data);
}
/**
* @return EMongoCriteria
*/
public function getFilterCriteria()
{
if($this->validate())
$this->search();
return $this->getDbCriteria();
}
}
Переписав экшн под использование интерфейса, мы бы могли использовать любой класс, который реализует интерфейс IMongoDataFilter
, не важно модель это или что-то другое.
Дата-провайдер:
Все что касается логики выборки необходимых данных — за это отвечает дата-провайдер. Порой он содержит так же довольно сложную логику, поэтому имеет смысл конфигурировать его класс с помощью $dataProviderClass
.
Например, в случае с расширением yiimongodbsuite
, в котором отсутствует возможность описать релейшены, нам необходимо подгружать их в ручную. (на самом деле лучше дописать это расширение, но пример хороший).
Логику подгрузки можно разместить и в каком-нибудь классе-РЕПОЗИТОРИИ, но если в обязанности конкретного дата-провайдера входит возвращение данных вместе с релейшенами, вызывать метод-подгрузчик РЕПОЗИТОРИЯ должен именно дата-провайдер. О реюзабельности дата-провайдеров я напишу ниже.
Критерия в использовании экшена:
Я хочу еще раз обратить внимание на самую «багогенерирующую» проблему:
Знание о том, кого нужно отобразить (в данном случае — неактивных пользователей) — это знание контроллера. Но вот знание о том, по какому критерию определяется неактивный пользователь — это знания модели.
В примере использования экшена все сделано правильно. С помощью скоупа мы указали кого хотим вывести, но сам скоуп находиться в модели.
На самом деле, скоуп — это «кусочек» СПЕЦИФИКАЦИИ. Можно легко переписать экшн чтоб работал с спецификациями. Хотя, это востребовано только в сложных приложениях. В большинстве случаев, скоуп — идеальное решение.
Про разделение контроллера и представления:
Иногда полностью отделять представление от контроллера нецелесообразно. Например, если для вывода списка нам необходимы только несколько атрибутов модели — глупо выбирать весь документ. Но это особенности конкретных экшенов, которые настраиваются с помощью конфигурирования (в данном случае — заданием select
у критерии). Самое главное что мы вынесли эти настройки из кода экшенов, сделав их реюзабельным.
Связка экшна с классом модели
В большинстве случаев контроллер (именно CController
) работает с одним классом (например с User
). В таком случае, нет особой нужды в каждом экшене указывать класс модели — проще указать его в контроллере. Но в экшене эту возможность оставить необходимо.
Чтобы разрулить эту ситуацию, в экшене нужно прописать геттер и сеттер для $modelClass. Вид геттера будет вот таким:
public function getModelClass()
{
if($this->_modelClass === null)
{
if($this->controller instanceof IModelController && ($modelClass = $this->controller->getModelClass()))
$this->_modelClass = $modelClass;
else
throw new CException('Model class must be setted');
}
return $this->_modelClass;
}
В принципе, можно сделать даже заготовку контроллера для стандартного CRUD:
/**
* Class BaseARController
*/
abstract class BaseARController extends Controller implements IModelController
{
/**
* @return string model class
*/
public abstract function getModelClass();
/**
* @return array default actions
*/
public function actions()
{
return [
'list' => ARListAction::class,
'view' => ARViewAction::class,
'create' => ARCreateAction::class,
'update' => ARUpdateAction::class,
'delete' => ARDeleteAction::class,
];
}
}
Теперь мы можем делать CRUD контроллер в несколько строк:
class UserController extends BaseARController
{
/**
* @return string model class
*/
public function getModelClass()
{
return User::class;
}
}
Итог по контроллерам
Большой набор гибко настраиваемых экшнов сокращает дублирование кода. Если разбить классы экшенов на четкую структуру (например, экшн по редактированию CActiveRecord
и EMongoDocument
отличаются лишь способом выборки объектов) — дублирования можно практически избежать. Такой код гораздо проще рефакторить. И в нем труднее сделать баг.
Конечно, подобными экшнами нельзя покрыть абсолютно все потребности. Но их значительную часть — однозначно да.
Представление
Yii дает нам шикарную инфраструктуру для ее построения. Это CWidget
, CGridColumn
, CGridView
, СMenu
и много другого. Не надо бояться все это использовать, расширять, переписывать.
Это все легко изучается чтением документации, я же хочу пояснить другое.
Выше я упоминал, что контроллер не должен знать как именно будет отображаться сущность, поэтому он не должен содержать кода для выборки данных для вьюх. Я прекрасно осознаю, что данное заявление вызовет массу протестов — все всегда подготавливают данные в контроллерах. Даже сам Yii нам как бы намекает что контроллер и вьюха связанны, передавая во вьюху экземпляр контроллера в качестве $this
.
Но это не так. Со стороны контроллера польза от избавления высокой связанности с вьюхами очевидна. Но что делать с вьюхами? На этот вопрос я отвечу здесь.
Рассматривать я буду два общих случая: представление сущности со связанными данными, и представление списка сущностей. Примеры тривиальны, но суть объяснят.
Допустим, у нас есть интернет-магазин. Есть клиент (модель Client
), его адрес (модель Address
) и заказы (модель Order
). Один клиент может иметь один адрес и много заказов.
Представление сущности со связанными данными
Допустим, нам нужно вывести инфу о клиенте, его адресе, и список его заказов.
По сути, каждая вьюха имеет свой собственный «интерфейс». Это передаваемые ей данные из CController::render
и сам экземпляр контроллера (доступный по $this
). Чем меньше данных ей передается — тем лучше, ибо тем более она независима. Такой подход позволит сделать вьюху реюзабельной в рамках проекта. Особенно учитывая, что в Yii вьюхи спокойно вкладываются друг в друга, и даже могут «общаться» между собой, например, с помощью CController::$clips
.
Необходимо-достаточными данными для вывода нашей вьюхи — объект клиента. Имея его, мы спокойно получим все остальные данные.
Здесь следует сделать отступление и обратить внимание на букву «М» из
MVC
.В каждой предметной области есть свои сущности и связи между ними. И очень важно, чтобы наш код максимально идентично их отображал.
В нашем магазине клиенту принадлежат и адрес и заказ. Это значит что в моделиClients
мы должны явно отобразить эти связи с помощью свойств$client->adress
или методов$client->getOrders()
Это очень важно. Подробнее об этом я расскажу в следующем посте.
Если предметная область правильно спроектирована, у нас всегда будет простой способ получить связанные данные. И это абсолютно решает проблему с тем, что контроллер нам не передал список заказов.
В таком случае, код вывода — максимально простой:
$this->widget('zii.widgets.CDetailView', [
'model' => $client,
'attributes' => [
'fio',
'age',
'address.city',
'adress.street'
]
]);
foreach($client->orders as $order)
{
$this->widget('zii.widgets.CDetailView', [
'model' => $order,
'attributes' => [
'date',
'sum',
'status',
]
]);
}
Если же мы решим разделить эту вьюху, чтоб потом использовать ее части независимо, то код будет таким:
$this->renderPartial('_client', $client);
$this->renderPartial('_address', $client->address);
$this->renderPartial('_orders', $client->orders);
Этот код прост, но имеет недостаток — если у клиента много заказов, нужно выводить его с пагинацией.
Никто не мешает нам запихнуть все это в дата провайдер. Допустим, модель Order
— это монго-документ. Заворачивать будем в EMongoDocumentDataProvider
:
$this->widget('zii.widgets.grid.CGridView', [
'dataProvider' => new EMongoDocumentDataProvider((new Order())->forClient($client)),
'columns' => ['date', 'sum', 'status']
]);
Создание дата-провайдера во вьюхе несколько непривычно. Но на самом деле здесь все на месте: Контроллер свои обязанности уже отработал, знание о том как связанны Client
и User
находятся в предметной области (благодаря скоупу forClient
), а знание о том как отображать данные находятся во вьюхе.
В действительности, некоторые мои коллеги, увидев это, крутили у виска — создание дата-провайдера в вьюхе — что за бред? При этом сами выполняли подобные действия в виджетах, не осознавая что виджет — это, в первую очередь, инфраструктура представления.
Виджет — это отличный инструмент для созданию реюзабельного и гибкого представления, а так же логического разграничения. Но его назначение — представление, поэтому нет концептуальной разницы где находится вышеприведенный код — в виджете или во вьюхе.
Представление списка сущностей
Представление списка сущностей отличается от представления конкретной сущности лишь выборкой данных.
Допустим, что Client
, Address
и Order
— это три разных коллекции в MongoDB
. В случае вывода одного клиента, мы спокойно можем вызвать $client->address
. Это сделает запрос к БД, но это неизбежно.
Если мы выберем 100 клиентов, и для каждого вызовем $client->address
— мы получим 100 запросов к БД — это неприемлемо. Загружать адреса нужно для всех клиентов разом.
Если бы мы использовали AR
, мы описали бы релейшены, и использовали их в критерии экшна. Но с MongoDB
(точнее, с расширением yiimongodbsuite
) это не пройдет.
Наилучшим местом для реализации выборки дополнительных данных является дата-провайдер. Он, как объект предназначенный для выборки данных, должен знать какие данные должен вернуть и как их выбрать.
Делается это как-то так:
class ClientsDataProvider extends EMongoDocumentDataProvider
{
/**
* @param bool $refresh
* @return array
*/
public function getData($refresh=false)
{
if($this->_data === null || $refresh)
{
$this->_data=$this->fetchData();
// Соберем список id адресов
$addressIds = [];
foreach($this->_data as $client)
$addressIds[] = $client->addressId;
// Выберем адреса
$adresses = Address::model()->findAllByPk($addressIds);
... перебор клиентов и адресов и присвоение клиентам их адреса ....
}
return $this->_data;
}
}
Тут есть 2 проблемы:
- он содержит знания о предметной области
- код подгрузки адресов невозможно реюзать
Решение — переместить код подгрузки в РЕПОЗИТОРИЙ, которым может являться сам класс модели.
Если мы переместим его туда, то наш дата-провайдер будет выглядеть вот так:
class ClientsDataProvider extends EMongoDocumentDataProvider
{
/**
* @param bool $refresh
* @return array
*/
public function getData($refresh=false)
{
if($this->_data === null || $refresh)
{
$this->_data=$this->fetchData();
Client::loadAddresses($this->_data);
}
return $this->_data;
}
}
Теперь все находиться на месте.
Отступление к «М»:
В качестве РЕПОЗИТОРИЯ мы могли использовать как классClient
, так иAddress
. Но существует четкая причина, почему я использовал именно Client. В нашей предметной области адрес абсолютно не важен вне контекста пользователя. Несмотря на то, что адрес имеет и свою коллекцию, и свой класс, логически он — всего лишь ОБЪЕКТ-ЗНАЧЕНИЕ. Поэтому он не должен знать ничего о том, кому принадлежит. Размещая код подгрузки адресов вClient
, мы избавляемся от двухсторонней связи классов. А это всегда хорошо.
Реюзабельность дата-провайдеров
Дата-провайдеры тоже реюзабельны (в рамках приложения). Допустим у нас есть 2 экшна: отображение списка заказов, и вышерассмотренная страница пользователя, где так же отображается список заказов.
В обоих случаях мы можем использовать один и тот-же дата-провайдер, который подгрузит нам необходимые данные.
Так же не вижу причин не делать их конфигурируемыми.
Контроллер как $this в вьюхах
На мой взгляд, это ошибка. Конечно, класс CController
выполняет много действий, не связанных с его концептуальным назначением. Но все же во вьюхах его непосредственное присутствие создает путаницу. Я много раз видел (да чего греха таить, и сам так делал), как логику представления выносили в контроллер (какие-то специальные методы для форматирования или что-то подобное) лишь по тому-что контроллер присутствовал во всех его вьюхах. Это не правильно. Вью должны представляться своим обособленным классом.
Заключение
Все примеры — сильно упрощены. Реальные класс контроллеров, структуры моделей намного масштабны.
Это слишком сложно и запутанно — многие так подумают. Многие, сев работать за подобный код, не разобравшись в структуре, просто вырежут его и напишут «по простому».
Это вполне понятно — я всего лишь описал взаимодействие нескольких классов — а уже дикая путаница, простейший в реализации код раскидан по куче файлов. Но на самом деле — это четкая и логичная структура классов, в которых каждая строчка находиться именно на своем месте.
Возможно, маленький проект такой подход погубит. На написание одной инфраструктуры необходимо довольно приличное время. Но для большого — это единственный шанс выжить.
Послесловие
Несмотря на то, что пост называется «как правильно делать», он не претендует на правильность. Я и сам не знаю как правильно. Он — попытка донести, что нам нужно более осмысленно подходить к проектированию классов и их взаимодействию.
Разработчики PHP подарили нам мощнейший язык. Разработчики Yii подарили нам великолепнейший фреймворк. Но посмотрите вокруг — представители других языков и фреймвороков считают нас быдлокодерами. PHP и Yii — мы позорим их.
Своим халатным отношением к проектированию, банальным незнанием основных принципов MVC, объектно-ориентированного проектирования, языка, на котором пишем, и фреймворка, который используем — всем этим мы подводим PHP. Мы подводим Yii. Мы подводим компании на которые работаем, и которые обеспечивают нас. Но самое главное — мы подводим себя.
Задумайтесь.
Всем добра.
Автор: mitaichik