Come iniziare con Akka Streams? [chiuso]


222

La biblioteca di Akka Streams è già dotata di una notevole quantità di documentazione . Tuttavia, il problema principale per me è che fornisce troppo materiale - mi sento abbastanza sopraffatto dal numero di concetti che devo imparare. Molti esempi mostrati lì sembrano molto pesanti e non possono essere facilmente tradotti in casi d'uso reali e sono quindi piuttosto esoterici. Penso che fornisca troppi dettagli senza spiegare come costruire tutti i blocchi insieme e come esattamente aiuta a risolvere problemi specifici.

Ci sono fonti, pozzi, flussi, fasi grafiche, grafici parziali, materializzazione, un grafico DSL e molto altro e non so da dove cominciare. La guida rapida è pensata per essere un punto di partenza ma non lo capisco. Getta solo nei concetti di cui sopra senza spiegarli. Inoltre, gli esempi di codice non possono essere eseguiti: mancano parti che rendono più o meno impossibile per me seguire il testo.

Qualcuno può spiegare i concetti fonti, pozzi, flussi, fasi dei grafici, grafici parziali, materializzazione e forse alcune altre cose che mi sono perso in parole semplici e con semplici esempi che non spiegano ogni singolo dettaglio (e che probabilmente non sono comunque necessari in l'inizio)?


2
Per informazioni, questo è in discussione su meta
DavidG,

10
Come prima persona a votare per chiudere questo (seguendo il thread Meta), lasciami prima dire che la tua risposta qui è ottima . È davvero approfondito ed è sicuramente una risorsa molto utile. Tuttavia purtroppo la domanda che hai posto è troppo ampia per Stack Overflow. Se in qualche modo la tua risposta può essere inviata a una domanda diversa, allora fantastico, ma non credo che possa essere. Consiglio vivamente di reinviarlo come post di blog o qualcosa di simile che tu stesso e gli altri potete utilizzare come risorsa di riferimento nelle risposte future.
James Donnelly,

2
Penso che scrivere questa domanda come un post sul blog non sarebbe efficace. Sì, questa è una domanda ampia - ed è davvero una bella domanda. Restringere il suo campo di applicazione non lo migliorerebbe. La risposta fornita è eccezionale. Sono sicuro che Quora sarebbe felice di portare gli affari lontano da SO per le grandi domande.
Mike Slinn,

11
@MikeSlinn non prova a discutere con persone SO su domande appropriate, seguono ciecamente le regole. Finché la domanda non viene rimossa, sono felice e non mi sento di passare a una piattaforma diversa.
Kiritsuku,

2
@sschaef Quanto è pedante. Sì, certo, le regole non valgono nulla, il tuo grande io lo sa molto meglio e tutti coloro che cercano di applicare le regole stanno solo seguendo ciecamente l'hype. / rant. più seriamente, questa sarebbe una grande aggiunta alla beta della documentazione, se ci sei dentro. Puoi ancora applicarlo e metterlo lì, ma dovresti almeno vedere che non si adatta perfettamente al sito principale.
Félix Gagnon-Grenier,

Risposte:


506

Questa risposta si basa sulla akka-streamversione 2.4.2. L'API può essere leggermente diversa in altre versioni. La dipendenza può essere consumata da sbt :

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

Bene, cominciamo. L'API di Akka Streams è composta da tre tipi principali. A differenza di Reactive Streams , questi tipi sono molto più potenti e quindi più complessi. Si presume che per tutti gli esempi di codice esistano già le seguenti definizioni:

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

Le importistruzioni sono necessarie per le dichiarazioni di tipo. systemrappresenta il sistema di attori di Akka e materializerrappresenta il contesto di valutazione del flusso. Nel nostro caso utilizziamo a ActorMaterializer, il che significa che i flussi vengono valutati in base agli attori. Entrambi i valori sono contrassegnati come implicit, il che dà al compilatore Scala la possibilità di iniettare automaticamente queste due dipendenze ogni volta che sono necessarie. Importiamo anche system.dispatcher, che è un contesto di esecuzione per Futures.

Una nuova API

Akka Streams ha queste proprietà chiave:

  • Implementano la specifica Reactive Streams , i cui tre obiettivi principali contropressione, confini asincroni e non bloccanti e interoperabilità tra diverse implementazioni si applicano pienamente anche per Akka Streams.
  • Forniscono un'astrazione per un motore di valutazione per i flussi, che viene chiamato Materializer.
  • I programmi sono formulati come componenti riutilizzabili, che sono rappresentati i tre tipi principali Source, Sinke Flow. I blocchi costitutivi formano un grafico la cui valutazione si basa su Materializere deve essere esplicitamente attivata.

Di seguito viene fornita un'introduzione più approfondita sull'uso dei tre tipi principali.

fonte

A Sourceè un creatore di dati, funge da fonte di input per il flusso. Ognuno Sourceha un singolo canale di uscita e nessun canale di ingresso. Tutti i dati fluiscono attraverso il canale di uscita verso tutto ciò che è collegato al Source.

fonte

Immagine tratta da boldradius.com .

A Sourcepuò essere creato in più modi:

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

Nei casi precedenti abbiamo fornito Sourcedati finiti, il che significa che alla fine verranno risolti. Non bisogna dimenticare che i Reattori reattivi sono pigri e asincroni per impostazione predefinita. Ciò significa che è necessario richiedere esplicitamente la valutazione del flusso. In Akka Streams questo può essere fatto attraverso i run*metodi. Non runForeachsarebbe diverso dalla foreachfunzione ben nota - attraverso l' runaggiunta si rende esplicito che chiediamo una valutazione del flusso. Poiché i dati finiti sono noiosi, continuiamo con uno infinito:

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

Con il takemetodo possiamo creare un punto di arresto artificiale che ci impedisce di valutare indefinitamente. Poiché il supporto per l'attore è integrato, possiamo anche alimentare facilmente il flusso con i messaggi inviati a un attore:

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

Possiamo vedere che Futuresvengono eseguiti in modo asincrono su thread diversi, il che spiega il risultato. Nell'esempio sopra non è necessario un buffer per gli elementi in entrata e quindi con OverflowStrategy.failnoi possiamo configurare che lo stream dovrebbe fallire in un buffer overflow. Soprattutto attraverso questa interfaccia per attori, possiamo alimentare il flusso attraverso qualsiasi fonte di dati. Non importa se i dati vengono creati dallo stesso thread, da uno diverso, da un altro processo o se provengono da un sistema remoto su Internet.

Lavello

A Sinkè sostanzialmente l'opposto di a Source. È l'endpoint di un flusso e quindi consuma i dati. A Sinkha un singolo canale di ingresso e nessun canale di uscita. Sinkssono particolarmente necessari quando vogliamo specificare il comportamento del raccoglitore di dati in modo riutilizzabile e senza valutare il flusso. I run*metodi già noti non ci consentono queste proprietà, quindi è preferibile utilizzare Sinkinvece.

Lavello

Immagine tratta da boldradius.com .

Un breve esempio di a Sinkin action:

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

La connessione a Sourcea Sinkpuò essere effettuata con il tometodo Restituisce un cosiddetto RunnableFlow, che è come vedremo in seguito una forma speciale di a Flow- un flusso che può essere eseguito semplicemente chiamando il suo run()metodo.

Flusso eseguibile

Immagine tratta da boldradius.com .

È ovviamente possibile inoltrare tutti i valori che arrivano ad un attore a un attore:

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

Flusso

Le origini dati e i sink sono fantastici se hai bisogno di una connessione tra i flussi Akka e un sistema esistente ma non puoi davvero farci nulla. I flussi sono l'ultimo pezzo mancante nell'astrazione base Akka Streams. Agiscono come un connettore tra diversi flussi e possono essere utilizzati per trasformare i suoi elementi.

Flusso

Immagine tratta da boldradius.com .

Se a Flowè collegato a Sourcea nuovoSource è il risultato. Allo stesso modo, un Flowcollegato ad un Sinkcrea un nuovo Sink. E un Flowcollegato con a Sourcee a Sinkrisulta in a RunnableFlow. Pertanto, si trovano tra il canale di ingresso e quello di uscita ma da soli non corrispondono a uno dei sapori purché non siano collegati ad a Sourceo a Sink.

Stream completo

Immagine tratta da boldradius.com .

Per capire meglio Flows, daremo uno sguardo ad alcuni esempi:

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

Tramite il viametodo possiamo connettere a Sourcecon a Flow. Dobbiamo specificare il tipo di input perché il compilatore non può inferirlo per noi. Come possiamo già vedere in questo semplice esempio, i flussi inverte doublesono completamente indipendenti da qualsiasi produttore e consumatore di dati. Trasformano solo i dati e li inoltrano al canale di uscita. Ciò significa che possiamo riutilizzare un flusso tra più flussi:

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

s1e s2rappresentano flussi completamente nuovi: non condividono dati attraverso i loro blocchi predefiniti.

Flussi di dati illimitati

Prima di procedere dovremmo rivedere alcuni degli aspetti chiave dei Reactive Streams. Un numero illimitato di elementi può arrivare in qualsiasi punto e può mettere un flusso in diversi stati. Oltre a un flusso eseguibile, che è il solito stato, un flusso può essere arrestato o per errore o per segnale che indica che non arriveranno ulteriori dati. Un flusso può essere modellato in modo grafico contrassegnando gli eventi su una sequenza temporale come è il caso qui:

Mostra che uno stream è una sequenza di eventi in corso ordinati in tempo

Immagine presa da dall'introduzione alla programmazione reattiva che ti sei perso .

Abbiamo già visto flussi eseguibili negli esempi della sezione precedente. Otteniamo un RunnableGraphogni volta che un flusso può effettivamente essere materializzato, il che significa che un Sinkè collegato a unSource . Finora ci siamo sempre materializzati al valore Unit, che può essere visto nei tipi:

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)

Per Sourcee Sinkil secondo parametro di tipo e per Flowil terzo parametro di tipo indicano il valore materializzato. In tutta questa risposta, il pieno significato della materializzazione non deve essere spiegato. Tuttavia, ulteriori dettagli sulla materializzazione sono disponibili all'indirizzo documentazione ufficiale . Per ora l'unica cosa che dobbiamo sapere è che il valore materializzato è ciò che otteniamo quando eseguiamo un flusso. Dato che fino ad ora eravamo interessati solo agli effetti collaterali, abbiamo ottenuto Unitil valore materializzato. L'eccezione a questo è stata una materializzazione di un lavandino, che ha portato a Future. Ci ha restituito aFuture, poiché questo valore può indicare quando il flusso collegato al sink è stato terminato. Finora, i precedenti esempi di codice erano belli da spiegare, ma erano anche noiosi perché ci occupavamo solo di flussi finiti o di infiniti molto semplici. Per renderlo più interessante, nel seguito verrà spiegato un flusso asincrono e illimitato completo.

Esempio ClickStream

Ad esempio, vogliamo avere un flusso che acquisisca eventi di clic. Per renderlo più impegnativo, supponiamo di voler anche raggruppare eventi di clic che si verificano in breve tempo uno dopo l'altro. In questo modo potremmo facilmente scoprire doppi, tripli o dieci volte clic. Inoltre, vogliamo filtrare tutti i singoli clic. Fai un respiro profondo e immagina come risolveresti il ​​problema in modo imperativo. Scommetto che nessuno sarebbe in grado di implementare una soluzione che funzioni correttamente al primo tentativo. In modo reattivo questo problema è banale da risolvere. In effetti, la soluzione è così semplice e diretta da implementare che possiamo persino esprimerla in un diagramma che descrive direttamente il comportamento del codice:

La logica dell'esempio del flusso di clic

Immagine tratta dall'introduzione alla programmazione reattiva che ti sei perso .

Le caselle grigie sono funzioni che descrivono come un flusso viene trasformato in un altro. Con la throttlefunzione accumuliamo clic entro 250 millisecondi, le funzioni mape filterdovrebbero essere autoesplicative. Le sfere di colore rappresentano un evento e le frecce descrivono il modo in cui fluiscono attraverso le nostre funzioni. Più avanti nelle fasi di elaborazione, otteniamo sempre meno elementi che scorrono nel nostro flusso, poiché li raggruppiamo e filtriamo. Il codice per questa immagine sarebbe simile al seguente:

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

Tutta la logica può essere rappresentata in sole quattro righe di codice! A Scala, potremmo scriverlo ancora più breve:

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

La definizione di clickStreamè un po 'più complessa, ma questo è solo il caso perché il programma di esempio viene eseguito sulla JVM, dove l'acquisizione di eventi clic non è facilmente possibile. Un'altra complicazione è che Akka di default non fornisce la throttlefunzione. Invece abbiamo dovuto scriverlo da soli. Dal momento che questa funzione (come nel caso delle funzioni mapo filter) è riutilizzabile in diversi casi d'uso, non conto queste righe per il numero di righe necessarie per implementare la logica. Nei linguaggi imperativi, tuttavia, è normale che la logica non possa essere riutilizzata così facilmente e che i diversi passaggi logici avvengano tutti in un unico posto invece di essere applicati in sequenza, il che significa che probabilmente avremmo deformato il nostro codice con la logica di limitazione. L'esempio di codice completo è disponibile comein sintesi e non sarà più discusso qui.

Esempio di SimpleWebServer

Quello che dovrebbe essere discusso invece è un altro esempio. Mentre il flusso di clic è un bell'esempio per consentire ad Akka Streams di gestire un esempio del mondo reale, manca del potere di mostrare l'esecuzione parallela in azione. Il prossimo esempio deve rappresentare un piccolo server Web in grado di gestire più richieste in parallelo. Il server Web deve essere in grado di accettare connessioni in entrata e ricevere sequenze di byte da esse che rappresentano segni ASCII stampabili. Queste sequenze o stringhe di byte devono essere divise in tutti i caratteri di nuova riga in parti più piccole. Successivamente, il server deve rispondere al client con ciascuna delle linee di divisione. In alternativa, potrebbe fare qualcos'altro con le linee e dare un token di risposta speciale, ma vogliamo mantenerlo semplice in questo esempio e quindi non introdurre alcuna funzionalità di fantasia. Ricorda, il server deve essere in grado di gestire più richieste contemporaneamente, il che significa sostanzialmente che nessuna richiesta è autorizzata a bloccare qualsiasi altra richiesta da ulteriori esecuzioni. Risolvere tutti questi requisiti può essere difficile in modo imperativo - con Akka Streams, tuttavia, non dovremmo aver bisogno di più di poche righe per risolvere nessuno di questi. Innanzitutto, diamo una panoramica sul server stesso:

server

Fondamentalmente, ci sono solo tre blocchi principali. Il primo deve accettare le connessioni in entrata. Il secondo deve gestire le richieste in arrivo e il terzo deve inviare una risposta. L'implementazione di tutti e tre questi blocchi è solo un po 'più complicata rispetto all'implementazione del flusso di clic:

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

La funzione mkServerprende (oltre all'indirizzo e alla porta del server) anche un sistema attore e un materializzatore come parametri impliciti. Il flusso di controllo del server è rappresentato da binding, che prende una fonte di connessioni in entrata e le inoltra a un sink di connessioni in entrata. All'interno di connectionHandler, che è il nostro lavandino, gestiamo ogni connessione tramite il flusso serverLogic, che verrà descritto più avanti. bindingrestituisce aFuture, che si completa quando il server è stato avviato o l'avvio non è riuscito, il che potrebbe essere il caso in cui la porta è già occupata da un altro processo. Il codice, tuttavia, non riflette completamente l'immagine poiché non possiamo vedere un blocco predefinito che gestisce le risposte. La ragione di ciò è che la connessione fornisce già questa logica da sola. È un flusso bidirezionale e non solo unidirezionale come i flussi che abbiamo visto negli esempi precedenti. Come nel caso della materializzazione, tali flussi complessi non devono essere spiegati qui. La documentazione ufficiale contiene molti materiali per coprire grafici di flusso più complessi. Per ora è sufficiente sapere che Tcp.IncomingConnectionrappresenta una connessione che sa come ricevere richieste e come inviare risposte. La parte che manca ancora è laserverLogic blocco. Può assomigliare a questo:

logica del server

Ancora una volta, siamo in grado di dividere la logica in diversi semplici blocchi che insieme formano il flusso del nostro programma. Per prima cosa vogliamo dividere la nostra sequenza di byte in linee, cosa che dobbiamo fare ogni volta che troviamo un carattere di nuova riga. Successivamente, i byte di ogni riga devono essere convertiti in una stringa perché lavorare con byte grezzi è complicato. Nel complesso potremmo ricevere un flusso binario di un protocollo complicato, il che renderebbe estremamente impegnativo lavorare con i dati grezzi in arrivo. Una volta che abbiamo una stringa leggibile, possiamo creare una risposta. Per ragioni di semplicità la risposta può essere qualsiasi cosa nel nostro caso. Alla fine, dobbiamo riconvertire la nostra risposta in una sequenza di byte che possono essere inviati via cavo. Il codice per l'intera logica può essere simile al seguente:

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

Sappiamo già che serverLogicè un flusso che richiede un ByteStringe deve produrre un ByteString. Con delimiterpossiamo dividere una ByteStringin parti più piccole - nel nostro caso deve accadere ogni volta che si verifica un carattere di nuova riga. receiverè il flusso che accetta tutte le sequenze di byte divisi e le converte in una stringa. Questa è ovviamente una conversione pericolosa, poiché solo i caratteri ASCII stampabili dovrebbero essere convertiti in una stringa ma per le nostre esigenze è abbastanza buono. responderè l'ultimo componente ed è responsabile della creazione di una risposta e della conversione della risposta in una sequenza di byte. A differenza della grafica, non abbiamo diviso quest'ultimo componente in due, poiché la logica è banale. Alla fine, colleghiamo tutti i flussi attraverso ilviafunzione. A questo punto ci si può chiedere se ci siamo occupati della proprietà multiutente menzionata all'inizio. E infatti lo abbiamo fatto anche se potrebbe non essere ovvio immediatamente. Guardando questo grafico dovrebbe essere più chiaro:

logica server e server combinata

Il serverLogiccomponente non è altro che un flusso che contiene flussi più piccoli. Questo componente accetta un input, che è una richiesta, e produce un output, che è la risposta. Poiché i flussi possono essere costruiti più volte e funzionano tutti indipendentemente l'uno dall'altro, otteniamo attraverso questo annidamento la nostra proprietà multiutente. Ogni richiesta viene gestita all'interno della propria richiesta e pertanto una richiesta in esecuzione breve può annullare una richiesta in esecuzione in precedenza avviata in precedenza. Nel caso ti chiedessi, la definizione di serverLogicciò che è stata mostrata in precedenza può ovviamente essere scritta molto più breve incorporando la maggior parte delle sue definizioni interne:

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

Un test del server Web potrebbe essere simile al seguente:

$ # 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?

Affinché l'esempio di codice sopra riportato funzioni correttamente, dobbiamo prima avviare il server, che è rappresentato dallo startServerscript:

$ # 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?

L'esempio di codice completo di questo semplice server TCP è disponibile qui . Non siamo solo in grado di scrivere un server con Akka Streams ma anche il client. Potrebbe apparire così:

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

Il client TCP con codice completo è disponibile qui . Il codice sembra abbastanza simile ma al contrario del server non dobbiamo più gestire le connessioni in entrata.

Grafici complessi

Nelle sezioni precedenti abbiamo visto come possiamo costruire semplici programmi fuori dai flussi. Tuttavia, in realtà spesso non è sufficiente basarsi solo su funzioni già integrate per costruire flussi più complessi. Se vogliamo essere in grado di utilizzare Akka Streams per programmi arbitrari, dobbiamo sapere come costruire le nostre strutture di controllo personalizzate e flussi combinabili che ci consentano di affrontare la complessità delle nostre applicazioni. La buona notizia è che Akka Streams è stato progettato per adattarsi alle esigenze degli utenti e per fornire una breve introduzione alle parti più complesse di Akka Streams, abbiamo aggiunto alcune funzionalità in più al nostro esempio client / server.

Una cosa che non possiamo ancora fare è chiudere una connessione. A questo punto inizia a diventare un po 'più complicato perché l'API dello stream che abbiamo visto finora non ci consente di arrestare uno stream in un punto arbitrario. Tuttavia, esiste l' GraphStageastrazione, che può essere utilizzata per creare fasi arbitrarie di elaborazione dei grafici con un numero qualsiasi di porte di input o output. Diamo prima un'occhiata al lato server, dove presentiamo un nuovo componente, chiamato 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)
    })
  }
}

Questa API sembra molto più ingombrante dell'API di flusso. Nessuna meraviglia, dobbiamo fare molti passi imperativi qui. In cambio, abbiamo un maggiore controllo sul comportamento dei nostri flussi. Nell'esempio sopra, specifichiamo solo una porta di ingresso e una di uscita e le rendiamo disponibili al sistema sovrascrivendo il shapevalore. Inoltre abbiamo definito un cosiddetto InHandlere un OutHandler, che sono in questo ordine responsabili della ricezione e dell'emissione degli elementi. Se hai esaminato attentamente l'esempio del flusso di clic completo, dovresti già riconoscere questi componenti. Nel InHandlerprendere un elemento e se è una stringa con un singolo carattere 'q', vogliamo chiudere il flusso. Per dare al cliente la possibilità di scoprire che lo stream verrà presto chiuso, emettiamo la stringa"BYE"e poi chiudiamo immediatamente il palco. Il closeConnectioncomponente può essere combinato con un flusso tramite il viametodo, che è stato introdotto nella sezione sui flussi.

Oltre a poter chiudere le connessioni, sarebbe anche bello poter mostrare un messaggio di benvenuto a una connessione appena creata. Per fare ciò dobbiamo ancora una volta andare un po 'oltre:

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

La funzione serverLogic ora accetta la connessione in entrata come parametro. All'interno del suo corpo utilizziamo un DSL che ci consente di descrivere comportamenti complessi del flusso. Con welcomecreiamo uno stream che può emettere solo un elemento: il messaggio di benvenuto. logicè ciò che è stato descritto come serverLogicnella sezione precedente. L'unica differenza notevole è che abbiamo aggiunto closeConnectionad esso. Ora in realtà arriva la parte interessante della DSL. La GraphDSL.createfunzione rende bdisponibile un builder , che viene utilizzato per esprimere lo stream come grafico. Con la ~>funzione è possibile collegare tra loro le porte di ingresso e uscita. Il Concatcomponente utilizzato nell'esempio può concatenare elementi ed è qui usato per anteporre il messaggio di benvenuto di fronte agli altri elementi che escono dainternalLogic. Nell'ultima riga, rendiamo disponibili solo la porta di input della logica del server e la porta di output del flusso concatenato perché tutte le altre porte devono rimanere un dettaglio di implementazione del serverLogiccomponente. Per un'introduzione approfondita al grafico DSL di Akka Streams, visitare la sezione corrispondente nella documentazione ufficiale . L'esempio di codice completo del server TCP complesso e di un client in grado di comunicare con esso è disponibile qui . Ogni volta che apri una nuova connessione dal client dovresti vedere un messaggio di benvenuto e digitando "q"sul client dovresti vedere un messaggio che ti dice che la connessione è stata annullata.

Ci sono ancora alcuni argomenti che non sono stati trattati da questa risposta. Soprattutto la materializzazione può spaventare un lettore o un altro, ma sono sicuro che con il materiale che è coperto qui tutti dovrebbero essere in grado di procedere da soli. Come già detto, la documentazione ufficiale è un buon posto per continuare a conoscere Akka Streams.


4
@monksy Non avevo intenzione di pubblicarlo altrove. Sentiti libero di ripubblicare questo sul tuo blog se vuoi. L'API al giorno d'oggi è stabile nella maggior parte delle parti, il che significa che probabilmente non devi nemmeno preoccuparti della manutenzione (la maggior parte degli articoli del blog su Akka Streams là fuori sono obsoleti poiché mostrano un'API che non esiste più).
Kiritsuku,

3
Non scomparirà. Perché dovrebbe?
Kiritsuku,

2
@sschaef Potrebbe benissimo scomparire perché la domanda è fuori tema ed è stata chiusa come tale.
DavidG,

7
@Magisch Ricorda sempre: "Non eliminiamo buoni contenuti". Non ne sono del tutto sicuro, ma immagino che questa risposta possa effettivamente qualificarsi, nonostante tutto.
Deduplicatore

9
Questo post potrebbe essere utile per la nuova funzione Documentazione di Stack Overflow, una volta aperta per Scala.
SL Barth - Ripristina Monica il
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.