пятница, 18 октября 2013 г.

[Из песочницы] 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;
}

// ...
}




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

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


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at fivefilters.org/content-only/faq.php#publishers. Five Filters recommends:



Комментариев нет:

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