Про Effective Akka

Очень полезная книга про правильное использование модели акторов из акки. Никакого срыва покровов или фундаментальных рассуждений о выстраивании архитектуры на акторах, просто краткое описание паттернов и хороших практик, многие из которых были для меня в новинку.

Универсальный дисклеймер к текстам про акку:

Акторы как объекты

Первая концепция, которая была для меня в новинку — это инициализация нескольких акторов одного типа, как объектов в классическом ООП подходе. Я привык к модели работы, когда в системе поднимается только один актор каждого типа (за редкими исключения). Система акторов образует граф, по рёбрам которого перемещаются сообщения, а каждая вершина существует в единственном экземпляре. А в книжке напоминается, что актор занимает в куче всего 400 байт, а значит можно плодить инстансы акторов также щедро, как экземпляры обычных Scala-классов. А при проектировании распределённых систем ещё и выстраивать иерархии, в которых несколько акторов, получающих сообщения через роутер, выполняют одну задачу.

Cameo Pattern

Допустим, нам надо отправить из одного актора сообщения в три других, а все три ответа вернуть одновременно. Можно написать такой код:

import akka.pattern.ask

val timeout = Timeout(10 seconds)

for {
  r1 <- (actor1 ? ComputeSomething1(x)).mapTo[SomeResponse1]
  r2 <- (actor2 ? ComputeSomething2(y)).mapTo[SomeResponse2]
  r3 <- (actor3 ? ComputeSomething3(z)).mapTo[SomeResponse3]
} yield replyTo ! AllResponses(r1, r2, r3)

Но так делать не надо, потому что при каждому вызове метода ask создаётся временный актор, который отслеживает не истёк ли таймаут и передаёт ответ через Promise. С точки зрения производительности это очень плохо.

Вместо этого можно создать один временный актор, который с учётом таймаута накопит в себе ответы от остальных акторов, отправит их в сообщении и умрёт. Это решение в плане производительности в несколько раз лучше цепочки вызова ask. Исходный код с примером применения паттерна есть на гитхабе Jamie Allen. Для себя я отдельно выделил полезнейшую функцию tell, которая подменяет отправителя сообщения.

Блокировочки

Много внимания уделено избеганию блокирующего кода. Начав с банального совета выносить продолжительные операции в Future, автор быстро перешёл к работе с ExecutionContext. Если вкратце, то испольнять все фьючи приложения в одном контексте плохо, потому что пул потоков не резиновый. Рекомендуется выделять отдельные контексты для мест с блокирующим IO и для различных зон ответственности (failure zones) — так в книге называются замкнутые относительно распространения ошибок части приложения.

Лучшей практикой создания контекста названо обращение к контексту, объявленному в конфиге.

implicit val ec: ExecutionContext = context.system.dispatchers.lookup("foo")

А ещё я познакомился с функцией scala.concurrent.blocking, в которую рекомендуется оборачивать блокирующий код.

Chaining Pattern

Вот хочу я, чтобы сообщения, отправляемые в несколько совершенно разных акторов сначала проходили через общую функцию receive, в которой сообщения определённых типов обрабатывались, а остальные уже отправлялись в целевые акторы. При этом я не хочу городить иерархии с наследованием. Здесь мне на помощь приходит функция orElse и Chaining Pattern. Осталось просто скомбинировать функции в нужном порядке, обернуть их в трейты и готово:

trait ChainingActor extends Actor {
  private var receives: List[Receive] = List()
  def registerReceive(newReceive: Receive) {
    receives = newReceive :: receives
  }
  
  def receive = chainedReceives.reduce(_ orElse _)
}

trait DoubleActor extends ChainingActor {
  registerReceive {
    case i: Double => println("Double!")
  }
}

trait StringActor extends ChainingActor {
  registerReceive {
    case s: String => println("String!")
  }
}

class MyActor extends IntActor with StringActor {
  registerReceive {
    case Hello => println("World!")
  }
}

Push Pattern

В качестве одного из свойств реактивных приложений приводится семантика “отправь и забудь”. В модели акторов она находит следующее выражение: когда мы хотим получить что-то от другого актора, мы отправляем ему запросы до тех пор, пока он не ответит. Приводится простой паттерн, который позволяет достаточно удобно реализовать это при помощи переключения контекстов:

class MyActor(otherActor: ActorRef) extends Actor {
  var cancellable: Option[Cancellable] = None
  def receive = {
    case Start =>
      context.become(dataHandler)
      cancellable = Some(context.system.scheduler.schedule(0 milliseconds, 500 milliseconds, otherActor, GetData))
  }
  
  def dataHandler: Receive = {
    case DataToHandle(data) =>
      // Do something
      cancellable map (_.cancel)
      context.unbecome
    }
}

А если мы хотим обрабатывать больше сигналов Start одновременно, можно поднимать временные акторы, как в паттерне Cameo.

Прочие советы

Системы, построенные на акторах, должны минимально зависеть от времени. А лучше, если состояние системы вообще не зависит от времени с момента запуска. Как только время становится важным, отладка системы усложняется в разы. Ошибки начинают приводить к совершенно неожиданным последствиям. При проектировании систем надо стараться придумать способы достижения целей, не зависящие от времени, либо изолировать таймеры и шедулеры от бизнес-логики.

А ещё для простоты отладки рекомендуется добавлять к сообщениям уникальные id. Я это не пробовал, но звучит разумно.

Итог

Книга рекомендуется к прочтению всем, кто работает с акторами из Акки.