在Laravel的数据库模型中使用状态模式

定义

允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。

解决什么问题

在实际的开发中我们经常会遇到一个表会存在不同的状态,比如常见的订单表一般会有预定支付已出货已取消等。注:由于我们使用的ORM工具会把数据库中表的每一行映射成一个对象实例,为了更好的表述模式,我会把表中的每一行称为对象。比如我会把某条订单记录称为订单对象,使得我们用面向对象的思维去思考业务。

我们常见的状态管理会向下面的代码这样,在Order类里面有3个行为分别是pay()shipping()cancel()。在执行每个行为方法之前我们都会去验证当前对象的状态是否满足执行条件。

这样做使得跟状态相关的验证会分散在不同的地方,甚至会把这些验证逻辑泄露到控制器和服务层,在对象状态复杂且需求多变的情况下,后期的维护成本很高且容易出错。为了体现开放封闭原则和单一原则,则可以使用状态模式来管理对象的不同状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Order
{
private $stateCode;
const RESERVED_STATE = 1; // 已预定
const PAID_STATE = 2; // 已支付
const SHIPPED_STATE = 3; // 已发货
const CANCELED_STATE = 4; // 已取消

// 支付
public function paid()
{
if ($this.stateCode != self::RESERVED_STATE) {
throw new Exception('只有预定后的订单才能付款');
}
$this.stateCode = self::PAID_STATE;
}

// 发货
public function shipping()
{
if ($this->stateCode != self::PAID_STATE) {
throw new Exception('只有支付后的订单才能发货');
}
$this.stateCode = self::SHIPPED_STATE;
}

// 取消订单
public function cancel()
{
if ($this->stateCode != self::RESERVED_STATE) {
throw new Exception('支付后的订单不能取消');
}
$this.stateCode = self::CANCELED_STATE;
}

}

状态模式UML

订单状态的接口

1
2
3
4
5
6
interface OrderState
{
function paid(); // 支付
function shipping(); // 发货
function cancel(); // 取消订单
}

已预定订单状态的实现

把跟每个状态相关的逻辑都封装在了每个状态对象里面,状态的行为需要调用order对象的方法来改变order的状态,所以在需要改变order状态的行为里面需要持有一个order对象的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ReservedOrderState implements OrderState
{
const STATE_CODE = 1;

public function paid(Order $order)
{
$order->setPaid();
}

public function shipping()
{
throw new Exception('预定状态不能发货');
}

public function cancel(Order $order)
{
$order->setCanceled();
}
}

已支付订单状态的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class PaidOrderState implements OrderState
{
const STATE_CODE = 2;

public function paid()
{
throw new Exception('已经支付');
}

public function shipping(Order $order)
{
$order->setShipped();
}

public function cancel()
{
throw new Exception('支付后不能取消订单');
}
}

已发货订单状态的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ShippedOrderState implements OrderState
{
const STATE_CODE = 3;

public function paid()
{
throw new Exception('已经支付');
}

public function shipping()
{
throw new Exception('已经发货');
}

public function cancel()
{
throw new Exception('发货后不能取消订单');
}
}

已取消订单状态的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class CanceledOrderState implements OrderState
{
const STATE_CODE = 4;

public function paid()
{
throw new Exception('订单取消后不能支付');
}

public function shipping()
{
throw new Exception('订单取消后不能出货');
}

public function cancel()
{
throw new Exception('已经取消');
}
}

订单类

这样一来把每个状态相关的逻辑都封装起来,很清晰的就能看出每个状态可以执行哪些行为哪些不能执行。每当要添加一个新的状态的时候只需要添加一个OrderState接口的一个实现就可以了。状态与状态之间互不依赖,也消除了之前对象行为的状态判断语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class Order
{
// 订单状态
private $orderState;

// 支付
public function paid()
{
$this->orderState->paid($this);
}

// 发货
public function shipping()
{
$this->orderState->shipping($this);
}

// 取消订单
public function cancel()
{
$this->orderState->cancel($this);
}

// 把订单状态设置成已预定状态
public function setReserved()
{
$this->orderState = new ReservedOrderState();
}

// 把订单状态设置成已支付状态
public function setPaid()
{
$this->orderState = new PaidOrderState();
}

// 把订单状态设置成已发货状态
public function setShipped()
{
$this->orderState = new ShippedOrderState();
}

// 把订单状态设置成已取消状态
public function setCanceled()
{
$this->orderState = new CanceledOrderState();
}

}

Laravel模型与数据库的映射

我在尝试在Laravel的模型上使用状态模式时遇到了一个问题,就是模型在数据库中查询到数据后怎么把字段state(通常叫state,也可以是其他表示状态的枚举字段)的值映射成我们对象上的某个状态对象。比如我们order表中的某一行的state字段是2,那么映射到order对象上应该是order对象有一个PaidOrderState的对象。通过查询文档和查看源代码找到Eloquent在查询到数据后会触发每个模型的retrieved的事件。通过监听监听这个事件,我们可以在获取到数据后编写代码自动把state状态值和具体的状态对象进行关系映射。

在Order模型上添加如下事件监听代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static function boot() {
parent::boot();
static::retrieved(function($model) {
switch($model->state) {
case 1: // 已预定
$model->orderState = new ReservedOrderState();
break;
case 2: // 已支付
$model->orderState = new PaidOrderState();
break;
case 3: // 已经发货
$model->orderState = new ShippedOrderState();
break;
case 4: // 已经取消
$model->orderState = new CanceledOrderState();
break;
}
});
}

这样一来就解决了从数据表到模型对象的映射问题。

目前为止还存在一个问题就是数据回写的问题,当我们从对象的某个状态迁移到另外一个状态的后再通过对象的save()方法保存到数据库的时候,其实这个时候state的字段值并没有改变。解决办法就是在对象修改状态的时候去修改state值。

像这样:

1
2
3
4
5
public function setPaid()
{
$this->orderState = new PaidOrderState();
$this->stateCode = PaidOrderState::STATE_CODE;
}

只需要添$this->stateCode = PaidOrderState::STATE_CODE; 来修改state的值就可以了。

由于Eloquent是基于活动记录(Activity record)的ORM,所以很难使用继承结构来使用更多的设计模式。但是Eloquent提供了2个非常有用的模型事件分别是retrievedsaving。retrieved事件我们可以在模型查询到数据的时候对模型对象做一些更改,比如上面用它解决了映射问题。saving是在模型保存的时候触发的事件,可以用它在保存到数据库的时候再对模型做一些更改,比如我们在解决state字段回写到数据库的问题也可以使用saving事件来解决。这样一来通过这2个事件,我们可以有更多的想象空间来运用更多的设计模式来解决复杂的业务问题。