...

суббота, 8 февраля 2014 г.

[Из песочницы] Правильное использование Yii

Вступление




На самом деле, в заголовке должен стоять знак вопроса. Довольно долго я не кодил как на 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. Мы подводим компании на которые работаем, и которые обеспечивают нас. Но самое главное — мы подводим себя.


Задумайтесь.


Всем добра.


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.


Комментариев нет:

Отправить комментарий