Допустим, у нас есть две простые таблицы:
![enter image description here](https://i.stack.imgur.com/Bbf9u.png)
Логический столбец 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()
.Например, при обновлении заказа мы хотим убедиться, что заказ не оплачен, и он создан текущим пользователем.Мы меняем коды в следующих трех классах:
добавляем update
действие в поведение guard
:
public function behaviors()
{
return [
'guard' => [
'only' => ['pay', 'update'],
// ...
],
];
}
проверка заполнениялогика операции обновления в классе заказа:
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, что лучше?или есть другой лучший способ?