Backend в проекте на Yii

В сети очень мало информации по созданию административных страничек в приложениях и наверно каждый использует свои решения. Именно сейчас хотел бы рассказать к чему я дошел за пол года разработки сайтов на Yii (статья только для тех, кто разбирается во фремворке).

При постановке цели всегда следует подумать, что действительно нужно. В случает с backend-частью сайта это:
  • Минимум кода и как следствие некоторое обобщение данных
  • Возможность расширения для особенных разделов
  • Отсутствие прямой связи frontend и backend частей сайта


Минимум кода

Думаю многие разработчики, которые генерировали CRUD с помощь встроенного генератора Yii, знают, что создается практически одинаковый код, что раздражает и немного противоречит принципам ООП. Дополнительно к этому можно сказать, что действия Create и Update различаются только наличием id (у новой записи id ещё не создан), поэтому можем исключить действие Create.

Некоторое обобщение данных

В данный момент единственное обобщение данных, которое я использую — это статус “В корзине” для записи в таблице. Если рассказать об этом очень кратко, то получится, что почти во всех таблицах у меня есть поле status (tinyint, index) со значениями 0 — обычное и 1 — удаленное состояние.

Возможность расширения для особенных разделов

Наверно во всех правилах есть исключения и их мы должны обязательно предусмотреть их решение. В случае с backend это возможность переопределить любое стандарное поведение базового функционала.

Отсутствие прямой связи frontend с backend

Вackend является дополнительным функционалом и должен безболезненно удалятся из приложения. В моём случае это безболезненное удаление одноименного модуля.

Практика в 7 шагов


1. Создаем модуль «backend» стандартными средствами Gii (я не буду это подробно описывать, для этого есть официальная документация).
2. Создаем базовую модель, которая в умеет случае умеет работать с корзиной.
abstract class TModel extends CActiveRecord
{
	const STATUS_DEFAULT = 0;
	const STATUS_REMOVED = 1;
	
	public function defaultScope()
	{
		return array(
			'condition' => 'status=' . self::STATUS_DEFAULT
		);
	}
	
	public function removed()
	{
		$this->resetScope()->getDbCriteria()->mergeWith(array(
			'condition' => 'status=' . self::STATUS_REMOVED
		));
		
		return $this;
	}
	
	public function restore()
	{
		if($this->getIsNewRecord())
			throw new CDbException(Yii::t('yii','The active record cannot be deleted because it is new.'));
		
		if($this->status != self::STATUS_REMOVED)
			return false;
		
		$this->status = self::STATUS_DEFAULT;
		$this->save(false, array('status'));
		
		return true;
	}
	
	public function beforeDelete()
	{
		if($this->status == self::STATUS_DEFAULT)
		{
			$this->status = self::STATUS_REMOVED;
			$this->save(false, array('status'));
		
			return false;
		}
		
		return parent::beforeDelete();
	}
}

3. Создаем базовый контроллер для CRUD.
class BackendController extends CController
{
	public $defaultAction = 'list';

	public function actions()
	{
		return array(
			'list' => 'backend.actions.ListAction',
			'update' => 'backend.actions.UpdateAction',
			'delete' => 'backend.actions.DeleteAction',
			'restore' => 'backend.actions.RestoreAction',
		);
	}
}

4. Создаем базовое действие, которое «догадывается» с какой моделью и представлением работает (обычно не комментирую код, но тут специально для статьи сделал несколько комментариев).
abstract class BackendAction extends CAction
{
	private $_modelName;
	private $_view;
	
	/**
	 * Упрощенная переадресация по действиям контроллера
	 * По-умолчанию переходим на основное действие контроллера
	*/
	public function redirect($actionId = null)
	{
		if($actionId === null)
			$actionId = $this->controller->defaultAction;
		
		$this->controller->redirect(array($actionId));
	}

	/**
	 * Рендер представление.
	 * По-умолчанию рендерим одноименное представление
	*/
	public function render($data, $return = false)
	{
		if($this->_view === null)
			$this->_view = $this->id;
		
		return $this->controller->render($this->_view, $data, $return);
	}
	
	/**
	 * Возвращаем новую модель или пытаемся найти ранее
	 * созданную запись, если известен id
	*/
	public function getModel($scenario = 'insert')
	{
		if(($id = Yii::app()->request->getParam('id')) === null)
			$model = new $this->modelName($scenario);
		else if(($model = CActiveRecord::model($this->modelName)->resetScope()->findByPk($id)) === null)
			throw new CHttpException(404, Yii::t('base', 'The specified record cannot be found.'));
		
		return $model;
	}
	
	/**
	 * Возвращает имя модели, с которой работает контроллер
	 * По-умолчанию имя модели совпадает с именем контроллера
	*/
	public function getModelName()
	{
		if($this->_modelName === null)
			$this->_modelName = ucfirst($this->controller->id);
		
		return $this->_modelName;
	}
	
	public function setView($value)
	{
		$this->_view = $value;
	}
	
	public function setModelName($value)
	{
		$this->_modelName = $value;
	}
}

5. Создаем базовое действие List.
class ListAction extends BackendAction
{
	public function run($showRemoved = null)
	{
		$model = $this->getModel('search');

		if($showRemoved !== null)
			$model->removed();
		
		if(isset($_GET[$this->modelName]))
			$model->attributes = $_GET[$this->modelName];
		
		$this->render(array(
			'model' => $model,
			'showRemoved' => $showRemoved,
		));
	}
}

6. Создаем базовое действие Update.
class UpdateAction extends BackendAction
{
	public function run()
	{
		$model = $this->getModel();
		
		if(isset($_POST[$this->modelName]))
		{
			$model->attributes = $_POST[$this->modelName];
			
			if($model->save())
				$this->redirect();
		}
		
		$this->render(array('model' => $model));
	}
}

6. Создаем базовое действие Delete.
class DeleteAction extends BackendAction
{
	public function run()
	{
		$this->getModel()->delete();
		$this->redirect();
	}
}

7. Создаем базовое действие Restore(восстановление).
class RestoreAction extends BackendAction
{
	public function run()
	{
		$this->getModel()->restore();
		$this->redirect();
	}
}


Проверка


Для проверки всего вышенаписанного создадим модель и контроллер Article.
class Article extends TModel
{
	public static function model($className=__CLASS__)
	{
		return parent::model($className);
	}

	public function tableName()
	{
		return 'article';
	}
	
	public function rules()
	{
		return array(
			array('name, content, createTime', 'required'),
		);
	}
}

class ArticleController extends BackendController
{
	//Сейчас все довольно стандартно, поэтому ничего более не пишем
}


Итог


В простейшем варианте получается, что для создание нового CRUD нужно создать пустой контроллер и два представления (list, update). Если нужно дополнительный функционал просто его дописываем или переписываем текущий.

Примечание


Я не буду описывать код представлений, ибо он скорей всего будет уникален для каждого разработчика, да и его можно взять из стандартного CRUD.
Поделиться публикацией

Похожие публикации

Комментарии 10

    +1
    По моему для такого подхода неплохо было бы дописать генераторов в Gii.

    Насчет Create\Update — да по умолчанию они одинаковы, но тут скорее рассчитано на то, что возможны разные действия при вставке\изменении в контролере, поэтому и разбито на 2 action`а.

    Понравилась идея разбить action и controller, ибо действительно получалось много, зачастую одинакового, кода.

    Не совсем понял почему именно Backend? Такой подход может быть применен для организации однотипных действий где угодно, и Backend не всегда самый удачный пример для этого, ибо там часто бывает много специфичных действий для работы с записями.
      0
      Разные действия для Create\Update зачастую лучше всего описать в beforeSave\afterSave модели. Пример с Backend привел потому, что в момент написания статьи вся голова была забита именно этим, но в любом случая уникальный код пишется большей частью для фронта (может быть и по другому, но пока ещё не сталкивался).
        0
        Насчет модели согласен. Но не всегда допустимо засунуть в модель все, ибо изменения могут не относиться напрямую к изменяемой сущности.

        Просто после прочтения заголовка, я сразу подумал об очередном холиваре на тему организации бэка для Yii.
      0
      По коду… если для какого-либо Action нужна отдельная модель, то можно сделать так:
      public function actions()
      {
           return Cmap::arrayMerge(parent::actions(), array(
                'list' => array(
                     'class' => 'backend.actions.ListAction',
                     'modelName' => 'SomeModel',
                ),
           ));
      }
      • НЛО прилетело и опубликовало эту надпись здесь
          +5
          Вещи типа универсальной корзины думаю, удобнее делать behavior'ами для модели (если хочется универсальности).

          Я например использую behavior для хранения автора/даты создания/даты последней правки (кроме добавления геттеров и сеттеров поведение навешивает на модель события beforeSave/afterSave, beforeValidate/afterValidate).

          И версионность, с ней по-моему особенно удачно получилось. Поведение (behavior) создает новую таблицу по подобию таблиц owner'а с суфиксом '_revisions', расширяет первичный ключ таблицы колонкой 'revision' и навешивает модели обработчик afterSave, который дополнительно сохраняет запись в таблицу с ревизиями. А также добавляет модели методы для работы с этим всем добром.

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

          И еще: да, описанный sdevalex'ом подход вполне имеет право на жизнь, но очень много удобства все-таки добавляет кодогенератор, собственные шаблоны рулят.

          p.s.: Оформляйте в виде расширения и выкладывайте на Yii extensions!
            0
            Не знал, что модель может вызывать у behavior события… спасибо за информацию, буду использовать. А кодогенераторы для меня пока чужое… они генерируют шаблонный код, то почему же нельзя реализовать этот код наследованием\расширением?
              +3
              В условии defaultScope лучше указывать alias:

              $alias = $this->getTableAlias(false, false);
              return array( 'condition' => "$alias.status=" . self::STATUS_DEFAULT);

              иначе при join'е таблиц с одинаковыми полями status будет неоднозначность.
                –2
                >2. Создаем базовую модель, которая в умеет случае умеет работать с корзиной.
                • НЛО прилетело и опубликовало эту надпись здесь

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

                  Самое читаемое