web-dev-qa-db-fra.com

Comment démarrer avec Akka Streams?

La bibliothèque Akka Streams contient déjà assez richesse de la documentation . Cependant, le principal problème pour moi est que cela fournit trop de matériel - je me sens assez dépassé par le nombre de concepts que je dois apprendre. De nombreux exemples sont très lourds et difficiles à traduire en cas d’utilisation. Ils sont donc très ésotériques. Je pense que cela donne beaucoup trop de détails sans expliquer comment construire tous les blocs de construction ensemble et comment cela aide à résoudre des problèmes spécifiques.

Il y a des sources, des puits, des flux, des étapes de graphe, des graphes partiels, une matérialisation, un graphe DSL et bien plus encore et je ne sais tout simplement pas par où commencer. Le guide de démarrage rapide est censé être un point de départ, mais je ne le comprends pas. Cela introduit simplement les concepts mentionnés ci-dessus sans les expliquer. De plus, les exemples de code ne peuvent pas être exécutés - il manque des éléments, ce qui me rend plus ou moins impossible de suivre le texte.

Quelqu'un peut-il expliquer les concepts sources, puits, flux, étapes de graphe, graphes partiels, matérialisation et peut-être quelques autres choses qui me manquent avec des mots simples et des exemples simples qui n'expliquent pas tous les détails le début)?

218
kiritsuku

Cette réponse est basée sur akka-stream version 2.4.2. L'API peut être légèrement différente dans les autres versions. La dépendance peut être consommée par sbt :

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

Bon, permet de commencer. L'API d'Akka Streams se compose de trois types principaux. Contrairement à flux réactifs , ces types sont beaucoup plus puissants et donc plus complexes. Il est supposé que pour tous les exemples de code, les définitions suivantes existent déjà:

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

Les instructions import sont nécessaires pour les déclarations de type. system représente le système d'acteurs d'Akka et materializer représente le contexte d'évaluation du flux. Dans notre cas, nous utilisons un ActorMaterializer, ce qui signifie que les flux sont évalués sur les acteurs. Les deux valeurs sont marquées comme suit: implicit, ce qui donne au compilateur Scala la possibilité d'injecter ces deux dépendances automatiquement quand elles sont nécessaires. Nous importons également system.dispatcher, qui est un contexte d'exécution pour Futures .

Une nouvelle API

Akka Streams ont les propriétés clés suivantes:

  • Ils implémentent la spécification des flux réactifs , dont les trois objectifs principaux: contrepression, frontières asynchrones et non bloquantes et interopérabilité entre différentes implémentations s'appliquent également à Akka Streams.
  • Ils fournissent une abstraction pour un moteur d'évaluation des flux, appelé Materializer.
  • Les programmes sont formulés sous forme de blocs de construction réutilisables, représentés par les trois types principaux Source, Sink et Flow. Les blocs de construction forment un graphe dont l'évaluation est basée sur Materializer et doit être explicitement déclenchée.

Dans ce qui suit, une introduction plus détaillée sur l’utilisation des trois types principaux sera donnée.

La source

Source est un créateur de données, il sert de source d’entrée au flux. Chaque Source a un seul canal de sortie et aucun canal d’entrée. Toutes les données transitent par le canal de sortie vers ce qui est connecté à Source.

Source

Image prise de boldradius.com .

Un Source peut être créé de plusieurs manières:

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

Dans les cas ci-dessus, nous avons alimenté le Source avec des données finies, ce qui signifie qu'elles finiront par se terminer. Il ne faut pas oublier que les flux réactifs sont paresseux et asynchrones par défaut. Cela signifie qu'il faut explicitement demander l'évaluation du flux. Dans Akka Streams, cela peut être effectué à l'aide des méthodes run*. runForeach ne serait pas différent de la fonction bien connue foreach - grâce à l’ajout de run, il est clairement indiqué que nous demandons une évaluation du flux. Puisque les données finies sont ennuyeuses, nous continuons avec l'infini:

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

Avec la méthode take, nous pouvons créer un point d'arrêt artificiel qui nous empêche d'évaluer indéfiniment. Étant donné que la prise en charge des acteurs est intégrée, nous pouvons également facilement alimenter le flux avec les messages envoyés à un acteur:

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

Nous pouvons voir que les Futures sont exécutés de manière asynchrone sur différents threads, ce qui explique le résultat. Dans l'exemple ci-dessus, une mémoire tampon pour les éléments entrants n'est pas nécessaire et, par conséquent, avec OverflowStrategy.fail, nous pouvons configurer que le flux doit échouer en cas de dépassement de mémoire tampon. Grâce à cette interface d'acteur, nous pouvons alimenter le flux par n'importe quelle source de données. Peu importe que les données soient créées par le même thread, par un autre, par un autre processus ou si elles proviennent d'un système distant via Internet.

Évier

Sink est fondamentalement le contraire de Source. C'est le point final d'un flux et consomme donc des données. Un Sink a un seul canal d'entrée et aucun canal de sortie. Sinks sont particulièrement nécessaires lorsque nous voulons spécifier le comportement du collecteur de données de manière réutilisable et sans évaluer le flux. Les méthodes déjà connues run* ne nous permettent pas ces propriétés, il est donc préférable d'utiliser Sink à la place.

Sink

Image prise de boldradius.com .

Un court exemple de Sink en 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

Connecter un Source à un Sink peut être fait avec la méthode to. Il renvoie ce que l’on appelle RunnableFlow, comme nous le verrons plus tard, sous la forme spéciale de Flow - un flux qui peut être exécuté en appelant simplement sa méthode run().

Runnable Flow

Image prise de boldradius.com .

Il est bien sûr possible de transmettre toutes les valeurs qui arrivent à un évier à un acteur:

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

Couler

Les sources et les puits de données sont parfaits si vous avez besoin d'une connexion entre les flux Akka et un système existant, mais que vous ne pouvez vraiment rien en faire. Les flux sont la dernière pièce manquante dans l'abstraction de la base Akka Streams. Ils agissent comme un connecteur entre différents flux et peuvent être utilisés pour transformer ses éléments.

Flow

Image prise de boldradius.com .

Si un Flow est connecté à un Source, un nouveau Source est obtenu. De même, un Flow connecté à un Sink crée un nouveau Sink. Et si Flow est associé à Source et à Sink, il en résulte un RunnableFlow. Par conséquent, ils se situent entre le canal d'entrée et le canal de sortie mais ne correspondent pas en eux-mêmes à l'une des saveurs tant qu'ils ne sont pas connectés à un Source ou à un Sink.

Full Stream

Image prise de boldradius.com .

Afin de mieux comprendre Flows, examinons quelques exemples:

scala> val source = Source(1 to 3)
source: akka.stream.scaladsl.Source[Int,akka.NotUsed] = ...

scala> val sink = Sink.foreach[Int](println)
sink: akka.stream.scaladsl.Sink[Int,scala.concurrent.Future[akka.Done]] = ...

scala> val invert = Flow[Int].map(elem => elem * -1)
invert: akka.stream.scaladsl.Flow[Int,Int,akka.NotUsed] = ...

scala> val doubler = Flow[Int].map(elem => elem * 2)
doubler: akka.stream.scaladsl.Flow[Int,Int,akka.NotUsed] = ...

scala> val runnable = source via invert via doubler to sink
runnable: akka.stream.scaladsl.RunnableGraph[akka.NotUsed] = ...

scala> runnable.run()
res10: akka.NotUsed = NotUsed
-2
-4
-6

Via la méthode via, nous pouvons connecter un Source à un Flow. Nous devons spécifier le type d'entrée car le compilateur ne peut pas nous en inférer. Comme nous pouvons déjà le voir dans cet exemple simple, les flux invert et double sont complètement indépendants de tout producteur et consommateur de données. Ils ne font que transformer les données et les transmettre au canal de sortie. Cela signifie que nous pouvons réutiliser un flux entre plusieurs flux:

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 et s2 représentent des flux entièrement nouveaux - ils ne partagent aucune donnée via leurs blocs de construction.

Flux de données non liés

Avant de poursuivre, nous devrions d'abord revoir certains des aspects clés des flux réactifs. Un nombre illimité d'éléments peut arriver à n'importe quel point et peut placer un flux dans différents états. Outre un flux exécutable, qui correspond à l'état habituel, un flux peut être arrêté soit par une erreur, soit par un signal indiquant qu'aucune donnée supplémentaire ne sera livrée. Un flux peut être modélisé de manière graphique en marquant des événements sur une timeline comme c'est le cas ici:

Shows that a stream is a sequence of ongoing events ordered in time

Image extraite de L'introduction à la programmation réactive que vous aviez manquante .

Nous avons déjà vu des flux exécutables dans les exemples de la section précédente. Nous obtenons un RunnableGraph chaque fois qu'un flux peut réellement être matérialisé, ce qui signifie qu'un Sink est connecté à un Source. Jusqu'ici nous nous sommes toujours matérialisés à la valeur Unit, ce qui peut être vu dans les types:

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)

Pour Source et Sink, le deuxième paramètre de type et pour Flow, le troisième paramètre de type indique la valeur matérialisée. Tout au long de cette réponse, le sens complet de la matérialisation ne sera pas expliqué. Cependant, vous pouvez trouver plus de détails sur la matérialisation sur le documentation officielle . Pour l'instant, la seule chose que nous devons savoir, c'est que la valeur matérialisée correspond à ce que nous obtenons lorsque nous gérons un flux. Comme nous n'étions intéressés que par les effets secondaires jusqu'à présent, nous avons obtenu Unit comme valeur matérialisée. La seule exception à cette règle est la matérialisation d'un évier, ce qui a abouti à un Future. Cela nous a retourné un Future, puisque cette valeur peut indiquer la fin du flux connecté au récepteur. Jusqu'ici, les exemples de code précédents étaient utiles pour expliquer le concept, mais ils étaient également ennuyeux car nous ne traitions que des flux finis ou d'infinis très simples. Pour le rendre plus intéressant, nous allons maintenant expliquer un flux complet asynchrone et sans limite.

Exemple ClickStream

Par exemple, nous souhaitons avoir un flux qui capture les événements de clic. Pour rendre les choses plus difficiles, supposons que nous voulions également regrouper les événements de clic qui se produisent peu de temps après les autres. De cette façon, nous pourrions facilement découvrir des clics doubles, triples ou multipliés. De plus, nous souhaitons filtrer tous les simples clics. Prenez une profonde respiration et imaginez comment résoudre ce problème de manière impérative. Je parie que personne ne pourrait mettre en œuvre une solution qui fonctionne correctement du premier coup. De manière réactive, ce problème est trivial à résoudre. En fait, la solution est si simple et simple à mettre en œuvre que nous pouvons même l'exprimer dans un diagramme qui décrit directement le comportement du code:

The logic of the click stream example

Image extraite de L'introduction à la programmation réactive que vous aviez manquante .

Les zones grises sont des fonctions décrivant comment un flux est transformé en un autre. Avec la fonction throttle, nous accumulons les clics dans un délai de 250 millisecondes. Les fonctions map et filter doivent être explicites. Les orbes de couleur représentent un événement et les flèches décrivent comment ils traversent nos fonctions. Plus tard dans les étapes de traitement, nous avons de moins en moins d'éléments qui circulent dans notre flux, car nous les regroupons et les filtrons. Le code de cette image ressemblerait à ceci:

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

Toute la logique peut être représentée dans seulement quatre lignes de code! En Scala, nous pourrions l'écrire encore plus court:

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

La définition de clickStream est un peu plus complexe, mais ce n'est que dans la mesure où l'exemple de programme est exécuté sur la machine virtuelle Java, où la capture des événements de clic n'est pas facilement possible. Une autre complication est qu’Akka, par défaut, ne fournit pas la fonction throttle. Au lieu de cela, nous avons dû l'écrire nous-mêmes. Comme cette fonction est (comme c'est le cas pour les fonctions map ou filter) réutilisable dans différents cas d'utilisation, je ne compte pas ces lignes dans le nombre de lignes dont nous avions besoin pour implémenter la logique. Cependant, dans les langages impératifs, il est normal que la logique ne puisse pas être réutilisée aussi facilement et que les différentes étapes logiques se produisent toutes au même endroit au lieu d’être appliquées séquentiellement, ce qui signifie que nous aurions probablement mal interprété notre code avec la logique de limitation. L'exemple de code complet est disponible sous la forme Gist et ne sera plus décrit ici.

Exemple SimpleWebServer

Ce qui devrait être discuté à la place est un autre exemple. Si le flux de clics est un bon exemple permettant à Akka Stream de gérer un exemple réel, il ne dispose pas du pouvoir nécessaire pour afficher une exécution parallèle en action. L'exemple suivant doit représenter un petit serveur Web capable de gérer plusieurs demandes en parallèle. Le serveur Web doit pouvoir accepter les connexions entrantes et en recevoir des séquences d'octets représentant des signes imprimables ASCII. Ces séquences d'octets ou ces chaînes doivent être divisées en parties plus petites sur tous les caractères de nouvelle ligne. Après cela, le serveur doit répondre au client avec chacune des lignes fractionnées. Alternativement, il pourrait faire autre chose avec les lignes et donner un jeton de réponse spécial, mais nous voulons garder les choses simples dans cet exemple et, par conséquent, n'introduire aucune fonctionnalité sophistiquée. Rappelez-vous que le serveur doit pouvoir gérer plusieurs demandes en même temps, ce qui signifie en principe qu'aucune demande n'est autorisée à bloquer toute autre demande d'une exécution ultérieure. La résolution de toutes ces exigences peut s'avérer difficile de manière impérative - avec Akka Streams, nous ne devrions toutefois pas avoir besoin de plus de quelques lignes pour les résoudre. D'abord, voyons le serveur lui-même:

server

Fondamentalement, il n'y a que trois blocs de construction principaux. Le premier doit accepter les connexions entrantes. Le second doit gérer les demandes entrantes et le troisième doit envoyer une réponse. L'implémentation de ces trois blocs de construction n'est qu'un peu plus compliquée que l'implémentation du flux de clics:

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 fonction mkServer prend (outre l'adresse et le port du serveur) également un système d'acteur et un matérialisateur en tant que paramètres implicites. Le flux de contrôle du serveur est représenté par binding, qui prend une source de connexions entrantes et les transmet à un collecteur de connexions entrantes. À l'intérieur de connectionHandler, qui est notre évier, nous traitons chaque connexion par le flux serverLogic, qui sera décrit plus tard. binding renvoie un Future, qui se termine lorsque le serveur a été démarré ou que le démarrage a échoué, ce qui peut être le cas lorsque le port est déjà pris par un autre processus. Cependant, le code ne reflète pas complètement le graphique car nous ne pouvons pas voir un bloc de construction qui gère les réponses. La raison en est que la connexion fournit déjà cette logique par elle-même. Il s’agit d’un flux bidirectionnel et non pas unidirectionnel comme les flux décrits dans les exemples précédents. Comme c'était le cas pour la matérialisation, de tels flux complexes ne seront pas expliqués ici. Le documentation officielle contient beaucoup de matériel pour couvrir des graphiques de flux plus complexes. Pour le moment, il suffit de savoir que Tcp.IncomingConnection représente une connexion qui sait comment recevoir des demandes et comment envoyer des réponses. La pièce qui manque toujours est le bloc de construction serverLogic. Cela peut ressembler à ceci:

server logic

Une fois encore, nous sommes en mesure de scinder la logique en plusieurs blocs de construction simples qui, tous ensemble, forment le flux de notre programme. Tout d'abord, nous voulons diviser notre séquence d'octets en lignes, ce que nous devons faire chaque fois que nous trouvons un caractère de nouvelle ligne. Après cela, les octets de chaque ligne doivent être convertis en chaîne, car il est fastidieux d’utiliser des octets bruts. Globalement, nous pourrions recevoir un flux binaire d'un protocole compliqué, ce qui rendrait extrêmement difficile l'utilisation des données brutes entrantes. Une fois que nous avons une chaîne lisible, nous pouvons créer une réponse. Pour des raisons de simplicité, la réponse peut être n'importe quoi dans notre cas. En fin de compte, nous devons reconvertir notre réponse en une séquence d'octets pouvant être envoyés par câble. Le code de la logique entière peut ressembler à ceci:

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

Nous savons déjà que serverLogic est un flux qui prend un ByteString et doit produire un ByteString. Avec delimiter, nous pouvons diviser un ByteString en parties plus petites. Dans notre cas, cela doit se produire chaque fois qu'un caractère de nouvelle ligne apparaît. receiver est le flux qui prend toutes les séquences d'octets divisées et les convertit en chaîne. Il s’agit bien entendu d’une conversion dangereuse, dans la mesure où seuls les caractères imprimables ASCII doivent être convertis en chaîne, mais pour nos besoins, ils sont suffisants. responder est le dernier composant et est chargé de créer une réponse et de la reconvertir en une séquence d'octets. Contrairement au graphique, nous n'avons pas divisé ce dernier composant en deux, car la logique est triviale. À la fin, nous connectons tous les flux via la fonction via. À ce stade, on peut se demander si nous nous sommes occupés de la propriété multi-utilisateur mentionnée au début. Et en effet nous l’avons fait même si cela n’est peut-être pas évident immédiatement. En regardant ce graphique, cela devrait devenir plus clair:

server and server logic combined

Le composant serverLogic n’est autre qu’un flux contenant des flux plus petits. Ce composant prend une entrée, qui est une requête, et produit une sortie, qui est la réponse. Étant donné que les flux peuvent être construits plusieurs fois et qu'ils fonctionnent tous indépendamment les uns des autres, nous réalisons cet imbrication de notre propriété multi-utilisateur. Chaque demande est traitée dans sa propre demande et par conséquent, une demande en cours d'exécution courte peut surcharger une demande en exécution longue précédemment lancée. Au cas où vous vous seriez demandé, la définition de serverLogic qui a été montrée précédemment peut bien sûr être rédigée beaucoup plus courte en alignant la plupart de ses définitions internes:

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 du serveur Web peut ressembler à ceci:

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

Pour que l'exemple de code ci-dessus fonctionne correctement, nous devons d'abord démarrer le serveur, qui est décrit par le script startServer:

$ # Server
$ ./startServer 127.0.0.1 6666
[DEBUG] Server started, listening on: /127.0.0.1:6666
[DEBUG] Incoming connection from: /127.0.0.1:37972
[DEBUG] Server received: Hello World
[DEBUG] Server received: How are you?

L'exemple de code complet de ce simple serveur TCP peut être trouvé ici . Nous sommes non seulement en mesure d'écrire un serveur avec Akka Streams, mais également le client. Cela peut ressembler à ceci:

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

Le code complet TCP client peut être trouvé ici . Le code semble assez similaire, mais contrairement au serveur, nous n’avons plus à gérer les connexions entrantes.

Graphes complexes

Dans les sections précédentes, nous avons vu comment construire des programmes simples à partir de flux. Cependant, en réalité, il ne suffit souvent pas de compter sur des fonctions déjà intégrées pour créer des flux plus complexes. Si nous voulons pouvoir utiliser Akka Streams pour des programmes arbitraires, nous devons savoir comment créer nos propres structures de contrôle personnalisées et nos flux combinables nous permettant de faire face à la complexité de nos applications. La bonne nouvelle est que Akka Streams a été conçu pour répondre aux besoins des utilisateurs. Afin de vous présenter brièvement les aspects les plus complexes d'Akka Streams, nous ajoutons quelques fonctionnalités supplémentaires à notre exemple client/serveur.

Une chose que nous ne pouvons pas encore faire est de fermer une connexion. À ce stade, la situation devient un peu plus compliquée, car l’API de flux que nous avons vue jusqu’à présent ne nous permet pas d’arrêter un flux à un moment quelconque. Cependant, il existe l'abstraction GraphStage, qui peut être utilisée pour créer des étapes de traitement de graphique arbitraires avec un nombre quelconque de ports d'entrée ou de sortie. Voyons d’abord le côté serveur, où nous introduisons un nouveau composant, appelé 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)
    })
  }
}

Cette API semble beaucoup plus lourde que l'API de flux. Pas étonnant, nous devons faire beaucoup d'étapes impératives ici. En échange, nous contrôlons davantage le comportement de nos flux. Dans l'exemple ci-dessus, nous ne spécifions qu'un port d'entrée et un port de sortie et les mettons à la disposition du système en remplaçant la valeur shape. De plus, nous avons défini un InHandler et un OutHandler, qui sont dans cet ordre responsables de la réception et de l’émission d’éléments. Si vous regardez attentivement l'exemple de flux de clics complet, vous devriez déjà reconnaître ces composants. Dans InHandler, nous saisissons un élément et s'il s'agit d'une chaîne avec un seul caractère 'q', nous voulons fermer le flux. Afin de permettre au client de savoir que le flux sera bientôt fermé, nous émettons la chaîne "BYE" puis nous fermons immédiatement la scène. Le composant closeConnection peut être combiné à un flux via la méthode via, introduite dans la section sur les flux.

En plus de pouvoir fermer des connexions, il serait également agréable de pouvoir afficher un message de bienvenue pour une connexion nouvellement créée. Pour ce faire, nous devons encore une fois aller un peu plus loin:

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 fonction serverLogic prend maintenant la connexion entrante en tant que paramètre. À l'intérieur de son corps, nous utilisons un DSL qui nous permet de décrire le comportement complexe d'un flux. Avec welcome, nous créons un flux qui ne peut émettre qu'un seul élément - le message de bienvenue. logic est ce qui a été décrit comme serverLogic dans la section précédente. La seule différence notable est que nous y avons ajouté closeConnection. Maintenant vient en fait la partie intéressante de la DSL. La fonction GraphDSL.create rend un générateur b disponible, qui permet d'exprimer le flux sous forme de graphique. Avec la fonction ~>, il est possible de connecter des ports d’entrée et de sortie. Le composant Concat utilisé dans l'exemple peut concaténer des éléments. Il est utilisé ici pour ajouter le message de bienvenue devant les autres éléments issus de internalLogic. Dans la dernière ligne, nous rendons uniquement le port d'entrée de la logique du serveur et le port de sortie du flux concaténé, car tous les autres ports doivent rester un détail d'implémentation du composant serverLogic. Pour une introduction détaillée au graphique DSL de Akka Streams, visitez la section correspondante dans le documentation officielle . Vous trouverez l'exemple de code complet du serveur complexe TCP et d'un client pouvant communiquer avec lui ici . À chaque fois que vous ouvrez une nouvelle connexion à partir du client, vous devriez voir un message de bienvenue. En tapant "q" sur le client, vous devriez voir un message vous informant que la connexion a été annulée.

Il y a encore des sujets qui n'ont pas été couverts par cette réponse. En particulier, la matérialisation peut faire peur à un lecteur ou à un autre, mais je suis sûr qu'avec le contenu couvert ici, tout le monde devrait être en mesure de franchir les étapes suivantes seul. Comme déjà dit, le documentation officielle est un bon endroit pour continuer à apprendre Akka Streams.

494
kiritsuku