В первой части заметок об 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 . |