翻新的轮胎

简单回顾一下,让我们来看看有限状态机的结构和可以发送给它的消息。

状态和数据

密克罗尼西亚联邦的三个州和跨州发送的数据是:

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)

}

信息

我们发送给密克罗尼西亚联邦的供应商和用户互动信息包括:

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)

}

有限状态机参与者的结构

这是我们在中看到的整体结构Part 1

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 => ...
  ...
  ...
}

初态

与任何状态机一样,状态机需要一个初始状态作为开始。这可以用一种非常直观的方法在阿克卡有限状态机中声明startWith。的startWith接受两个参数——初始状态和初始数据。

class CoffeeMachine extends FSM[MachineState, MachineData] {

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

...
...

上面的代码只是说密克罗尼西亚联邦的初始状态是Open咖啡机打开时的初始数据是MachineData(currentTxTotal = 0, costOfCoffee = 5, coffeesLeft = 10)

既然机器刚刚启动,自动售货机就从零开始。它还没有与任何用户交互,因此此交易的当前显示余额为0。咖啡的价格定为5美元,这台机器总共能卖出10杯咖啡。一旦咖啡售出10杯,剩下0杯,机器就会关闭。

执行国家

啊,终于来了!!

我觉得在不同的状态下观察与自动售货机的交互最简单的方法是将交互分组,围绕它编写测试用例,并伴随着在状态机中的实现。

如果您引用的是GitHub代码,那么所有的测试都在CoffeeSpec密克罗尼西亚联邦是CoffeeMachine

以下所有测试都封装在咖啡规格测试类中,其声明如下:

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

咖啡价格的设定和获取

正如我们上面看到的,机器数据的起价是每杯咖啡5美元,容量是10杯咖啡。这只是一个初始状态。供应商必须有能力随时设定咖啡的价格和机器的容量。

通过发送SetCostOfCoffee给演员的信息。我们也应该有能力得到咖啡的价格。这是通过使用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)
    }
...
...
...

实施

就像我们在Part 1,发送到密克罗尼西亚联邦的每条消息都被接收并封装在一个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这仅仅意味着在处理该消息的末尾(SetCostOfCoffee或者GetCostOfCoffee),咖啡机保持在相同的状态,即Open就我们而言。

goto另一方面,转换到不同的状态。我们将在讨论Deposit信息。

检查的实施情况stay功能:

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

你可能已经猜到了using函数允许我们将修改后的数据传递到下一个状态。在的情况下SetCostOfCoffee消息,我们设置了costOfCoffee的领域MachineData对即将到来的price包裹在SetCostOfCoffee。由于State是一个case类(除非你在奇数时间有兴趣调试,否则强烈建议不变),我们做一个copy

stateData

stateData只是一个函数,它给我们一个处理有限状态机数据的句柄MachineData本身。因此,以下代码块是等效的:

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

最大咖啡分配量的实现GetNumberOfCoffeeSetNumberOfCoffee几乎与设置和获取价格本身相同。让我们跳过这一步,开始有趣的部分:买咖啡。

购买咖啡

所以,咖啡爱好者为咖啡存钱,但是我们不能让机器分配咖啡,直到他输入咖啡的价格。此外,如果他给了额外的现金,我们将不得不给他余额。所以,各种情况是这样的:

  1. 在用户存钱买咖啡之前,我们会记录他的累计存款金额stayOpen州。
  2. 一旦累积现金超过咖啡的价格,我们将过渡到ReadyToBuy并允许他购买咖啡。
  3. 某事发生以后ReadyToBuy他可以改变主意Cancel交易期间他所有的累积Balance被返回。
  4. 如果他想喝咖啡,他会给机器发一个BrewCoffee取而代之的是消息,在此期间,我们分发咖啡并把Balance钱也是。(实际上,在我们的代码中,我们不分配咖啡。我们只是从他的存款中减去咖啡的价格,然后给他余额。真是敲竹杠!!(

让我们来看看上面的每一个例子

案例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.SubscribeTransitionCallBack(callerActorRef)这使得呼叫者能够被通知任何状态转换。订阅时发送的第一个通知是CurrentState,它告诉我们密克罗尼西亚联邦目前处于什么状态。接下来是几个Transition当这种情况发生时。

实施

因此,我们将存款加到累计交易总额中,并保持在Open等待更多Deposit

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在此期间,我们期望从ReadyToBuyOpen在分配咖啡时。最后,针对机器中剩余的咖啡数量(现在是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值为$1的消息

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,我们检查是否有要分配的余额。如果没有,我们就过渡到Open从咖啡总数中减去一杯咖啡后的状态。否则,我们支付余额并过渡到Open减去咖啡数量后的状态。(就像我之前说的,在这个例子中,我们实际上不分配咖啡)

  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无论他处于何种状态。正如我们在第1部分中所讨论的,保存这类通用消息的最佳位置是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消息和PoweredOff陈述,但如果你期待他们的解释,请留下评论。

一如既往,代码可在GitHub