Статьи

Akka Notes — Конечные автоматы — 2

В первой части заметок об Akka FSM мы познакомились с основами Akka FSM и схемой торгового автомата Coffee, которую мы планировали построить — структурой Actor и списком сообщений, которые мы передаем Actor. Во второй и последней части мы продолжим реализацию каждого из этих государств.

резюмировать

В качестве краткого резюме, давайте посмотрим на структуру FSM и сообщения, которые могут быть отправлены на него.

Состояния и данные

Три состояния FSM и данные, отправляемые через эти штаты:

01
02
03
04
05
06
07
08
09
10
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:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
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)
 
}

Структура ФСМ Актер

Вот общая структура, которую мы видели в части 1

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 принимает два аргумента — начальное состояние и начальные данные.

1
2
3
4
5
6
class CoffeeMachine extends FSM[MachineState, MachineData] {
 
  startWith(Open, MachineData(currentTxTotal = 0, costOfCoffee =  5, coffeesLeft = 10))
 
...
...

Приведенный выше код просто говорит, что начальное состояние FSM — « Open а начальные данные при открытой кофемашине — « MachineData(currentTxTotal = 0, costOfCoffee = 5, coffeesLeft = 10) .

Так как автомат только начался, торговый автомат запускается с чистого листа. У него еще не было взаимодействия с каким-либо пользователем, и поэтому текущий показанный баланс для этой транзакции равен 0. Цена кофе установлена ​​на уровне 5 долларов США, а общее количество кофе, которое машина может продать в общей сложности, равно 10. После того, как кофе был продан 10 кофе и осталось 0, машина выключается.

Реализация штатов

Ах, наконец!

Я чувствовал, что самый простой способ взглянуть на взаимодействия с торговым автоматом в разных состояниях — это сгруппировать взаимодействия, написать тестовые сценарии вокруг него и сопровождать их реализацией в FSM.

Если вы ссылаетесь на код github, все тесты находятся в CoffeeSpec, а FSM — это CoffeeMachine

Все следующие тесты обернуты внутри класса теста CoffeeSpec, объявление которого выглядит так:

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

Установка и получение цены на кофе

Как мы видели выше, MachineData инициируется по 5 долларов за кофе и имеет емкость 10 кофе. Это просто начальное состояние. Продавец должен иметь возможность устанавливать цену на кофе и мощность машины в любой момент времени.

Установка цены достигается путем отправки сообщения SetCostOfCoffee Актеру. У нас также должна быть возможность узнать цену на кофе. Это делается с помощью сообщения GetCostOfCoffee на которое машина отвечает текущей установленной ценой.

Прецедент

01
02
03
04
05
06
07
08
09
10
11
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)
    }
...
...
...

Реализация

Как мы обсуждали в части 1 , каждое сообщение, отправляемое в FSM, принимается и переносится в класс Event который также оборачивается вокруг MachineData :

1
2
3
4
5
6
7
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 ) CoffeeMachine остается в том же состоянии, которое в нашем случае Open .

goto , с другой стороны, переходит в другое состояние. Посмотрим, как это будет сделано при обсуждении Deposit .

Не удивительно, проверьте реализацию функции stay

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

using

Как вы уже могли догадаться, функция using позволяет нам передавать измененные данные в следующее состояние. В случае сообщения SetCostOfCoffee мы устанавливаем в поле costOfCoffee входящую price заключенную в SetCostOfCoffee . Поскольку State — это класс case (настоятельно рекомендуется использовать неизменяемость, если вы не отлаживаете отладку в неурочные часы), мы делаем copy .

stateData

stateData — это просто функция, которая дает нам дескриптор данных FSM, то есть самой MachineData . Итак, следующие блоки кода эквивалентны

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

Реализация максимального количества кофе, которое должно быть GetNumberOfCoffee с использованием GetNumberOfCoffee и SetNumberOfCoffee , практически совпадает с настройкой и получением самой цены. Давайте пропустим это и перейдем к интересной части — покупке кофе.

Покупка кофе

Таким образом, кофейный энтузиаст вкладывает деньги в кофе, но мы не можем позволить машине раздавать кофе, пока он не введет стоимость кофе. Кроме того, если он дал дополнительные деньги, мы должны дать ему баланс. Итак, различные случаи выглядят так:

  1. Пока пользователь не внесет деньги на кофе, мы будем следить за накопленной суммой, которую он внес, и stay в Open состоянии.
  2. Как только накопленная сумма превысит цену кофе, мы ReadyToBuy состояние ReadyToBuy и позволим ему покупать кофе.
  3. В этом состоянии ReadyToBuy он мог передумать и Cancel транзакцию, во время которой возвращается весь его накопленный Balance .
  4. Если он хочет выпить кофе, он вместо этого отправляет в машину сообщение BrewCoffee во время которого мы BrewCoffee кофе и возвращаем ему деньги Balance . (На самом деле, в нашем коде мы не распределяем кофе. Мы просто вычитаем цену кофе из его депозита и выдаем ему баланс. Такой грабёж !!)

Давайте рассмотрим каждый из перечисленных случаев.

Случай 1 — пользователь вносит наличные деньги, но не соответствует цене кофе

Прецедент

Тестовый набор начинается с установки стоимости кофе в 5 долларов и общего количества кофе в автомате равным 10. Затем мы вносим 2 доллара, что меньше цены кофе, и проверяем, находится ли автомат в Open состоянии, и общее количество кофе в машине остается на 10.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
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 состоянии в ожидании дополнительного Deposit

1
2
3
4
5
6
7
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 во время которого мы ожидаем снова переход от ReadyToBuy к Open после подачи кофе. Наконец, делается утверждение относительно оставшегося количества кофе в машине (сейчас 9).

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
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 доллар.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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)
    }

Реализация

Реализация намного проще, чем сами тесты. Если сумма депозита больше или равна стоимости кофе, мы ReadyToBuy состояние ReadyToBuy используя накопленную сумму.

1
2
3
4
5
6
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 состояние после вычитания количества кофе. (Как я уже говорил ранее, мы на самом деле не подаем кофе в этом примере)

01
02
03
04
05
06
07
08
09
10
11
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 . Мы также должны убедиться, что если пользователь внес депозит до того, как отменить, мы должны вернуть его ему.

Реализация

1
2
3
4
5
6
7
8
whenUnhandled {
  ...
  ...
    case Event(Cancel, MachineData(currentTxTotal, _, _)) => {
      sender ! Balance(value = currentTxTotal)
      goto(Open) using stateData.copy(currentTxTotal = 0)
    }
  }

Прецедент

Тестовый случай просто такой же, как тот, который мы видели выше, за исключением того, что остаток, полученный при аннулировании, представляет собой накопительный депозит.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 .

Ссылка: Akka Notes — Finite State Machines — 2 от нашего партнера JCG Аруна Маниваннана в блоге Rerun.me .