Wie fange ich mit Akka Streams an? [geschlossen]

222

Die Akka Streams-Bibliothek enthält bereits eine Fülle von Dokumentationen . Das Hauptproblem für mich ist jedoch, dass es zu viel Material liefert - ich fühle mich ziemlich überwältigt von der Anzahl der Konzepte, die ich lernen muss. Viele der dort gezeigten Beispiele fühlen sich sehr schwer an und können nicht einfach in reale Anwendungsfälle übersetzt werden. Sie sind daher ziemlich esoterisch. Ich denke, es gibt viel zu viele Details, ohne zu erklären, wie alle Bausteine ​​zusammengesetzt werden und wie genau es hilft, bestimmte Probleme zu lösen.

Es gibt Quellen, Senken, Flüsse, Graphstufen, Teilgraphen, Materialisierung, ein Graph-DSL und vieles mehr, und ich weiß einfach nicht, wo ich anfangen soll. Die Kurzanleitung soll als Ausgangspunkt dienen, aber ich verstehe sie nicht. Es werden nur die oben genannten Konzepte eingefügt, ohne sie zu erklären. Außerdem können die Codebeispiele nicht ausgeführt werden - es fehlen Teile, die es mir mehr oder weniger unmöglich machen, dem Text zu folgen.

Kann jemand die Konzepte Quellen, Senken, Flüsse, Graphstufen, Teilgraphen, Materialisierung und vielleicht einige andere Dinge erklären, die ich in einfachen Worten und mit einfachen Beispielen vermisst habe, die nicht jedes einzelne Detail erklären (und die wahrscheinlich sowieso nicht benötigt werden) der Anfang)?

kiritsuku
quelle
2
Für Informationen, dies wird auf Meta
DavidG
10
Lassen Sie mich als erste Person, die abstimmt, um dies zu schließen (gemäß dem Meta-Thread), zunächst sagen, dass Ihre Antwort hier großartig ist . Es ist wirklich ausführlich und sicherlich eine sehr hilfreiche Ressource. Leider ist die von Ihnen gestellte Frage für den Stapelüberlauf viel zu weit gefasst. Wenn Ihre Antwort irgendwie auf eine anders formulierte Frage gepostet werden kann, dann großartig, aber ich denke nicht, dass es so sein kann. Ich empfehle dringend, dies erneut als Blog-Beitrag oder ähnliches einzureichen, das Sie und andere als Referenzressource für zukünftige Antworten verwenden können.
James Donnelly
2
Ich denke, diese Frage als Blog-Beitrag zu schreiben, wäre nicht effektiv. Ja, das ist eine breite Frage - und es ist eine wirklich gute Frage. Eine Einschränkung des Anwendungsbereichs würde ihn nicht verbessern. Die Antwort ist großartig. Ich bin sicher, Quora würde SO gerne das Geschäft für die großen Fragen wegnehmen.
Mike Slinn
11
@ MikeSlinn versuchen nicht, mit SO-Leuten über geeignete Fragen zu diskutieren, sie folgen blind den Regeln. Solange die Frage nicht entfernt wird, bin ich glücklich und habe nicht das Gefühl, auf eine andere Plattform zu wechseln.
Kiritsuku
2
@sschaef Wie pedantisch. Ja, natürlich sind Regeln nichts wert, dein großes Selbst weiß es so viel besser und jeder, der versucht, Regeln anzuwenden, folgt einfach blind dem Hype. /schimpfen. Im Ernst, dies wäre eine großartige Ergänzung zur Dokumentation Beta, wenn Sie dabei sind. Sie können sich weiterhin bewerben und dort platzieren, aber Sie sollten zumindest sehen, dass es nicht gut zur Hauptseite passt.
Félix Gagnon-Grenier

Antworten:

506

Diese Antwort basiert auf der akka-streamVersion 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 importAnweisungen werden für die Typdeklarationen benötigt. systemrepräsentiert das Akteursystem von Akka und materializerreprä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, Sinkund Flow. Die Bausteine ​​bilden ein Diagramm, dessen Auswertung auf dem basiert Materializerund explizit ausgelöst werden muss.

Im Folgenden wird eine tiefere Einführung in die Verwendung der drei Haupttypen gegeben.

Quelle

A Sourceist ein Datenersteller und dient als Eingabequelle für den Stream. Jeder Sourcehat einen einzelnen Ausgangskanal und keinen Eingangskanal. Alle Daten fließen über den Ausgangskanal zu dem, was an das angeschlossen ist Source.

Quelle

Bild genommen von boldradius.com .

A Sourcekann 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 Sourcemit 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 runForeachwäre nicht anders als die bekannte foreachFunktion - 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 takeMethode 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 Futuresasynchron 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.failkö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 Sinkist im Grunde das Gegenteil von a Source. Es ist der Endpunkt eines Streams und verbraucht daher Daten. A Sinkhat einen einzelnen Eingangskanal und keinen Ausgangskanal. Sinkswerden 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.

Sinken

Bild genommen von boldradius.com .

Ein kurzes Beispiel für eine SinkAktion:

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 Sourcemit a Sinkkann mit der toMethode erfolgen. Es gibt einen sogenannten Stream zurück, RunnableFlowwie wir später sehen werden, eine spezielle Form von a Flow- einen Stream, der durch einfaches Aufrufen seiner run()Methode ausgeführt werden kann .

Runnable Flow

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.

Fließen

Bild genommen von boldradius.com .

Wenn a Flowmit a verbunden ist, ist Sourceein neues Sourcedas Ergebnis. Ebenso schafft ein Flowmit einem verbundenes Sinkein neues Sink. Und a Flowverbunden mit a Sourceund a Sinkergibt a RunnableFlow. Daher befinden sie sich zwischen dem Eingangs- und dem Ausgangskanal, entsprechen jedoch nicht einer der Geschmacksrichtungen, solange sie weder mit a Sourcenoch mit a verbunden sind Sink.

Voller Stream

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 viaMethode können wir a Sourcemit 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 invertund doublesind 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

s1und s2stellen 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:

Zeigt an, dass ein Stream eine Folge von laufenden Ereignissen ist, die zeitlich geordnet sind

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, RunnableGraphwann immer ein Stream tatsächlich materialisiert werden kann, was bedeutet, dass a Sinkmit a verbunden ist Source. Bisher haben wir uns immer auf den Wert materialisiert, der Unitin 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 Sourceund Sinkder zweite Typparameter und für Flowden 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 Unitals 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:

Die Logik des Click-Stream-Beispiels

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 throttleFunktion akkumulieren wir Klicks innerhalb von 250 Millisekunden. Die Funktionen mapund filtersollten 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 clickStreamist 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 throttleFunktion standardmäßig nicht bereitstellt . Stattdessen mussten wir es selbst schreiben. Da diese Funktion ist (wie es für das mapoder 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:

Server

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 connectionHandlerunserer Spüle behandeln wir jede Verbindung durch den Fluss serverLogic, der später beschrieben wird. bindinggibt a zurückFutureDies 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 derserverLogicBaustein. Es kann so aussehen:

Serverlogik

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 serverLogicein Fluss ist, der a nimmt ByteStringund a produzieren muss ByteString. Mit können delimiterwir ein ByteStringin kleinere Teile teilen - in unserem Fall muss es passieren, wenn ein Zeilenumbruch auftritt. receiverist 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. responderist 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 dieviaFunktion. 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:

Server und Serverlogik kombiniert

Die serverLogicKomponente 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 startServerSkript 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 GraphStageAbstraktion, 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 shapeWerts zur Verfügung. Weiterhin haben wir ein sogenanntes InHandlerund 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 InHandlergreifen 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 closeConnectionKomponente kann über die viaMethode, 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 welcomeerstellen wir einen Stream, der nur ein Element ausgeben kann - die Willkommensnachricht. logicist das, was wie serverLogicim vorherigen Abschnitt beschrieben wurde. Der einzige bemerkenswerte Unterschied ist, dass wir hinzugefügt closeConnectionhaben. Jetzt kommt tatsächlich der interessante Teil des DSL. Die GraphDSL.createFunktion stellt einen Builder zur bVerfü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 Concatim 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 serverLogicKomponente 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.

kiritsuku
quelle
4
@monksy Ich hatte nicht vor, dies irgendwo anders zu veröffentlichen. Fühlen Sie sich frei, dies auf Ihrem Blog erneut zu veröffentlichen, wenn Sie möchten. Die API ist heutzutage größtenteils stabil, was bedeutet, dass Sie sich wahrscheinlich nicht einmal um die Wartung kümmern müssen (die meisten Blog-Artikel über Akka Streams da draußen sind veraltet, da sie eine API zeigen, die nicht mehr existiert).
Kiritsuku
3
Es wird nicht verschwinden. Warum sollte es?
Kiritsuku
2
@sschaef Es könnte verschwinden, da die Frage nicht zum Thema gehört und als solche geschlossen wurde.
DavidG
7
@Magisch Denken Sie immer daran: "Wir löschen keine guten Inhalte." Ich bin mir nicht ganz sicher, aber ich denke, diese Antwort könnte sich trotz allem tatsächlich qualifizieren.
Deduplikator
9
Dieser Beitrag ist möglicherweise gut für die neue Dokumentationsfunktion von Stack Overflow geeignet - sobald diese für Scala geöffnet ist.
SL Barth - Stellen Sie Monica