Questa risposta si basa sulla akka-stream
versione 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 import
istruzioni sono necessarie per le dichiarazioni di tipo. system
rappresenta il sistema di attori di Akka e materializer
rappresenta 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
, Sink
e Flow
. I blocchi costitutivi formano un grafico la cui valutazione si basa su Materializer
e 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 Source
ha 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
.
Immagine tratta da boldradius.com .
A Source
può 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 Source
dati 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 runForeach
sarebbe diverso dalla foreach
funzione ben nota - attraverso l' run
aggiunta 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 take
metodo 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 Futures
vengono 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.fail
noi 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 Sink
ha un singolo canale di ingresso e nessun canale di uscita. Sinks
sono 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 Sink
invece.
Immagine tratta da boldradius.com .
Un breve esempio di a Sink
in 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 Source
a Sink
può essere effettuata con il to
metodo 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.
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.
Immagine tratta da boldradius.com .
Se a Flow
è collegato a Source
a nuovoSource
è il risultato. Allo stesso modo, un Flow
collegato ad un Sink
crea un nuovo Sink
. E un Flow
collegato con a Source
e a Sink
risulta 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 Source
o a Sink
.
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 via
metodo possiamo connettere a Source
con 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 invert
e double
sono 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
s1
e s2
rappresentano 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:
Immagine presa da dall'introduzione alla programmazione reattiva che ti sei perso .
Abbiamo già visto flussi eseguibili negli esempi della sezione precedente. Otteniamo un RunnableGraph
ogni 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 Source
e Sink
il secondo parametro di tipo e per Flow
il 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 Unit
il 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:
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 throttle
funzione accumuliamo clic entro 250 millisecondi, le funzioni map
e filter
dovrebbero 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 throttle
funzione. Invece abbiamo dovuto scriverlo da soli. Dal momento che questa funzione (come nel caso delle funzioni map
o 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:
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 mkServer
prende (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. binding
restituisce 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.IncomingConnection
rappresenta una connessione che sa come ricevere richieste e come inviare risposte. La parte che manca ancora è laserverLogic
blocco. Può assomigliare a questo:
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 ByteString
e deve produrre un ByteString
. Con delimiter
possiamo dividere una ByteString
in 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 ilvia
funzione. 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:
Il serverLogic
componente 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 serverLogic
ciò 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 startServer
script:
$ # 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' GraphStage
astrazione, 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 shape
valore. Inoltre abbiamo definito un cosiddetto InHandler
e 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 InHandler
prendere 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 closeConnection
componente può essere combinato con un flusso tramite il via
metodo, 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 welcome
creiamo uno stream che può emettere solo un elemento: il messaggio di benvenuto. logic
è ciò che è stato descritto come serverLogic
nella sezione precedente. L'unica differenza notevole è che abbiamo aggiunto closeConnection
ad esso. Ora in realtà arriva la parte interessante della DSL. La GraphDSL.create
funzione rende b
disponibile 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 Concat
componente 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 serverLogic
componente. 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.