close
文章出處

AKKA 筆記 - 有限狀態機 -2

原文地址: http://rerun.me/2016/05/22/akka-notes-finite-state-machines-2/

在上一節的Akka FSM筆記中,我們看了一些基本的使用Akka FSM和咖啡機的使用方式 - Actor的數據結構和一隊我們要發給Actor的消息。這次的第二部分也是最終部分,我們會過一遍這些狀態的實現細節。

總結

作為一個快速的總結,讓我們先看一下FMS的結構和我們要發過去的消息。

狀態和數據

FSM的三個狀態和要在各個狀態發送的數據是:

object CoffeeMachine {

  sealed trait MachineState
  case object Open extends MachineState
  case object ReadyToBuy extends MachineState
  case object PoweredOff extends MachineState

  case class MachineData(currentTxTotal: Int, costOfCoffee: Int, coffeesLeft: Int)

}

消息

我們發給FSM的咖啡機和用戶交互的消息是:

object CoffeeProtocol {

  trait UserInteraction
  trait VendorInteraction

  case class   Deposit(value: Int) extends UserInteraction
  case class   Balance(value: Int) extends UserInteraction
  case object  Cancel extends UserInteraction
  case object  BrewCoffee extends UserInteraction
  case object  GetCostOfCoffee extends UserInteraction

  case object  ShutDownMachine extends VendorInteraction
  case object  StartUpMachine extends VendorInteraction
  case class   SetNumberOfCoffee(quantity: Int) extends VendorInteraction
  case class   SetCostOfCoffee(price: Int) extends VendorInteraction
  case object  GetNumberOfCoffee extends VendorInteraction

  case class   MachineError(errorMsg:String)

}

FSM ACTOR的結構

這是我們在第一節看到的大致結構:

class CoffeeMachine extends FSM[MachineState, MachineData] {

  //What State and Data must this FSM start with (duh!)
  startWith(Open, MachineData(..))

  //Handlers of State
  when(Open) {
  ...
  ...

  when(ReadyToBuy) {
  ...
  ...

  when(PoweredOff) {
  ...
  ...

  //fallback handler when an Event is unhandled by none of the States.
  whenUnhandled {
  ...
  ...

  //Do we need to do something when there is a State change?
  onTransition {
    case Open -> ReadyToBuy => ...
  ...
  ...
}

狀態初始化

跟其他狀態機一樣, FSM在啟動時需要一個初始化狀態。這個可以在Akka FSM內聲明一個叫startWith的方法來實現。startWith接受兩個參數 - 初始化狀態和初始化數據。

class CoffeeMachine extends FSM[MachineState, MachineData] {

  startWith(Open, MachineData(currentTxTotal = 0, costOfCoffee =  5, coffeesLeft = 10))

...
...

以上代碼說明了FSM的初始化狀態是Open并且當咖啡機Open時的初始化數據是

MachineData(currentTxTotal = 0, costOfCoffee = 5, coffeesLeft = 10).

當機器啟動時,咖啡機是一個干凈的狀態。它跟用戶還沒有任何交互,當前的余額是0。咖啡的價格唄設置成5元,總共能提供的咖啡設置為10杯。當咖啡機沖了10杯咖啡后數量為0時,咖啡機會shut down。

狀態的實現

終于到最后了!!

我覺得最簡單的方式來看咖啡機狀態的交互就是給交互做個分組,為FSM的實現寫測試用例。

如果你看下github的代碼,所有的測試用例都在CoffeeSpec并且FSM在CoffeeMachine

以下所有的測試都被CoffeeSpec測試類包裝了,聲明就像這樣:

class CoffeeSpec extends TestKit(ActorSystem("coffee-system")) with MustMatchers with FunSpecLike with ImplicitSender  

設置并得到咖啡的價格

像我們之前看到的,MachineData初始化時設置為每杯咖啡5元并總數為10杯。這只是一個初始狀態,咖啡機必須能在任何時候設置咖啡的價格和能提供的數量。

通過發送SetCostOfCoffee消息給Actor可以設置價格。我們也應該能拿到咖啡的價格。這個可以通過發送GetCostOfCoffee消息給機器來獲得。

測試用例

describe("The Coffee Machine") {

   it("should allow setting and getting of price of coffee") {
      val coffeeMachine = TestActorRef(Props(new CoffeeMachine()))
      coffeeMachine ! SetCostOfCoffee(7)
      coffeeMachine ! GetCostOfCoffee
      expectMsg(7)
    }
...
...
...

實現

像我們在第一節討論的,所有發給FSM的消息都被包裝成Event類,并且也被MachineData包裝:

 when(Open) {
     case Event(SetCostOfCoffee(price), _) => stay using stateData.copy(costOfCoffee = price)
    case Event(GetCostOfCoffee, _) => sender ! (stateData.costOfCoffee); stay()
   ...
   ...
  }
}

以上代碼有幾個新詞 - stay,usingstateData,讓我們下面看下。

STAYGOTO

想法是每一個被阻塞的case都必須返回一個State。這個可以用stay來完成,含義是已經在處理這條消息的最后了(SetCostOfCoffeeGetCostOfCoffee),咖啡機還在用一個狀態,在這里是Open狀態。

goto, 將狀態變為另一個。我們在討論Deposit時能看到它是怎么做的。

沒啥奇怪的,看下stay方法的實現:

  final def stay(): State = goto(currentState.stateName)

USING

你可能已經猜到了,using方法可以讓我們把改過的數據傳給下個狀態。在SetCostOfCoffee消息的例子里,我們設置了MachineDatacostOfCoffee域。由于狀態是個用例的例子(強烈建議使用不可變除非你喜歡debug),我們做了個copy

狀態數據STATEDATA

stateData是一個我們用來操作FSM數據的方法,就是MachineData。 所以,以下代碼塊是等價的

case Event(GetCostOfCoffee, _) => sender ! (stateData.costOfCoffee); stay()  
case Event(GetCostOfCoffee, machineData) => sender ! (machineData.costOfCoffee); stay()  

GetNumberOfCoffeeSetNumberOfCoffee設置最大咖啡數的實現幾乎與設置價格的方法差不多。我們先跳過這個來到更有趣的部分 - 買咖啡。

買咖啡

當咖啡愛好者為咖啡交了錢,我們還不能讓咖啡機做咖啡,要等到得到了一杯咖啡的錢才行。而且如果多給了現金,我們還要找零錢,所以,例子會變成這樣:

  1. 直到用戶開始存錢了,我們開始追蹤他的存款并stayOpen狀態。
    2.當現金數達到一杯咖啡的錢了,我們會轉移成ReadyToBuy狀態并允許他買咖啡。
  2. ReadyToBuy狀態,他可以改變主意Cancel取消這次交易并拿到所有的退款Balance
  3. 如果他想要喝咖啡,它發給咖啡機BrewCoffee煮咖啡的消息。(事實上,我們的代碼里并不會分發咖啡。我們只是從用戶的存款里減掉了咖啡的價格并找零。)

讓我們看下以下的用例

用例1 用戶存錢單但存的錢低于咖啡的價格

用例開始設置咖啡的價格為5元并且咖啡總數為10。 我們存2元并檢查機器是不是在Open狀態并且咖啡總數仍然是10.

 it("should stay at Transacting when the Deposit is less then the price of the coffee") {
      val coffeeMachine = TestActorRef(Props(new CoffeeMachine()))
      coffeeMachine ! SetCostOfCoffee(5)
      coffeeMachine ! SetNumberOfCoffee(10)
      coffeeMachine ! SubscribeTransitionCallBack(testActor)

      expectMsg(CurrentState(coffeeMachine, Open))

      coffeeMachine ! Deposit(2)

      coffeeMachine ! GetNumberOfCoffee

      expectMsg(10)
    }

我們怎樣確保機器在Open狀態?

每個FSM都能處理一條叫FSM.SubscribeTransitionCallBack(callerActorRef)的特殊消息,能讓調用者在任何狀態變動時被通知。第一條發給訂閱者的通知消息是CurrentState, 告訴我們FSM在哪個狀態。 這之后會有若干條Transition消息。

實現

我們繼續存錢并維持在Open狀態并等待存更多的錢

when(Open) {  
...
...
  case Event(Deposit(value), MachineData(currentTxTotal, costOfCoffee, coffeesLeft)) if (value + currentTxTotal) < stateData.costOfCoffee => {
        val cumulativeValue = currentTxTotal + value
        stay using stateData.copy(currentTxTotal = cumulativeValue)
  }

用例2和4 - 用戶存錢并達到咖啡的價錢

測試用例1 - 存與咖啡價格等值的錢

我們的用例啟動機器,確認是否當前狀態是Open并存5元錢。 我們之后假定機器狀態從OpenReadyToBuy,這可以通過接受一條Transition消息來證明咖啡機狀態的變更。在第一個例子,轉換是從OpenReadyToBuy

下一步我們讓凱飛機BrewCoffee煮咖啡,這時應該會有一條轉換,ReadToBuyOpen。 最終我們斷言咖啡機中的數量(就是9)。

it("should transition to ReadyToBuy and then Open when the Deposit is equal to the price of the coffee") {  
      val coffeeMachine = TestActorRef(Props(new CoffeeMachine()))
      coffeeMachine ! SetCostOfCoffee(5)
      coffeeMachine ! SetNumberOfCoffee(10)
      coffeeMachine ! SubscribeTransitionCallBack(testActor)

      expectMsg(CurrentState(coffeeMachine, Open))

      coffeeMachine ! Deposit(5)

      expectMsg(Transition(coffeeMachine, Open, ReadyToBuy))

      coffeeMachine ! BrewCoffee
      expectMsg(Transition(coffeeMachine, ReadyToBuy, Open))

      coffeeMachine ! GetNumberOfCoffee

      expectMsg(9)
    }

測試用例2 - 存大于咖啡價格的錢

第二個例子跟第一個比有90%一樣,除了我們存在錢更多了(是6元)。 因為我們把咖啡價格設為5元, 現在我們期望應該有一塊錢的Balance找零消息

it("should transition to ReadyToBuy and then Open when the Deposit is greater than the price of the coffee") {  
      val coffeeMachine = TestActorRef(Props(new CoffeeMachine()))
      coffeeMachine ! SetCostOfCoffee(5)
      coffeeMachine ! SetNumberOfCoffee(10)
      coffeeMachine ! SubscribeTransitionCallBack(testActor)

      expectMsg(CurrentState(coffeeMachine, Open))

      coffeeMachine ! Deposit(2)
      coffeeMachine ! Deposit(2)
      coffeeMachine ! Deposit(2)

      expectMsg(Transition(coffeeMachine, Open, ReadyToBuy))

      coffeeMachine ! BrewCoffee

      expectMsgPF(){
        case Balance(value)=>value==1
      }

      expectMsg(Transition(coffeeMachine, ReadyToBuy, Open))

      coffeeMachine ! GetNumberOfCoffee

      expectMsg(9)
    }

實現

這個實現比之前的測試用例簡單。如果存款大于咖啡價格,那么我們轉到goto ReadyToBuy狀態。

when(Open){  
...
...
 case Event(Deposit(value), MachineData(currentTxTotal, costOfCoffee, coffeesLeft)) if (value + currentTxTotal) >= stateData.costOfCoffee => {
      goto(ReadyToBuy) using stateData.copy(currentTxTotal = currentTxTotal + value)
    }

一旦轉到ReadyToBuy狀態, 當用戶發送BrewCoffee,我們檢查是否有零錢找零。

  when(ReadyToBuy) {
    case Event(BrewCoffee, MachineData(currentTxTotal, costOfCoffee, coffeesLeft)) => {
      val balanceToBeDispensed = currentTxTotal - costOfCoffee
      logger.debug(s"Balance is $balanceToBeDispensed")
      if (balanceToBeDispensed > 0) {
        sender ! Balance(value = balanceToBeDispensed)
        goto(Open) using stateData.copy(currentTxTotal = 0, coffeesLeft = coffeesLeft - 1)
      }
      else goto(Open) using stateData.copy(currentTxTotal = 0, coffeesLeft = coffeesLeft - 1)
    }
  }

用例3 用戶要取消交易

實際上, 用戶應該可以在交易的任何時間點Cancel取消,無論他在什么狀態。我們之前在第一部分討論過,最好的保存這里通用消息的地方在whenUnhandled代碼塊。我們要確定用戶在取消前是否存了一些錢,我們要還給他們。

實現

  whenUnhandled {
  ...
  ...
    case Event(Cancel, MachineData(currentTxTotal, _, _)) => {
      sender ! Balance(value = currentTxTotal)
      goto(Open) using stateData.copy(currentTxTotal = 0)
    }
  }

測試用例

這個例子跟我們以上看到的差不多,除了找零。

 it("should transition to Open after flushing out all the deposit when the coffee is canceled") {
      val coffeeMachine = TestActorRef(Props(new CoffeeMachine()))
      coffeeMachine ! SetCostOfCoffee(5)
      coffeeMachine ! SetNumberOfCoffee(10)
      coffeeMachine ! SubscribeTransitionCallBack(testActor)

      expectMsg(CurrentState(coffeeMachine, Open))

      coffeeMachine ! Deposit(2)
      coffeeMachine ! Deposit(2)
      coffeeMachine ! Deposit(2)

      expectMsg(Transition(coffeeMachine, Open, ReadyToBuy))

      coffeeMachine ! Cancel

      expectMsgPF(){
        case Balance(value)=>value==6
      }

      expectMsg(Transition(coffeeMachine, ReadyToBuy, Open))

      coffeeMachine ! GetNumberOfCoffee

      expectMsg(10)
    }

代碼

我不想煩死你所以跳過了解釋ShutDownMachine消息和PowerOff狀態,如果你想要解釋,可以留言。

像之前一樣,代碼在github


文章來自微信平臺「麥芽面包」
微信公眾號「darkjune_think」轉載請注明。
如果覺得有趣,微信掃一掃關注公眾號。


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 AutoPoster 的頭像
    AutoPoster

    互聯網 - 大數據

    AutoPoster 發表在 痞客邦 留言(0) 人氣()