Как избежать грязных данных, вызванных дублированием операций на разных устройствах в Yii2? - PullRequest
0 голосов
/ 09 октября 2018

Допустим, у нас есть две простые таблицы:

enter image description here

Логический столбец is_paid таблицы order используется для обозначения того,заказ оплачен.Всякий раз, когда заказ оплачен, мы вставляем запись в таблицу log.Заказ можно оплатить, выполнив действие order/pay:

/**
 * Controller
 */
class OrderController extends \yii\web\Controller
{
    public function actionPay($id)
    {
        $this->findModel($id)->pay();
        Yii::$app->session->setFlash('success', 'You have paid the order.');

        return $this->goHome();
    }
}


/**
 * Model
 */
class Order extends \yii\db\ActiveRecord
{
    /**
     * Pay an order
     */
    public function pay()
    {
        $this->is_paid = 1;

        // whenever the order is paid, log the payment
        $this->on(self::EVENT_AFTER_UPDATE, [$this, 'log'], "paid order # {$this->id}");

        if (!$this->save()) {
            throw new \yii\db\Exception('Failed');
        }
    }

    /**
     * After the order was paid, log the payment.
     */
    public function log($event)
    {
        $message = $event->data;

        $log = new Log(['message' => $message]);

        if (!log->save()) {
            throw new \yii\db\Exception('Failed');
        }
    }
}

1 Воспроизведите проблему

Предположим, что в таблице order есть неоплаченный заказ, сначала конечный пользовательпосетил order/index на ПК, затем снова посетил order/index на мобильном телефоне.Теперь статус заказа на обоих устройствах был неоплаченным.

Конечный пользователь нажал кнопку «оплатить» на ПК, чтобы оплатить заказ, после операции значение is_paid заказа было изменено на1, и в таблице log появилась новая запись.Через некоторое время пользователь снова открыл страницу order/index на мобильном телефоне, , поскольку страница не была перезагружена, статус заказа по-прежнему НЕПЛАЧЕННЫЙ , если пользователь нажимает кнопку «Оплатить», будет повторяющаяся запись в журнале , происходят грязные данные.

2 Как избежать

2.1 Решение A: Проверяйте каждое действие

Перед выполнением $model->pay(), мы могли бы сделать предварительную проверку:

class OrderController extends \yii\web\Controller
{
    public function actionPay($id)
    {
        $model->$this->findModel($id);

        // pre-check 
        if($model->is_paid) {
            Yii::$app->session->setFlash('warning', 'This order has been paid.');
            return $this->redirect(Yii::$app->request->referrer);
        }

        $model->pay();
        Yii::$app->session->setFlash('success', 'You have paid the order.');

        return $this->goHome();
    }
}

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

2.2 Решение B: Фильтр действий

Мы могли бы создать универсальный фильтр действий под названием StatusGuardFilter:

namespace backend\behaviors;

use Yii;
use yii\web\NotFoundHttpException;

class StatusGuardFilter extends \yii\base\ActionFilter
{
    public $modelClass;

    // find the specified AR record
    protected function findModel()
    {
        $modelClass = $this->modelClass;
        $keys = $modelClass::primaryKey();
        $values = [];
        foreach ($keys as $key) {
            $values[] = Yii::$app->request->get($key);
        }
        $model = $modelClass::findOne(array_combine($keys, $values));

        if (isset($model)) {
            return $model;
        } else {
            throw new NotFoundHttpException("Object not found.");
        }
    }

    public function beforeAction($action)
    {
        $model = $this->findModel();

        // check if $action is allowed, method detail is following
        $errorMsg = $model->getStatusGuardErrorMessage($action->id);

        if ($errorMsg) {
            Yii::$app->session->setFlash('warning', $errorMsg);
            $action->controller->redirect(Yii::$app->request->referrer);

            return false;
        }

        return true;
    }
}


/**
 * Model
 */
class Order extends \yii\db\ActiveRecord
{
    // ...

    /**
     * Check whether an order could run $action
     *
     * @param string $action action name
     *
     * @return string|null if the action is forbidden, an error message will be returned, or null if the action is allowed.
     */
    public function getStatusGuardErrorMessage($action)
    {
        switch ($action) {
            case 'pay':
                if ($this->is_paid) {
                    return 'This order has been paid.';
                }
                break;
        }

        return null;
    }
}

На основе приведенного выше кода, теперь мы переписываем OrderController следующим образом:

/**
 * Controller
 */

use backend\behaviors\StatusGuardFilter;

class OrderController extends \yii\web\Controller
{
    /**
     * @inheritdoc
     */
    public function behaviors()
    {
        return [
            'guard' => [
                'only' => ['pay'],
                'class' => StatusGuardFilter::className(),
                'modelClass' => '\backend\models\Order',
            ],
        ];
    }

    public function actionPay($id)
    {
        $this->findModel($id)->pay();
        Yii::$app->session->setFlash('success', 'You have paid the order.');

        return $this->goHome();
    }
}

2.2.1 Плюсы и минусы решения B

Плюсы

Код действия выглядит чистым;Фильтр действий можно использовать повторно, для подобных случаев нам нужно сделать следующее: config behaviors(), затем заполнить логический код в getStatusGuardErrorMessage().Например, при обновлении заказа мы хотим убедиться, что заказ не оплачен, и он создан текущим пользователем.Мы меняем коды в следующих трех классах:

  1. добавляем update действие в поведение guard:

    public function behaviors()
    {
        return [
            'guard' => [
                'only' => ['pay', 'update'],
                // ...
            ],
        ];
    }
    
  2. проверка заполнениялогика операции обновления в классе заказа:

    public function getStatusGuardErrorMessage($action)
    {
        switch ($action) {
            // ...
            case 'update':
                if ($this->is_paid) {
                    return 'Only unpaid order updating is allowed.';
                }
                if ($this->created_by != Yii::$app->user->id) {
                    return 'Only order's owner can update the order';
                }
                break;
        }
    
        return null;
    }
    

Минусы: дополнительный запрос к БД

Первый запрос в StatusGuardFilter:

class StatusGuardFilter extends \yii\base\ActionFilter
{
    protected function findModel()
    {
        // first DB query: findOne()
        $model = $modelClass::findOne(array_combine($keys, $values));
    }
}

Если заказу разрешено оплатить, выполняется повторный запрос к БД в findModel() из OrderController:

class OrderController extends \yii\web\Controller
{
    public function actionPay($id)
    {
        // in findModel(), we execute Order::findOne() twice
        $this->findModel($id)->pay();
    }
}

Кажется, что findOne() не может быть кэширован, поэтому решение B генерируетдополнительная стоимость запроса к БД.

3 Вопрос

Решение A против решения B, что лучше?или есть другой лучший способ?

...