Знайомство з Akka Typed. Частина перша: Protocols and Behaviors ([переклад] Tour of Akka Typed: Protocols and Behaviors)

В цій серії публікацій ми будемо розбирати Akka Typed, новий API для роботи з Akka Typed який має істотні переваги порівнюючи з класичною імплементацією.

Переклад з англійської оригінальної статті Мануеля Бернґарта від 11го липня 2019.

Akka Typed готовий до використання на продакшені починаючи з Квітня 2019, і не дивлячись на те, що API позначений як такий що може мінятися, я вважаю, це гарний час дослідити і дізнатися що ж нового.

Ви не знайомі з Akka Actor API? Не турбуйтеся - ця серія призначена для розуміння без попереднього знання. Якщо ж ви знайомі з Actor API, тоді ця серія допоможе вам зрозуміти і вивчити як працювати з Akka Typed.

Чому Akka Typed

Actor модель зарекомендувала себе як потужна абстракція при побудові стійких до відмов [fault-tolerant], розпаралелених [concurrent] та розподілених систем [distributed systems], які здатні вирішувати реальні проблеми. Модель базується на понятті передачі повідомлення між окремими суб'єктами (акторами), які розуміють ці повідомлення та реагують на них. Стійкість до відмов [fault-tolerance] забезпечується за допомогою ієрархічного нагляду (supervision): актори можуть створювати дочірніх акторів, у яких вони відслідковують відмови та некоректну поведінку, а при необхідності можуть їх перезапустити або відтворити. Таким чином, частини акторської системи [actor system] (або система в цілому) здатні оздоровитись після збоїв.

Класичний Akka API актора втілює ці принципи, надаючи дуже простий набір методів обробки вхідних повідомлень, надсилання повідомлень та створення дітей [child actors]:

// Actor trait (клас AbstractActor у Java) є точкою входу для використання API
class OrderProcessor extends Actor {
 
  // метод receive, де повідомлення оброблюються
  def receive: Receive = {
    case order @ OrderProcessor.ProcessOrder =>
      // метод actorOf створює нового дочірнього актора відносно того, що викликає
      val connection = context.actorOf(
        BankConnection.props(order.bankIdentifier)
      )
      // метод ! означає відправлення (cпосіб fire-and-forget)
      connection ! BankConnection.ExecuteOrder(order)
  }
}

Ця модель та API мають досить велику перевагу над потоками: обробка повідомлень актором гарантовано відбувається послідовно, стан актора може змінюватися лише самим актором і, таким чином, можна набагато легше робити висновки про те, що відбувається, порівнюючи з ситуацією коли є стан [shared state], доступ до якого одночасно мають різні потоки.

В той самий час, у цього API є кілька обмежень, деякі з найпоширеніших - описані в серії анти-паттернів Akka.

Основне питання - і я бачив, що це траплялося в досить великих проектах написаних на Akka протягом років - полягає в тому, що з часом із попереднім API ускладнюється масштабування та підтримка великих систем акторів [actor system] у мірі їх розростання. Це пояснюється тим, що API не застосовує «протокол орієнтований» [protocol-first] підхід. Дійсно, одне з перших, про що ви дізнаєтесь у вступному навчальному курсі Akka, - це чітко визначити свій протокол з точки зору оброблюваних повідомлень, а також використовувати повний шлях (OrderProcessor.ProcessOrder) для доступу до повідомлень. Повертаючись до прикладу вище, ось як виглядає протокол актора OrderProcessor:

// компаньйон-об'єкт - загальне місце, для визначення повідомлень в Scala
// У Java, ви виктористовували б статичні класи, щоб досягти такого ж ефекту
// для кластерних або стійких систем повідомлення визначаються за допомогою відповідного механізму серіалізації, такого як protobuf
object OrderProcessor {
  sealed trait Command
  final case class ProcessOrder(bank: BankId, fromAccount: AccountId, toAccount: AccountId, amount: Amount) extends Command
}

Так і є, ця best practice не заведе вас дуже далеко: навіть якщо ви ретельно використовуєте її скрізь, все одно є одна основна проблема. Ніщо не заважає вам надіслати повідомлення акторові, який не може обробити його. Метод прийому [receive method] актора прийме будь-яке повідомлення, і поведінка за замовчуванням полягає в тому, щоб передати ці необроблені повідомлення спеціальному методу [unhandled method] актора, який записує їх в лог за замовчуванням (звичайно, якщо записування в лог необроблених повідомлень налаштовано правильно). Це може стати джерелом сильних розчарувань для новачків, оскільки ви не можете бачити, що пішло не так, хоч ваша система не працює.

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

Саме в цей час Akka Typed приходить на допомогу. Цей API розроблений як «протокол орієнтований» [protocol-first]: у вас більше немає вибору, але треба витратити хоча б трохи часу на роздуми про повідомлення, з якими може працювати кожен актор. На відміну від класичного API, де слідування цій best practice необов’язково, вам потрібно формалізувати набір оброблюваних повідомлень під час розробки.

Є ще один момент на якому я хочу наголосити, побачивши неабияку кількість реальних систем побудованих на Akka: мета Akka Typed не просто переконатися в тому, що повідомлення оголошуються структуровано і гарантувати, що кілька не оброблених повідомлень не пропущено. Натомість мета API - змусити вас по-справжньому подумати про дизайн системи наперед. При правильному наборі акторів, які мають правильну деталізацію і спілкуються з правильними паттернами повідомлень, можна створити дуже потужні системи, які при цьому, по своїй суті, все ще досить прості - або, я повинен сказати, настільки прості, як дозволяє галузь [domain] застоcування. На жаль, те, що я багато разів спостерігав, - це те, що люди схильні перестаратися з частиною «багато акторів, багато повідомлень» і весь дизайн закінчується непотрібною складністю, віж якої важко позбутися згодом. Як каже Мартін Томпсон:

Будуємо платіжний процесор

Для цієї серії статей ми ще раз будемо використовувати приклад платіжного процесора (як у серії Tour of Akka Cluster - цей домен ніколи не старіє (і, мабуть, я маю досить великий досвід роботи з ним, Akka добре підходить для побудови системи транзакцій великого обсягу [high-volume] та низькою затримкою [low-latency]).

Наш платіжний процесор здатний обробляти платежі різного типу та способів оплати: різні кредитні картки (Visa, Master Card, American Express тощо), SEPA платежі, Apple Pay, Google Pay, Amazon Pay, PayPal. Він підтримує різноманітні потоки платежів з різними механізмами перевірки, повторними платежами та багатьма іншими варіантами подібного.

Для вирішення такої універсальності в бізнес-вимогах наша система розділена на кілька компонентів, щоб можна було легко додавати нові способи оплати:

Початкові компоненти Платіжного процесора
  • API: це точка входу до нашого платіжного процесора. Він обробляє аутентифікацію, підтримує безліч форматів і розсилає запити до потрібних компонентів нижче. В цій серії статей ми будемо намагатися триматися дуже простої реалізації.
  • Payment Handler: ключовий компонент який розуміє основні запити на оплату. Виходячи з інформації, яку він отримує з компонента конфігурації, він організовує обробку платіжного запиту, який може мати кілька кроків (перевірка, виконання тощо).
  • Configuration: цей компонент зберігає конфігурацію, пов’язану з користувачами API, а також з організаціями, яким дозволено приймати платежі (іншими словами, це торговець).
  • Payment processors: ця група компонентів відповідає за виконання платежів. У нашому прикладі ми представимо лише простий процесор оплати за допомогою кредитної картки Credit Card Payment Processor, в реальних системах цих компонентів буде набагато більше. Процесори також зазвичай спілкуються з більш низькими компонентами або сторонніми системами, але заради простоти ми це ігноруємо.

Зауважте, що в реальній системі виникає більше проблем, ніж тут моделюється - наприклад, ми взагалі не говоримо про реєстрацію способів оплати в нашому процесорі - але для демонстрації Akka Typed цього достатньо.

Протоколи в Akka Typed

Як було пояснено раніше, протоколи мають значення, і Akka Typed дає змогу їх ефективно моделювати і виразити (або принаймні, набагато більш детально, ніж за допомогою класичного Akka API).

"Але що таке протокол?", запитаєте ви. "Хіба це не просто повідомлення?". У цьому сенсі протокол є трохи більше. Простіше кажучи, я б визначив протокол як набір повідомлень, якими обмінюються дві або більше сторони у певному порядку та комбінації. Існує безліч сімей протоколів (ознайомтеся з моделлю OSI), і ви вже, ймовірно, знайомі з відомими протоколами, такими як TCP або HTTPS. У нашому випадку ми працюємо на рівні додатку [application layer]. Протоколи можна розглядати як "API на стероїдах": в той час як API описують лише окремі виклики (включаючи параметри, вміст запиту та вміст відповіді), протоколи описують, як взаємодіють між собою запити з тією метою, щоб досягти бажаного цільового стану системи протягом спілкування.

У Typed Actor API протоколи можна моделювати з допомогою класів та посилань на типізовані актори [typed actor reference]. Візьмемо приклад простого протоколу, який дозволяє нам отримувати конфігураційні дані з компонента Сonfiguration:

sealed trait ConfigurationMessage
final case class RetrieveConfiguration(merchantId: MerchantId, replyTo: ActorRef[ConfigurationResponse]) extends ConfigurationMessage
 
sealed trait ConfigurationResponse
final case class Configuration(merchantId: MerchantId, ...) extends ConfigurationResponse
final case class ConfigurationNotFound(merchanId: MerchantId) extends ConfigurationResponse

Цей приклад дотримується "запит-відповідь" [request-response] паттерну спілкування повідомленнями [message pattern]. Щоб дізнатися більше про паттерни спілкування повідомленнями (та про дизайн реактивних систем в цілому), ознайомтеся з книгою "Reactive Design Patterns".

Якщо ви вже використовували класичний Akka API для акторів, ви помітите дві основні відмінності в імплементації цього паттерну. По-перше, адреса відправника міститься у визначенні повідомлення. У класичному API це оброблялося Akka під капотом, а саме визначалося посилання на актора-відправника з повідомлення та дозволялось відповісти на нього, надсилаючи повідомлення на метод sender(). По-друге, і найголовніше, що ActorRef тепер типізоване: воно посилається на певний тип повідомлення, який відправник може зрозуміти. У нашому випадку ми використовуємо trait-ти (як, наприклад, trait ConfigurationResponse), щоб відправник міг розуміти більш ніж один тип відповіді.

Для того, щоб зрозуміти, чому це важливо, і як ця ключова зміна дозволяє Akka Typed бути безпечнішим та легшим до змін у використанні, ніж класичний варіант, нам потрібно ознайомитися з тим як створити та описати Актора. Скажімо, ми хочемо реалізувати Configuration актора. Одним із способів описати та визначити було б:

class Configuration extends AbstractBehavior[ConfigurationMessage] {
  // ...
}

Що тут можна помітити, це те, що ми визначаємо клас, який успадковується від trait-а AbstractBehavior, який приймає параметр типу. Таким чином, ми заявляємо, що Configuration актор розуміє лише повідомлення типу ConfigurationMessage - іншими словами, це дає можливість компілятору статично перевірити, чи дійсно одержувач повідомлення може обробляти повідомлення, яке йому надсилається.

Зауважте, що у наведеному вище прикладі ми використовуємо об’єктно-орієнтований стиль [object-oriented style] оголошення актора у цьому прикладі - ми розглянемо функціональний стиль [functional style] пізніше.

Імплементація нашого першого актора

Почнемо з розробки досить простої версії компонента Configuration, який повинен бути здатний отримати (а згодом і зберегти) конфігурацію продавця. Ми продовжимо використовувати об’єктно-орієнтований стиль, так як ми почали його використовувати раніше - якщо ви використовували класичний API актора раніше, то цей стиль повинен бути досить знайомим.

Розширення trait-у AbstractBehavior вимагає від нас імплементувати метод onMessage, який повертає Behavior:

//AbstractBehavior trait є точкою входу для використання об'єктно-орієнтованого ситлю API
class Configuration(context: ActorContext[ConfigurationMessage]) extends AbstractBehavior[ConfigurationMessage] {
 
  // тут стан, що може змінюватися, містить значення конфігурації кожного продавця, про якого ми знаємо
  var configurations: Map[MerchantId, MerchantConfiguration] = Map.empty
 
  // метод onMessage визначає початкову поведінку, що застосовується до повідомлення після отримання
  override def onMessage(msg: ConfigurationMessage): Behavior[ConfigurationMessage] = msg match {
    case RetrieveConfiguration(merchantId, replyTo) =>
      configurations.get(merchantId) match {
        case Some(configuration) =>
          // відповідь відправнику, використовуючи парадигму fire-and-forget
          replyTo ! ConfigurationFound(merchantId, configuration)
        case None =>
          // відповідь відправнику, використовуючи парадигму fire-and-forget
          replyTo ! ConfigurationNotFound(merchantId)
      }
      // у кінці, повернення Behavior, що буде застосовано до наступного отриманого повідомлення
      // у цьому випадку це такий самий Behavior, як у нас
      this
  }
}

На перший погляд, ця реалізація виглядає досить схоже на приклад класичного API актора на початку цієї статті. Ми перевизначаємо[override] onMessage і співставляємо [match] отримане повідомлення, а потім робимо щось в результаті.

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

  • він може надсилати одне або більше повідомлень іншим суб'єктам
  • він може створити нових дітей-акторів
  • він може вказати (можливо іншу) поведінку, яка буде застосована до наступного повідомлення

В Akka Typed API, Behavior несе відповідальність за обробку повідомлень, а також вказує на те, як слід обробляти наступне повідомлення - що можна реалізувати, повернувши наступний Behavior. Якщо нічого не змінюється в поведінці (як у наведеному вище прикладі), можна повернути той самий Behavior (саме тому ми повертаємо this в нашому прикладі, що у випадку об’єктно-орієнтованого API має сенс, оскільки екземпляр класу актора імплементує Behavior ).

Ми розповімо пізніше більше про Behavior протягом всієї серії статтей . Behavior є однією із найважливіших складових Akka Typed, і як ми побачимо далі, ці поведінки можна легко комбінувати і тестувати.

Якщо говорити про тестування, імлементований вище актор може бути легко протестований за допомогою Typed Akka TestKit. У поєднанні зі ScalaTest, таким чином можна побудувати тест-кейс [test case]:

class ConfigurationSpec extends ScalaTestWithActorTestKit with WordSpecLike {
 
  "The Configuration actor" should {
 
    "not find a configuration for an unknown merchant" in {
      // визначення probe, який дозволяє легко надсилати йому повідомлення
      val probe = createTestProbe[ConfigurationResponse]()
 
      // породження нового актора Configuration як дочірнього вартового актора TestKit
      val configurationActor = spawn(Configuration())
 
      // надсилання повідомлення акторові, що тестується, із посиланням probe як відправника
      configurationActor ! Configuration.RetrieveConfiguration(MerchantId("unknown"), probe.ref)
 
      // очікування певного типу повідомлення як відповіді. існує багато різних способів отримання
      // чи очікування повідомлень
      val response = probe.expectMessageType[Configuration.ConfigurationNotFound]
      response.merchanId shouldBe MerchantId("unknown")
    }
 
  }
 
}

Контроль(Supervising) та запуск актора

Актори не можуть працювати поодинці в ізоляції - вони є частиною Actor System, яка є середовищем, яке виділяє ресурси та забезпечує загальну інфраструктуру для існування та взаємодії акторів.

У рамках Akka Actor System кожен актор є нащадком іншого актора. Актор, що знаходиться в вершині цієї ієрархії, називається "кореневим вартовим" [root guardian (/)], його прямими нащадками є "користувацького вартового" [user guardian (/user)] для акторів, створених у просторі користувача, і "вартовий системи" [system guardian (/system)] для акторів, створених та керованих самим фреймворком Akka. Тому "шлях" або "адреса" [path] усіх акторів, яких ми будемо створювати, починається від /user.

Ми могли би йти далі і створити Configuration актора як нащадка "користувацького вартового" [user guardian (/user)] , але це не було б гарним рішенням - адже наш актор буде частиною системи нових акторів, в якій ці актори взаємодіють один з одним, і тому для цих акторів було б сенс мати якусь свою "координацію", якщо виникне проблема. Проблема полягає в тому, що в моделі акторів "батьківський нагляд" [parental supervision] (в ієрархії акторів) йде рука об руку з обробленням помилок - батько актора відповідає за рішення, що робити у випадку аварії нащадка (що він робить, якщо актор кидає exception), а отже групування акторів безпосередньо впливає на те, як можна обробляти помилки та збої.

Ієрахія актора - визначені користувачем актори зеленого кольору

Якщо ми створимо Configuration актор як нащадка "користувацького вартового" [user guardian (/user)], то у випадку збоїв рішення буде прийнято "за замовчуванням" - Configuration актор (або будь-хто з його "братів та сестер") буде просто перезапущений у випадку аварії. Хоча така поведінка працює досить добре у деяких випадках, може бути те, що "сліпий" перезапуск у всіх випадках, не зробить нічого доброго для деяких акторів, які ми плануємо створити. Таким чином, для того, щоб мати можливість контролювати нагляд [control supervision], ми збираємося створити актор PaymentProcessor, який буде батьківським для всіх компонентів, які ми створимо:

Ієрахія актора - визначені користувачем актори зеленого кольору

Сам по собі PaymentProcessor актор не має ніякої бізнес логіки як такої, окрім створення Configuration нащадка-актора, коли він запускається. Він не має стану і йому не потрібно обробляти повідомлення. Ми будемо використовувати цього разу функціональний стиль [functional style] Typed Actor API для його імплементації, тому замість розширення trait-у, ми створимо функцію, яка повертає Behavior:

object PaymentProcessor {
 
  def payment() = Behaviors.setup[Nothing] { context =>
    context.log.info("Typed Payment Processor started")
    context.spawn(Configuration(), "config")
    Behaviors.empty
  }
 
}

Метод Behaviors.setup є вхідною точкою для створення Behavior. Він надає ActorContext, який ми використовуватимемо для того щоб записати в лог факт запуску процесора та створення першого дочірнього Configuration актора методом spawn. Якщо ви не знайомі зі Scala, перший аргумент, переданий Configuration як методу, викличе метод apply() компаніон об'єкта [companion object] Configuration (див. Нижче), який і створює Behavior. Другий аргумент - ім’я актора, яке також використовується в "шляху" [path] (/user/payment/config). Зауважте, що ми викликали setup[Nothing] - і дійсно, PaymentProcessor актор не обробляє жодних повідомлень.

Для того, щоб породити Configuration актора-нащадка, нам потрібен його Behavior. Передаючи новий екземпляр самого класу Configuration, який є підкласом AbstractBehavior, ми цього не досягнемо - нам потрібно інкапсулювати цей Behavior в Behavior-налаштування таким чином:


object Configuration {
 
  def apply(): Behavior[ConfigurationMessage] = Behaviors.setup(context => new Configuration(context))
 
}

Тепер, коли працює супервізор, все, що нам потрібно для того, щоб почати роботу, - це ActorSystem, якій ми доручимо створити нашого актора найвищого рівня /user/payment. На щастя, існує factory method в ActorSystem, який стоворює актора найвищого рівня з "поведінки вартового" [guardian behavior]:


object Main extends App {
 
  override def main(args: Array[String]): Unit = {
    ActorSystem[Nothing](PaymentProcessor.payment(), "typed-payment-processor")
  }
 
}

І це все! Якщо ми зараз запустимо нашу програму, ми побачимо, як стартує PaymentProcessor:

[info] Running io.bernhardt.typedpayment.Main
[INFO] [07/10/2019 09:36:42.483] [typed-payment-processor-akka.actor.default-dispatcher-5] [akka://typed-payment-processor/user] Typed Payment Processor started

Для того, щоб побачити, як Configuration актор робить щось, нам знадобиться інший актор для взаємодії з ним, який ми створимо в наступній статті цієї серії.

Таблиця порівняння концептів

В кінці кожної статті ми матимемо невелику таблицю, в якій збиратимемо співвідношення знайомих понять класичного API з Typed API (це не означає, що це суворе відображення, думайте про це як про швидкий спосіб посилатися на Akka Typed API, якщо ви знайомі з класичним Akka Actor API).

Для цієї статті:

ActorRefActorRef[T]
extends Actorextends AbstractActor[T] (об'єктно-орієнтований стиль)
context.actorOfcontext.spawn
PropsBehaviors.setup
/user вартовий надається Akka/user вартовий надається користувачами, що передається як Behavior до ActorSystem
рішення стратегії нагляду за замовчуванням: перезапустити дочірнього актора при помилцірішення стратегії нагляду за замовчуванням: зупинити дочірнього актора при помилці

Це все для цієї першої частини огляду Akka Typed API! Код цієї статті можна знайти тут.

Слідкуй за CodeGalaxy

Мобільний додаток Beta

Get it on Google Play
Зворотній Зв’язок
Cosmo
Зареєструйся Зараз
або Підпишись на майбутні тести