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」转载请注明。

长按图片识别关注公众号。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,133评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,682评论 3 390
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,784评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,508评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,603评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,607评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,604评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,359评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,805评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,121评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,280评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,959评论 5 339
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,588评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,206评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,442评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,193评论 2 367
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,144评论 2 352

推荐阅读更多精彩内容