web-dev-qa-db-ja.com

Akka Streamsを使い始める方法

Akka Streamsライブラリにはすでにかなりの 豊富なドキュメント が付属しています。しかしながら、私にとっての主な問題は、それがあまりにも多くの資料を提供しているということです - 私が学ばなければならない多くの概念に非常に圧倒されていると感じます。そこに示されている例の多くは非常に重く感じられ、現実世界のユースケースに簡単に翻訳することはできません、そしてそれ故にかなり難解です。すべてのビルディングブロックをまとめて構築する方法や、具体的な問題を解決するために正確にどのように役立つかを説明せずに、詳細を説明し過ぎると思います。

ソース、シンク、フロー、グラフステージ、部分グラフ、マテリアライゼーション、グラフDSLなどがあり、どこから始めればいいのかわかりません。 クイックスタートガイド は出発点となることを意図していますが、私は理解していません。それはそれらを説明せずに上で言及された概念を単に投げる。さらに、コード例を実行することはできません - 欠けている部分があるため、テキストをたどることがほぼ不可能になっています。

誰もが概念のソース、シンク、フロー、グラフステージ、部分グラフ、具体化、そして私が見逃した他のことを簡単な例で説明しているわけではありません。始まり)?

215
kiritsuku

この回答は、akka-streamバージョン2.4.2に基づいています。 APIは、他のバージョンでは若干異なる場合があります。依存関係は sbt で消費できます。

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

さあ、始めましょう。 Akka StreamsのAPIは、主に3つのタイプで構成されています。 Reactive Streams とは対照的に、これらのタイプははるかに強力であるため、より複雑です。すべてのコード例について、次の定義がすでに存在すると想定されています。

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コンパイラーは、これら2つの依存関係が必要なときに自動的に注入できるようになります。また、 Futures の実行コンテキストであるsystem.dispatcherもインポートします。

新しいAPI

Akka Streamsには次の重要なプロパティがあります。

  • Reactive Streams specification を実装し、その3つの主な目標であるバックプレッシャー、非同期および非ブロッキング境界、異なる実装間の相互運用性は、Akka Streamsにも完全に適用されます。
  • これらは、Materializerと呼ばれるストリームの評価エンジンの抽象化を提供します。
  • プログラムは、SourceSinkFlowの3つの主要なタイプとして表される再利用可能なビルディングブロックとして定式化されます。ビルディングブロックはグラフを形成し、その評価はMaterializerに基づいており、明示的にトリガーする必要があります。

以下では、3つの主要なタイプの使用方法について、より深く紹介します。

ソース

Sourceはデータ作成者であり、ストリームへの入力ソースとして機能します。各Sourceには単一の出力チャネルがあり、入力チャネルはありません。すべてのデータは、出力チャネルを介して、Sourceに接続されているものに流れます。

Source

boldradius.com から取得した画像。

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に有限データを入力しました。つまり、それらは最終的に終了します。 Reactive Streamsはデフォルトで遅延型で非同期であることを忘れないでください。これは、ストリームの評価を明示的に要求する必要があることを意味します。 Akka Streamsでは、これは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を使用して、バッファオーバーフローでストリームが失敗するように構成できます。特にこのアクターインターフェイスを介して、任意のデータソースを介してストリームをフィードできます。データが同じスレッドで作成されたか、別のスレッドで作成されたか、別のプロセスで作成されたか、またはそれらがインターネット上のリモートシステムからのものであるかは関係ありません。

シンク

Sinkは、基本的にSourceの反対です。これはストリームのエンドポイントであるため、データを消費します。 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

SourceSinkに接続するには、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 から取得した画像。

FlowSourceに接続されている場合、新しいSourceが結果になります。同様に、Flowに接続されたSinkは、新しいSinkを作成します。また、FlowSourceの両方に接続されたSinkは、RunnableFlowになります。したがって、入力チャネルと出力チャネルの間に位置しますが、SourceまたはSinkのいずれにも接続されていない限り、それ自体はフレーバーの1つに対応しません。

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メソッドを使用して、SourceFlowに接続できます。コンパイラーが入力タイプを推測できないため、入力タイプを指定する必要があります。この簡単な例ですでにわかるように、フロー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は完全に新しいストリームを表します-それらは、ビルディングブロックを介してデータを共有しません。

無制限のデータストリーム

先に進む前に、まずリアクティブストリームの重要な側面のいくつかを再確認する必要があります。無制限の数の要素が任意のポイントに到着し、ストリームをさまざまな状態にすることができます。通常の状態である実行可能なストリームのほかに、ストリームはエラーまたはそれ以上データが到着しないことを示す信号によって停止する場合があります。ここにあるように、タイムライン上のイベントをマークすることにより、ストリームをグラフィカルな方法でモデル化できます。

Shows that a stream is a sequence of ongoing events ordered in time

欠落しているリアクティブプログラミングの概要 からの画像。

前のセクションの例で、実行可能なフローを見てきました。ストリームを実際にマテリアライズできる場合は常にRunnableGraphを取得します。つまり、SinkSourceに接続されます。これまでのところ、常に変数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の場合、2番目のタイプのパラメーター、Flowの場合、3番目のタイプのパラメーターは具体化された値を示します。この答え全体を通して、具体化の完全な意味は説明されません。ただし、具体化についての詳細は 公式ドキュメント にあります。現時点で知っておく必要があるのは、ストリームを実行したときに実体化された値が得られることだけです。これまでのところ副作用にのみ関心があったため、実体化された値としてUnitを取得しました。これの例外はシンクの実体化であり、Futureになりました。この値は、シンクに接続されているストリームが終了したことを示すことができるため、Futureを返しました。これまで、前のコード例は概念を説明するのにうれしいですが、有限のストリームまたは非常に単純な無限のストリームのみを扱っていたため、退屈でもありました。それをより興味深いものにするために、以下では、完全な非同期および無制限のストリームについて説明します。

ClickStreamの例

例として、クリックイベントをキャプチャするストリームが必要です。それをより難しくするために、互いに短時間で発生するクリックイベントをグループ化するとします。これにより、ダブル、トリプル、または10倍のクリックを簡単に発見できました。さらに、すべてのシングルクリックを除外します。深呼吸をして、命令型の方法でその問題をどのように解決するか想像してください。最初の試行で正しく機能するソリューションを実装できる人はいないでしょう。事後対応的に、この問題を解決するのは簡単です。実際、ソリューションは実装が非常に簡単で簡単なので、コードの動作を直接説明する図で表現することもできます。

The logic of the click stream example

欠落しているリアクティブプログラミングの概要 からの画像。

灰色のボックスは、あるストリームが別のストリームに変換される方法を説明する関数です。 throttle関数を使用すると、250ミリ秒以内にクリックが蓄積されます。map関数とfilter関数は一目瞭然です。色のオーブはイベントを表し、矢印は機能をどのように流れるかを示しています。処理ステップの後の方で、ストリームを流れる要素はどんどん少なくなります。それらをグループ化し、フィルターで除外するからです。この画像のコードは次のようになります。

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

ロジック全体を4行のコードで表すことができます! Scalaでは、さらに短く書くことができます。

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

clickStreamの定義はもう少し複雑ですが、例のプログラムはJVM上で実行されるため、クリックイベントのキャプチャは容易ではありません。もう1つの複雑な点は、Akkaがデフォルトでthrottle関数を提供しないことです。代わりに、自分でそれを書かなければなりませんでした。この関数は(mapまたはfilter関数の場合のように)さまざまなユースケースで再利用可能であるため、これらの行をロジックの実装に必要な行数にカウントしません。しかし、命令型言語では、ロジックを簡単に再利用できず、さまざまな論理ステップが順番に適用されるのではなく、すべて1か所で発生するのが普通です。完全なコード例は Gist として入手でき、ここではこれ以上説明しません。

SimpleWebServerの例

代わりに説明する必要があるのは別の例です。クリックストリームは、Akka Streamsが実際の例を処理できるようにするための良い例ですが、実際の並列実行を表示する能力はありません。次の例は、複数のリクエストを並行して処理できる小さなWebサーバーを表しています。 Webサーバーは、着信接続を受け入れ、印刷可能なASCII記号を表すバイトシーケンスを受信できる必要があります。これらのバイトシーケンスまたは文字列は、すべての改行文字でより小さな部分に分割する必要があります。その後、サーバーは各分割線でクライアントに応答します。または、行を使用して別の処理を行い、特別な応答トークンを与えることもできますが、この例ではシンプルに保ちたいため、派手な機能は導入しません。サーバーは複数の要求を同時に処理できる必要があることを覚えておいてください。これは基本的に、他の要求がそれ以上実行されるのをブロックする要求が許可されないことを意味します。これらのすべての要件を解決することは必須の方法では困難な場合があります。ただし、Akka Streamsでは、これらのいずれかを解決するために数行を超える必要はありません。まず、サーバー自体の概要を見てみましょう。

server

基本的に、主要な構成要素は3つだけです。最初のものは着信接続を受け入れる必要があります。 2番目は着信要求を処理し、3番目は応答を送信する必要があります。これら3つのビルディングブロックをすべて実装するのは、クリックストリームを実装するよりも少しだけ複雑です。

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は、Futureを返します。これは、サーバーが起動したとき、または起動が失敗したときに完了します。これは、ポートがすでに別のプロセスによって使用されている場合です。ただし、応答を処理するビルディングブロックが表示されないため、コードはグラフィックを完全には反映していません。この理由は、接続がすでにこのロジックを単独で提供しているためです。これは双方向のフローであり、前の例で見たような単方向のフローではありません。実体化の場合のように、このような複雑なフローはここでは説明しません。 公式ドキュメント には、より複雑なフローグラフをカバーするための十分な資料があります。現時点では、Tcp.IncomingConnectionは、要求の受信方法と応答の送信方法を知っている接続を表していることを知るだけで十分です。まだ欠落している部分は、serverLogic構築ブロックです。次のようになります。

server logic

繰り返しますが、ロジックをいくつかの単純な構成要素に分割して、プログラムのフローを形成します。最初に、バイトのシーケンスを行に分割します。これは、改行文字を見つけるたびに行う必要があります。その後、生のバイトを扱うのは面倒なので、各行のバイトを文字列に変換する必要があります。全体として、複雑なプロトコルのバイナリストリームを受信できたため、受信した生データの操作が非常に困難になりました。読み取り可能な文字列を取得したら、回答を作成できます。簡単にするために、この場合の答えは何でも構いません。最後に、ネットワーク経由で送信できる一連のバイトに回答を変換する必要があります。ロジック全体のコードは次のようになります。

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)
}

serverLogicByteStringを使用するフローであり、ByteStringを生成する必要があることは既にわかっています。 delimiterを使用すると、ByteStringをより小さな部分に分割できます。この場合、改行文字が発生するたびに発生する必要があります。 receiverは、すべての分割バイトシーケンスを取得して文字列に変換するフローです。印刷可能なASCII文字のみを文字列に変換する必要があるため、これはもちろん危険な変換ですが、必要に応じて十分です。 responderは最後のコンポーネントであり、回答を作成し、回答をバイトシーケンスに変換します。グラフィックとは対照的に、この最後のコンポーネントを2つに分割しませんでした。ロジックが簡単なためです。最後に、すべてのフローをvia関数を介して接続します。この時点で、最初に言及したマルチユーザープロパティを処理したかどうかを尋ねることができます。そして、すぐに明らかではないかもしれませんが、実際に行いました。この図を見ると、より明確になるはずです。

server and server logic combined

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(_))

Webサーバーのテストは次のようになります。

$ # 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スクリプトで示されています。

$ # 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サーバーの完全なコード例は、 here にあります。 Akka Streamsを使用してサーバーを作成できるだけでなく、クライアントも作成できます。次のようになります。

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クライアントは、 here にあります。コードは非常に似ていますが、サーバーとは対照的に、着信接続を管理する必要はありません。

複雑なグラフ

前のセクションでは、フローから簡単なプログラムを構築する方法を説明しました。ただし、実際には、より複雑なストリームを構築するために、すでに組み込まれている関数に依存するだけでは不十分な場合がよくあります。 Akka Streamsを任意のプログラムに使用できるようにするには、アプリケーションの複雑さに対処できる独自のカスタム制御構造と組み合わせ可能なフローを構築する方法を知る必要があります。良いニュースは、Akka Streamsはユーザーのニーズに合わせて拡張できるように設計されていることです。AkkaStreamsのより複雑な部分を簡単に紹介するために、クライアント/サーバーの例にいくつかの機能を追加します。

まだできないことの1つは、接続を閉じることです。この時点で、これまで見てきたストリーム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よりもかなり面倒です。当然、ここで多くの必須の手順を実行する必要があります。代わりに、ストリームの動作をより詳細に制御できます。上記の例では、1つの入力ポートと1つの出力ポートのみを指定し、shape値をオーバーライドしてシステムで使用できるようにします。さらに、いわゆるInHandlerOutHandlerを定義しました。これらはこの順序で要素の受信と送信を行います。フルクリックストリームの例をよく見ると、これらのコンポーネントを既に認識しているはずです。 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を使用して、1つの要素(ウェルカムメッセージ)のみを送信できるストリームを作成します。 logicは、前のセクションでserverLogicとして説明されたものです。唯一の顕著な違いは、closeConnectionを追加したことです。 DSLの興味深い部分が実際に登場しました。 GraphDSL.create関数は、ビルダーbを使用可能にします。これは、ストリームをグラフとして表現するために使用されます。 ~>関数を使用すると、入力ポートと出力ポートを相互に接続できます。この例で使用されるConcatコンポーネントは要素を連結でき、ここではinternalLogicから出てくる他の要素の前にウェルカムメッセージを追加するために使用されます。最後の行では、サーバーロジックの入力ポートと連結ストリームの出力ポートのみを使用可能にします。これは、他のすべてのポートがserverLogicコンポーネントの実装詳細のままになるためです。 Akka StreamsのグラフDSLの詳細な紹介については、 公式ドキュメント の対応するセクションをご覧ください。複雑なTCPサーバーとそれと通信できるクライアントの完全なコード例は、 here にあります。クライアントから新しい接続を開くたびに歓迎のメッセージが表示され、クライアントで"q"と入力すると、接続がキャンセルされたことを知らせるメッセージが表示されます。

この回答でカバーされなかったいくつかのトピックがまだあります。特に実体化は読者を怖がらせるかもしれませんが、ここで取り上げられている資料では、誰もが次のステップに進むことができるはずです。すでに述べたように、 公式ドキュメント はAkka Streamsについて学び続けるのに適した場所です。

494
kiritsuku