Хотелось бы рассказать об одной проблеме, с которой мы столкнулись при разработке нашего стартап-проекта для управленческого учета.
Для нашего проекта, как учетной системы, характерно производить изменения в других объектах после сохранения текущего, например, проведение документа по регистрам после сохранения. Суть в том, что после сохранения объекта в транзакции ActiveRecord будет считать, что все изменения прошли успешно, хотя это не гарантировано, ведь последующие изменения могут вызвать Exception, а он в свою очередь к откату транзакции. В нашем случае, это грозит тем, что при ошибочном создании записи, экземпляр ActiveRecord уже будет иметь статус существующей записи (флаг isNewRecord == false) или для новой записи уже будет присвоен primaryKey. Если вы при рендере опирались на эти атрибуты (как мы в нашем проекте), то в результате получите ошибочное представление.
Это, практически, код по урокам Yii. За одним лишь исключением — сохранение объекта в БД обернуто в транзакцию.
Что делать? Нужно после rollback() восстановить исходное состояние ActiveRecord. В нашем случае нужно было еще восстанавливать все ActiveRecord`ы, измененные внутри исходной модели.
Для начала обращаемся к всемирному разуму, вдруг, мы изобретаем велосипед. На Гитхабе эта проблема уже обсуждалась. Разработчики сказали, что решать на уровне фреймворка у них в планах этого нет, так как это ресурсоемко. Их можно понять для большинства проектов достаточно предварительной валидации модели. Нам не хватает — пишем свое решение проблемы.
Расширяем класс CDbTransaction.
Добавляем в класс BaseActiveRecord (расширение CActiveRecord, в нашем проекте он уже существовал) методы restoreState(), hasStoredState() и storeState().
Как видно из кода мы бэкапируем только флаг isNewRecord и текущие атрибуты (в том числе primaryKey). Теперь остается только поправить наш первый фрагмент кода для того чтобы запомнить состояние модели до сохранения.
В своем проекте мы пошли чуть дальше — перенесли $transaction->storeModelStateForRollback($model) в метод save() самого BaseActiveRecord.
Это позволило во всем остальном коде не задумываться, что модель необходимо восстанавливать после отката транзакций, а так же заставляет рекурсивно бэкапировать все участвующие модели в сохранении текущей модели.
Может показаться, что проблема и ее решение не стоит внимания, но как показала практика, что если не учесть этого сразу при разработке, то можно долго искать причину непонятных багов.
Для нашего проекта, как учетной системы, характерно производить изменения в других объектах после сохранения текущего, например, проведение документа по регистрам после сохранения. Суть в том, что после сохранения объекта в транзакции 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;
}
// ...
}
Это позволило во всем остальном коде не задумываться, что модель необходимо восстанавливать после отката транзакций, а так же заставляет рекурсивно бэкапировать все участвующие модели в сохранении текущей модели.
Может показаться, что проблема и ее решение не стоит внимания, но как показала практика, что если не учесть этого сразу при разработке, то можно долго искать причину непонятных багов.