[C#] 使用 Switch Expression 建立 State Machine 控管審核流程

使用 Switch Expression 建立 State Machine 控管審核流程

前言

在實作審核流程時,會限制每個「審核狀態」下允許執行的「動作」控制邏輯,這時候可以考慮把這些邏封裝在 State Machine 中,統一由此 State Machine 控制先前所提到的「審核狀態」及「動作」的互動關係,讓邏輯不至於分散四處;本文使用 C# 8.0 的 Switch Expression 特性來建立一個 State Machine 進行說明。

 

 

情境

先以一個簡單的情境來進行說明,通常審核流程都會包含以下幾個「審核狀態」及「動作」,而案件在各個「審核狀態」流動會依照狀態圖中的「動作」進行變化,沒有定義到的路線就表示不允許這樣的操作 (e.g. 例如案件狀態為草稿時,是不允許直接透過任何的動作來直接改變狀態為審核通過)。

 

 

實作

先把各物件的角色及作用定義一下:

  • FormState: 定義所有審核狀態
  • FormAction: 定義所有審核動作
  • FormStateMachine: 狀態機,管理狀態與操作的互動
  • ProductForm: 商品審核表單
  • Product: 商品本身

 

先把所有的「審核狀態」及「動作」整理出來:

審核狀態包含【草稿】、【待審核】、【審核通過】及【審核被拒】

/// <summary>
/// 表單狀態
/// </summary>
public enum FormState
{
    /// <summary>
    /// 狀態:草稿
    /// </summary>
    Draft,

    /// <summary>
    /// 狀態:審核中
    /// </summary>
    Approving,

    /// <summary>
    /// 狀態:審核通過
    /// </summary>
    Approved,

    /// <summary>
    /// 狀態:審核被拒
    /// </summary>
    Reject,
}

 

動作包含【送審】、【核准】、【拒絕】及【編輯】

/// <summary>
/// 表單動作
/// </summary>
public enum FormAction
{
    /// <summary>
    /// 動作:送審
    /// </summary>
    Submit,

    /// <summary>
    /// 動作:核准
    /// </summary>
    Approve,

    /// <summary>
    /// 動作:退回
    /// </summary>
    Reject,

    /// <summary>
    /// 動作:編輯
    /// </summary>
    Edit,
}

 

接著利用 Switch Expression 特性建立一個 FormStateMachine 物件,並且依照先前定義的狀態圖解析「狀態」與「動作」的互動於 Manipulate 方法中;描述的方式就是以【目前狀態】執行某個【動作】後會轉變成的【新狀態】進行定義,而沒有定義在上面的情境就表示非法操作,我們直接拋出例外錯誤即可。

後續若需額外求允許其他的操作時,僅需異動 FormStateMachine 的 Manipulate 邏輯即可。
/// <summary>
/// 表單 State Machine
/// </summary>
public class FormStateMachine
{
    /// <summary>
    /// 目前狀態
    /// </summary>
    private FormState _currentState;

    /// <summary>
    /// 建構子
    /// </summary>
    /// <param name="state">初始狀態</param>
    public FormStateMachine(FormState state)
    {
        _currentState = state;
    }

    /// <summary>
    /// 定義動作與狀態互動關係
    /// </summary>
    /// <param name="action">動作</param>
    /// <returns>新狀態</returns>
    private FormState Manipulate(FormAction action)
        => (_currentState, action) switch
        {
            // 【草稿】 執行"送審" => 【待審核】
            (FormState.Draft, FormAction.Submit) => FormState.Approving,

            // 【待審核】 執行"核准" => 【審核通過】
            (FormState.Approving, FormAction.Approve) => FormState.Approved,

            // 【待審核】 執行"退回" => 【送審被拒】
            (FormState.Approving, FormAction.Reject) => FormState.Reject,

            // 【審核通過】 執行"編輯" => 【草稿】
            (FormState.Approved, FormAction.Edit) => FormState.Draft,

            // 【送審被拒】 執行"編輯" => 【草稿】
            (FormState.Reject, FormAction.Edit) => FormState.Draft,

            _ => throw new Exception($"目前狀態 {_currentState} 不允許執行 {action} 動作!"),
        };

    /// <summary>
    /// 執行動作來改變狀態
    /// </summary>
    /// <param name="action">動作</param>
    /// <returns>新狀態</returns>
    public FormState Transition(FormAction action)
    {
        // 執行動作取得新狀態
        var newState = Manipulate(action);

        // 更新目前狀態
        _currentState = newState;

        return newState;
    }

    /// <summary>
    /// 提供允許執行動作清單
    /// </summary>
    /// <returns>允許執行動作清單</returns>
    public List<FormAction> AvailableActions()
    {
        var availableActions = new List<FormAction>();

        foreach (FormAction action in Enum.GetValues(typeof(FormAction)))
        {
            try
            {
                // 模擬執行動作
                Manipulate(action);

                // 有定義此狀態可以執行的動作時就加入清單
                availableActions.Add(action);
            }
            catch (Exception)
            {
                // 非法操作就不列入
            }
        }

        return availableActions;
    }
}

 

建立一個 Product 物件作為 DB 資料代表,其中包含此商品的「審核狀態」資訊於物件中。

/// <summary>
/// Product
/// </summary>
public class Product
{
    public int ProductId { get; set; }

    public string ProductName { get; set; }

    /// <summary>
    /// 審核狀態
    /// </summary>
    public FormState State { get; set; }
}

 

接著我們就是要在審核的實作中導入 FormStateMachine 來幫我們控制審核流程。

要進行審核流程時,先建立一個  ProductForm 代表商品審核表單物件實體,在建構子中傳入 Product 商品資訊,並以此商品的審核狀態初始 FormStateMachine 物件來產生實體,後續當使用者要對此商品進行審核流程的操作時,可以透過 DoAction 指定動作來對此商品執行審核動作,其中可否執行此動作的邏輯就交由 FormStateMachine 統一決定,若此操作是合法的就會回覆新狀態,若否則拋出錯誤表示此操作尚未定義。

/// <summary>
/// Product 審核表單
/// </summary>
public class ProductForm
{
    private readonly Product _product;

    private readonly FormStateMachine _stateMachine;


    /// <summary>
    /// 建構子
    /// </summary>
    /// <param name="product">Product 資訊</param>
    public ProductForm(Product product)
    {
        // 傳入 Product 物件
        _product = product;

        // 取得 Product 的目前狀態後,初始 StateMachine 實體
        var currentState = _product.State;
        _stateMachine = new FormStateMachine(currentState);
    }

    /// <summary>
    /// 執行表單動作
    /// </summary>
    /// <param name="action">表單動作</param>
    public void DoAction(FormAction action)
    {
        // 取得目前狀態下執行【傳入動作】的下個狀態
        var newState = _stateMachine.Transition(action);

        // 如果目前狀態不能執行【傳入動作】行為 => 會拋出 Exception
        // 如果目前狀態可以執行【傳入動作】行為 => 會取得新狀態 newState

        // TODO: 使用新狀態去更新該筆資料(DB)的狀態
        Console.WriteLine($"目前狀態 {_product.State} 執行 {action} 動作後,狀態改變成 {newState} ");
        _product.State = newState;
    }
    
    /// <summary>
    /// 提供允許執行動作清單
    /// </summary>
    /// <returns>允許執行動作清單</returns>
    public List<FormAction> AvailableActions()
    {
        return _stateMachine.AvailableActions();
    }
}

 

 

實際演練

模擬目前從 DB 取出的商品狀態為「草稿」,建立一個商品審核表單並載入商品資訊,接著執行「送審」、「核准」及「編輯」三個動作,預期會依照我們的規劃(如下圖)去轉換不同的審核狀態。

internal class Program
{
    private static void Main(string[] args)
    {

        // 模擬從資料庫讀出商品資訊
        var product = new Product { State = FormState.Draft };

        // 建立商品審核表單
        var productForm = new ProductForm(product);

        try
        {            
            // 透過產品表單將產品執行審核流程動作
            
            Console.Write("目前狀態可執行的 Action 為 ");
            productForm.AvailableActions().ForEach(action => Console.Write("{0}, ", action));
            productForm.DoAction(FormAction.Submit); // 送審

            Console.Write("目前狀態可執行的 Action 為 ");
            productForm.AvailableActions().ForEach(action => Console.Write("{0}, ", action));
            productForm.DoAction(FormAction.Approve); // 核准

            Console.Write("目前狀態可執行的 Action 為 ");
            productForm.AvailableActions().ForEach(action => Console.Write("{0}, ", action));
            productForm.DoAction(FormAction.Edit);    // 編輯
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }

    }
}

 

執行結果正常輸出,狀態與動作的互動正常,轉換的新狀態也如預期變化。

 

再來調整一下流程,測試一下非法的狀態與動作互動;初始的狀態仍為「草稿」,首先會先進行「送審」這個合法的操作,然後執行「編輯」這個動作,但因為在「待審核」這個狀態下並無定義「編輯」這個動作於 FormStateMachine 中,因此會被判斷為非法行為並拋出錯誤。

internal class Program
{
    private static void Main(string[] args)
    {

        // 模擬從資料庫讀出商品資訊
        var product = new Product { State = FormState.Draft };

        // 建立商品審核表單
        var productForm = new ProductForm(product);

        try
        {            
            // 透過產品表單將產品執行審核流程動作
            
            Console.Write("目前狀態可執行的 Action 為 ");
            productForm.AvailableActions().ForEach(action => Console.Write("{0}, ", action));
            productForm.DoAction(FormAction.Submit); // 送審

            Console.Write("目前狀態可執行的 Action 為 ");
            productForm.AvailableActions().ForEach(action => Console.Write("{0}, ", action));
            productForm.DoAction(FormAction.Edit);   // 編輯
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }

    }
}

 

結果也如預期般呈現,審核中的表單是不允許執行編輯動作的,因此顯示錯誤訊息。

 

 

後記

透過 State Machine 來管理狀態的變化,讓職責可以切分出來,可避免到處存在 if else 判斷式造成程式的複雜度及錯誤風險升高,如果有類似的需求可以考慮使用這種方式進行實作,讓程式碼乾淨又好維護。

 

 


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !