在 Laravel 的資料庫模型中使用狀態模式

codercat發表於2018-10-15

在講怎麼在Laravel模型中使用狀態模式之前先讓我們來熟悉一下狀態模式。

狀態模式

定義

允許一個物件在其內部狀態改變時改變它的行為。物件看起來似乎修改了它的類。

解決什麼問題

在實際的開發中我們經常會遇到一個表會存在不同的狀態,比如常見的訂單表一般會有預定支付已出貨已取消等。注:由於我們使用的ORM工具會把資料庫中表的每一行對映成一個物件例項,為了更好的表述模式,我會把表中的每一行稱為物件。比如我會把某條訂單記錄稱為訂單物件,使得我們用物件導向的思維去思考業務。

我們常見的狀態管理會向下面的程式碼這樣,在Order類裡面有3個行為分別是pay(),shipping(),cancel()。在執行每個行為方法之前我們都會去驗證當前物件的狀態是否滿足執行條件。
這樣做使得跟狀態相關的驗證會分散在不同的地方,甚至會把這些驗證邏輯洩露到控制器和服務層,在物件狀態複雜且需求多變的情況下,後期的維護成本很高且容易出錯。為了體現開放封閉原則和單一原則,則可以使用狀態模式來管理物件的不同狀態。

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

訂單狀態的介面
interface OrderState
{
    function paid(); // 支付
    function shipping(); // 發貨
    function cancel();  // 取消訂單
}
已預定訂單狀態的實現

把跟每個狀態相關的邏輯都封裝在了每個狀態物件裡面,狀態的行為需要呼叫order物件的方法來改變order的狀態,所以在需要改變order狀態的行為裡面需要持有一個order物件的引用。

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();
    }
}
已支付訂單狀態的實現
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('支付後不能取消訂單');
    }
}
已發貨訂單狀態的實現
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('發貨後不能取消訂單');
    }
}
已取消訂單狀態的實現
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介面的一個實現就可以了。狀態與狀態之間互不依賴,也消除了之前物件行為的狀態判斷語句。

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模型上新增如下事件監聽程式碼:

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值。

像這樣:

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個事件,我們可以有更多的想象空間來運用更多的設計模式來解決複雜的業務問題。

相關文章