ActiveRecord и откат транзакций в Yii

от автора

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

Для нашего проекта, как учетной системы, характерно производить изменения в других объектах после сохранения текущего, например, проведение документа по регистрам после сохранения. Суть в том, что после сохранения объекта в транзакции ActiveRecord будет считать, что все изменения прошли успешно, хотя это не гарантировано, ведь последующие изменения могут вызвать Exception, а он в свою очередь к откату транзакции. В нашем случае, это грозит тем, что при ошибочном создании записи, экземпляр ActiveRecord уже будет иметь статус существующей записи (флаг isNewRecord == false) или для новой записи уже будет присвоен primaryKey. Если вы при рендере опирались на эти атрибуты (как мы в нашем проекте), то в результате получите ошибочное представление.

    /**      * Creates a new model.      */     public function actionCreate()     {         /** @var BaseActiveRecord $model */         $model = new $this->modelClass('create');          $this->performAjaxValidation($model);          $model->attributes = Yii::app()->request->getParam($this->modelClass, array());          if (Yii::app()->request->isPostRequest && !Yii::app()->request->isAjaxRequest) {             $transaction = $model->getDbConnection()->beginTransaction();             try {                 $model->save();                 $transaction->commit();                 $url = array('update', 'id' => $model->primaryKey);                 $this->redirect($url);             } catch (Exception $e) {                 $transaction->rollback();             }         }          $this->render('create', array('model' => $model));     } 

Это, практически, код по урокам Yii. За одним лишь исключением — сохранение объекта в БД обернуто в транзакцию.

Что делать? Нужно после rollback() восстановить исходное состояние ActiveRecord. В нашем случае нужно было еще восстанавливать все ActiveRecord`ы, измененные внутри исходной модели.

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

Расширяем класс CDbTransaction.

/**  * Class DbTransaction  * Stores models states for restoring after rollback.  */ class DbTransaction extends CDbTransaction {     /** @var BaseActiveRecord[] models with stored states */     private $_models = array();      /**      * Checks if model state is already stored.      * @param BaseActiveRecord $model      * @return boolean      */     public function isModelStateStoredForRollback($model)     {         return in_array($model, $this->_models, true);     }      /**      * Stores model state for restoring after rollback.      * @param BaseActiveRecord $model      */     public function storeModelStateForRollback($model)     {         if (!$this->isModelStateStoredForRollback($model)) {             $model->storeState(false);             $this->_models[] = $model;         }     }      /**      * Rolls back a transaction.      * @throws CException if the transaction or the DB connection is not active.      */     public function rollback()     {         parent::rollback();         foreach ($this->_models as $model) {             $model->restoreState();         }         $this->_models = array();     } } 

Добавляем в класс BaseActiveRecord (расширение CActiveRecord, в нашем проекте он уже существовал) методы restoreState(), hasStoredState() и storeState().

abstract class BaseActiveRecord extends CActiveRecord {      /** @var array сохраненное состояние модели */     protected $_storedState = array();      /**      * Проверка наличия сохраненного состояния модели      * @return boolean      */     public function hasStoredState()     {         return $this->_storedState !== array();     }      /**      * Сохранение состояния модели      * @param boolean $force флаг принудительного сохранения      * @return void      */     public function storeState($force = false)     {         if (!$this->hasStoredState() || $force) {             $this->_storedState = array(                 'isNewRecord' => $this->isNewRecord,                 'attributes' => $this->getAttributes(),             );         }     }      /**      * Восстановаление состояния модели      * @return void      */     public function restoreState()     {         if ($this->hasStoredState()) {             $this->isNewRecord = $this->_storedState['isNewRecord'];             $this->setAttributes($this->_storedState['attributes'], false);             $this->_storedState = array();         }     } } 

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

    /**      * Creates a new model.      */     public function actionCreate()     {         /** @var BaseActiveRecord $model */         $model = new $this->modelClass('create');          $this->performAjaxValidation($model);          $model->attributes = Yii::app()->request->getParam($this->modelClass, array());          if (Yii::app()->request->isPostRequest && !Yii::app()->request->isAjaxRequest) {             $transaction = $model->getDbConnection()->beginTransaction();              // Сохраняем состояние объекта             $transaction->storeModelStateForRollback($model);              try {                 $model->save();                 $transaction->commit();                 $url = array('update', 'id' => $model->primaryKey);                 $this->redirect($url);             } catch (Exception $e) {                 $transaction->rollback();             }         }          $this->render('create', array('model' => $model));     } 

В своем проекте мы пошли чуть дальше — перенесли $transaction->storeModelStateForRollback($model) в метод save() самого BaseActiveRecord.

abstract class BaseActiveRecord extends CActiveRecord {     // ...      /**      * Сохранение экземпляра модели (с поддержкой транзакционности)      * @param boolean $runValidation необходимость выполнения валидации перед сохранением      * @param array   $attributes    массив атрибутов для сохранения      * @throws Exception|UserException      * @return boolean результат операции      */     public function save($runValidation = true, $attributes = null)     {         /** @var DbTransaction $transaction */         $transaction = $this->getDbConnection()->getCurrentTransaction();         $isExternalTransaction = ($transaction !== null);          if ($transaction === null) {             $transaction = $this->getDbConnection()->beginTransaction();         }          $transaction->storeModelStateForRollback($this);          $exception = null;          try {             $result = parent::save($runValidation, $attributes);         } catch (Exception $e) {             $result = false;             $exception = $e;         }          if ($result) {             if (!$isExternalTransaction) {                 $transaction->commit();             }         } else {             if (!$isExternalTransaction) {                 $transaction->rollback();             }             throw $exception;         }          return $result;     }      // ... } 

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

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

ссылка на оригинал статьи http://habrahabr.ru/post/198100/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *