Как начать работу с Akka Streams?

В библиотеке Akka Streams уже имеется довольно богатая документация . Однако главная проблема для меня заключается в том, что она предоставляет слишком много материала - я чувствую себя совершенно ошеломленным количеством понятий, которые мне нужно изучить. Множество примеров, показанных там, чувствуют себя очень тяжеловесными и не могут быть легко переведены в реальные случаи использования в мире и поэтому весьма эзотеричны. Я думаю, что это дает слишком много деталей, не объясняя, как объединить все строительные блоки и как именно это помогает решить конкретные проблемы.

Есть источники, стоки, потоки, этапы графа, частичные графики, материализация, график DSL и многое другое, и я просто не знаю с чего начать. руководство по быстрому запуску должно быть начальным, но я этого не понимаю. Он просто бросает в понятия, упомянутые выше, не объясняя их. Кроме того, примеры кода не могут быть выполнены - отсутствуют недостающие части, что делает более или менее невозможным для меня следовать тексту.

Может ли кто-нибудь объяснить истоки источников, потоки, потоки, этапы графа, частичные графики, материализацию и, возможно, некоторые другие вещи, которые я пропустил простыми словами и с легкими примерами, которые не объясняют каждую деталь (и которые, вероятно, не являются необходимо в любом случае в начале)?

Ответ 1

Этот ответ основан на akka-stream версии 2.4.2. API может немного отличаться в других версиях. Зависимость может потребляться sbt:

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

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

import scala.concurrent._
import akka._
import akka.actor._
import akka.stream._
import akka.stream.scaladsl._
import akka.util._

implicit val system = ActorSystem("TestSystem")
implicit val materializer = ActorMaterializer()
import system.dispatcher

Операторы import необходимы для объявлений типа. system представляет собой актерскую систему Akka и materializer представляет собой контекст оценки потока. В нашем случае мы используем ActorMaterializer, что означает, что потоки оцениваются поверх участников. Оба значения отмечены как implicit, что дает компилятору Scala возможность автоматически вставлять эти две зависимости, когда это необходимо. Мы также импортируем system.dispatcher, который является контекстом выполнения для Futures.

Новый API

Потоки Akka имеют следующие ключевые свойства:

  • Они реализуют спецификацию Реактивные потоки, три противодавления которых, асинхронные и неблокирующие границы, а также совместимость между различными реализациями полностью применяются для потоков Akka тоже.
  • Они предоставляют абстракцию для механизма оценки потоков, который называется materializer.
  • Программы формулируются как многоразовые строительные блоки, которые представлены в виде трех основных типов Source, Sink и Flow. Строительные блоки образуют график, оценка которого основана на materializer и должна быть явно вызвана.

Далее следует дать более глубокое введение в том, как использовать три основных типа.

Источник

A Source является создателем данных, он служит источником входных данных для потока. Каждый Source имеет единственный выходной канал и не имеет входного канала. Все данные передаются через выходной канал на все, что связано с Source.

Source

Изображение взято из boldradius.com.

A Source может быть создан несколькими способами:

scala> val s = Source.empty
s: akka.stream.scaladsl.Source[Nothing,akka.NotUsed] = ...

scala> val s = Source.single("single element")
s: akka.stream.scaladsl.Source[String,akka.NotUsed] = ...

scala> val s = Source(1 to 3)
s: akka.stream.scaladsl.Source[Int,akka.NotUsed] = ...

scala> val s = Source(Future("single value from a Future"))
s: akka.stream.scaladsl.Source[String,akka.NotUsed] = ...

scala> s runForeach println
res0: scala.concurrent.Future[akka.Done] = ...
single value from a Future

В приведенных выше случаях мы кормили Source конечными данными, что означает, что они в конечном итоге прекратятся. Не следует забывать, что реактивные потоки по умолчанию ленивы и асинхронны. Это означает, что нужно явно запросить оценку потока. В потоках Akka это можно сделать с помощью методов run*. runForeach не будет отличаться от хорошо известной функции foreach - с помощью добавления run он делает явным, что мы запрашиваем оценку потока. Поскольку конечные данные скучны, мы продолжаем бесконечное:

scala> val s = Source.repeat(5)
s: akka.stream.scaladsl.Source[Int,akka.NotUsed] = ...

scala> s take 3 runForeach println
res1: scala.concurrent.Future[akka.Done] = ...
5
5
5

С помощью метода take мы можем создать искусственную точку остановки, которая не позволяет нам оценивать неопределенно. Поскольку поддержка актера встроена, мы также можем легко передать поток сообщениям, которые отправляются актеру:

def run(actor: ActorRef) = {
  Future { Thread.sleep(300); actor ! 1 }
  Future { Thread.sleep(200); actor ! 2 }
  Future { Thread.sleep(100); actor ! 3 }
}
val s = Source
  .actorRef[Int](bufferSize = 0, OverflowStrategy.fail)
  .mapMaterializedValue(run)

scala> s runForeach println
res1: scala.concurrent.Future[akka.Done] = ...
3
2
1

Мы видим, что Futures выполняется асинхронно на разных потоках, что объясняет результат. В приведенном выше примере буфер для входящих элементов не требуется, и поэтому с OverflowStrategy.fail мы можем настроить, что поток должен завершиться с ошибкой при переполнении буфера. Особенно через этот интерфейс актера мы можем передавать поток через любой источник данных. Неважно, если данные создаются одним и тем же потоком другим, другим процессом или если они поступают из удаленной системы через Интернет.

Раковина

A Sink в основном противоположно a Source. Это конечная точка потока и поэтому потребляет данные. A Sink имеет единственный входной канал и выходной канал. Sinks особенно необходимы, когда мы хотим указать поведение сборщика данных многоразовым способом и без оценки потока. Уже известные методы run* не позволяют нам этих свойств, поэтому вместо этого рекомендуется использовать Sink.

Sink

Изображение взято из boldradius.com.

Краткий пример действия Sink:

scala> val source = Source(1 to 3)
source: akka.stream.scaladsl.Source[Int,akka.NotUsed] = ...

scala> val sink = Sink.foreach[Int](elem => println(s"sink received: $elem"))
sink: akka.stream.scaladsl.Sink[Int,scala.concurrent.Future[akka.Done]] = ...

scala> val flow = source to sink
flow: akka.stream.scaladsl.RunnableGraph[akka.NotUsed] = ...

scala> flow.run()
res3: akka.NotUsed = NotUsed
sink received: 1
sink received: 2
sink received: 3

Подключение Source к Sink может быть выполнено с помощью метода to. Он возвращает так называемый RunnableFlow, который будет таким, как мы увидим позже, специальную форму Flow - поток, который может быть выполнен путем вызова его метода run().

Runnable Flow

Изображение взято из boldradius.com.

Конечно, можно переслать все значения, которые приходят к раковине актеру:

val actor = system.actorOf(Props(new Actor {
  override def receive = {
    case msg => println(s"actor received: $msg")
  }
}))

scala> val sink = Sink.actorRef[Int](actor, onCompleteMessage = "stream completed")
sink: akka.stream.scaladsl.Sink[Int,akka.NotUsed] = ...

scala> val runnable = Source(1 to 3) to sink
runnable: akka.stream.scaladsl.RunnableGraph[akka.NotUsed] = ...

scala> runnable.run()
res3: akka.NotUsed = NotUsed
actor received: 1
actor received: 2
actor received: 3
actor received: stream completed

Поток

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

Flow

Изображение взято из boldradius.com.

Если a Flow связано с a Source, то получается новый Source. Аналогично, a Flow, связанный с a Sink, создает новый Sink. А Flow, связанный с a Source и a Sink, приводит к RunnableFlow. Поэтому они располагаются между входным и выходным каналами, но сами по себе не соответствуют одному из вкусов, если они не подключены ни к a Source, ни к Sink.

Full Stream

Изображение взято из boldradius.com.

Чтобы лучше понять Flows, мы рассмотрим несколько примеров:

scala> val source = Source(1 to 3)
source: akka.stream.scaladsl.Source[Int,akka.NotUsed] = ...

scala> val sink = Sink.foreach[Int](println)
sink: akka.stream.scaladsl.Sink[Int,scala.concurrent.Future[akka.Done]] = ...

scala> val invert = Flow[Int].map(elem => elem * -1)
invert: akka.stream.scaladsl.Flow[Int,Int,akka.NotUsed] = ...

scala> val doubler = Flow[Int].map(elem => elem * 2)
doubler: akka.stream.scaladsl.Flow[Int,Int,akka.NotUsed] = ...

scala> val runnable = source via invert via doubler to sink
runnable: akka.stream.scaladsl.RunnableGraph[akka.NotUsed] = ...

scala> runnable.run()
res10: akka.NotUsed = NotUsed
-2
-4
-6

С помощью метода via мы можем подключить Source с помощью Flow. Нам нужно указать тип ввода, потому что компилятор не может сделать это для нас. Как мы уже можем видеть в этом простом примере, потоки invert и double полностью независимы от любых производителей данных и потребителей. Они только преобразуют данные и перенаправляют их на выходной канал. Это означает, что мы можем повторно использовать поток из нескольких потоков:

scala> val s1 = Source(1 to 3) via invert to sink
s1: akka.stream.scaladsl.RunnableGraph[akka.NotUsed] = ...

scala> val s2 = Source(-3 to -1) via invert to sink
s2: akka.stream.scaladsl.RunnableGraph[akka.NotUsed] = ...

scala> s1.run()
res10: akka.NotUsed = NotUsed
-1
-2
-3

scala> s2.run()
res11: akka.NotUsed = NotUsed
3
2
1

s1 и s2 представляют собой совершенно новые потоки - они не передают никакие данные через свои строительные блоки.

Неограниченные потоки данных

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

Показывает, что поток представляет собой последовательность текущих событий, упорядоченных по времени

Изображение, взятое из Введение в реактивное программирование, которое вы отсутствовали.

Мы уже видели текущие потоки в примерах предыдущего раздела. Мы получаем a RunnableGraph всякий раз, когда поток фактически может быть материализован, а это означает, что a Sink связано с a Source. До сих пор мы всегда материализовались со значением Unit, которое можно увидеть в типах:

val source: Source[Int, NotUsed] = Source(1 to 3)
val sink: Sink[Int, Future[Done]] = Sink.foreach[Int](println)
val flow: Flow[Int, Int, NotUsed] = Flow[Int].map(x => x)

Для Source и Sink параметра второго типа и для Flow параметр третьего типа обозначает материализованное значение. Во всем этом ответе полное объяснение материализации не объясняется. Однако дальнейшие подробности о материализации можно найти в официальной документации . Пока что нам нужно знать только то, что материализованная ценность - это то, что мы получаем, когда запускаем поток. Поскольку нас интересовали только побочные эффекты, мы получили Unit как материализованное значение. Исключением для этого была материализация раковины, в результате которой был Future. Это дало нам Future, так как это значение может означать, когда поток, который подключен к приемнику, был завершен. До сих пор предыдущие примеры кода были хорошими, чтобы объяснить концепцию, но они также были скучными, потому что мы рассматривали только конечные потоки или очень простые бесконечные. Чтобы сделать его более интересным, в дальнейшем должен быть объяснен полный асинхронный и неограниченный поток.

Пример ClickStream

В качестве примера мы хотим иметь поток, который захватывает события кликов. Чтобы сделать его более сложным, скажем, мы также хотим группировать события щелчка, которые происходят через короткое время после друг друга. Таким образом, мы могли бы легко обнаружить двойные, тройные или десятикратные клики. Кроме того, мы хотим отфильтровать все отдельные клики. Сделайте глубокий вдох и представьте, как вы решительно решаете эту проблему. Бьюсь об заклад, никто не сможет реализовать решение, которое работает правильно с первой попытки. Реактивным способом эта проблема тривиальна. На самом деле решение настолько просто и просто реализовать, что мы можем даже выразить его на диаграмме, которая непосредственно описывает поведение кода:

Логика примера потока кликов

Изображение, взятое из Введение в реактивное программирование, которое вы отсутствовали.

Серые прямоугольники - это функции, описывающие, как один поток преобразуется в другой. С помощью функции throttle мы накапливаем клики в течение 250 миллисекунд, функции map и filter должны быть понятны. Цветные шары представляют собой событие, а стрелки изображают, как они текут через наши функции. Позже на этапах обработки мы получаем все меньше и меньше элементов, которые текут через наш поток, поскольку мы группируем их вместе и отфильтровываем. Код для этого изображения будет выглядеть примерно так:

val multiClickStream = clickStream
    .throttle(250.millis)
    .map(clickEvents => clickEvents.length)
    .filter(numberOfClicks => numberOfClicks >= 2)

Вся логика может быть представлена ​​только в четырех строках кода! В Scala мы могли бы записать его еще короче:

val multiClickStream = clickStream.throttle(250.millis).map(_.length).filter(_ >= 2)

Определение clickStream немного сложнее, но это происходит только потому, что примерная программа работает на JVM, где захват событий щелчка не представляется возможным. Еще одно осложнение заключается в том, что Akka по умолчанию не предоставляет функцию throttle. Вместо этого мы должны были написать это сами. Поскольку эта функция (как и в случае с функциями map или filter) многократно используется в разных вариантах использования, я не считаю эти строки числом строк, необходимых для реализации логики. Однако на императивных языках логика не может быть легко использована повторно и что разные логические шаги происходят в одном месте вместо того, чтобы применяться последовательно, а это значит, что мы, вероятно, сменили бы наш код с помощью дроссельной логики. Полный пример кода доступен как gist и не обсуждается здесь дальше.

Пример SimpleWebServer

То, что следует обсуждать, является другим примером. Хотя поток кликов - хороший пример, позволяющий Akka Streams обрабатывать пример реального мира, ему не хватает возможности показать параллельное выполнение в действии. Следующий пример должен представлять собой небольшой веб-сервер, который может обрабатывать несколько запросов параллельно. Веб-сервер должен принимать входящие соединения и принимать последовательности байтов из них, которые представляют собой печатные знаки ASCII. Эти байтовые последовательности или строки должны быть разделены на все символы новой строки на более мелкие части. После этого сервер должен ответить клиенту каждой из разделенных строк. В качестве альтернативы, он может сделать что-то еще с линиями и дать специальный токен ответа, но мы хотим сохранить его простым в этом примере и, следовательно, не вводить никаких причудливых функций. Помните, что сервер должен иметь возможность обрабатывать несколько запросов одновременно, что в основном означает, что никому не разрешается блокировать любой другой запрос от дальнейшего выполнения. Решение всех этих требований может быть сложным по-настоящему - с потоками Akka, однако нам не нужно больше нескольких строк для решения любого из этих вопросов. Во-первых, дайте обзор самому серверу:

server

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

def mkServer(address: String, port: Int)(implicit system: ActorSystem, materializer: Materializer): Unit = {
  import system.dispatcher

  val connectionHandler: Sink[Tcp.IncomingConnection, Future[Unit]] =
    Sink.foreach[Tcp.IncomingConnection] { conn =>
      println(s"Incoming connection from: ${conn.remoteAddress}")
      conn.handleWith(serverLogic)
    }

  val incomingCnnections: Source[Tcp.IncomingConnection, Future[Tcp.ServerBinding]] =
    Tcp().bind(address, port)

  val binding: Future[Tcp.ServerBinding] =
    incomingCnnections.to(connectionHandler).run()

  binding onComplete {
    case Success(b) =>
      println(s"Server started, listening on: ${b.localAddress}")
    case Failure(e) =>
      println(s"Server could not be bound to $address:$port: ${e.getMessage}")
  }
}

Функция mkServer принимает (помимо адреса и порта сервера) также актерскую систему и материализатор как неявные параметры. Поток управления сервером представлен binding, который берет источник входящих соединений и пересылает их в приемник входящих соединений. Внутри connectionHandler, который является нашей раковиной, мы обрабатываем каждое соединение потоком serverLogic, который будет описан ниже. binding возвращает a Future, который завершается, когда сервер был запущен или сбой запуска, что может быть в случае, когда порт уже занят другим процессом. Однако код не полностью отражает графику, так как мы не можем видеть строительный блок, который обрабатывает ответы. Причина этого в том, что соединение уже обеспечивает эту логику само по себе. Это двунаправленный поток, а не только однонаправленный, как потоки, которые мы видели в предыдущих примерах. Как это было в случае материализации, такие сложные потоки здесь не объясняются. официальная документация содержит множество материалов для охвата более сложных графиков потока. На данный момент достаточно знать, что Tcp.IncomingConnection представляет собой соединение, которое знает, как получать запросы и как отправлять ответы. Часть, которая все еще отсутствует, представляет собой строительный блок serverLogic. Это может выглядеть так:

логика сервера

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

val serverLogic: Flow[ByteString, ByteString, Unit] = {
  val delimiter = Framing.delimiter(
    ByteString("\n"),
    maximumFrameLength = 256,
    allowTruncation = true)

  val receiver = Flow[ByteString].map { bytes =>
    val message = bytes.utf8String
    println(s"Server received: $message")
    message
  }

  val responder = Flow[String].map { message =>
    val answer = s"Server hereby responds to message: $message\n"
    ByteString(answer)
  }

  Flow[ByteString]
    .via(delimiter)
    .via(receiver)
    .via(responder)
}

Мы уже знаем, что serverLogic - поток, который принимает a ByteString и должен произвести a ByteString. С delimiter мы можем разделить ByteString на более мелкие части - в нашем случае это должно происходить всякий раз, когда возникает символ новой строки. receiver - это поток, который принимает все последовательности разделенных байтов и преобразует их в строку. Это, конечно, опасное преобразование, поскольку только печатные символы ASCII должны быть преобразованы в строку, но для наших нужд это достаточно хорошо. responder является последним компонентом и отвечает за создание ответа и преобразование ответа обратно в последовательность байтов. В отличие от графика, мы не разделили этот последний компонент на два, так как логика тривиальна. В конце мы соединяем все потоки через функцию via. На этом этапе можно спросить, заботились ли мы о многопользовательской собственности, о которой упоминалось в начале. И действительно, мы это сделали, хотя это может быть не сразу. Посмотрев на эту графику, она должна стать более понятной:

объединена серверная и серверная логика

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

val serverLogic = Flow[ByteString]
  .via(Framing.delimiter(
      ByteString("\n"),
      maximumFrameLength = 256,
      allowTruncation = true))
  .map(_.utf8String)
  .map(msg => s"Server hereby responds to message: $msg\n")
  .map(ByteString(_))

Тест веб-сервера может выглядеть следующим образом:

$ # Client
$ echo "Hello World\nHow are you?" | netcat 127.0.0.1 6666
Server hereby responds to message: Hello World
Server hereby responds to message: How are you?

Чтобы приведенный выше пример кода работал правильно, сначала нужно запустить сервер, который изображен startServer script:

$ # Server
$ ./startServer 127.0.0.1 6666
[DEBUG] Server started, listening on: /127.0.0.1:6666
[DEBUG] Incoming connection from: /127.0.0.1:37972
[DEBUG] Server received: Hello World
[DEBUG] Server received: How are you?

Здесь приведен полный пример этого простого TCP-сервера здесь. Мы можем не только писать сервер с потоками Akka, но и клиентом. Это может выглядеть так:

val connection = Tcp().outgoingConnection(address, port)
val flow = Flow[ByteString]
  .via(Framing.delimiter(
      ByteString("\n"),
      maximumFrameLength = 256,
      allowTruncation = true))
  .map(_.utf8String)
  .map(println)
  .map(_ ⇒ StdIn.readLine("> "))
  .map(_+"\n")
  .map(ByteString(_))

connection.join(flow).run()

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

Сложные графики

В предыдущих разделах мы видели, как мы можем построить простые программы из потоков. Однако на самом деле часто недостаточно просто полагаться на уже встроенные функции для построения более сложных потоков. Если мы хотим использовать потоки Akka для произвольных программ, нам нужно знать, как создавать собственные настраиваемые структуры управления и комбинируемые потоки, которые позволяют нам решать сложность наших приложений. Хорошей новостью является то, что Akka Streams был разработан с учетом потребностей пользователей и для краткого ознакомления с более сложными частями потоков Akka, мы добавили еще несколько возможностей для нашего примера клиент/сервер.

Единственное, что мы не можем сделать, это закрыть соединение. На этом этапе он начинает немного усложняться, потому что API-интерфейс потока, который мы видели до сих пор, не позволяет остановить поток в произвольной точке. Тем не менее, существует абстракция GraphStage, которая может использоваться для создания произвольных этапов обработки графа с любым количеством входных или выходных портов. Давайте сначала посмотрим на серверную сторону, где мы вводим новый компонент, называемый closeConnection:

val closeConnection = new GraphStage[FlowShape[String, String]] {
  val in = Inlet[String]("closeConnection.in")
  val out = Outlet[String]("closeConnection.out")

  override val shape = FlowShape(in, out)

  override def createLogic(inheritedAttributes: Attributes) = new GraphStageLogic(shape) {
    setHandler(in, new InHandler {
      override def onPush() = grab(in) match {
        case "q" ⇒
          push(out, "BYE")
          completeStage()
        case msg ⇒
          push(out, s"Server hereby responds to message: $msg\n")
      }
    })
    setHandler(out, new OutHandler {
      override def onPull() = pull(in)
    })
  }
}

Этот API выглядит намного более громоздким, чем API потока. Неудивительно, что здесь мы должны сделать много императивных шагов. Взамен мы больше контролируем поведение наших потоков. В приведенном выше примере мы указываем только один вход и один выходной порт и делаем их доступными для системы, переопределяя значение shape. Кроме того, мы определили так называемые InHandler и a OutHandler, которые в этом порядке отвечают за прием и излучение элементов. Если вы внимательно посмотрите на полный поток потоков, вы уже должны распознать эти компоненты. В InHandler мы захватываем элемент, и если это строка с единственным символом 'q', мы хотим закрыть поток. Чтобы дать клиенту возможность узнать, что поток скоро будет закрыт, мы испускаем строку "BYE", а затем сразу же закрываем сцену. Компонент closeConnection может быть объединен с потоком через метод via, который был введен в разделе о потоках.

Помимо возможности закрыть соединения, было бы неплохо, если бы мы могли показать приветственное сообщение для вновь созданного соединения. Чтобы сделать это, мы снова должны пойти немного дальше:

def serverLogic
    (conn: Tcp.IncomingConnection)
    (implicit system: ActorSystem)
    : Flow[ByteString, ByteString, NotUsed]
    = Flow.fromGraph(GraphDSL.create() { implicit b ⇒
  import GraphDSL.Implicits._
  val welcome = Source.single(ByteString(s"Welcome port ${conn.remoteAddress}!\n"))
  val logic = b.add(internalLogic)
  val concat = b.add(Concat[ByteString]())
  welcome ~> concat.in(0)
  logic.outlet ~> concat.in(1)

  FlowShape(logic.in, concat.out)
})

Функция serverLogic теперь принимает входящее соединение как параметр. Внутри его тела мы используем DSL, который позволяет нам описывать сложное поведение потока. С помощью welcome мы создаем поток, который может излучать только один элемент - приветственное сообщение. logic - это то, что было описано как serverLogic в предыдущем разделе. Единственная заметная разница заключается в том, что мы добавили к ней closeConnection. Теперь на самом деле идет интересная часть DSL. Функция GraphDSL.create позволяет создать построитель b, который используется для выражения потока в виде графика. С помощью функции ~> можно подключать входные и выходные порты друг к другу. Компонент Concat, который используется в этом примере, может конкатенировать элементы и здесь используется для добавления приветственного сообщения перед другими элементами, выходящими из internalLogic. В последней строке мы делаем только входной порт логики сервера и выходной порт конкатенированного потока, поскольку все остальные порты должны оставаться деталью реализации компонента serverLogic. Подробное введение в график DSL потоков Akka см. В соответствующем разделе официальной документации . Полный код примера сложного TCP-сервера и клиента, который может с ним общаться, можно найти здесь. Всякий раз, когда вы открываете новое соединение с клиентом, вы должны увидеть приветственное сообщение, и, набрав "q" на клиенте, вы увидите сообщение, в котором сообщается, что соединение было отменено.

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