この回答は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つの依存関係が必要なときにいつでも自動的に挿入できるようになります。またsystem.dispatcher
、の実行コンテキストであるもインポートしFutures
ます。
新しいAPI
Akkaストリームには、次の主要なプロパティがあります。
- これらは、Reactive Streams仕様を実装しています。その3つの主要な目標は、バックプレッシャー、非同期および非ブロッキング境界、および異なる実装間の相互運用性は、Akkaストリームにも完全に適用されます。
- これらは、ストリームと呼ばれる評価エンジンの抽象化を提供します
Materializer
。
- プログラムは、3つの主要なタイプとして表現され、再利用可能なビルディング・ブロックとして処方され
Source
、Sink
そしてFlow
。ビルディングブロックは、評価がに基づいておりMaterializer
、明示的にトリガーする必要があるグラフを形成します。
以下では、3つの主要なタイプの使用方法のより深い紹介が与えられます。
ソース
A 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
、有限のデータをに与えました。つまり、それらは最終的に終了します。Reactive Streamsはデフォルトではレイジーで非同期であることを忘れないでください。つまり、ストリームの評価を明示的に要求する必要があります。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
は基本的にの反対ですSource
。これはストリームのエンドポイントであるため、データを消費します。A Sink
には単一の入力チャネルがあり、出力チャネルはありません。Sinks
ストリームを評価せずに再利用可能な方法でデータコレクターの動作を指定する場合に特に必要です。既知のrun*
方法ではこれらのプロパティを使用できないため、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
a- run()
メソッドを呼び出すだけで実行できるストリームです。
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ベースの抽象化で最後に欠けている部分です。これらは異なるストリーム間のコネクタとして機能し、その要素を変換するために使用できます。
boldradius.comから取得した画像。
a Flow
がa に接続されている場合、結果はSource
新しいSource
です。同様に、にFlow
接続Sink
すると新しいが作成されますSink
。そして、Flow
a Source
とaの両方に接続すると、Sink
結果はになりRunnableFlow
ます。したがって、これらは入力チャネルと出力チャネルの間に位置しますが、a Source
またはaに接続されていない限り、それ自体はフレーバーの1つに対応しませんSink
。
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
完全に新しいストリームを表します-それらはビルディングブロックを通じてデータを共有しません。
無制限のデータストリーム
次に進む前に、まず、Reactive Streamsの主要な側面のいくつかを再検討する必要があります。無制限の数の要素が任意の時点に到達し、ストリームをさまざまな状態にすることができます。通常の状態である実行可能なストリームの他に、エラーまたはそれ以上データが到着しないことを示す信号によってストリームが停止する場合があります。次のように、タイムラインでイベントをマークすることにより、ストリームをグラフィカルにモデル化できます。
あなたが見逃していたリアクティブプログラミングの紹介から撮影した画像。
前のセクションの例ではすでに実行可能なフローを見てきました。RunnableGraph
ストリームが実際に実体化できるときはいつでもa を取得Sink
しSource
ます。つまり、a はに接続されます。これまでのところ、私たちは常に値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の例
例として、クリックイベントをキャプチャするストリームが必要です。これをより困難にするために、お互いに短時間で発生するクリックイベントもグループ化するとします。この方法で、ダブルクリック、トリプルクリック、または10倍クリックを簡単に見つけることができます。さらに、すべてのシングルクリックを除外します。深呼吸して、命令型の方法でその問題を解決する方法を想像してください。最初の試行で正しく機能するソリューションを実装できる人は誰もいないと思います。この問題は事後対応的に解決するのは簡単です。実際、ソリューションは非常にシンプルで簡単に実装できるため、コードの動作を直接説明する図で表すこともできます。
あなたが見逃していたリアクティブプログラミングの紹介から撮影した画像。
灰色のボックスは、あるストリームが別のストリームに変換される方法を説明する関数です。このthrottle
関数を使用するmap
と、250ミリ秒以内にクリック数が累積されます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で実行され、クリックイベントのキャプチャが簡単にできないため、これは当てはまります。別の問題は、Akkaがデフォルトでthrottle
機能を提供しないことです。代わりに、私たちは自分でそれを書かなければなりませんでした。この関数は(map
or filter
関数の場合と同様に)さまざまなユースケースで再利用できるため、ロジックの実装に必要な行数にこれらの行を数えません。ただし、命令型言語では、ロジックを簡単に再利用できず、さまざまな論理ステップが順番に適用されるのではなく、すべて1か所で発生するのが普通です。つまり、スロットルロジックでコードを誤った形にした可能性があります。完全なコード例は、要旨とここではこれ以上議論してはなりません。
SimpleWebServerの例
代わりに議論すべきは別の例です。クリックストリームは、Akka Streamsに実際の例を処理させる良い例ですが、実際の並列実行を表示する力がありません。次の例は、複数の要求を並行して処理できる小さなWebサーバーを表しています。Webサーバーは、着信接続を受け入れ、それらから印刷可能なASCII記号を表すバイトシーケンスを受信できる必要があります。これらのバイトシーケンスまたは文字列は、すべての改行文字で小さな部分に分割する必要があります。その後、サーバーは分割された各行でクライアントに応答します。代わりに、それは行で何か他のことを行い、特別な応答トークンを与えることができますが、この例ではそれを単純に保ちたいので、凝った機能を導入しません。覚えて、サーバーは、同時に複数の要求を処理できる必要があります。つまり、基本的に、他の要求がそれ以上実行されるのをブロックする要求は許可されません。これらすべての要件を解決することは、命令的な方法では難しい場合があります。ただし、Akka Streamsを使用すると、これらのいずれかを解決するために数行を超える必要はありません。まず、サーバー自体の概要を見てみましょう。
基本的に、主な構成要素は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
建築用ブロック。次のようになります。
この場合も、ロジックをいくつかの単純なビルディングブロックに分割して、プログラムのフローを形成することができます。最初に、バイトシーケンスを行に分割します。改行文字を見つけるたびに分割する必要があります。その後、生のバイトを扱うのは面倒なので、各行のバイトを文字列に変換する必要があります。全体として、複雑なプロトコルのバイナリストリームを受信する可能性があり、受信した生データの操作が非常に困難になります。読みやすい文字列が得られたら、答えを作成できます。単純化の理由から、私たちの場合、答えは何でもかまいません。最後に、答えをネットワーク経由で送信できる一連のバイトに変換し直す必要があります。ロジック全体のコードは次のようになります。
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
は、それがを取り、ByteString
を生成しなければならないフローであることをすでに知っていByteString
ます。ではdelimiter
、ByteString
を小さな部分に分割できます。この場合、改行文字が発生するたびに発生する必要があります。receiver
分割されたバイトシーケンスをすべて受け取り、文字列に変換するフローです。もちろん、これは危険な変換です。印刷可能なASCII文字だけを文字列に変換する必要があるからですが、私たちのニーズには十分です。responder
最後のコンポーネントであり、回答を作成し、回答をバイトのシーケンスに変換します。グラフィックとは対照的に、ロジックは簡単なので、この最後のコンポーネントを2つに分割しませんでした。最後に、すべてのフローを接続します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(_))
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サーバーの完全なコード例は、こちらにあります。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クライアントはここにあります。コードは非常に似ていますが、サーバーとは対照的に、着信接続を管理する必要はもうありません。
複雑なグラフ
前のセクションでは、フローから単純なプログラムを構築する方法を見てきました。ただし、実際には、すでに組み込まれている関数に依存してより複雑なストリームを構築するだけでは十分でないことがよくあります。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
値をオーバーライドすることでシステムで利用できるようにしています。さらに、いわゆるとを定義しましInHandler
たOutHandler
。これらは、この順序で要素の送受信を行います。完全なクリックストリームの例をよく見ると、これらのコンポーネントをすでに認識しているはずです。では、InHandler
要素を取得し、それが1文字の文字列である場合は'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サーバーと、それと通信できるクライアントの完全なコード例は、こちらにあります。クライアントから新しい接続を開くたびに、ウェルカムメッセージが表示さ"q"
れ、クライアントに入力すると、接続がキャンセルされたことを示すメッセージが表示されます。
この回答でカバーできなかったトピックがまだいくつかあります。特に実体化は読者を怖がらせるかもしれませんが、ここでカバーされている資料で、誰もが自分で次のステップに進むことができると確信しています。すでに述べたように、公式ドキュメントはAkka Streamsについて学び続けるための良い場所です。