Вступление
На самом деле, в заголовке должен стоять знак вопроса. Довольно долго я не кодил как на 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. Мы подводим компании на которые работаем, и которые обеспечивают нас. Но самое главное — мы подводим себя.
Задумайтесь.
Всем добра.
ссылка на оригинал статьи http://habrahabr.ru/post/211739/
Добавить комментарий