Kill all humans with a cat, or state machines on Akka.FSM

  • Tutorial
As I already wrote in my first article, not so long ago I switched from C ++ to Scala. And along with this, I began to study the model of actors performed by Akka. The most vivid impression on me was the ease of implementation and testing of finite-state machines (FSM), which this library provides. I don’t know why this happened, given the abundance of other wonderful and useful things in Akka. But now, in my first Scala project, I use state machines for every opportunity that comes up, backed by expediency (as I sincerely hope). And so I decided that I was ready to share with the community the knowledge about Akka.FSM, as well as some tricks and personal best practices that I managed to accumulate. I didn’t find a similar topic on the hub (and generally with articles about Scala and Akka it’s somehow not a lot here), and I decided, without delay, to fix the situation and speak out, until someone said everything before me. And so that it would not be boring - I propose together to implement the behavior of a real electronic cat. I would like to believe that some lonely romantic soul, inspired by my article, will refine the functionality offered in it to a full-fledged “Tamakotchi”, as homework. The main thing is that such a soul does not forget after sharing her results with the community in the comments. Ideally, it would be possible to create a project on a github with a shared access, so that everyone can bring their own personal contribution to the development of ideas of transhumanism. And now - in the direction of jokes and fantasies, we roll up our sleeves. We will start from scratch, and for the sake of enhancing 7D and the effect of presence, I will go through every step with you. TDD attached: with an uncertified robocot, it will certainly not be a joke.

The information in the article is intended for those who are already at least a bit zakom with Scala, and has at least a superficial understanding of the model of actors. For those who would like to get acquainted, but don’t know where to start, as a bonus, I wrote a small starting instruction and hid it under the spoiler so that the rest would not interfere. It talks about how to create a clean Scala project with all the necessary libraries without too much effort.


So, as you already understood, for a start we need a clean project with the latest versions of the akka-actor, akka-testkit and scalatest libraries (at the time of writing this article is akka 2.3.4 and scalatest 2.1.6.

'' Uhhh ... But what kind of garbage can this be? '', Or for those who are not in the subject
Warning # 1: if you have never touched Scala with your bare hands at all, and have not even spied on it through the keyhole, then you most likely will not be able to understand any certain part of everything written later in this article. But for the most stubborn (I approve, I myself am) I’ll explain how it is possible to create a new project on Scala without unnecessary difficulties using the fashionable and shiny Typesafe Activator bun.

Warning # 2: the following command line actions are valid for OS Linux and Mac OS X. The actions required for Windows are similar to those described, but differ from them (at least by the lack of a tilde in front of the Projects directory name, the backslash, the word "folder" instead of the words “directory” or “directory”, and the presence in the archive of a special activator.bat file designed for Windows).

Create a project


So let's go. The easiest way for me personally to create a new project is to download the mentioned typesafe activator from the official site. The library versions declared on the website at the time of writing are: Activator 1.2.10, Akka 2.3.4, Scala 2.11.1. Everything is downloaded as a ZIP archive. While it is downloading, we need to preheat the oven to 230 degrees Celsius. In the meantime, you think: “Why do we need an oven? o_0 ”- 352MB of archive has already been downloaded. Unpack all this stuff somewhere on the disk. I will do all the manipulations in the ~ / Projects directory. So:

$ mkdir ~/Projects
$ cd ~/Projects
$ unzip ~/Downloads/typesafe-activator-1.2.10.zip

After the archive is unpacked, do not forget to grease the pan with oil. Everything, I promise, then everything will be extremely serious. Now we have two ways to create a project: through the graphical interface and through the command line. As labor Jedi, we, of course, choose the path of power (especially since the terminal is already open - do not close it because of some kind of UI there):

$ activator-1.2.10/activator new kote hello-akka

With this straightforward line, we tell the activator to create a ( new ) kote project in the current folder (and, as we recall, we stayed in ~ / Projects), from the template called hello-akka . This template already includes the build.sbt file configured for the required libraries . The possibilities of the dark side, as always, are easier and more attractive, so if someone doesn’t succeed on the command line, you can type ./activator ui(or just ui , if you are already in the activator’s console) and do everything in the browser that opens. Everything is very beautiful there, look at least just for fun - I promise you will like it. After the project is created, go to its directory:

$ cd kote

IDE or non-IDE


Then, each Jedi decides for himself what his strength is: use ed, vi, vim, emacs, Sublime, TextMate, Atom, something else, or a full-fledged IDE. Personally, with the transition to Scala, I started using IntelliJ IDEA, so I will immediately generate project files for this environment. To make it work, you need to add a line addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.5.2")to the project / plugins.sbt file:

$ echo "addSbtPlugin(\"com.github.mpeltonen\" % \"sbt-idea\" % \"1.5.2\")" > project/plugins.sbt

Then we launch the activator, and then he will do everything that is necessary, at our command:

$
$ ./activator
> gen-idea sbt-classifiers

Now you can open the project in IDEA.

Or is it not an IDE?


If you think that the IDE is the dark side of the force (or vice versa), and the Jedi is not worthy - this is your full right. In this case, you can stay on the command line of the activator, and edit the files in any convenient way. And then only two activator teams will decide the whole future fate of our cat:
  1. compile - compilation of the project.
  2. test - run all tests. It will call compile if necessary, so I lied, you can get by with this command alone.

I will not launch kote in production as part of this article, but a potential developer of the final version of Tamagotchi will be able to do this using the run command .

We clean the place for kote


All cats, as you know, are pedantic neat. Therefore, we will start by preparing a clean and tidy home for our future pet. That is, we delete all the extra files that come with the newly created project as part of the hello-akka template. Personally, I consider superfluous the src / main / java, src / test / java directories with all contents, as well as all .scala files that we don’t need: src / main / scala / HelloAkkaScala.scala and src / test / scala /HelloAkkaSpec.scala. Well, now we are ready to proceed.


First step


In the beginning there was a test. And the test did not compile. This statement, as you know, is the fundamental postulate of TDD, of which I am currently a supporter. Therefore, I will start my description not with the machine itself, but with the creation of the first test for it in order to demonstrate the testing capabilities provided by the Akka TestKit library. Along with the activator that I use, there is already a testing framework - scalatest. He quite suits me, and I see no reason not to use it in our project. In general, Akka TestKit can be used with spec2 or something else, since it is framework independent . In order not to bother with the names of the test packages, I will put the file directly in src / test / scala / KoteSpec.scala

import akka.actor.ActorSystem
import akka.testkit.{ImplicitSender, TestFSMRef, TestKit}
import org.scalatest.{BeforeAndAfterAll, FreeSpecLike, Matchers}
class KoteSpec(_system: ActorSystem) extends TestKit(_system) with ImplicitSender with Matchers with FreeSpecLike with BeforeAndAfterAll {
  def this() = this(ActorSystem("KoteSpec"))
  import kote.Kote._
  override def afterAll(): Unit = {
    system.shutdown()
    system.awaitTermination(10.seconds)
  }
  "A Kote actor" - {
    // All future tests go here
  }
}

It is further assumed that I will add all tests to the body of this class, immediately below the comment. I use FreeSpecLike, and not, say, FlatSpecLike, because it’s much more convenient for me personally to clearly structure a lot of tests for various states and automaton transitions on it. Since we are ready to start creating our first test, I suggest starting with what cats like to do more than anything else - to sleep. So, taking into account the principles of TDD, we will create a test that will verify that the newly “born” cat is sleeping from the very beginning:

"should sleep at birth" in {
  val kote = TestFSMRef(new Kote)
  kote.stateName should be(State.Sleeping)
  kote.stateData should be(Data.Empty)
}


Now try to figure it out in order. TestFSMRef is the class that the Akka TestKit framework offers us to simplify the testing of finite state machines implemented using the FSM class. To be more precise, TestFSMRef is a class with a companion object, the apply method of which we call. And this method returns to us an instance of the TestFSMRef class, which is the successor of the most ordinary ActorRef, i.e., we can send messages to our machine as a simple actor. However, the functionality of TestFSMRef is somewhat expanded in comparison with the simple ActorRef, and these extensions are designed specifically for testing. One of these extensions is the two functions we used: stateName and stateData, which provide access to the current state of our tested kitten. Why are there two functions, one state? Indeed, in our usual understanding, a state is a set of current values ​​of the internal parameters of an automaton. Where did the two variables come from, and why exactly two? The fact is that to describe the current state of the automaton, Akka.FSM (based on the principles of the design of automata in Erlang) separates the concepts of the “name” of the state and the “data” associated with it. Also Akkarecommends avoiding the use of mutable properties (var) in the automaton class, justifying this with the advantage that in this way the state of the automaton in the program code will be possible to change only in a few predetermined and well-known places and avoid unobvious and implicit changes. Moreover, there is no direct access from within our future class to these two variables: they are declared private in the FSM base class. However, TestFSMRef provides access to them for testing purposes. And how to reach them from the class of the machine itself will become clear further.

So, our state of sleep I called Sleeping. And thrust it into the auxiliary object State, which from now on will store all the names of our states for clarity of the code and to avoid confusion. As for the data - at this stage we still do not know what they will be. But you still have to “feed” the machine as data, otherwise it will not work. Therefore, I decided to name the variable by the name Empty, this is my personal choice, and does not oblige you to anything. It can be called in another way: Nothing, Undefined. As for me, Empty is short and informative enough. I am also used to storing data in a specially allocated object, which I called Data. In my “combat” assault rifles of various types of data there are sometimes not less, or even more than state names, therefore I always store them in a dedicated place: cutlets separately, flies separately.

Well, are we compiling? It is clear that compilation will fail, due to the absence of the types and variables that we refer to in the test. This means that we are ready to move on to the next stage of the TDD cycle.

In order to declare the class of our automaton, we need two basic types, from which all classes and objects describing the names of states and their data will be inherited. In order not to litter the environment, we will create a companion object that will store all the definitions necessary for a kitten's life. This is a common norm in the Scala world, and no one will blame us for that. If for tests with the name of the package we did not bother, then for the project itself I will still create it. Let's call him kote. And we put the implementation file of our pet, respectively, in src / main / scala / kote / Kote.scala. So, let's begin:

package kote
import akka.actor.FSM
import scala.concurrent.duration._
/** Kote companion object */
object Kote {
  sealed trait State
  sealed trait Data
}

These definitions are enough to declare a kitten class:

/** Kote Tamakotchi mimimi njawka! */
class Kote extends FSM[Kote.State, Kote.Data] {
  import Kote._
}

Inside the class, I added the import of everything that will be further declared in the auxiliary object, to simplify further access. We can only declare the values ​​of the name and data for our initial "sleepy" state:

/** Kote companion object */
object Kote {
  sealed trait State
  sealed trait Data
  object State {
    case object Sleeping extends State
  }
  object Data {
    case object Empty extends Data
  }
}

Before compiling the test, the last step was left. Since from the tests we reference (and want to refer to from now on) to the interiors of the Kote object as easily and simply as from the class itself, it will be convenient for us to add import to the body of the KoteSpec class. You can immediately after declaring an alternative constructor:

...
  def this() = this(ActorSystem("KoteSpec"))
  import Kote._
...

Well, do not forget to add import kote.Kote to the import section in the KoteSpec.scala file. Now the project has successfully compiled, and you can run the test. What? Red? NullPointerException? And you thought - is it so easy to create a new kitten? Nature has spoiled millions of years of evolution! Oh well, no panic. Probably the problem is that we did not tell our animal what to do immediately after birth. It is very easy to do:

class Kote extends FSM[Kote.State, Kote.Data] {
  import Kote._
  startWith(State.Sleeping, Data.Empty)
}

We start the test, and - voila! Green, as I love! The kitten seemed to come to life, but it’s somehow boring: it sleeps stupidly for itself - and that’s it. It is sad. Let's wake him up.

“Sleep, my joy!”, Or how to realize the behavior in the initial state


How would we do this? Do not slow down the monitor while the test runs? Let's be constructive and think about it: if our kitten is an actor, then the only way to communicate with him is to send messages. Such an important kote-bureaucrat, you just have to hire a secretary to him, so that he sorted out the correspondence. What message should he send so that he wakes up? We could write him simply: kote! 'Prosnis'! Wake up! ” But I personally consider sending messages in lines as a bad manners, because you can always make a mistake in some character, and the compiler will not even notice it, and then it will be very difficult to debug. And our newborn kote, if you fantasize, should not yet understand the human language. I propose to develop a special cat language of teams, which he seems to be beginning to learn from birth. Well, instinctively, or something. And we will contribute to the development of his instincts. The first team we train him with is called WakeUp. And put it in our auxiliary object, in the Commands subobject:

object Kote {
  ...
  object Commands {
    case object WakeUp
  }
}

Now let's start the test:

"should wake up on command" in {
  val kote = TestFSMRef(new Kote)
  kote ! Commands.WakeUp
  kote.stateName should be (State.Awake)
}

Of course, the test does not compile. We forgot to declare the name of our condition:

  case object Awake extends State

Now the test has compiled, but, apparently, it was destined for us, it flies with another exception: NoSuchElementException: key not found: Sleeping . What do all these barbaric writings mean? Only one thing: we told our young lover of quantum experiments that he should sleep, and he really sleeps obediently, but he still does not know what to sleep and how to do it. And we, in addition, are trying to send him a message in this state of uncertainty. But let’s not be like the well-known torturers and poisoners of cats and keep the poor animal in desperate ignorance, and simply describe its behavior:

when(State.Sleeping, Data.Empty) {
  FSM.NullFunction
}

For a start, not bad. when is the most common scala function with two pairs of brackets. That is, when () (). First, we indicate the name of the state for which we want to describe the behavior, and secondly (the second brackets are not visible here, since scala allows us not to indicate them in this case), the partial function, which characterizes the behavior of our animal in this condition. So let's call it - funky behavior. And the behavior is a reaction to various external stimuli. Simply - on incoming messages. The normal reaction can be of three types - either the machine remains in the current state (stay), or goes into a new state (goto), or stops work (stop). The fourth option - an “abnormal” reaction - is when the machine cannot cope with a piled-up problem and throws an exception (and then,current supervision strategy ). I will touch on the topic of exceptions a bit later.

FSM.NullFunction is a function helpfully provided by the Akka library, which tells us that the cat in this state does absolutely nothing and does not react to anything, and it passes all incoming messages past its ears. We could write {case _ =>} , but that would not be exactly the same, and I will also mention this later. It is convenient to use NullFunction as a “gag” for describing future states, the details of which are not important at this stage, but we already need to test the transition to them.

“Wake up, lazy beast!”, Or how to respond to an event by a transition to a new state


So, let's run the test now - and now the reason for the fall is completely different: Sleeping was not equal to Awake. Of course, after all, our cat learned to sleep, but we have not yet taught him how to react to the WakeUp team. Let's try to stir it up a bit:

when(State.Sleeping) {
  case Event(Commands.WakeUp, Data.Empty) =>
    goto(State.Awake)
}

As I said, we do not have direct access to variables with the name of the state and data. We get access to them only when a message arrives to our machine. FSM wraps this message in a case class Event, and adds the current status data there. Now we can apply pattern matching and isolate everything we need from the “arrived” event. In this case, we make sure that in the state with the name Sleeping we received the WakeUp command, and our data was Data.Empty. And we respond to this whole vinaigrette with a transition to a new state: Awake. This approach to describing behavior allows you to process various options for combining state names with current data to it. That is, to find in the same state, we can react differently to the same message depending on the current data.

Now I would like to note the features of the mentioned transition functions between states: goto and stay. By themselves, these are “pure” functions that do not have any side-effects. Which means that the fact of their call does not lead to a change in the current state. They only return the state value we need (specified by the user in the case of goto, or current in the case of stay), converted to a type that FSM understands. In order for a change to occur, it must be returned from our behavior function.

We figured it out. Now run the test - but again fail: Next state Awake does not exist. I intentionally wanted to show what happens if the next state is not declared using when: the transition simply does not happen, and the machine remains in the same state. Exceptions, as it happened to us with the starting state, are also not thrown out. Often, in a fit of development, I forgot about this, and took the time to figure out why the transition does not occur and the test crashes. The message “Next state Awake does not exist” in non-trivial tests in the log can simply not be trite to notice among others. But over time, you begin to get used to this feature.

So, declare the null function as our next state, and the test will turn green:

  when(State.Awake)(FSM.NullFunction)


“Stroke the cat!”, Or how to respond to an event while maintaining unwavering


Well, now you can pet our kitten, taking advantage of the fact that he woke up. I hope that and where to add - have you figured it out yet?

Command:
  case object Stroke

Test:
"should purr on stroke" in {
  val kote = TestFSMRef(new Kote)
  kote ! Commands.WakeUp
  kote ! Commands.Stroke
  expectMsg("purrr")
  kote.stateName should be (State.Awake)
}

Kote:
when(State.Awake) {
  case Event(Commands.Stroke, Data.Empty) =>
    sender() ! "purrr"
    stay()
}


the same can be written more succinctly:
when(State.Awake) {
  case Event(Commands.Stroke, Data.Empty) =>
    stay() replying "purrr"
}

“Don’t wake the cat twice!”, Or how to test without repeating without repeating


Stop stop! Well, it turns out, in order to pet the cat in the test, we wake him first, and then pet him? Excellent, that is, if we still have 10-15 intermediate ones to the tested state (and if 100-150?) - then we will need to go through everything correctly, without making a single mistake, to get into the right one? What if it’s still a mistake, and we are not where we think? Or did something change in the transitions between intermediate states over time? For this case, TestFSMRef gives us the opportunity to guarantee the required state and data using the setState function, without having to go through all the intermediate steps. So, let's change our test:

"should purr on stroke" in {
  val kote = TestFSMRef(new Kote)
  kote.setState(State.Awake, Data.Empty)
  kote ! Commands.Stroke
  expectMsg("purrr")
  kote.stateName should be (State.Awake)
}

Well, for testing the same state for several different irritants, I personally invented this way of getting rid of duplicate code:

class TestedKote {
  val kote = TestFSMRef(new Kote)
}

And now I can safely replace all our tests with:

"should sleep at birth" in new TestedKote {
  kote.stateName should be (State.Sleeping)
  kote.stateData should be (Data.Empty)
}
"should wake up on command" in new TestedKote {
  kote ! Commands.WakeUp
  kote.stateName should be (State.Awake)
}
"should purr on stroke" in new TestedKote {
  kote.setState(State.Awake, Data.Empty)
  kote ! Commands.Stroke
  expectMsg("purrr")
  kote.stateName should be (State.Awake)
}

As for testing the same non-starting state several times, I came up with the following straightforward trick:

"while in Awake state" - {
  trait AwakeKoteState extends TestedKote {
    kote.setState(State.Awake, Data.Empty)
  }
  "should purr on stroke" in new AwakeKoteState {
    kote ! Commands.Stroke
    expectMsg("purrr")
    kote.stateName should be(State.Awake)
  }
}

As you can see, I created a frame with the subtitle “while in Awake state” for all “awake” tests, and put trait AwakeKoteState (it can be a class, it’s not the essence), which immediately initializes the cat into a waking state without unnecessary gestures. Now all the tests in this state I will declare with its help.

“Breathe more life”, or how to add meaningful data to the state


Let's think about what our cat is missing. Well, as for me - that feeling of hunger. I had cats, I know what I'm talking about! They constantly want to eat! If they don’t sleep, of course. And in a dream, you see, they see awesomely healthy bowls with their beloved grub! But where would we put the cat his hunger? I don’t know where it is located exactly at his living relatives, but at our machine, in addition to the name of the state, it is for these purposes that there is data that is now empty. I propose to think a little: at birth, a normal cat immediately looks for a boobs. So, he is already born slightly hungry. And if you feed him, then he will be full, that is, hungry. If he sleeps, runs, even eats - there is always a feeling of hunger / satiety. This means that our data can no longer be Empty in any of the states that we can imagine for ourselves. And that means that it’s time to announce other data, and to throw out these and forget. Their time has passed, evolution has decided so, and we will not grieve for them. So, we denote the hunger level by the variable hunger: Int, and suppose that a level of 100 means the kitten dies of hunger, a level of 0 or lower means overeating (this is what our family calls an excessive level of lack of hunger). And he will be born with a level of, say, 60 - that is, already slightly hungry, but still bearable. We shove our new variable in the case class VitalSigns, and delete the case object Empty. I will continue to store the data description in the Data object. So: that level 100 means death of the kitten from starvation, level 0 or lower - from overeating (as our family calls the excessive level of lack of hunger). And he will be born with a level of, say, 60 - that is, already slightly hungry, but still bearable. We shove our new variable in the case class VitalSigns, and delete the case object Empty. I will continue to store the data description in the Data object. So: that level 100 means death of the kitten from starvation, level 0 or lower - from overeating (as our family calls the excessive level of lack of hunger). And he will be born with a level of, say, 60 - that is, already slightly hungry, but still bearable. We shove our new variable in the case class VitalSigns, and delete the case object Empty. I will continue to store the data description in the Data object. So:

...
  object Data {
    case class VitalSigns(hunger: Int) extends Data
  }
...

Naturally, now in the whole project you need to change Data.Empty to Data.VitalSigns. Starting at the startWith line:

  startWith(State.Sleeping, Data.VitalSigns(hunger = 60))

In fact, in the existing behavior of the kitten in the states already described, we (he, of course) do not care about its vital indicators, so we can safely replace Data.Empty here with an underscore, and not with VitalSigns:

when(State.Sleeping) {
  case Event(Commands.WakeUp, _) =>
    goto(State.Awake)
}
when(State.Awake) {
  case Event(Commands.Stroke, _) =>
    stay() replying "purrr"
}

Now our kitten has evolved even more, and can complicate its behavior, and rumble when stroking only if it is sufficiently fed up:

when(State.Awake) {
  case Event(Commands.Stroke, Data.VitalSigns(hunger)) if hunger < 30 =>
    stay() replying "purrr"
  case Event(Commands.Stroke, Data.VitalSigns(hunger)) =>
    stay() replying "miaw!!11"
}


And tests:

"while in Awake state" - {
  trait AwakeKoteState extends TestedKote {
    def initialHunger: Int
    kote.setState(State.Awake, Data.VitalSigns(initialHunger))
  }
  trait FullUp {
    def initialHunger: Int = 15
  }
  trait Hungry {
    def initialHunger: Int = 75
  }
  "should purr on stroke if not hungry" in new AwakeKoteState with FullUp {
    kote ! Commands.Stroke
    expectMsg("purrr")
    kote.stateName should be(State.Awake)
  }
  "should miaw on stroke if hungry" in new AwakeKoteState with Hungry {
    kote ! Commands.Stroke
    expectMsg("miaw!!11")
    kote.stateName should be(State.Awake)
  }
}

"The animal is starving!", Or how to plan events


The kitten should “gain” the level of hunger over time (What? “Prank”? There is no such word in the Russian language!) To do this, we will plan a GrowHungry message for every 5 minutes immediately after the cat’s “birth”, and the path will remain with him until his death. Cruel? This is life!

Message:
  case class GrowHungry(by: Int)

Kote:
class Kote extends FSM[Kote.State, Kote.Data] {
  import Kote._
  import context.dispatcher
  startWith(State.Sleeping, Data.VitalSigns(hunger = 60))
  val hungerControl = context.system.scheduler.schedule(5.minutes, 5.minutes, self, Commands.GrowHungry(3))
  override def postStop(): Unit = {
    hungerControl.cancel()
  }
...

Уровень «набираемого» чувства голода я сделал переменным, так как наряду с естественным процессом «оголодания» (добавляющего котенку +3 к голоду каждые 5 минут) животное может заниматься подвижной деятельностью, в случае чего аппетит его будет расти гораздо быстрее. hungerControl является экземпляром Cancellable, и перед остановкой сердца котенка его нужно отменять в postStop, чтобы избежать утечек, так как dispatcher не следит за остановкой акторов, и будет дальше слать сообщения мертвому котенку прямиком на тот свет, а покойников, даже если они котята, беспокоить негоже. Ну и еще один момент: для вызова планировщика нужно указать implicit ExecutionContext, поэтому появилась строка import context.dispatcher.

«А давай просто его убьем!», или как обрабатывать события, общие для всех состояний


In order not to delay the article, I immediately realize the death of the cat from hunger (hunger> = 100), and the transition to a particularly hungry state (hunger> 85), where the kitten should be busy only with meowing and begging for food regularly. We will believe that Akka tested her planners, and the message will arrive on time, and we will write how the cat will respond to it. It is worth noting here that “natural fat burning” will occur in all states: whether the cat is sleeping, woke up, asks for food, eats or plays with the mouse. What to do in this case? Describe the same behavior for all possible states? Together with tests? And if at some point we forget to write a test, and the cat, having found such a ball, freezes in one state and enjoys eternal satiety? Yes, to hell with him, and let them enjoy, do not mind but, after all, this baleen bastard, according to an old habit, will regularly eat, and fats will not be burned - and in the end he will die from an overdose of food in the body! In this case, FSM, sincerely caring for your kitten, suggests using the whenUnhandled function, which works in any state for messages that did not match any of the options in the behavior for the current state. Remember, I wrote thatFSM.NullFunction not like {_ =>} ? I think now you guess what exactly. If in the first case we don’t process any messages that came to the actor, and all of them fall into the whenUnhandled function, then in the second case the situation is the opposite - all messages are simply “absorbed” by the behavior function, and they don’t reach whenUnhandled.

whenUnhandled {
  case Event(Commands.GrowHungry(by), Data.VitalSigns(hunger)) =>
    val newHunger = hunger + by
    if (newHunger < 85)
      stay() using Data.VitalSigns(newHunger)
    else if (newHunger < 100)
      goto(State.VeryHungry) using Data.VitalSigns(newHunger)
    else
      throw new RuntimeException("They killed the kitty! Bastards!")
}

Here we can see the emergence of another function - using. From the context, it is clear that it allows you to bind specific data to the state during the transition, and can be used with both stay and goto. That is, using using we can stay in the current state, but with new data, or move to a new one with new data. If using is not specified, as in all previous versions of our code, then the data does not change and remains the same.

"Life-giving pathology", or how to test exceptional situations


I will not describe all tests to save time and space. They are not much different from the previous ones. Of the interesting things, I consider it necessary to mention a way to test the thrown exception. Actually, for FSM this is no different from the method applicable to ordinary actors:

"should die of hunger" in new AwakeKoteState with Hungry {
  intercept[RuntimeException] {
    kote.receive(Commands.GrowHungry(1000)) // headshot
  }
}

Instead of sending a message (which would cause the delivery of the reception not to the test, but to the supervisor actor, which in this case is the user guardian, and the test would simply not pass), we use the actor's receive method call directly. This is necessary in order to make sure that the machine throws the correct exception in a specific situation. Indeed, it will continue to depend on this whether our poor animal will reanimate the almighty supervisor, having found for the thrown exception the corresponding mark in his strategy. It was to demonstrate this test that I used the exception as the cause of the death of the cat. Or you could just return stop () - but normal cats die of old age, and not starvation.

“Stop already breathing, sleepyhead!”, Or how to limit the length of stay in a state


So that we ourselves do not fall asleep, I will talk about the last feature that I would not want to bypass. This is the ability to set the maximum timeout for a state. It is set simply: it is indicated after the decimal point in the when function, immediately after the name of the state. For example, after 3 hours of sleep, the cat wakes up himself, and you do not need to wake him:

when(State.Sleeping, 3.hours) {
  case Event(Commands.WakeUp, _) =>
    goto(State.Awake)
}

What do you think, wake up? Nope. By itself, the timeout neither state nor data does not change. This can be done only by the cat himself, by his own volitional decision (well, Neo, but I heard he will not come back; old Chuck is no longer a cake). And in a single place - in the function of behavior (this does not apply to Neo, he could anywhere, like Chuck in his youth). But now, after a specified period of time, if no one wakes the cat, it will receive a StateTimeout message. And how to react to it is up to him to decide:

when(State.Sleeping, 3.hours) {
  case Event(Commands.WakeUp | StateTimeout, _) =>
    goto(State.Awake)
}

Now he can wake up from two reasons: if he slept long enough and slept, or if he was woken up by force. You can separate these two events, and react to them differently: in one case, be active and playful, and in the other, be an evil stink, constantly meow and annoy and upset everyone. In any case, if the cat leaves the dream state, then the timeout will be automatically canceled (unlike the stupid scheduler, which must be canceled by itself) and nothing supernatural will happen. By the way, if the cat receives a timeout, but continues to sleep (returning stay ()), then he will receive it again after 3 hours, as expected. That is, a timeout, not being explicitly canceled or reassigned (using stay (). ForMax (20.hours), more on that), but caught in a behavioral function and accompanied by the stay () response,

In addition to the fact that state timeout can be specified in the when function (and then it will act each time it enters this state), it can also be specified directly when passing to the goto function and even in the stay function using the already mentioned forMax function (for example, stay (). forMax (1.minute) , or goto (State.Sleeping) .using (Data.Something) .forMax (1.minute) ), and then such a timeout will only work on this particular transition (replacing the value in when, if it is also indicated there):

when(State.Sleeping, 3.hours) {
  case Event(Commands.WakeUp, _) =>
    goto(State.Awake).forMax(3.hours)
  case Event(StateTimeout, _) =>
    goto(State.Awake).forMax(5.hours)
}

Now our cat, being awakened by force, will stay awake for 3 hours, and sleep normally - 5 hours. Of course, provided that we handle the StateTimeout event in Awake state.

"What?! Does he also snore? !! ”, or how to set automatic actions during transitions between states


And finally, the very last thing, I promise. There is another useful feature with Akka FSM: the onTransition method. It allows you to set some actions during transitions from state to state. Use it like this:

onTransition {
  case State.Sleeping -> State.Awake =>
    log.warning("Meow!")
  case _ -> State.Sleeping =>
    log.info("Zzzzz...")
}

Everything seems to be obvious, but just in case, I’ll explain: at the moment of transition from the sleeping to awake state, the kitten meows in a special way exactly once. Upon transition from any state to a state of sleep, it emits one single snoring (that is how it sounds in English. From which we conclude that our kote is British).

These actions work even when you set the state in the test using the FSMActorRef.setState function(if the transition from the current to the target state coincides with one of those described in onTransition, of course). Thus, accordingly, they can be tested. Well, remember that using the goto function here will be pointless. I tell you this as a person who once stubbornly tried to change data during a state transition, and for a long time could not understand why it does not work. Another nuance that I discovered: the transition trigger will work even if you stay in the current state by returning stay () from the behavioral function. This was promised to be fixed in future versions of Akka, but for now this will mean that if you return stay () when you react to something from the Sleeping state, then onTransition will work, and your kitten will snore.

the end


That is all I wanted to talk about today. And as a task for independent research, I propose to answer the question: what happens if the when function is called several times in a row for the same state name? Or call the whenUnhandled function several times in a row. Thank you all for your attention.

PS Not a single cat, neither alive nor dead, was hurt in any significant way in the process of writing this article.

Also popular now: