Pull to refresh

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

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

Для нашего проекта, как учетной системы, характерно производить изменения в других объектах после сохранения текущего, например, проведение документа по регистрам после сохранения. Суть в том, что после сохранения объекта в транзакции 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;
    }

    // ...
}

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

Может показаться, что проблема и ее решение не стоит внимания, но как показала практика, что если не учесть этого сразу при разработке, то можно долго искать причину непонятных багов.
Tags:
Hubs:
Total votes 8: ↑4 and ↓40
Comments13

Articles