Netty (2/6) : Modèle de programmation

949 views
Skip to first unread message

Laurent Caillette

unread,
Dec 20, 2016, 1:25:51 AM12/20/16
to tec...@googlegroups.com
Parlons de la façon dont Netty nous aide à concevoir le monde.

Au cœur du modèle de programmation de Netty on a la notion de
``Channel`` qui correspond à une connexion, avec une pile de
traitements associés. La pile de traitement porte le nom de
``ChannelPipeline``. Chaque étage de traitement porte le nom
disgracieux de ``ChannelHandler``.

Un ``ChannelPipeline`` n'impose pas de contrainte directe sur le type
des messages traités. Par contre il orienté, au sens où tous les
messages sont entrants (ils viennent du côté ``Socket``) soit sortants
(le sens opposé).

Le contrat minimal d'un ``ChannelHandler`` c'est :
- Je consomme un objet qui correspond à un message entrant ou sortant.
- Je peux dire au ``ChannelPipeline`` de passer objet au
``ChannelHandler`` précédent ou suivant.
- Je peux injecter un objet à une des extrémités du ``ChannelPipeline``.
- Je peux écrire directement un tableau d'octets vers la connexion.
- Je suis informé des changements de structure du ``ChannelPipeline``.
- Toutes les opérations d'un ``ChannelPipeline`` ont lieu dans le même
fil d'exécution.

Bon j'ai simplifié un peu, ceux qui connaissent Netty auront noté que
ces opérations sur un ``ChannelPipeline`` passent par un
``ChannelContext`` qui permet de partager des ``ChannelPipeline``
entre connexions. De plus avant `Netty 5` il y a une distinction entre
``ChannelInboundHandler`` et ``ChannelOutboundHandler``, mais ça n'est
pas très important non plus. L'important, ce sont les possibilités
offertes par un contrat apparemment très simple.

À quoi ressemble la création d'un ``ChannelHandler`` pour une
connection HTTP côté serveur ? Le code d'initialisation ajoute un
``HttpServerCodec``, puis un ``HttpObjectAggregator``. Le
``HttpServerCodec`` consomme des paquets d'octets pour en faire des
morceaux de requête HTTP (eh oui une requête HTTP peut être "chunked"
c'est à dire qu'elle arrive en petits morceaux,). Quand il a un
morceau présentable (``HttpRequest`` ou ``HttpContent``) il le passe à
l'étage suivant par le biais du ``ChannelHandler``. Le
``ChannelHandler`` trouve alors le ``HttpObjectAggregator`` va
entasser les morceaux jusqu'à en faire un objet ``FullHttpRequest`` et
passer à l'étage suivant ; si on en a mis un ça sera pour effectuer un
traitement applicatif, qui enverra par exemple une
``FullHttpResponse`` qui dit que tout s'est bien passé. Le traitement
de la ``FullHttpResponse`` correspond à peu près au cheminement
inverse.

Arrivé là on peut penser que les Servlets fournissent une interface
programmatique plus simple. Disons que Netty permet d'ouvrir le capot
pour voir la magie, et y mettre les doigts si besoin. Le code de
l'exemple ci-dessus est à peu près aussi long que son équivalent Jetty
(juste ajouter un ``HttpServerCodec``, un ``HttpObjectAggregator``, et
du code applicatif). Le modèle de programmation de Netty permet par
contre d'effectuer des traitements tout au long de l'arrivée de la
requête HTTP. Dans l'exemple ci-dessus le ``HttpAggregator`` permet
d'avoir la requête en un seul morceau, mais s'il y a une tonne
d'en-têtes, ou le téléchargement d'un contenu volumineux, on peut
utiliser ses propres ``ChannelHandler`` qui prendront les décisions
appropriées. Un exemple (un peu artificiel) c'est de dégager très tôt
les requêtes HTTP dont le verbe n'est pas GET et du HEAD, avant même
de payer le coût du décodage des en-têtes. Arrivé là on voit bien que
si Netty ne nécessite pas d'effort particulier pour supporter un cas
d'utilisation classique, le mécanisme du ``ChannelPipeline`` se prête
à toutes sortes d'optimisations et de contorsions.

La "documentation"
https://netty.io/4.1/api/io/netty/channel/ChannelPipeline.html
décrit le fonctionnement du ``ChannelPipeline`` comme une variation du
modèle de conception "filtres intercepteurs"
http://www.oracle.com/technetwork/java/interceptingfilter-142169.html
utilisé notamment par les Servlets. Mais le modèle J2EE ne prévoit pas
qu'un filtre "décide" d'envoyer des requêtes ou des réponses de sa
propre initiative.

Une contrainte majeure du modèle de programmation basé sur le
``ChannelPipeline``, c'est qu'on ne doit jamais effectuer
d'entrées-sorties bloquantes. On organise les données qui circulent
entre les étages du ``ChannelPipeline`` de façon à se rapprocher du
mode de fonctionnement où on remplit le tampon mémoire d'une carte
réseau avant de déclencher une interruption ; autrement dit au lieu de
boucler sur la lecture ou l'écriture d'un ``InputStream`` ou un
``OutputStream``, on manipule des objets d'une taille limitée.

Si les entrées-sorties ne sont pas bloquantes, comment programmer un
comportement séquentiel, ou par exemple on force le transfert des
données du tampon ("flush") une fois l'écriture terminée ? Ou, tout
simplement, comment récupérer une erreur d'entrée-sortie ? Facile, on
attache à l'opération une fonction de rappel ("callback"), exécutée
quand l'écriture est finie, ou si une erreur a eu lieu.

On trouve tout un tas de commentaires sur l'imbrication des fonctions
de retour qui finit par devenir illisible ("callback hell"). Dans la
pratique il est possible de réduire la complexité avec une bonne
répartition des rôles entre ``ChannelHandler`` et en explicitant le
graphe de transition d'état. Il ne faut pas demander à Netty de faire
de la magie en rendant faussement simple des choses intrinsèquement
compliquées.

Dès qu'on a une fonction de rappel il faut se poser la question : dans
quel fil d'exécution s'exécute-elle ? Autrement dit, quels sont les
risques de concurrence d'accès ? Netty apporte une réponse magistrale
: pour une connexion donnée, il ne peut pas y avoir plus d'un fil
d'exécution exécutant le code des ``ChannelsHandler`` et des fonctions
de retour associés. Autrement dit, au sein d'un ``ChannelPipeline``,
il n'y a pas à se préoccuper des problèmes de concurrence d'accès,
donc on reste bien dans cette logique de ne pas bloquer les fils
d'exécution. Évidemment il y a toujours moyen de se vautrer lors
d'accès concurrents à des données partagées entre les
``ChannelPipeline`` mais ce n'est pas la faute de Netty.

D'ailleurs, pourquoi ne pas bloquer les fils d'exécution (avec des
entrées-sorties ou autre chose) ? En gros, chaque fois qu'un cœur du
processeur commute entre deux fils d'exécution, les caches sont
invalidés, donc il faut aller chercher des information dans la mémoire
centrale, dont les temps d'accès sont quelques ordres de grandeur plus
élevés que ceux des caches. On obtient le rendement optimal avec un
fil d'exécution dédié à chaque cœur, et qui consomme des tâches à
partir d'une file d'exécution, sans jamais bloquer sur quoi que ce
soit.

Les entrées-sorties non-bloquantes et asynchrones de Java fournissent
les primitives de bas niveau pour cela. Pour un ensemble de
``java.net.Socket``, un ``java.nio.channels.Selector`` indique si la
``Socket`` a reçu des données ; c'est le code applicatif qui choisit
quand et comment les consommer. De la même façon, l'écriture sur un
``java.nio.channels.AsynchronousChannel`` se conclut par l'appel d'un
``java.nio.channels.CompletionHandler`` (ou l'achèvement d'une
``Future``). Mais dans ce que fournit Java, il n'y a rien qui
structure l'utilisation des fils d'exécution.

C'est là que Netty intervient avec sa boucle d'exécution. Quand on
crée une connexion client ou serveur, on passe une instance
d'``EventLoop`` qui va se débrouiller pour parler aux
``java.net.Socket`` et à enchaîner les appels de ``ChannelHandler``
sans jamais que deux fils d'exécution se marchent sur les pieds. En
général on crée une ``EventLoop`` avec un certain nombre de fils
d'exécution et une ``ThreadFactory`` pour garantir un nommage cohérent
(on peut affecter un ``ThreadGroup`` si on veut se la jouer).

Là encore j'ai simplifié, la hiérarchie de classes autour de
l'``EventLoop`` est assez compliquée car elle supporte beaucoup de cas
différents (entrées-sorties bloquantes ou non-bloquantes, côté client
ou serveur). Le plus important, ce sont les garanties de
non-concurrence d'accès fournies par le ``ChannelPipeline``. Pour
modéliser ce contrat qui dit en substance "J'exécuterai ce bout de
code quand j'aurai le temps", le ``ChannelPipeline`` expose
l'interface ``java.util.concurrent.ScheduledExecutorService`` qui de
nombreuses facilités pour l'exécution asynchrone, éventuellement après
un certain délai et selon une certaine fréquence. La façon imparable
d'arrêter un service basé sur Netty, c'est de terminer sa boucle
d'événement avec un
``java.util.concurrent.ExecutorService#awaitTermination``, qui
garantit que toutes les tâches en cours se terminent. (Bon là je
simplifie trop ; pour être propre il faut d'abord bloquer les nouveaux
messages entrants, envoyer des messages de déconnexion, puis
finalement fermer les connexions réseau).

Cela dit le contrat du ``ScheduledExecutorService`` se limite à
exéctuer une tâche générique. Mais souvent on veut brancher une
fonction de retour sur une tâche déterminée, par exemple l'écriture
dans les tampons. À cette fin Netty utilise systématiquement un dérivé
de ``java.util.concurrent.Future``. Une ``Future`` Java, c'est la
représentation d'un traitement dont le résultat n'est peut-être pas
encore disponible. On peut savoir si la ``Future`` s'est réalisée,
ainsi que l'exception qui est la cause d'une erreur éventuelle. Netty
ajoute un mécanisme d'écouteur
(``io.netty.util.concurrent.GenericFutureListener``) pour être notifié
de l'évolution de l'état d'une ``Future``. Évidemment, l'écouteur
d'une ``Future`` est appelé de façon à respecter les règles de
non-concurrence d'accès au sein d'un ``ChannelPipeline``.

Le mécanisme de la ``Future`` est tellement utile qu'on le retrouve
en-dehors du ``ChannelPipeline``. On veut établir une connexion
(client ou serveur)? Pas de problème, on demande à Netty de faire le
boulot, et il renvoie une ``Future``. S'il y a besoin on bloque le
code appelant (concrètement avec ``ChannelFuture#sync()``), sinon le
code appelant reprend la main aussitôt.

Dans le prochain épisode nous verrons comment Netty facilite le
recyclage de la mémoire.
Reply all
Reply to author
Forward
0 new messages