Akka Streams и реактивные потоки

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

План

  1. Реактивные потоки
  2. Akka Streams: базовые элементы
  3. Обзор готовых операторов
  4. Обработка ошибок
  5. Интеграция

Реактивные потоки

Преобразования потоков данных.

  • ETL - Extract, Transform, Load:
    загрузка данных, индексы, перебалансировка хранилищ
  • Поточные запросы и ответы HTTP, прокси:
    отдача больших объемов или медленная отдача по готовности
  • Выборки, статистики, верификация данных
    (пока не возникает сложная математика)

Асинхронная обработка

  • Возможности сложных схем
    распараллеливание, группировки в batch, троттлинг
  • Работа со сбоями и отзывчивость
  • Эффективность при большом числе потоков

Проблемы, которые нужно решить:

  • Регулировка скорости producer
  • API для интеграции и композиции

При синхронной обработке скорость
регулируется автоматически.

Back pressure можно делать на акторах, но сложно.

Не делаем сложные протоколы, используем Streams

Reactive streams - API для интеграции,
не для конечного пользователя.

https://www.reactive-streams.org/

Входит в JDK 9+

  • Akka Streams и Akka HTTP (+Play)
  • FS2 - стримы экосистемы Cats
  • Netty - http клиенты, драйверы СУБД и очередей
  • Spring 5, ряд веб-серверов
  • RxJava
  • Vert.x

В идеальном мире нам нужно писать только преобразование.

Позже посмотрим интеграцию с другим асинхронным кодом.

Akka Streams: базовые элементы

  • Source - источник (producer)
  • Flow - цепочка преобразований
  • Sink - назначение (consumer)

https://doc.akka.io/docs/akka/current/stream/stream-composition.html

Два API для сборки цепочек:

  • Обычный, похожий на API коллекций
  • GraphDSL для сложных схем
    (циклы, расщепление и дублирование потоков и т.п.)

Подключаем в проект


libraryDependencies += 
  "com.typesafe.akka" %% "akka-stream" % "2.6.19"
					

версия должна соответствовать версии Akka

можно использовать в приложениях без акторов


// не путаем с javadsl
import akka.stream.scaladsl._
					

Пример - замена Future.sequence


def func(x: Int): Future[Int] = ???
val input: Seq[Int] = Seq.range(1, 10000)

val output: Future[Seq[Int]] = Future.sequence(input.map(func))
					

проблема - неограниченный параллелизм, перегрузка ExecutionContext


// классическая ActorSystem (можно и typed)
implicit val actorSystem: ActorSystem = ActorSystem()

def func(x: Int): Future[Int] = ???
val input: List[Int] = List.range(1, 10000)

val output: Future[Seq[Int]] = 
  Source(input).mapAsync(10)(func).runWith(Sink.seq)
                      // 10 параллельных запусков
					

Materialization - запуск интерпретации. Включает оптимизации.


val source: Source[Int, NotUsed] = Source(input)
// final class Source[+Out, +Mat]
					

Mat - materialized value, возникает при запуске

может быть у любого компонента,
но обычно у Source/Sink

Пример Source с materialized value

val source: Source[Int, SourceQueueWithComplete[Int]] =
  Source.queue[Int](bufferSize = 1000, 
                    OverflowStrategy.backpressure)

очередь возникает при материализации


val (queue, result): 
    (SourceQueueWithComplete[Int], Future[Done]) =
  source
    .toMat(Sink.foreach(println))(Keep.both)
    .run()
					

Разбираем на компоненты


val source: Source[Int, NotUsed] = Source(List.range(1, 10000))
val flow: Flow[Int, Int, NotUsed] = Flow[Int].mapAsync(10)(func)
val sink: Sink[Int, Future[Seq[Int]]] = Sink.seq[Int]

val output: Future[Seq[Int]] = source.via(flow).runWith(sink)
					

компоненты можно переиспользовать

Обзор готовых операторов

Более 100 операторов

Работа с синхронными функциями

  • map[T](f: Out => T)
  • mapConcat[T](f: Out => immutable.Iterable[T])
    (это не flatMap!)
  • filter(p: Out => Boolean)

Работа с асинхронными функциями

  • mapAsync[T](parallelism: Int)(f: Out => Future[T])
  • mapAsyncUnordered[T](parallelism: Int)(f: Out => Future[T])

flatMap?

  • flatMapConcat[T, M](f: Out => Graph[SourceShape[T], M])
  • flatMapMerge[T, M](breadth: Int, f: Out => Graph[SourceShape[T], M])

Группировки

  • grouped(n: Int)
  • groupedWithin(n: Int, d: FiniteDuration)

Группировка:


def processBatch(v: Seq[Int]): Future[Seq[Int]] = ???

source
  .groupedWithin(1000, 1 minute) // до 1000, в течении минуты
  .mapAsync(16)(processBatch)
  .mapConcat(identity) // поток Seq[Int] в поток Int
  .runWith(Sink.ignore)
					

обработка "пачками" часто эффективнее

Ограничение скорости

throttle(elements: Int, per: FiniteDuration)

есть еще аналоги с "весом" элемента

Несколько источников:


val source1 = ???
val source2 = ???

// один за другим
source1.concat(source2)

// по 10 из каждого по порядку
source1.interleave(source2, 10)

// в порядке готовности
source1.intersperse(source2)
					

Sink

  • ignore: Sink[Any, Future[Done]]
  • seq[T]: Sink[T, Future[Seq[T]]]
    (еще есть Sink.collection)
  • foreach[T](f: T => Unit): Sink[T, Future[Done]]
  • fold[U, T](zero: U)(f: (U, T) => U): Sink[T, Future[U]]
    (еще есть Sink.foldAsync)

Обработка ошибок

Stream завершается при возникновении ошибки.

Можно работать с Try в потоке.

Рестарт при сбоях


RestartSource.onFailuresWithBackoff(
  RestartSettings(
    minBackoff = 100 millis, 
    maxBackoff = 10 minutes, 
    randomFactor = 0.2))(() => source)
					

бывают еще RestartFlow и RestartSink

val source: Source[Int, SourceQueueWithComplete[Int]] =
  Source.queue[Int](bufferSize = 1000,
    OverflowStrategy.backpressure)

val (queue, newSource) = source.preMaterialize()

val func: (Int => Future[Int]) = ???

val safeSource = RestartSource.onFailuresWithBackoff(
  RestartSettings(
  minBackoff = 100.millis,
  maxBackoff = 10.minutes,
  randomFactor = 0.2))(() => newSource.mapAsync(1)(func))

Пример: поточная отдача JSON в Akka HTTP

(подробнее в 12-лекции курса 2020)


get {
  val source: Source[Data, NotUsed] = getDatabaseStream(user)
  
  complete(source) // порождает Json массив
}
					

что с ошибками?

Ошибки вместо пустых ответов:


def dataOrFail[T]
    (source: Source[T, NotUsed]): Future[Source[T, NotUsed]] = {
  source
    .prefixAndTail(1) // дает Source из одного элемента
    .runWith(Sink.head) 
    .map { case (first, rest) => // (Seq[T], Source[T, NotUsed]
      Source(first).concat(rest)
    }
}
					

(future падает при ошибке получения 1-го элемента)

Интеграция

  • Ищем готовые решения,
    например Alpakka
  • HTTP клиент (Akka HTTP, play-ws и др.)
  • Reactive Streams
  • Самостоятельная реализация

Интеграция через Source.queue

val source: Source[Int, SourceQueueWithComplete[Int]] =
  Source.queue[Int](bufferSize = 1000, 
                    OverflowStrategy.backpressure)
// dropHead | dropTail | dropBuffer | fail
val queue: SourceQueueWithComplete[Int] = source
                  .to(Sink.foreach(println))
                  .run() 
// очередь - "материализованное" значение
val result: Future[QueueOfferResult] = queue.offer(1000)
// при backpressure нужно ждать вычисления Future

Source.unfold


// числа Фибоначчи
val fib: Source[Int, NotUsed] = Source.unfold(0 -> 1) {
  case (a, _) if a > 10000000 => 
    None
  case (a, b) => 
    Some((b -> (a + b)) -> a)
}
					

есть асинхронный аналог - unfoldAsync
(Пример: scroll запросы к Elasticsearch)

Интеграция с акторами


// для typed акторов

libraryDependencies += 
  "com.typesafe.akka" %% "akka-stream-typed" % "2.6.19"
					
  • ActorSource.actorRef - без backpressure, аналог очереди
  • ActorSink.actorRef
  • ActorSource.actorRefWithBackpressure
  • ActorSink.actorRefWithBackpressure

ActorSink


// T - тип значения
// M - протокол актора
// A - тип Ack
def actorRefWithBackpressure[T, M, A](
    ref: ActorRef[M],
    messageAdapter: (ActorRef[A], T) => M,
    onInitMessage: ActorRef[A] => M,
    ackMessage: A,
    onCompleteMessage: M,
    onFailureMessage: Throwable => M): Sink[T, NotUsed]
					

стрим наблюдает за жизнью актора

Напоминаю: