Diese Antwort basiert auf der akka-stream
Version 2.4.2
. Die API kann in anderen Versionen leicht abweichen. Die Abhängigkeit kann von sbt genutzt werden :
libraryDependencies += "com.typesafe.akka" %% "akka-stream" % "2.4.2"
Okay, lass uns anfangen. Die API von Akka Streams besteht aus drei Haupttypen. Im Gegensatz zu Reactive Streams sind diese Typen viel leistungsfähiger und daher komplexer. Es wird davon ausgegangen, dass für alle Codebeispiele bereits folgende Definitionen existieren:
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
Die import
Anweisungen werden für die Typdeklarationen benötigt. system
repräsentiert das Akteursystem von Akka und materializer
repräsentiert den Bewertungskontext des Streams. In unserem Fall verwenden wir a ActorMaterializer
, was bedeutet, dass die Streams über den Akteuren ausgewertet werden. Beide Werte sind als gekennzeichnet implicit
, wodurch der Scala-Compiler die Möglichkeit hat, diese beiden Abhängigkeiten bei Bedarf automatisch einzufügen. Wir importieren auch system.dispatcher
, was ein Ausführungskontext für ist Futures
.
Eine neue API
Akka Streams haben folgende Schlüsseleigenschaften:
- Sie implementieren die Reactive Streams-Spezifikation , deren drei Hauptziele Gegendruck, asynchrone und nicht blockierende Grenzen sowie Interoperabilität zwischen verschiedenen Implementierungen auch für Akka Streams vollständig gelten.
- Sie bieten eine Abstraktion für eine Evaluierungs-Engine für die Streams, die aufgerufen wird
Materializer
.
- Programme werden als wiederverwendbare Bausteine formuliert, die als die drei Haupttypen dargestellt werden
Source
, Sink
und Flow
. Die Bausteine bilden ein Diagramm, dessen Auswertung auf dem basiert Materializer
und explizit ausgelöst werden muss.
Im Folgenden wird eine tiefere Einführung in die Verwendung der drei Haupttypen gegeben.
Quelle
A Source
ist ein Datenersteller und dient als Eingabequelle für den Stream. Jeder Source
hat einen einzelnen Ausgangskanal und keinen Eingangskanal. Alle Daten fließen über den Ausgangskanal zu dem, was an das angeschlossen ist Source
.
Bild genommen von boldradius.com .
A Source
kann auf verschiedene Arten erstellt werden:
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
In den oben genannten Fällen haben wir die Source
mit endlichen Daten gespeist , was bedeutet, dass sie irgendwann enden werden. Man sollte nicht vergessen, dass Reactive Streams standardmäßig faul und asynchron sind. Dies bedeutet, dass man explizit die Auswertung des Streams anfordern muss. In Akka Streams kann dies über die run*
Methoden erfolgen. Das runForeach
wäre nicht anders als die bekannte foreach
Funktion - durch dierun
Hinzufügung wird deutlich, dass wir um eine Auswertung des Streams bitten. Da endliche Daten langweilig sind, fahren wir mit unendlich fort:
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
Mit der take
Methode können wir einen künstlichen Stopppunkt erstellen, der uns daran hindert, unbegrenzt zu bewerten. Da die Schauspielerunterstützung integriert ist, können wir den Stream auch problemlos mit Nachrichten versorgen, die an einen Schauspieler gesendet werden:
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
Wir können sehen, dass die Futures
asynchron auf verschiedenen Threads ausgeführt werden, was das Ergebnis erklärt. Im obigen Beispiel ist ein Puffer für die eingehenden Elemente nicht erforderlich. Daher OverflowStrategy.fail
können wir mit konfigurieren, dass der Stream bei einem Pufferüberlauf fehlschlagen soll. Insbesondere über diese Akteurschnittstelle können wir den Stream durch jede Datenquelle speisen. Es spielt keine Rolle, ob die Daten von demselben Thread, von einem anderen, von einem anderen Prozess erstellt werden oder ob sie von einem Remote-System über das Internet stammen.
Sinken
A Sink
ist im Grunde das Gegenteil von a Source
. Es ist der Endpunkt eines Streams und verbraucht daher Daten. A Sink
hat einen einzelnen Eingangskanal und keinen Ausgangskanal. Sinks
werden insbesondere benötigt, wenn das Verhalten des Datenkollektors wiederverwendbar und ohne Auswertung des Streams angegeben werden soll. Die bereits bekannten run*
Methoden erlauben uns diese Eigenschaften nicht, daher wird sie bevorzugt verwendet Sink
.
Bild genommen von boldradius.com .
Ein kurzes Beispiel für eine Sink
Aktion:
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
Das Verbinden von a Source
mit a Sink
kann mit der to
Methode erfolgen. Es gibt einen sogenannten Stream zurück, RunnableFlow
wie wir später sehen werden, eine spezielle Form von a Flow
- einen Stream, der durch einfaches Aufrufen seiner run()
Methode ausgeführt werden kann .
Bild genommen von boldradius.com .
Es ist natürlich möglich, alle Werte, die zu einer Senke kommen, an einen Schauspieler weiterzuleiten:
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
Fließen
Datenquellen und -senken sind großartig, wenn Sie eine Verbindung zwischen Akka-Streams und einem vorhandenen System benötigen, aber mit ihnen nichts wirklich anfangen können. Flows sind das letzte fehlende Teil in der Basisabstraktion von Akka Streams. Sie fungieren als Konnektor zwischen verschiedenen Streams und können zur Transformation ihrer Elemente verwendet werden.
Bild genommen von boldradius.com .
Wenn a Flow
mit a verbunden ist, ist Source
ein neues Source
das Ergebnis. Ebenso schafft ein Flow
mit einem verbundenes Sink
ein neues Sink
. Und a Flow
verbunden mit a Source
und a Sink
ergibt a RunnableFlow
. Daher befinden sie sich zwischen dem Eingangs- und dem Ausgangskanal, entsprechen jedoch nicht einer der Geschmacksrichtungen, solange sie weder mit a Source
noch mit a verbunden sind Sink
.
Bild genommen von boldradius.com .
Um dies besser zu verstehen Flows
, werden wir uns einige Beispiele ansehen:
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
Über die via
Methode können wir a Source
mit a verbinden Flow
. Wir müssen den Eingabetyp angeben, da der Compiler ihn für uns nicht ableiten kann. Wie wir bereits in diesem einfachen Beispiel sehen können, fließen die Daten invert
und double
sind völlig unabhängig von Datenproduzenten und -konsumenten. Sie transformieren nur die Daten und leiten sie an den Ausgangskanal weiter. Dies bedeutet, dass wir einen Fluss zwischen mehreren Streams wiederverwenden können:
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
und s2
stellen völlig neue Streams dar - sie teilen keine Daten über ihre Bausteine.
Ungebundene Datenströme
Bevor wir fortfahren, sollten wir zunächst einige der wichtigsten Aspekte von Reactive Streams erneut betrachten. Eine unbegrenzte Anzahl von Elementen kann an jedem Punkt ankommen und einen Stream in verschiedene Zustände versetzen. Abgesehen von einem ausführbaren Stream, der der übliche Zustand ist, kann ein Stream entweder durch einen Fehler oder durch ein Signal gestoppt werden, das angibt, dass keine weiteren Daten ankommen. Ein Stream kann grafisch modelliert werden, indem Ereignisse auf einer Zeitachse markiert werden, wie dies hier der Fall ist:
Bild aus Die Einführung in die reaktive Programmierung, die Sie vermisst haben .
In den Beispielen des vorherigen Abschnitts haben wir bereits lauffähige Flows gesehen. Wir erhalten ein, RunnableGraph
wann immer ein Stream tatsächlich materialisiert werden kann, was bedeutet, dass a Sink
mit a verbunden ist Source
. Bisher haben wir uns immer auf den Wert materialisiert, der Unit
in den Typen zu sehen ist:
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)
Für Source
und Sink
der zweite Typparameter und für Flow
den dritten Typparameter bezeichnen den materialisierten Wert. In dieser Antwort wird die volle Bedeutung der Materialisierung nicht erklärt. Weitere Details zur Materialisierung finden Sie jedoch in der offiziellen Dokumentation . Im Moment müssen wir nur wissen, dass der materialisierte Wert das ist, was wir erhalten, wenn wir einen Stream ausführen. Da wir bisher nur an Nebenwirkungen interessiert waren, erhielten wir Unit
als materialisierten Wert. Die Ausnahme war die Materialisierung einer Spüle, die zu einerFuture
. Es gab uns ein zurückFuture
, da dieser Wert angeben kann, wann der mit der Senke verbundene Stream beendet wurde. Bisher waren die vorherigen Codebeispiele nett, um das Konzept zu erklären, aber sie waren auch langweilig, weil wir uns nur mit endlichen oder sehr einfachen unendlichen Strömen befassten. Um es interessanter zu machen, wird im Folgenden ein vollständiger asynchroner und unbegrenzter Stream erläutert.
ClickStream-Beispiel
Als Beispiel möchten wir einen Stream haben, der Klickereignisse erfasst. Nehmen wir an, wir möchten auch Klickereignisse gruppieren, die kurz nacheinander auftreten. Auf diese Weise konnten wir leicht doppelte, dreifache oder zehnfache Klicks feststellen. Außerdem wollen wir alle Einzelklicks herausfiltern. Atmen Sie tief ein und stellen Sie sich vor, wie Sie dieses Problem unbedingt lösen würden. Ich wette, niemand könnte eine Lösung implementieren, die beim ersten Versuch korrekt funktioniert. In reaktiver Weise ist dieses Problem trivial zu lösen. Tatsächlich ist die Lösung so einfach und unkompliziert zu implementieren, dass wir sie sogar in einem Diagramm ausdrücken können, das das Verhalten des Codes direkt beschreibt:
Bild aus Die Einführung in die reaktive Programmierung, die Sie vermisst haben .
Die grauen Kästchen sind Funktionen, die beschreiben, wie ein Stream in einen anderen umgewandelt wird. Mit der throttle
Funktion akkumulieren wir Klicks innerhalb von 250 Millisekunden. Die Funktionen map
und filter
sollten selbsterklärend sein. Die Farbkugeln stellen ein Ereignis dar und die Pfeile zeigen, wie sie durch unsere Funktionen fließen. Später in den Verarbeitungsschritten erhalten wir immer weniger Elemente, die durch unseren Stream fließen, da wir sie zusammenfassen und herausfiltern. Der Code für dieses Bild würde ungefähr so aussehen:
val multiClickStream = clickStream
.throttle(250.millis)
.map(clickEvents => clickEvents.length)
.filter(numberOfClicks => numberOfClicks >= 2)
Die gesamte Logik kann in nur vier Codezeilen dargestellt werden! In Scala könnten wir es noch kürzer schreiben:
val multiClickStream = clickStream.throttle(250.millis).map(_.length).filter(_ >= 2)
Die Definition von clickStream
ist etwas komplexer, aber dies ist nur der Fall, weil das Beispielprogramm auf der JVM ausgeführt wird, wo das Erfassen von Klickereignissen nicht einfach möglich ist. Eine weitere Komplikation ist, dass Akka die throttle
Funktion standardmäßig nicht bereitstellt . Stattdessen mussten wir es selbst schreiben. Da diese Funktion ist (wie es für das map
oder das der Fall istfilter
Funktionen ) für verschiedene Anwendungsfälle wiederverwendbar ist, zähle ich diese Zeilen nicht zur Anzahl der Zeilen, die wir zur Implementierung der Logik benötigen. In imperativen Sprachen ist es jedoch normal, dass Logik nicht so einfach wiederverwendet werden kann und dass die verschiedenen logischen Schritte alle an einem Ort stattfinden, anstatt nacheinander angewendet zu werden, was bedeutet, dass wir unseren Code wahrscheinlich mit der Drossellogik falsch geformt hätten. Das vollständige Codebeispiel ist als verfügbarKern und wird hier nicht weiter diskutiert.
SimpleWebServer Beispiel
Was stattdessen diskutiert werden sollte, ist ein weiteres Beispiel. Während der Klick-Stream ein gutes Beispiel dafür ist, wie Akka Streams ein Beispiel aus der realen Welt verarbeiten kann, fehlt ihm die Fähigkeit, die parallele Ausführung in Aktion zu zeigen. Das nächste Beispiel soll einen kleinen Webserver darstellen, der mehrere Anforderungen parallel verarbeiten kann. Der Web-Server muss in der Lage sein, eingehende Verbindungen zu akzeptieren und Byte-Sequenzen von diesen zu empfangen, die druckbare ASCII-Zeichen darstellen. Diese Bytefolgen oder Zeichenfolgen sollten bei allen Zeilenumbrüchen in kleinere Teile aufgeteilt werden. Danach antwortet der Server dem Client mit jeder der geteilten Leitungen. Alternativ könnte es etwas anderes mit den Zeilen tun und ein spezielles Antwort-Token geben, aber wir möchten es in diesem Beispiel einfach halten und daher keine ausgefallenen Funktionen einführen. Merken, Der Server muss in der Lage sein, mehrere Anforderungen gleichzeitig zu verarbeiten. Dies bedeutet im Grunde, dass keine Anforderung andere Anforderungen für die weitere Ausführung blockieren darf. Das Lösen all dieser Anforderungen kann auf zwingende Weise schwierig sein - mit Akka Streams sollten wir jedoch nicht mehr als ein paar Zeilen benötigen, um diese zu lösen. Lassen Sie uns zunächst einen Überblick über den Server selbst geben:
Grundsätzlich gibt es nur drei Hauptbausteine. Der erste muss eingehende Verbindungen akzeptieren. Der zweite muss eingehende Anfragen bearbeiten und der dritte muss eine Antwort senden. Die Implementierung all dieser drei Bausteine ist nur wenig komplizierter als die Implementierung des Klick-Streams:
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}")
}
}
Die Funktion verwendet mkServer
(neben der Adresse und dem Port des Servers) auch ein Akteursystem und einen Materialisierer als implizite Parameter. Der Kontrollfluss des Servers wird durch dargestellt binding
, der eine Quelle eingehender Verbindungen nimmt und diese an eine Senke eingehender Verbindungen weiterleitet. Innerhalb connectionHandler
unserer Spüle behandeln wir jede Verbindung durch den Fluss serverLogic
, der später beschrieben wird. binding
gibt a zurückFuture
Dies wird abgeschlossen, wenn der Server gestartet wurde oder der Start fehlgeschlagen ist. Dies kann der Fall sein, wenn der Port bereits von einem anderen Prozess belegt wird. Der Code spiegelt jedoch die Grafik nicht vollständig wider, da wir keinen Baustein sehen können, der Antworten verarbeitet. Der Grund dafür ist, dass die Verbindung diese Logik bereits selbst bereitstellt. Es ist ein bidirektionaler Fluss und nicht nur ein unidirektionaler Fluss, wie wir ihn in den vorherigen Beispielen gesehen haben. Wie bei der Materialisierung sollen solche komplexen Strömungen hier nicht erklärt werden. Die offizielle Dokumentation enthält reichlich Material für komplexere Flussdiagramme. Im Moment reicht es aus, diesen zu kennen Tcp.IncomingConnection
eine Verbindung darstellt, die weiß, wie man Anfragen empfängt und wie man Antworten sendet. Der Teil, der noch fehlt, ist derserverLogic
Baustein. Es kann so aussehen:
Wieder einmal können wir die Logik in mehrere einfache Bausteine aufteilen, die alle zusammen den Ablauf unseres Programms bilden. Zuerst wollen wir unsere Bytesequenz in Zeilen aufteilen, was wir tun müssen, wenn wir ein Zeilenumbruchzeichen finden. Danach müssen die Bytes jeder Zeile in eine Zeichenfolge konvertiert werden, da das Arbeiten mit Rohbytes umständlich ist. Insgesamt könnten wir einen Binärstrom eines komplizierten Protokolls erhalten, was die Arbeit mit den eingehenden Rohdaten äußerst schwierig machen würde. Sobald wir eine lesbare Zeichenfolge haben, können wir eine Antwort erstellen. Aus Gründen der Einfachheit kann die Antwort in unserem Fall alles sein. Am Ende müssen wir unsere Antwort in eine Folge von Bytes zurückkonvertieren, die über die Leitung gesendet werden können. Der Code für die gesamte Logik kann folgendermaßen aussehen:
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)
}
Wir wissen bereits, dass dies serverLogic
ein Fluss ist, der a nimmt ByteString
und a produzieren muss ByteString
. Mit können delimiter
wir ein ByteString
in kleinere Teile teilen - in unserem Fall muss es passieren, wenn ein Zeilenumbruch auftritt. receiver
ist der Fluss, der alle Split-Byte-Sequenzen in eine Zeichenfolge konvertiert. Dies ist natürlich eine gefährliche Konvertierung, da nur druckbare ASCII-Zeichen in eine Zeichenfolge konvertiert werden sollten, aber für unsere Anforderungen ist dies gut genug. responder
ist die letzte Komponente und ist dafür verantwortlich, eine Antwort zu erstellen und die Antwort wieder in eine Folge von Bytes umzuwandeln. Im Gegensatz zur Grafik haben wir diese letzte Komponente nicht in zwei Teile geteilt, da die Logik trivial ist. Am Ende verbinden wir alle Flüsse durch dievia
Funktion. An dieser Stelle kann man sich fragen, ob wir uns um die eingangs erwähnte Mehrbenutzer-Eigenschaft gekümmert haben. Und tatsächlich haben wir es getan, obwohl es möglicherweise nicht sofort offensichtlich ist. Wenn Sie sich diese Grafik ansehen, sollte es klarer werden:
Die serverLogic
Komponente ist nichts anderes als ein Fluss, der kleinere Flüsse enthält. Diese Komponente nimmt eine Eingabe, die eine Anforderung ist, und erzeugt eine Ausgabe, die die Antwort ist. Da Flows mehrfach erstellt werden können und alle unabhängig voneinander arbeiten, erreichen wir durch diese Verschachtelung unsere Mehrbenutzereigenschaft. Jede Anforderung wird innerhalb ihrer eigenen Anforderung behandelt, und daher kann eine Anforderung mit kurzer Laufzeit eine zuvor gestartete Anforderung mit langer Laufzeit überlaufen. Falls Sie sich gefragt haben, kann die Definition serverLogic
, die zuvor gezeigt wurde, natürlich viel kürzer geschrieben werden, indem die meisten seiner inneren Definitionen eingefügt werden:
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(_))
Ein Test des Webservers kann folgendermaßen aussehen:
$ # 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?
Damit das obige Codebeispiel ordnungsgemäß funktioniert, müssen wir zuerst den Server starten, der im startServer
Skript dargestellt wird :
$ # 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?
Das vollständige Codebeispiel für diesen einfachen TCP-Server finden Sie hier . Wir können nicht nur einen Server mit Akka Streams schreiben, sondern auch den Client. Es kann so aussehen:
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()
Den vollständigen Code-TCP-Client finden Sie hier . Der Code sieht ziemlich ähnlich aus, aber im Gegensatz zum Server müssen wir die eingehenden Verbindungen nicht mehr verwalten.
Komplexe Graphen
In den vorherigen Abschnitten haben wir gesehen, wie wir einfache Programme aus Flows erstellen können. In der Realität reicht es jedoch oft nicht aus, sich nur auf bereits integrierte Funktionen zu verlassen, um komplexere Streams zu erstellen. Wenn wir Akka Streams für beliebige Programme verwenden möchten, müssen wir wissen, wie wir unsere eigenen benutzerdefinierten Steuerungsstrukturen und kombinierbaren Abläufe erstellen, um die Komplexität unserer Anwendungen zu bewältigen. Die gute Nachricht ist, dass Akka Streams so konzipiert wurde, dass es mit den Bedürfnissen der Benutzer skaliert. Um Ihnen eine kurze Einführung in die komplexeren Teile von Akka Streams zu geben, fügen wir unserem Client / Server-Beispiel einige weitere Funktionen hinzu.
Eine Sache, die wir noch nicht tun können, ist das Schließen einer Verbindung. Ab diesem Zeitpunkt wird es etwas komplizierter, da die Stream-API, die wir bisher gesehen haben, es uns nicht erlaubt, einen Stream an einem beliebigen Punkt zu stoppen. Es gibt jedoch die GraphStage
Abstraktion, mit der beliebige Grafikverarbeitungsstufen mit einer beliebigen Anzahl von Eingabe- oder Ausgabeports erstellt werden können. Schauen wir uns zunächst die Serverseite an, auf der wir eine neue Komponente mit dem Namen vorstellen 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)
})
}
}
Diese API sieht viel umständlicher aus als die Flow-API. Kein Wunder, dass wir hier viele wichtige Schritte unternehmen müssen. Im Gegenzug haben wir mehr Kontrolle über das Verhalten unserer Streams. Im obigen Beispiel geben wir nur einen Eingabe- und einen Ausgabeport an und stellen sie dem System durch Überschreiben des shape
Werts zur Verfügung. Weiterhin haben wir ein sogenanntes InHandler
und ein definiert OutHandler
, die in dieser Reihenfolge für das Empfangen und Senden von Elementen verantwortlich sind. Wenn Sie sich das Beispiel für einen vollständigen Klick-Stream genau angesehen haben, sollten Sie diese Komponenten bereits erkennen. In der InHandler
greifen wir ein Element und wenn es eine Zeichenfolge mit einem einzelnen Zeichen ist 'q'
, möchten wir den Stream schließen. Um dem Client die Möglichkeit zu geben, herauszufinden, dass der Stream bald geschlossen wird, geben wir die Zeichenfolge aus"BYE"
und danach schließen wir sofort die Bühne. Die closeConnection
Komponente kann über die via
Methode, die im Abschnitt über Flüsse vorgestellt wurde, mit einem Stream kombiniert werden .
Neben der Möglichkeit, Verbindungen zu schließen, wäre es auch schön, wenn wir eine Willkommensnachricht zu einer neu erstellten Verbindung anzeigen könnten. Dazu müssen wir noch einmal ein bisschen weiter gehen:
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)
})
Die Funktion serverLogic
nimmt nun die eingehende Verbindung als Parameter. In seinem Körper verwenden wir ein DSL, mit dem wir komplexes Stream-Verhalten beschreiben können. Mit welcome
erstellen wir einen Stream, der nur ein Element ausgeben kann - die Willkommensnachricht. logic
ist das, was wie serverLogic
im vorherigen Abschnitt beschrieben wurde. Der einzige bemerkenswerte Unterschied ist, dass wir hinzugefügt closeConnection
haben. Jetzt kommt tatsächlich der interessante Teil des DSL. Die GraphDSL.create
Funktion stellt einen Builder zur b
Verfügung, mit dem der Stream als Diagramm ausgedrückt wird. Mit der ~>
Funktion ist es möglich, Eingangs- und Ausgangsanschlüsse miteinander zu verbinden. Die Concat
im Beispiel verwendete Komponente kann Elemente verketten und wird hier verwendet, um die Begrüßungsnachricht vor die anderen Elemente zu stellen, aus denen sie hervorgehtinternalLogic
. In der letzten Zeile stellen wir nur den Eingabeport der Serverlogik und den Ausgabeport des verketteten Streams zur Verfügung, da alle anderen Ports ein Implementierungsdetail der serverLogic
Komponente bleiben sollen . Eine ausführliche Einführung in die Grafik DSL von Akka Streams finden Sie im entsprechenden Abschnitt in der offiziellen Dokumentation . Das vollständige Codebeispiel des komplexen TCP-Servers und eines Clients, der mit ihm kommunizieren kann, finden Sie hier . Wenn Sie eine neue Verbindung vom Client aus öffnen, sollte eine Begrüßungsnachricht angezeigt werden. Wenn Sie "q"
auf dem Client eingeben , sollte eine Nachricht angezeigt werden, die Sie darüber informiert, dass die Verbindung abgebrochen wurde.
Es gibt noch einige Themen, die in dieser Antwort nicht behandelt wurden. Insbesondere die Materialisierung mag den einen oder anderen Leser erschrecken, aber ich bin mir sicher, dass mit dem hier behandelten Material jeder in der Lage sein sollte, die nächsten Schritte für sich zu tun. Wie bereits erwähnt, ist die offizielle Dokumentation ein guter Ort, um weiter über Akka Streams zu lernen.