В первой части заметок об 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 , практически совпадает с настройкой и получением самой цены. Давайте пропустим это и перейдем к интересной части — покупке кофе.
Покупка кофе
Таким образом, кофейный энтузиаст вкладывает деньги в кофе, но мы не можем позволить машине раздавать кофе, пока он не введет стоимость кофе. Кроме того, если он дал дополнительные деньги, мы должны дать ему баланс. Итак, различные случаи выглядят так:
- Пока пользователь не внесет деньги на кофе, мы будем следить за накопленной суммой, которую он внес, и
stayвOpenсостоянии. - Как только накопленная сумма превысит цену кофе, мы
ReadyToBuyсостояниеReadyToBuyи позволим ему покупать кофе. - В этом состоянии
ReadyToBuyон мог передумать иCancelтранзакцию, во время которой возвращается весь его накопленныйBalance. - Если он хочет выпить кофе, он вместо этого отправляет в машину сообщение
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 . |