Як вбити всіх людей за допомогою кота, або знайомство з Akka.FSM

Як я вже писав у своїй першої статті, не так давно я перейшов c С++ на Scala. І разом з цим я почав вивчати модель акторів у виконанні Akka. Найбільш яскраве враження на мене справила легкість реалізації і тестування кінцевих автоматів (finite state machines, FSM), яку надає ця бібліотека. Вже не знаю, чому саме так вийшло, враховуючи достаток інших прекрасних і корисних речей в Akka. Але тепер у моєму першому проекті на Scala я використовую кінцеві автомати при кожній випадає можливості, підкріпленої доцільністю (як я щиро сподіваюся). І ось я вирішив, що готовий поділитися з співтовариством тими знаннями про Akka.FSM, а також деякими хитрощами і особистими напрацюваннями, які я встиг накопичити. Подібної теми на хабре я не знайшов (та й взагалі зі статтями про Scala і Akka тут якось не густо), і вирішив, не затягуючи, виправити становище і виговоритися, поки хтось не сказав раніше за мене. А щоб не було скучно — пропоную разом реалізувати поведінка самого цього електронного кота. Хотілося б вірити, що якась самотня романтична душа, надихнувшись моєю статтею, допрацює пропонований в ній функціонал до повноцінного «Тамакотчи», в якості домашнього завдання. Головне, щоб така душа не забула після поділитися своїми результатами з спільнотою в коментарях. В ідеальному варіанті можна було б створити проект на гітхабі з загальним доступом, щоб кожен бажаючий міг внести свій особистий вклад у розвиток ідей трансгуманізму. А тепер — убік жарти і фантазії, закочуємо рукави. Починати ми будемо з самого нуля, а я для більшого 7D і ефекту присутності я буду робити кожен крок разом з вами. TDD додається: з неоттестированным робокотом вже точно буде не до жартів.

Інформація в статті призначена для тих, хто вже хоча б трохи заком зі Scala, і має хоча б поверхневе уявлення про моделі акторів. Для тих же, хто хотів би познайомитися, але не знає, з чого почати, як бонус я написав невелику стартову інструкцію і сховав її під спойлер, щоб іншим не заважала. У ній йдеться про те, як без зайвих зусиль створити чистий проект на Scala з усіма потрібними бібліотеками.


Отже, як ви вже зрозуміли, для початку нам знадобиться чистий проект зі свіжими версіями бібліотек akka-actor, akka-testkit і scalatest (на момент написання статті це akka 2.3.4 і scalatest 2.1.6.

«Ээээ… А че це за фігня ваще?», або для тих, хто не в теміПопередження №1: якщо ви взагалі ні разу не мацали Scala голими руками, і навіть не підглядали за нею через замкову щілину — то вам, швидше за все, навряд чи буде зрозуміла якась певна частина з усього написаного далі в цій статті. Але для самих упертих (схвалюю, сам такий) я поясню, як саме можна без зайвих труднощів створити новий проект на Scala з використанням модною і блискучою такий плюшки Typesafe Activator.

Попередження №2: нижчеописані дії в командному рядку справедливі для OS Linux і Mac OS X. Дії, необхідні для Windows, подібні описаним, але відрізняються від них (як мінімум, відсутністю тільди перед назвою каталогу Projects, зворотним нахилом слеша, словом «папка» замість слів «каталог» або «директорія», і присутністю в архіві спеціального файлу activator.bat, призначеного для Windows).

Створюємо проект
Отже, поїхали. Найпростіший особисто для мене спосіб створити новий проект — завантажити згаданий typesafe activator з офіційного сайту. Заявлені на сайті версії бібліотек на момент написання статті: Activator 1.2.10, Akka 2.3.4, Scala 2.11.1. Скачується все у вигляді ZIP-архіву. Поки воно скачується — нам необхідно попередньо розігріти духовку до 230 градусів Цельсія. А поки ви думаєте: «Навіщо нам духовка? о_0» — 352МБ архіву вже скачалось. Розпаковуємо все це добро куди-небудь на диск. Я проделаю всі маніпуляції в каталозі ~/Projects. Отже:

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

Після того, як архів распаковался, не забудьте змастити сковороду маслом. Все, обіцяю, що далі все буде гранично серйозно. Тепер у нас є два способи створення проекту: через графічний інтерфейс і через командний рядок. Як тру-джедаї ми, звичайно ж, вибираємо шлях сили (тим більше, термінал вже відкрито — не закривати його з-за якогось там UI):

$ activator-1.2.10/activator new kote hello-akka

Цією нехитрою рядком ми говоримо активатора створити (new) проект kote в поточній папці (а ми, як пам'ятаємо, залишилися в ~/Projects), з темплейта під назвою hello-akka. Цей темплейт вже включає налаштований під потрібні бібліотеки файл build.sbt. Можливості темної сторони, як завжди, більш легкі і привабливі, так що якщо у кото-то не виходить в командному рядку можна набрати
./activator ui
(або ui, якщо ви вже в консолі активатора), і виконати все у відкритому браузері. Там все дуже красиво, загляньте хоча б просто заради інтересу — обіцяю, вам сподобається. Після того, як проект створений — переходимо в його каталог:

$ cd kote

IDE або не IDE
Далі кожен джедай сам для себе вирішує, в чому його сила: використовувати ed, vi, vim, emacs, Sublime, TextMate, Atom, щось-там-ще, чи повноцінну IDE. Особисто я, з переходом на Scala, почав користуватися IntelliJ IDEA, тому я відразу сгенерирую файли проекту для цього середовища. Щоб все вийшло, потрібно додати рядок
addSbtPlugin("com.github.mpeltonen" % "sbt-idea" % "1.5.2")
в файл project/plugins.sbt:

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

Потім запускаємо активатор, а далі він зробить все, що треба, по нашій команді:

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

Тепер можна відкривати проект IDEA.

Або все ж не IDE?
Якщо ви вважаєте, що IDE-це темна сторона сили (або навпаки), і джедая вона не варта — це ваше повне право. На цей випадок можна залишитися в командному рядку активатора, а файли редагувати будь-яким зручним способом. І тоді лише дві команди активатора вирішать всю подальшу долю нашого кота:
  1. compile — компіляція проекту.
  2. test — запуск всіх тестів. Викличе compile при необхідності, так що я збрехав, можна обійтися однією тільки цією командою.
Запускати коте в продакшен в рамках цієї статті я не буду, але потенційний розробник фінальної версії тамагочі зможе це зробити за допомогою команди run.

Чистимо місце для коте
Всі коти, як відомо,- педантичні чистюлі. Тому ми почнемо з підготовки чистого й акуратного житла для нашого майбутнього улюбленця. Тобто, поудаляем всі зайві файли, які йдуть в комплекті з новоствореним проектом в рамках темплейта hello-akka. Зайвими особисто я вважаю непотрібні нам каталоги src/main/java src/test/java з усім вмістом, а також всі .scala файли, вони нам теж не знадобляться: src/main/scala/HelloAkkaScala.scala і src/test/scala/HelloAkkaSpec.scala. Ну от, тепер ми готові приступати.


Перший крок
На початку був тест. І тест не компилился. Саме це твердження, як відомо, є основоположним постулатом TDD, прихильником якого я є в даний момент. Тому свій опис я почну не з самого автомата, а з створення першого тесту для нього, щоб продемонтсрировать можливості тестування, що надаються бібліотекою Akka TestKit. У комплекті з активатором, яким я користуюся вже є фреймворк для тестування — scalatest. Мене він цілком влаштовує, і я не бачу причин не скористатися ним у нашому проекті. А взагалі, Akka TestKit можна використовувати з spec2 або чимось іншим, так як він є фреймворконезависимым. Щоб не морочитися з назвами пакетів для тестів, файл я покладу прямо у 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
}

}

Далі передбачається, що всі тести я буду додавати в тіло цього класу, відразу під коментарем. Я використовую саме FreeSpecLike, а не, скажімо, FlatSpecLike, тому що наочно структурувати безліч тестів для різних станів і переходів автомата на ньому особисто мені набагато зручніше. Оскільки ми готові приступити до створення нашого першого тесту, я пропоную почати з того, що коти люблять робити більше всього на світі — спати. Отже, взявши на озброєння принципи TDD, ми створимо тест, який буде перевіряти, що знову «народжений» кіт спочатку спить:

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


Тепер спробуємо розібратися у всьому по-порядку. TestFSMRef — це клас, який пропонує нам фреймворк Akka TestKit для спрощення тестування кінцевих автоматів, реалізованих з допомогою класу FSM. Якщо бути більш точним — то TestFSMRef це клас з допоміжним об'єктом (companion object), метод apply якого ми викликаємо. А повертає нам цей метод екземпляр класу TestFSMRef, який є спадкоємцем самого звичайного ActorRef, тобто, ми можемо посилати нашу автомату повідомлення, як простому актору. Однак, функціонал у TestFSMRef дещо розширено порівняно з простим ActorRef, і розширення ці призначені саме для тестування. Одним з таких розширень є дві використані нами функції: stateName і stateData, які надають доступ до поточного стану нашого тестрируемого кошеня. Чому ж дві функції, стан-то одне? Адже в звичному нам розумінні, стан — це сукупність поточних значень внутрішніх параметрів автомата. Звідки ж тут дві змінні, і чому саме дві? Справа в тому, що для опису поточного стану автомата Akka.FSM (ґрунтуючись на принципах дизайну автоматів в Erlang) розділяє поняття «назви» стану, і «даних», пов'язаних з ним. Крім того, Akka рекомендує уникати використання змінних (mutable) властивостей (var) в класі автомата, обґрунтовуючи це тим перевагою, що таким чином стан автомата в коді програми буде можливим змінити тільки в декількох заздалегідь визначених і добре відомих місцях і уникнути неочевидних і неявних змін. Більш того, прямого доступу зсередини нашого майбутнього класу до цим двом змінним немає: вони оголошені як private в базовому класі FSM. Однак, TestFSMRef надає до них доступ для можливості тестування. А про те, як достукатися до них з самого класу автомата, — стане зрозуміло далі.

Отже, наш стан сну я назвав Sleeping. І засунув його в допоміжний об'єкт State, який відтепер буде зберігати всі назви наших станів для наочності коду й уникнення плутанини. Що стосується даних на цьому етапі ми ще не знаємо, якими вони будуть. Але що-то «згодувати» автомату в якості даних все ж доведеться, інакше він не запрацює. Тому я вирішив назвати змінну ім'ям Empty, це особисто мій вибір, і ні до чого вас не зобов'язує. Можна назвати і по-іншому: Nothing, Undefined. Як на мене, Empty — досить коротко та інформативно. Дані я теж звик зберігати в спеціально виділеному об'єкті, який я назвав Data. В моїх «бойових» автоматах різних типів даних деколи не менше, а то й більше, ніж назв станів, тому я завжди зберігаю їх у виділеному місці: котлети окремо, мухи окремо.

Ну що, компілюємо? Зрозуміло, що компіляція не пройде, за відсутністю тих типів та змінних, до яких ми звертаємося в тесті. А це означає, що ми готові перейти до наступного етапу циклу TDD.

Для того, щоб оголосити клас нашого автомата, нам знадобляться два базових типи, від яких будуть успадковуватись всі класи і об'єкти, що описують назви станів і їх дані. Щоб не засмічувати навколишнє середовище, створимо допоміжний об'єкт (companion object), який буде зберігати всі необхідні для життя кошеня визначення. Це загальноприйнята норма поведінки у світі Scala, і за це ніхто нас не осудить. Якщо для тестів з назвою пакету ми не морочилися, то для самого проекту я його все-таки створю. Назвемо його kote. А файл реалізації нашого вихованця покладемо, відповідно, у src/main/scala/kote/Kote.scala. Отже, почнемо:

package kote

import akka.actor.FSM
import scala.concurrent.duration._

/** Kote companion object */
object Kote {
sealed State trait
sealed trait Data
}

Цих визначень достатньо, щоб оголосити клас кошеня:

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

Всередині класу я додав імпорт всього, що буде надалі оголошено у допоміжному об'єкті, для спрощення подальшого доступу. Нам залишається тільки оголосити значення найменування і даних для нашого споконвічного «сонного» стану:

/** Kote companion object */
object Kote {
sealed State trait
sealed trait Data

object State {
case object Sleeping extends State
}

object Data {
case object Empty extends Data
}
}

Перед тим, як компілювати тест, залишився останній крок. Так як з тестів ми посилаємося (і хочемо посилатися надалі) на нутрощі об'єкта Kote так само легко і просто, як з самого класу, то нам зручно буде додати імпорт в тіло класу KoteSpec. Можна відразу після оголошення альтернативного конструктора:

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

Ну і ще не забудьте додати import kote.Kote в розділ импортов у файлі KoteSpec.scala. Ось тепер проект вдало скомпилировался, і можна запускати тест. Що? Червоний? NullPointerException? А ви думали — так просто створити нового кошеня? Природа мільйони років еволюції на це портатила! Ну да ладно, без паніки. Ймовірно, проблема в тому, що ми не сказали нашому тварині, чим йому зайнятися відразу після народження. Зробити це дуже просто:

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

Запускаємо тест, і вуа-ля! Зелененький, як я люблю! Кошеня ніби ожив, але щось він якийсь нудний: тупо спить собі — і все. Це невесело. Давайте його розбудимо.

«Спи, моя радість!», або як реалізувати поведінку в початковий стан
Як би нам це зробити? Не торсати же монітор, поки тест відпрацьовує? Давайте будемо конструктивними і подумаємо: якщо наш кошеня — це актор, то єдиний метод спілкування з ним — це відправлення повідомлень. Такий собі важливий коте-бюрократ, залишилося тільки сисястую секретарку йому найняти, щоб розбирала кореспонденцію. Яке ж повідомлення йому відправити, щоб він прокинувся? Ми могли б написати йому просто: kote! «Prosnis'! Wake up!». Але відправляти повідомлення рядками особисто я вважаю моветоном, тому що завжди можна помилитися в якомусь символі, і компілятор цього навіть не помітить, а налагодити буде потім дуже важко. Та й новонароджений наш коте, якщо пофантазувати, не має ще розуміти человечьего мови. Пропоную розробити спеціальний котячий язик команд, які він ніби починає освоювати з народження. Ну інстинктивно, чи що. А ми посприяємо розвитку його інстинктів. Першу команду, якій ми його навчимо, ми назвемо WakeUp. І засунемо її в наш допоміжний об'єкт, в подъобъект Commands:

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

Тепер приступимо до тесту:

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

Звичайно, тест не відбудеться створення. Ми забули оголосити назву нашого стану:

case object Awake extends State

Тепер тест скомпилировался, але, як вже, мабуть, було нам судилося, вилітає з іншим винятком: NoSuchElementException: key not found: Sleeping. Що означають всі ці варварські письмена? Тільки одне: ми сказали нашому юному любителю квантових експериментів, що він повинен спати, і він дійсно слухняно спить, але при цьому що таке спати і це потрібно робити — він ще не знає. А ми, на додачу, намагаємося відправити йому повідомлення у це його стан невизначеності. Не будемо ж уподабливаться відомим мучителям і отравителям котів і тримати бідна тварина у відчайдушному невіданні, і просто опишемо його поведінку:

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

Для початку непогано. when — це сама звичайна для scala функція з двома парами дужок. Тобто, when()(). У перших ми вказуємо назву стану, для якого ми хочемо описати поведінку, а по-друге (тут друге дужок не видно, так як scala дозволяє їх у цьому випадку не вказувати) — часткову функцію (partial function), яка і характеризує поведінку нашого тварини в цьому стані. Так і назвемо її — функція поведінки. А поведінка полягає у реакції на різні зовнішні подразники. Просто — на що приходять повідомлення. Нормальна реакція може бути трьох видів — небудь автомат залишається в поточному стані (stay), або переходить в нове (goto), або зупиняє роботу (stop). Четвертий варіант — «ненормальна» реакція — це коли автомат не може впоратися з навалившейся проблемою і викидає виключення (а далі, як і у випадку зі звичайним актором, його супервізор вирішує, що з ним робити, у відповідність з поточною стратегією супервізії). Тему винятків я ще торкнуся трохи пізніше.

FSM.NullFunction — це люб'язно надана бібліотекою Akka функція, яка говорить нам, що кіт у цьому стані абсолютно нічого не робить і ні на що не реагує, а всі приходять повідомлення пропускає повз вуха. Ми могли б написати { case _ => }, але це було б не зовсім те ж саме, і далі про це я теж згадаю. NullFunction зручно використовувати як «затичку» для опису майбутніх станів, деталі релізації яких поки не важливі на даному етапі, але перехід в які нам вже потрібно відтестувати.

«Прокинься, лінива скотина!», або як відреагувати на подію переходом в новий стан
Отже, запустимо тест зараз — і тепер причина падіння зовсім інша: Sleeping was not equal to Awake. Звичайно, адже наш кіт навчився спати, але ми ще не навчили його реагувати на команду WakeUp. Спробуємо розбудити його трохи:

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

Як я вже говорив, прямого доступу до змінних з назвою стану і даних у нас немає. Ми отримуємо доступ до них тільки тоді, коли нашому автомату приходить повідомлення. FSM загортає це повідомлення case class Event, і туди ж додає поточні дані стану. Тепер ми можемо застосувати pattern matching і виокремити з «прилетів» події все, що нам потрібно. В даному випадку, ми переконуємося, що будучи в стані з назвою Sleeping ми отримали команду WakeUp, і наші дані при цьому були Data.Empty. А ми реагуємо на весь цей вінегрет переходом в новий стан: Awake. Такий підхід до опису поведінки дозволяє обрабатвыть різні варіанти поєднання назв стану з поточними даними до нього. Тобто, знаходячись в одному і тому ж стані, ми можемо по-різному реагувати на одне і те ж повідомлення в залежності від поточних даних.

Тепер хотілося б відзначити особливості згаданих функцій переходів між станами: goto та stay. Самі по собі це «чисті» (pure) функції, що не мають ніяких побічних ефектів (side-effects). Що означає, що сам факт їх виклику до зміни поточного стану не призводить. Вони лише повертають потрібне нам значення стану (вказане у випадку з goto, або поточне у випадку зі stay), приведений до типу, зрозумілому FSM. Для того, щоб зміна відбулася, його потрібно повернути з нашої функції поведінки.

З цим розібралися. Тепер запускаємо тест — але знову невдача: Next state Awake does not exist. Я навмисно хотів показати, що буває, якщо таке стан не оголошено з допомогою when: переходу просто не відбувається, і автомат залишається у попередньому стані. Винятки, як вийшло у нас зі стартовим станом, теж не викидається. Часто в пориві розробки я про це забував, і витрачав час на те, щоб розібратися, чому ж не відбувається переходу і тест падає. Повідомлення «Next state Awake does not exist» в нетривіальних тестах в балці можна просто банально не помітити серед інших. Але з часом починаєш звикати до цієї особливості.

Отже, оголосимо нульову функцію нашим наступним станом, і тест загориться зеленим:

when(State.Awake)(FSM.NullFunction)


«Погладь кота!», або як відреагувати на подію, зберігаючи непохитність
Що ж, тепер можна і погладити нашого кошеня, скориставшись тим, що він прокинувся. Сподіваюся, що і куди треба додавати — вже розібралися?

Команда:
case object Stroke

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

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


те ж саме можна записати більш лаконічно:
when(State.Awake) {
case Event(Commands.Stroke, Data.Empty) =>
stay() replying "purrr"
}

«Не буди кота двічі!», або як тестувати, не повторюючись не повторюючись
Стоп-стоп! Це що ж, виходить, щоб погладити кота в тесті, ми його спочатку будимо, а потім гладимо? Відмінно, тобто, якщо у нас до стану досліджуваного ще 10-15 проміжних (а якщо 100-150?) — то нам через все потрібно буде правильно пройти, не допустивши жодної помилки, щоб потрапити в потрібне? А раптом все-таки помилка, і ми опинилися не там, де ми думаємо? Або з часом щось змінилося в переходах між проміжними станами? На цей випадок TestFSMRef надає нам можливість гарантовано задати необхідний стан та дані за допомогою функції setState, без необхідності проходити через всі проміжні етапи. Отже, змінимо наш тест:

"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)
}

Ну а для тестів одного і того ж стану на кілька різних подразників особисто я для себе винайшов такий спосіб позбавлення від повторюваного коду:

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

І тепер всі наші тести я сміливо можу замінити на:

"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)
}

Що стосується тестування одного і того ж нестартового стани кілька разів, то я вивів для себе такий нехитрий прийом:

"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)
}
}

Як бачите, я створив обрамлення з підзаголовком «while in Awake state» для всіх «жайворонків» тестів, і в нього помістив trait AwakeKoteState (можна і class, не суть), який при ініціалізації відразу ж поміщає кота в бадьорий стан без зайвих рухів. Тепер всі тести в цьому стані я буду оголошувати з його допомогою.

«Вдихнемо побільше життя», або як додати значущі даних до стану
Подумаємо, чого не вистачає нашому коту. Ну, як по мені — то почуття голоду. У мене були коти, я знаю, про що кажу! Вони постійно хочуть жерти! Якщо не сплять, звичайно. А уві сні напевно ж бачать офігенно здорові миски зі своєю коханою жратвой! Ось тільки куди б нам засунути коту його почуття голоду? Не знаю, де воно там точно розташовується у його живих родичів, але у нашого автомата, на додачу до назви стану, саме для цих цілей існують дані, які зараз пустують. Пропоную трохи подумати: при народженні нормальний кіт відразу шукає цицьку. Значить, він вже народжується трохи голодним. А якщо його нагодувати — то він буде ситий, тобто, неголодних. Якщо він спить, бігає, навіть їсть — почуття голоду/ситості є завжди. Отже, наші дані не можуть бути Empty ні в одному із станів, які ми можемо собі уявити. А це означає, що прийшла пора оголосити інші дані, а ці викинути і забути. Їх час минув, еволюція так вирішила, і ми не будемо про них засмучуватися. Отже, рівень голоду ми позначимо змінної hunger: Int, і припустимо, що рівень 100 — означає смерть кошеня від голоду, рівень 0 або нижче — від переїдання (так у нас в родині називають надмірний рівень відсутності голоду). А народжуватися він буде з рівнем, скажімо, 60 — тобто, вже злегка голодним, але ще терпимо. Засунемо нашу нову змінну case class VitalSigns, а case object Empty видалимо. Опис даних я як і раніше буду зберігати в об'єкті Data. Отже:

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

Природно, тепер у всьому проекті потрібно поміняти Data.Empty на Data.VitalSigns. Починаючи з рядка startWith:

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

По суті, в існуючому поведінці кошеня в описаних вже станах нам (йому, звичайно) не важливі його життєві показники, тому ми сміливо можемо заміняти тут Data.Empty на символ підкреслення, а не на VitalSigns:

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

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

Тепер наш кошеня ще більше еволюціонував, і може ускладнити свою поведінку, і бурчати при доторканні тільки в тому випадку, якщо достатньо ситий:

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"
}


І тести:

"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)
}
}

«Тварина голодує!», або як планувати події
Кошеня повинен «набирати» рівень голоду з часом (Що? «Проголадываться»? Немає такого слова в руссом мовою!) Для цього заплануємо повідомлення GrowHungry на кожні 5 хвилин відразу після «народження» кота, і шлях воно буде з ним до самої смерті. Жорстоко? Це життя!

Повідомлення:
case class GrowHungry(by: Int)

Коте:
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 контексті виконання, тому з'явилася рядок import context.dispatcher.

«А давай просто його вб'ємо!», або як обробляти події, загальні для всіх станів
Щоб не затягувати статтю, я відразу ж реалізую смерть кота від голоду (hunger >= 100), і перехід в особливо голодний стан (hunger > 85), де кошеня повинен бути зайнятий тільки тим, що регулярно нявкати і клянчити їжу. Повіримо, що Акка протестувала своїх планувальників, і повідомлення буде приходити вчасно, і займемося написанням того, як кіт на нього відреагує. Тут варто зауважити, що «природне спалювання жирів» буде відбуватися у всіх станах: спить кіт, прокинувся, просить їсти, їсть або грає з мишею. Як же бути в такому випадку? Описувати одне і те ж поведінка для всіх можливих станів? Разом з тестами? А якщо в якийсь момент ми забудемо написати тест, і кіт, знайшовши таку кулі, зависне в одному стані і буде насолоджуватися вічним ситістю? Та чорт з ним, і нехай би насолоджувався, не шкода, але ж ця сволота вусата за старою звичкою буде регуларно жерти, а жири спалюватися не будуть — і врешті-решт він таки помре від передозування їжі в організмі! На цей випадок FSM, щиро піклуючись про ваше кошеня, пропонує використовувати функцію whenUnhandled, яка спрацьовує у будь-якому стані для повідомлень, які не збігаються ні з одним із варіантів поведінки функції для поточного стану. Пам'ятаєте, я писав про те, що FSM.NullFunction не схожа на { _ => }? Думаю, тепер ви здогадуєтеся, чим саме. Якщо в першому випадку у нас не обробляється жодне повідомлення, яке прийшло актору, і всі вони потрапляють в функцію whenUnhandled, то в другому випадку ситуація протилежна — всі повідомлення просто «поглинаються» функцією поведінки, і в 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!")
}

Тут ми можемо бачити появу ще однієї функції — using. З контексту зрозуміло, що вона дозволяє прив'язувати конкретні дані до стану при переході, причому, може використовуватися як з stay, так і з goto. Тобто, з допомогою using ми можемо залишитися в поточному стані, але з новими даними, або перейти в нове з новими даними. Якщо using не вказується, як у всіх попередніх варіантах нашого коду, дані не змінюються і залишаються колишніми.

«Цілюща патологоанатомия», або як тестувати виняткові ситуації
Всі тести я описувати не буду, для економії часу і простору. Вони мало чим відрізняються від попередніх. З цікавого вважаю за потрібне згадати спосіб тестування викидається виняток. Власне, для FSM це нічим не відрізняється від способу, застосовного до звичайних акторам:

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

Замість відправки повідомлення (що викликало б доставку эксепшена не тест, а актору-супервізору, яким в даному випадку є user guardian, і тест би просто не відбувся) ми використовуємо виклик методу receive актора безпосередньо. Потрібно це для того, щоб переконатися, що автомат викидає правильне виняток в конкретній ситуації. Адже від цього далі буде залежати, реанімує наша бідна тварина всемогутній супервізор, знайшовши для викинутого виключення відповідну позначку у своїй стратегії. Саме для демонстрації цього тесту я і використовував як виняток причину смерті кота. А можна було б просто повернути stop() — але так нормальні коти помирають від старості, а не від голоду.

«Кінчай вже спати, соня!», або як обмежити тривалість перебування в стані
Щоб ми з вами самі вже не поснули, розповім про останній особливості, яку не хотілося б обходити стороною. Це можливість встановлювати максмимальное час перебування (timeout) для стану. Задається просто: вказується після коми функції when, відразу після назви стану. Наприклад, через 3 години сну кіт сам прокидається, і будити його не потрібно:

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

Як думаєте, прокинеться? Не-а. Сам по собі таймаут ні стану, ні даних не змінює. Це здатний зробити лише сам кіт, власним вольовим рішенням (ну ще Нео, але я чув, він більше не вернеться; а старий Чак вже не торт). І в єдиному місці — в функції поведінки (до Нєо це не відноситься, він міг де завгодно, як і Чак в юності). Але тепер, через зазначений проміжок часу, якщо кота ніхто не розбудить, йому прийде повідомлення StateTimeout. І як на нього реагувати — вирішувати вже йому самому:

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

Ось тепер він може прокидатися від двох причин: якщо він проспав досить довго і виспався, або якщо його розбудили насильно. Можна розділити ці дві події, і відреагувати на них по-різному: в одному випадку бути активним і грайливим, а в іншому — бути злим вонючкою, постійно нявкати і всіх дратувати і засмучувати. У будь-якому випадку, якщо кіт вийшов зі стану сну, — то таймаут автоматично відміниться (на відміну від дурного планувальника, якого потрібно скасовувати самому) і нічого надзвичайного не трапиться. До речі, якщо кіт отримає тайм-аут, але продовжить спати (повернувши stay()) — то він отримає його ще раз через 3 години, як годиться. Тобто, тайм-аут, не будучи явно скасованим або переназначеным (використанням stay().forMax(20.hours), про що далі), але при цьому спійманим в поведінкової функції і супроводом відповіддю stay(), «вистрілить» знову через заданий проміжок часу.

Крім того, що state timeout можна вказати у функції when (і тоді він буде діяти кожен раз при переході в цей стан), можна вказати і безпосередньо при переході в функції goto і навіть у функції stay з допомогою вже згаданої функції forMax (наприклад, stay().forMax(1.minute), або goto(State.Sleeping).using(Data.Something).forMax(1.minute)), і тоді такий таймаут буде діяти тільки за конкретно цьому переході (заміщаючи значення в when, якщо воно і там теж вказано):

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

Тепер наш кіт, будучи розбудженим насильно, буде спати 3 години, а нормально виспавшись — 5 годин. Звичайно ж, за умови, що ми опрацюємо подія StateTimeout в стані Awake.

«Що?! Він ще й хропе?!!», або як поставити автоматичні дії при переходах між станами
Ну і, нарешті, саме останнє, обіцяю. Є ще одна корисна особливість у Akka FSM: метод onTransition. Він дозволяє задавати якісь дії при переходи з стану в стан. Іспользутся це так:

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

Начебто все очевидно, але на всяк випадок поясню: в момент переходу з режиму у бадьорий стан кошеня нявчить особливим чином рівно один раз. При переході з будь-якого стану в стан сну — видає один-єдиний хропіння (саме так він звучить на англійській мові. З чого робимо висновок, що наш коте — британець).

Спрацьовують ці дії навіть тоді, коли ви встановлюєте стан в тесті за допомогою функції FSMActorRef.setState (якщо перехід з поточного на цільове стан збігається з одним з описаних в onTransition, зрозуміло). Таким чином, відповідно, їх і можна тестувати. Ну і пам'ятаємо, що тут використання функції goto буде безглуздим. Це я вам кажу як людина, яка одного разу наполегливо намагався змінити дані в ході зміни станів, і довго не міг зрозуміти, чому воно не спрацьовує. Ще один нюанс, який я виявив: тригер переходу буде відпрацьовувати навіть у тому випадку, якщо ви залишаєтеся в поточному стані, повернувши stay() з поведінкової функції. Це обіцяли пофіксити в наступних версіях Akka, а поки що це буде означати, що якщо ви повернете stay() при реакції на що-небудь зі стану Sleeping, то onTransition спрацює, і ваш кошеня видасть хропіння.

Кінець
Це все, про що я хочу сьогодні розповісти. А як завдання для самостійного дослідження пропоную відповісти на запитання: що станеться, якщо викликати функцію when кілька разів поспіль для одного і того ж назви стану? Або викликати кілька разів поспіль функцію whenUnhandled. Спасибі всім за увагу.

P.S. Жоден кіт, ні живий, ні мертвий, не постраждав жодним значним чином в процесі написання цієї статті.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.