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,using和stateData,讓我們下面看下。
STAY和GOTO
想法是每一個被阻塞的case都必須返回一個State。這個可以用stay來完成,含義是已經在處理這條消息的最后了(SetCostOfCoffee或GetCostOfCoffee),咖啡機還在用一個狀態,在這里是Open狀態。
goto, 將狀態變為另一個。我們在討論Deposit時能看到它是怎么做的。
沒啥奇怪的,看下stay方法的實現:
final def stay(): State = goto(currentState.stateName)
USING
你可能已經猜到了,using方法可以讓我們把改過的數據傳給下個狀態。在SetCostOfCoffee消息的例子里,我們設置了MachineData的costOfCoffee域。由于狀態是個用例的例子(強烈建議使用不可變除非你喜歡debug),我們做了個copy。
狀態數據STATEDATA
stateData是一個我們用來操作FSM數據的方法,就是MachineData。 所以,以下代碼塊是等價的
case Event(GetCostOfCoffee, _) => sender ! (stateData.costOfCoffee); stay()
case Event(GetCostOfCoffee, machineData) => sender ! (machineData.costOfCoffee); stay()
用GetNumberOfCoffee和SetNumberOfCoffee設置最大咖啡數的實現幾乎與設置價格的方法差不多。我們先跳過這個來到更有趣的部分 - 買咖啡。
買咖啡
當咖啡愛好者為咖啡交了錢,我們還不能讓咖啡機做咖啡,要等到得到了一杯咖啡的錢才行。而且如果多給了現金,我們還要找零錢,所以,例子會變成這樣:
- 直到用戶開始存錢了,我們開始追蹤他的存款并stay在Open狀態。
2.當現金數達到一杯咖啡的錢了,我們會轉移成ReadyToBuy狀態并允許他買咖啡。 - 在ReadyToBuy狀態,他可以改變主意Cancel取消這次交易并拿到所有的退款Balance。
- 如果他想要喝咖啡,它發給咖啡機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元錢。 我們之后假定機器狀態從Open到ReadyToBuy,這可以通過接受一條Transition消息來證明咖啡機狀態的變更。在第一個例子,轉換是從Open到ReadyToBuy。
下一步我們讓凱飛機BrewCoffee煮咖啡,這時應該會有一條轉換,ReadToBuy到Open。 最終我們斷言咖啡機中的數量(就是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 |