Pull to refresh

Правильное использование Yii

Reading time 18 min
Views 115K

Вступление


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

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

Всем добра.
Tags:
Hubs:
+53
Comments 54
Comments Comments 54

Articles