iT邦幫忙

2021 iThome 鐵人賽

DAY 26
0
Software Development

From State Machine to XState系列 第 26

Day26 - 使用 Guard 來實作一個馬克杯的狀態機

還記得在 Day 15 馬克杯 裝水 Guard 的例子嗎?

另外一組例子,一個水瓶或是馬克杯的狀態
一開始是『空的』,有「裝水」這個事件,每次要裝水時,都會當前水量 + 預計倒水量,有沒有超過容器最大容積
「裝水」可以一次裝滿,轉移到『滿水位』的狀態;或是每次都裝一點,轉移到『有部份水量』的狀態
但每次「裝水」事件都會檢查容量夠不夠。
最後『滿水位』時,配上「封蓋」的事件,可以把我們的水瓶或者是馬克杯『蓋上』

https://www.researchgate.net/profile/Peter-Padawitz/publication/225176063/figure/fig4/AS:670040647020553@1536761547209/A-state-machine-with-events-and-guards-but-no-actions-Figure-4-4-of-37.png

今天我們就來實作這個狀態機吧!
TLDR CodeSandbox Demo
按理我們會先思考規劃出上面的狀態圖,接著再將狀態圖實作成為程式碼。

已經有狀態圖了所以來想想實作的部分!

1. States 狀態

暫定命名為「空杯」、「半滿」、「滿水」、「蓋上」來對應上述英文的狀態

const containerMachineConfig = {
  id: "馬克杯",
  initial: "空杯",
  states:{
    空杯:{},
    半滿:{},
    滿水:{},
    蓋上:{}
  }
}

2. Events 事件

由上圖可看出這個狀態機滿單純的,只有兩種事件!
就是『加水』跟『封蓋』

const containerMachineConfig = {
  id: "馬克杯",
  initial: "空杯",
  context: { 容量: 300, 當前水量: 0 },
  states:{
+   空杯:{on:{加水:...}},
+   半滿:{on:{加水:...}},
+   滿水:{on:{封蓋:...}},
+   蓋上:{type: "final"} // 最終狀態
  }
}

為了實作事件,我們繼續往下思考

3. Context 擴充狀態、資料

這個馬克杯的狀態機有哪些資料需要被儲存起來、共享,也就是 context 需要什麼?

  1. 馬克杯的容量(因為要驗證加水量有沒有超過容量),也就是上圖的 capacity
  2. 當前杯子內的水量(因為要讓每個狀態間都能知道現在水量多少,有可能不是一次加滿、只加入一點點),也就是上圖的 contents
const containerMachineConfig = {
  id: "馬克杯",
  initial: "空杯",
+ context: { 容量: 300, 當前水量: 0 },
  ...

4. Transition 轉移

4-1. 加水

如何實作加水這件事呢?想必『加水』這個事件,會將使用者倒入(輸入)的水量做驗證並且存進 context。

如何將使用者輸入帶進來狀態機呢?以 redux 假設,我們可以發起一個 redux action ,這個 action 會指名 type ,讓 reducer 判別進行什麼狀態處理,並透過 payload 將額外的資料。

// 自己手寫 action
{ type: 'ADD_TODO', text: 'Go to swimming pool' }
{ type: 'TOGGLE_TODO', index: 1 }
{ type: 'SET_VISIBILITY_FILTER', filter: 'SHOW_ALL' }

// 使用 Redux Toolkit 的 action creator 及 其他附加功能
{ type: 'ADD_TODO', payload: { text: 'Go to swimming pool' } }

在 XState 當中,想將使用者額外的資料帶進去 Machine 裡,也可以一起放在 event object 內。

- service.send({ type: "加水"});
+ service.send({ type: "加水", 加水量: 30 });

這樣子在撰寫 Machine config 時,就能透過這個額外的 key 決定要對使用者額外的資料做什麼處理!
在這邊也跟讀者說聲不好意思,我忽然發現前面解釋 XState 中的「事件」沒有說得很仔細。


接著我們回去處理『加水』事件本身。依照狀態圖,「空杯」+『加水』可能轉換到兩種狀態 「半滿」or「滿水」,所以『加水』事件的轉移要使用 Array [],儲存多種轉移的可能!

-   空杯:{on:{加水:...}},
+   空杯:{on:{加水:[]}},

「空杯」+『加水』可能轉換到兩種狀態 「半滿」or「滿水」

-   空杯:{on:{加水:[]}},
+   空杯:{on:{加水:[{ target: "滿水" }, { target: "半滿" }]}},

「半滿」or「滿水」都要更新 context 裡的 當前水量
為了更新 context ,我們必須使用 XState 的 side effect -> actions,並透過 assign API 來更新 context

-   空杯:{on:{加水:[{ target: "滿水" }, { target: "半滿" }]}},
+   空杯: {
+     on: {
+       加水: [
+         { // 初始的 當前水量 是 0 ,直接拿 event 的 加水量 即可
+           actions: [
+             assign({ 當前水量: (context, event) => event["加水量"] })
+           ],
+           target: "滿水",
+         },
+         { // 初始的 當前水量 是 0 ,直接拿 event 的 加水量 即可
+           actions: [
+             assign({ 當前水量: (context, event) => event["加水量"] })
+           ],
+           target: "半滿",
+         },
+       ]
+     }
+   }

4-2. 決定『加水』能轉換到什麼狀態!受保護的「半滿」跟「滿水」

為了決定該從「空杯」轉換成「半滿」還是「滿水」或者是停留在「空杯」不動
我們需要透過在 event 的轉換描述中使用 key cond ,保護轉換的進行

    空杯: {
      on: {
        加水: [
          { // 初始的 當前水量 是 0 ,直接拿 event 的 加水量 即可
            actions: [
              assign({ 當前水量: (context, event) => event["加水量"] })
            ],
            target: "滿水",
+           // 加超過杯子的容量,直接進入滿水位
+           cond: (context, event) => event["加水量"] >= context["容量"],
          },
          { // 初始的 當前水量 是 0 ,直接拿 event 的 加水量 即可
            actions: [
              assign({ 當前水量: (context, event) => event["加水量"] })
            ],
            target: "半滿",
+           // 未超過杯子的容量,進入半滿水位
+           cond: (context, event) => event["加水量"] < context["容量"],
          },
        ]
      }
    }

這邊直接在 cond 後面寫入 Guards Condition Functions (Predicate callback) ,而不是外掛在 createMachine(machineConfig,{ guards:{ guard1,guard2} }) 是比較偷懶的方法。

XState 也提供這樣的方式,讓我們能快速測試原型、驗證想法,但等到開發中後期,還是建議將 Guards Condition Functions ( Predicate callback ) 寫進 const someMachine = createMachine(machineConfig,extraOptions) 的 extraOptions 當中。這樣子我們比較好 除錯、測試以及更漂亮的呈現在 Visualizer 上。

Refactoring inline guard implementations in the guards property of the machine options makes it easier to debug, serialize, test, and accurately visualize guards.
XState - Guards Condition Functions


由於 cond 的判別式都已經呈現在一開始的狀態圖了,因此我們就同理類推「半滿」的狀態轉換

    半滿: {
      on: {
        加水: [
          { // 把杯子已有的 當前水量 加上 使用者倒入的 加水量
            actions: [
              assign({
                當前水量: (context, event) =>
                  context["當前水量"] + event["加水量"]
              })
            ],
            target: "滿水",
            // 加超過杯子的容量,直接進入滿水位
            cond: (context, event) =>
              event["加水量"] + context["當前水量"] >= context["容量"]
          },
          { // 把杯子已有的 當前水量 加上 使用者倒入的 加水量
            actions: [
              assign({
                當前水量: (context, event) =>
                  context["當前水量"] + event["加水量"]
              })
            ],
            target: "半滿"
          }
        ]
      }
    },

最後最後,我們可以完成以下狀態機

CodeSandbox Demo
https://ithelp.ithome.com.tw/upload/images/20211012/20130721Ahe7nTBd09.png

還可以加強什麼?

  1. 相信眼尖的讀者應該可以發現 cond 中,有滿多類似的 Guards Condition Functions!誠如官方推薦,我們可以將其抽象化、放入 extraOptions 中的 guards ,用 字串 在 machineConfig 裡描述。
  2. 狀態機加強,我們現在其實可以倒超過滿水位的水,仍被儲存(容量:300,當前水量:350?!?!),因此 assign 的邏輯可以在被調整、加強。

參考資料

https://xstate.js.org/docs/guides/guards.html


上一篇
Day25 - 保護你的狀態轉移,在 XState 中使用 Guard Transition
下一篇
Day27 - 子狀態 or 子狀態機?與外部溝通!概念簡介: invoke services v.s. spawn actors in XState
系列文
From State Machine to XState31

尚未有邦友留言

立即登入留言