Часть 9. Event Sourcing и CQRS.
Akka Persistence.

Страничка курса: https://maxcom.github.io/scala-course-2018/

План

  1. Статус по практическому заданию
  2. Event Sourcing
  3. Akka Persistence
  4. Снапшоты
  5. CQRS
  6. Akka Persistence Query

План задания

  1. Классификатор текстов
  2. Reads для vk.com (только reads! writes не нужно)
  3. Стемминг и диагностика
  4. Сервис категоризации
  5. Оценка сообщений соц. сетей - API vk.com
  6. Опрос новых записей
  7. хранение состояния на диске

Event Sourcing

"Идеальная жизнь" с реляционной СУБД

  • Данные нормализованы
  • Ссылочная целостность и другие constraint'ы
  • ACID транзакции
    (Atomicity, Consistency, Isolation, Durability)

Можно ли доверять значению в ячейке?

Например количеству денег на счете в банке?

Нет, правда только в журнале операций над счетом.

Значение в БД - просто cache, вычисленный из журнала.

Еще пример: типичный компонент "enterprise" системы

  • Разнообразные "бизнес"-сущности моделированы в виде табличек БД
  • Формочки для редактирования и прочий CRUD

Этапы развития:

  • Версия 1 - кодируем модели, формочки; запускаем в работу
  • Версия 2 - удаление через флаг вместо DELETE
  • Версия 3 - отслеживаем дату последнего исправления и имя редактора
  • Версия 4 - полная история изменений

История может быть двух видов:

  • Техническая - создается автоматически; не понятна пользователю
  • "Бизнес"-история - пишется "руками" одновременно с update

Event Sourcing - храним все события, используем их как основной источник "правды".

Событие - неизменяемый уже произошедший факт.

Что это дает:

  • Полную "честную" историю изменений
  • Запись append-only: хорошо для многих видов хранилищ
  • Возможность записать дополнительные сведения

Есть две проблемы:

  • Истории может быть слишком много
  • Не понятно как быстро читать и делать запросы

Решения обоих проблем дальше

Akka Persistence

PersistentActor - актор, который сохраняет события в журнал. При рестарте он восстанавливает состояние из журнала.

build.sbt


// версии всех akka-* должны быть одинаковые
libraryDependencies += 
  "com.typesafe.akka" %% "akka-persistence" % "2.5.12"
					

Еще нужен плагин для storage. Обычно используют Cassandra, для отладки проще LevelDB


// плагин встроен, нужна только сама LevelDB
libraryDependencies += 
  "org.fusesource.leveldbjni" % "leveldbjni-all" % "1.8"
					

Команда - запрос актору на выполнение действия.

Команда проходит валидацию, сохраняет событие и выполняет побочный эффект.

class CounterActor extends PersistentActor {
  override def persistenceId: String = "my-counter-actor"

  private var value: Int = 0

  override def receiveCommand: Receive = {
    case Increment(n) ⇒ persist(Incremented(n)) { event ⇒ 
      // callback
      value += event.n
      sender() ! Ack
    }
  }

  override def receiveRecover: Receive = ???
}

persist асинхронный, но для нас он делает:

  • stash, так что события не приходят пока идет запись
  • callback запускается в receive актора, имеет доступ к state
  • sender() соответствует отправителю команды

receiveRecover - восстанавливает актор; не делает побочных эффектов


override def receiveCommand: Receive = {
  case Increment(n) ⇒ // команда
    persist(Incremented(n)) { event ⇒
      value += event.n
      sender() ! Ack
    }
}

override def receiveRecover: Receive = {
  case Incremented(n) ⇒ // событие
    value += n // не отправляем Ack
}
					

Снапшоты

Проблема: много событий и восстановление слишком долгое.

Решение - периодически записывать снапшоты.

// case class State(value: Int)
// val SnapshotInterval = 10000
					
  override def receiveCommand: Receive = {
    case Increment(n) ⇒ // команда
      persist(Incremented(n)) { event ⇒
        value += event.n
        sender() ! Ack

        if (lastSequenceNr % SnapshotInterval == 0 
	    && lastSequenceNr != 0)
          saveSnapshot(State(value))  
      }
  } 

Восстановление:


  override def receiveRecover: Receive = {
    case Incremented(n) ⇒ 
      value += n 
    case SnapshotOffer(metadata, snapshot: State) ⇒ 
      value = snapshot.value
  }
					

восстановление по-умолчанию стартует с последнего снапшота (можно настроить разные варианты)

Что если история слишком большая и не нужна?


    case SaveSnapshotSuccess(metadata) ⇒
      deleteMessages(metadata.sequenceNr)
					

это вариант для "технических" акторов, которым просто нужно восстановление при рестарте

CQRS

command-query responsibility segregation

Проблема: реалиционные БД универсальны, но они не всегда могут быстро выполнить запрос.

И на практике могут выполнить не любой запрос (например не могут полнотекстовый поиск).

В итоге появляются:

  • Производные данные в БД (предрассчитанные аггрегаты, значения из связанных таблиц, и другие варианты денормализации)
  • Внешние системы: поиск, аналитика, кеши и т.п.

Принцим CQRS - четкое разделение на две части:

  • Write side - валидирует и выполняет команды
  • Read side - только читает данные.

Каждая часть

  • Имеет свое хранилище
  • Имеет свои программные интерейсы

© A CQRS Journey – Microsoft

Если Write Side - реляционная СУБД, то нам нужны:

  • надежная очередь (транзакции, durability)
  • записывать все события в БД и в очередь

Event sourcing + CQRS:

  • Write Side: пишет события в журнал
  • Read Side: дополнительные consumer'ы журнала

Akka Persistence Query

build.sbt


// версии всех akka-* должны быть одинаковые
libraryDependencies += 
  "com.typesafe.akka" %% "akka-persistence-query" % "2.5.12"
  					

Поток событий - реактивный поток


// создаем один раз на приложение
val queries: LeveldbReadJournal = 
  PersistenceQuery(system).
    readJournalFor[LeveldbReadJournal]
      (LeveldbReadJournal.Identifier)

// получаем новый поток событий
val events: Source[EventEnvelope, NotUsed] =
  queries.currentEventsByPersistenceId("my-counter-actor", 
                                       fromSequenceNr = 0)

// нам интересно env.event и env.sequenceNr
					

Что можно делать с потоком событий (примеры)?

  • Строить актор с выдержкой из текущего состояния
  • Перекачивать во внешние хранилище (индекс, БД, т.п.)
  • Считать статистику над потоком событий
  • и др.

Read side обновляется асинхронно.

Иногда нужно прочитать результат после записи.

Можно при записи отдать sequenceNq пользователю.

После реализовать логику ожидания нужного номера в view.

Напоминаю: