Выносим CRUD действия контроллеров в классы в Yii

Мусор

Любой программист с опытом осознаёт, что в неудачно спроектированном тяжёлом проекте изначально или со временем накапливается много неуправляемого и ненужного мусора. Это, например, повторяющийся код. В описании контроллера официального руководства Yii Framework указано, что Yii поддерживает вынос действий в отдельные классы и описывается процедура подключения этих действий к контроллерам. Но мало кто пользуется этим способом, так как не находит действительно тяжёлых повторяющихся экшенов.

Попробуем применить этот приём к тому что есть, чтобы по возможности сократить наш код.

С помощью модуля Gii можно автоматически генерировать каркас контроллера с действиями для просмотра, создания, редактирования и удаления любых сущностей (так называемых CRUD-операций).

Для управления записями блога получаемый контроллер имеет следующую структуру (фильтр доступа рассматривать не будем, так как для понимания смысла он не нужен):

class PostController extends Controller
{
    // ...
 
    public function actionView($id){
        // ..
    }
 
    public function actionCreate(){
        // ...
    }
 
    public function actionUpdate($id){
        // ...
    }
 
    public function actionDelete($id){
        // ...
    }
 
    public function actionIndex(){
        // ...
    }
 
    public function actionAdmin(){
        // ...
    }
 
    public function loadModel($id){
        // ...
    }
 
    protected function performAjaxValidation($model){
        // ...
    }
}

Как видим, здесь собраны базовые действия

  • actionView($id);
  • actionCreate();
  • actionUpdate($id);
  • actionDelete($id);
  • actionIndex();
  • actionAdmin()

и вспомогательные методы

  • loadModel($id);
  • performAjaxValidation($model).

Код этих действий максимально прост, но при умелом разделении обязанностей всех компонентов (а именно перенесение логики из контроллера в толстые модели, отдельные фильтры, формы, правила валидации) они практически без изменения могут покрыть почти все требования по работе с различными сущностями сайта.

Если на публичной стороне вывод в разных модулях порой значительно различается, то в админке обычно всё стандартно. При наличии на сайте нескольких десятков типовых сущностей образуется соразмерное количество однотипных контроллеров с одинаковыми методами. Гораздо экономичнее вместо копирования одних и тех же экшенов вынести общие стандартные действия в отдельные классы и по необходимости подключать к контроллерам.

К базовым действиям полезно добавить ещё actionToggle($id, $attribute) для переключения некоторых флагов модели, которое можно использовать для работы с DToggleColumn.

Немного модернизируем базовые действия и вынесем в классы:

Коллекция действий на GitHub

Можно скопировать всю папку crud в директорию protected/modules и использовать как модуль. Для этого нужно будет подключить данный модуль в конфигурационном файле protected/config.main.php

'modules'=>array(
    // ...
    'crud',
)),

Также можно взять только файлы действий из папки crud/components и поместить их в protected/components/crud. Для работы переводов сообщений необходимо содержимое папки crud/messages поместить в общую папку приложения protected/messages, а в коде все вызовы

Yii::t('CrudModule.crud', ...)

вручную заменить на

Yii::t('crud', ...)

Вдальнейшем будут различаться только пути для импортирования классов:

// при использовании как модуль
Yii::import('crud.components.*');
 
// при использовании без модуля
Yii::import('application.components.crud.*');

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

В качестве примера создадим простейший контроллер для управления записями блога с использованием этих действий:

Yii::import('crud.components.*');
 
class PostController extends Controller
{
    public function actions()
    {
        return array(
            'index'=>'DIndexAction',
            'admin'=>'DAdminAction',
            'create'=>'DCreateAction',
            'update'=>'DUpdateAction',
            'toggle'=>'DToggleAction',
            'delete'=>'DDeleteAction',
            'view'=>'DViewAction',
        );
    }
 
    public function getIndexProviderModel()
    {
        return Post::model()->published();
    }
 
    public function createModel()
    {
        $model = new Post;
        $model->date = date('Y-m-d H:i:s');
        return $model;
    }
 
    public function loadModel($id)
    {
        $model = Post::model()->findByPk($id);
        if($model === null)
            throw new CHttpException(404, 'Страница не найдена');
        return $model;
    }    
 
    public function performAjaxValidation($model){
        if(isset($_POST['ajax']) && $_POST['ajax']==='blog-post-form'){
            echo CActiveForm::validate($model);
            Yii::app()->end();
        }
    }
}

При работе этим действиям необходимо обеспечить поставку нужных моделей. Для этого в контроллере должны быть определены соответствующие методы:

// Delete, Toggle, Update, View 
public function loadModel($id){...}
 
// Admin, Create 
public function createModel(){...}
 
// Index
public function getIndexProviderModel(){...}

Для работы Ajax валидации добавьте стандартный метод performAjaxValidation и объявите его публичным

// Create, Update
public function performAjaxValidation($model)
{
    if(isset($_POST['ajax']) && $_POST['ajax']==='blog-post-form'){
        echo CActiveForm::validate($model);
        Yii::app()->end();
    }
}

Он подхватится действиями Create и Update автоматически.

Также для контроля операций действия запрашивают у контроллера некоторые методы обратного вызова:

// Create
public function beforeCreate($model){...}
 
// Update
public function beforeUpdate($model){...}
 
// Toggle
public function beforeToggle($model){...}
 
// Delete
public function beforeDelete($model){...}

Использовать их необязательно. Все методы должны быть объявлены публичными. В них можно производить, например, присвоение необходимых значений атрибутам модели или проверку доступа.

Следующий пример показывает, как прервать операцию когда пользователь пытается удалить чужую запись:

Yii::import('crud.components.*');
 
class PostController extends Controller
{
    public function actions()
    {
        return array(
            'index'=>'DIndexAction',
            'admin'=>'DAdminAction',
            'create'=>'DCreateAction',
            'update'=>'DUpdateAction',
            'delete'=>'DDeleteAction',
            'view'=>'DViewAction',
        );
    }
 
    public function beforeDelete($model)
    {        
        if (!$this->checkAccess($model))
            throw new CHttpException(403, 'Вы не можете удалить данную запись');
    }
 
    protected function checkAccess($model)
    {
        $isAuthor = $model->author_id == Yii::app()->user->id;
        $isAdmin = Yii::app()->user->checkAccess(User::ROLE_ADMIN);         
        return $isAuthor || $isAdmin;        
    }
 
    // ...
}

Метод beforeDelete также пригодится для проверок вроде «В данной категории есть товары. Удалите или переместите их в другие категории» и подобных.

Каждое действие имеет свои настройки. Их можно подсмотреть в исходном коде классов.

Конфигурируются действия стандартным образом:

Yii::import('crud.components.*');
 
class PostAdminController extends Controller
{
    public function actions()
    {
        return array(
            // в админке используем по умолчанию actionAdmin вместо actionIndex
            // и задаём отдельное представление для оптимизации Ajax обновления грида
            'index'=>array(
                'class'=>'DAdminAction',
                'view'=>'index',
                'ajaxView'=>'_grid'
            ),
            'update'=>'DUpdateAction',
            'toggle'=>array(
                'class'=>'DToggleAction',
                'attributes'=>array('public', 'popular')
            ),
            'delete'=>'DDeleteAction',
            // Разрешаем получение данных по JSON при наличии $_GET['json'] 
            'view'=>array(
                'class'=>'DViewAction',
                'json'=>true
            )
        );
    }
 
    // ...
}

Если какое либо из действий вам не нужно, то просто не подключайте его. Если стандартное не подходит, то просто напишите вместо него своё. По крайней мере, действие удаления в стандартном виде пригодится почти во всех контроллерах.

При успешном сохранении модели действия Create, Toggle и Update вместо обновления формы вызовом CController::refresh() производят перенаправление на действие View. Если просмотр в админке вам не нужен (например, нужно после сохранения вернуться на форму), то создайте фиктивное представление view.php с повтором сообщения и с нужным редиректом вроде

<?php
<!-- Обновляем Flash-сообщения -->
<?php $this->reflash(); ?>
<!-- Выходим из админки на просмотр непосредственно на сайте -->
<?php $this->redirect($model->getUrl()); ?>

или

<?php
<!-- Обновляем Flash-сообщения -->
<?php $this->reflash(); ?>
<!-- Возвращаемся на форму редактирования -->
<?php $this->redirect('update', array('id'=>$model->id)); ?>

Метод reflash() просто восстанавливает все мгновенные сообщения. Его можно поместить в базовый контроллер:

class Controller extends CController 
{
    public function reflash()
    {
        foreach (array('success', 'error', 'notice') as $type){
            if(Yii::app()->user->hasFlash($type))
                Yii::app()->user->setFlash($type, Yii::app()->user->getFlash($type));
        }
    }
}

Таким образом, мы избавились от необходимости повторять стандартные методы во всех контроллерах, чем изрядно сэкономили бумагу килобайты.

У этого способа есть альтернативная замена – это наследование общих действий от базового контроллера.

Старайтесь тоже сокращать код. Берегите лес...

Комментарии

 

Donna Insolita

Ещё один вариант - создать свой базовый контроллер с публичным свойством для передачи названия модели, описать в нём стандартные экшены, и после наследоваться от него. По необходимости в наследниках экшены можно переопределять и дополнять..

Ответить

 

Елисеев Дмитрий

О наследовании экшенов я думал, но из-за меньшей гибкости и большей сложности отказался от него. Написал об этом в в новой статье.

А создать публичное свойство с именем модели в контроллере можно и сейчас. Нужно лишь перенести методы createModel() и loadModel($id) в базовый контроллер.

Ответить

 

seobot

Супер рецепты! Полезно! Спасибо!

Ответить

 

Andrey

Спасибо за статью!

Как вариант - можно ли в DCrudAction засунуть filters и accessRules ?

Ответить

 

Andrey

В CrudModule ничего писать не надо?

Я подключил в модулях 'crud', но к примеру находясь в actionAdmin ловлю ошибку:

include(DAdminAction.php) [<a href='function.include'>function.include</a>]: failed to open stream: No such file or directory
Ответить

 

Andrey
'modules'=>array(
     ...
        'crud',
    ...
),
Ответить

 

Дмитрий Елисеев

Добавьте путь к файлам в import-секцию конфига или произведите импорт перед вашим контроллером:

Yii::import('application.modules.crud.components.*');
Ответить

 

Andrey

Это проделано, работает!

Ответить

 

Andrey

Добрый день,

Почему может вылазить ошибка:

Method CController::createModel() not found

Это из DCrudAction, я подключил модуль в конфиге и сделал импорт. Сами действия подключаю в контроллере.

Ответить

 

Дмитрий Елисеев

А в контроллер добавлен метод createModel()?

Ответить

 

Дмитрий

А зачем необходим метод "createModel()"?
Почему просто не указывать имя модели в свойстве класса, например "public $modelName;" и уже во внешнем CAction брать его с данного свойства. Просто не могу понять в чем плюс такого решения как "createModel()".

Ответить

 

Дмитрий Елисеев

Например, чтобы указать там сценарий или присвоить полям значения по умолчанию.

Ответить

 

Дмитрий

Ну касательно полей, я думаю что это лучше делать в модели - например в beforeSave. А касательно сценария - его можно указывать в свойствах класса по умолчанию, и если нужно у нас будет возможность задать сценарий при подключении самого action, так как это реализовано у вас в AdminAction.

Ответить

 

Андрей Сучилов

Всем привет! Спасибо за рецепт. Добавил в него пару мелочей:
В DCrudAction :

	
public $pageTitle = false;
public $breadcrumbs = false;
public $menu = false;
	
protected function prepare () {
		
	if ($this->pageTitle !== false)
		$this->controller->pageTitle = $this->pageTitle;
		
	if ($this->breadcrumbs !== false)
		$this->controller->breadcrumbs = $this->breadcrumbs;
		
	if ($this->menu !== false)
		$this->controller->menu = $this->menu;
		
	return true;
}

в его наследниках, например DAdminAction:

public function run() {
	$this->prepare ();
	...
}

В контроллере :

public function actions() {
	return array(
		'index'=>array(
			'class' => 'DAdminAction',
			'pageTitle' => 'Управление пользователями',
		),
		...
	);
}
Ответить

 

Andrey

Подскажите пожалуйста, как нужно делать реализацию в модели метода published()
В классе Stock и его поведениях не найден метод или замыкание с именем "published". Имеется ввиду cdbCriteria('public = 1') ?

Ответить

 

Andrey

Как правильно можно исправить такую запись в базовой модели?

class ActiveRecord extends CActiveRecord {
...
    public function published(){
        $criteria = new CDbCriteria();
        $criteria->condition = 'EXIST = '.STATUS_PUBLIC;
        $model = $this::model(get_called_class())->findAll($criteria);
        return $model;
    }
...
}
Ответить

 

Дмитрий Елисеев

Добавьте именованную группу условий:

public function scopes()
{
    return array(
        'published'=>array(
            'condition'=>'EXIST=' . STATUS_PUBLIC,
        );
    }
}

и используйте как метод:

Stock::model()->published()->findAll()
Ответить

 

Andrey

Благодарю!

Ответить

 

Andrey

Добрый день, из за чего может вываливаться такая ошибка?

'Контроллер ConfigUserController не может найти представление "_grid" и как влияет на процесс ajaxView?

Ответить

 

Andrey
if ($this->ajaxView && Yii::app()->request->isAjaxRequest) {
    $this->controller->renderPartial($this->ajaxView, [
        'model'=>$model,
        'title'=>$modelName::TITLE
    ]);
}

Видимо тут ошибка.

Ответить

 

Дмитрий Елисеев

Значит у Вас нет представления _grid.php.

Ответить

 

Andrey

Благодарю!

Ответить

 

Grigory

На сайте есть форма заказа обратного звонка. Нужно защитить её капчей. Форма обратного звонка отображается на всём сайте и используется со всеми контроллерами текущими и будущими.
И в тогда данный совет очень помог: У этого способа есть альтернативная замена – это наследование общих действий от базового контроллера.

Спасибо!

Ответить

 

Дмитрий Елисеев

Обычно такое делается виджетом, в ActiveForm которого прописывается 'action' => ['/site/call'] и указывается captсhaAction.

Ответить

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

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>