Как стать автором
Обновить

Убить всех человеков с помощью кота, или конечные автоматы на Akka.FSM

Время на прочтение 21 мин
Количество просмотров 24K
Как я уже писал в своей первой статье, не так давно я перешел 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 trait State
  sealed trait Data
}

Этих определений достаточно, чтобы объявить класс котенка:

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

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

/** 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
  }
}

Перед тем, как компилировать тест, остался последний шаг. Так как из тестов мы ссылаемся (и хотим ссылаться впредь) на внутренности объекта 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 ExecutionContext, поэтому появилась строка import context.dispatcher.

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


Чтобы не затягивать статью, я сразу же реализую смерть кота от голода (hunger >= 100), и переход в особо голодное состояние (hunger > 85), где котенок должен быть занят только тем, что регулярно мяукать и клянчить еду. Поверим, что Aкка протестировала своих планировщиков, и сообщение будет приходить вовремя, и займемся написанием того, как кот на него отреагирует. Здесь стоить заметить, что «естественное сжигание жиров» будет происходить во всех состояниях: спит ли кот, проснулся, просит есть, ест или играет с мышью. Как же быть в таком случае? Описывать одно и то же поведение для всех возможных состояний? Вместе с тестами? А если в какой-то момент мы забудем написать тест, и кот, найдя такую шару, зависнет в одном состоянии и будет наслаждаться вечной сытостью? Да черт с ним, и пускай бы наслаждался, не жалко, но ведь эта сволочь усатая по старой привычке будет регуларно жрать, а жиры сжигаться не будут — и в конце концов он таки помрет от передозировки еды в организме! На этот случай 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. Ни один кот, ни живой, ни мертвый, не пострадал никоим значительным образом в процессе написания этой статьи.
Теги:
Хабы:
+20
Комментарии 15
Комментарии Комментарии 15

Публикации

Истории

Работа

Scala разработчик
20 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн