PHP Railway Flow Based Programming
le 25 mars 2021
La présentation liée à l'article :
Le code source lié à l'article :
https://github.com/darkwood-fr/flow
Cet article à pour but d'introduire le concept, ainsi que de voir comment faire son implémentation en PHP.
Il faut préciser que cet article se base sur les travaux menés par Anton Mishchuk.
Ce qui m'intéresse ici est d'introduire ce concept puis nous verrons à la fin de l'article comment on peut implémenter cela en PHP.
Quel est la problématique ?
Lorsque l'on parle de programmation au sens conventionnel, on se réfère en général à une approche orienté objet ou fonctionnelle.
- Le code est représenté sous une forme procédural et séquentielle. Dans cette approche, le parallélisme n'est pas quelque chose de natif lorsque l'on conçoit un programme. Le parallélisme sera en général l'object d'une problématique en fin de projet en vue d'optimiser l'execution du code pour prévoir les montées en charges.
- Le code sera plus souvent représenté sous une forme hiérarchique structurelle, alors qu'on pourrait en avoir une représentation de flux d'entrée et sortie de données.
- Le code doit être simple a représenter visuellement.
Avant d'introduire le concept, nous avons besoin d'introduire deux autres concepts sous-jacents : le Flow Based Programming et le Railway Oriented Programming.
Flow Based Programming
Le Flow Based Programming (FBP) est un paradigme de programmation qui a été introduit par J. Paul Rodker Morrison dans les années 1960. Il a ensuite été implémenté dans différents langages (Javascript, PHP, C) par Henri Bergius. Le principe du FBP est d'exprimer un modèle d'application en terme de graphe de processus indépendants pour lesquels les données sont échangées à travers les connections. L'interêt est d'exprimer un problème en terme de transformations de flux de données.
Par exemple, prenons le cas d'une usine de production de bouteilles. Une bouteille vide va d'abord passer par une étape remplissage. Ensuite, lorsque l'opération est finie, la même bouteille passe par l'étape d'encapsulation pour fermer hermétiquement le contenu. Et enfin, la bouteille sera labellisée dans la dernière étape et prête à la consommation.
Dans l'approche FBP, il y a deux manière de voir ce système. Premièrement l'approche dite basse dite business logic dans laquelle nous nous intéressons à chacun de nos composants (ou processus) de manière unitaire. Chaque composant est doté d'une ou plusieurs entrées ainsi que d'une ou plusieurs sorties. Le rôle de chaque composant sera alors de traiter les données d'entrées (input) et de renvoyer leur valeur de retour en sortie (output). Chaque composant est libre d'être implémenté dans n'importe quel langage de programmation.
Deuxièmement, l'approche dite haute. Dans cette approche, on parle alors de logique de communication die : communication logic. C'est à dire que l'on va représenter notre flow de données en assemblant les composants les uns avec les autres par les connections du graphe. Il est aussi possible d'utiliser le même composant à différents endroits. Les données circulant dans un tel graphe sont appelées Information Packet. Par ailleurs, un tel graphe ainsi constitué peut être finalement aussi assimilé à une grosse boite noire doté lui aussi d'entrée et de sortie. Ainsi il est possible d'être consistant à tout niveau d'échelle, puisque l'on peut créer de nouveaux graphes à partir de graphes fraichement crées.
Quels sont les avantages à ce système :
- Le parallélisme devient naturel. En effet, si un composant venait à avoir une surcharge de données à traiter, il suffit simplement de dupliquer ce même compostant, à savoir scaler, pour augmenter la capacité de traitement. Ce phénomène est appelé couramment back-pressure lorsque trop d'information packet saturent le traitement d'un composant du graphe.
- Chaque composant peut facilement être testé de manière unitaire par rapport au processus qu'il doit effectuer.
- Il n'y a pas de problème de mémoire partagée et de lock. C'est évident puisque les composants sont indépendants les uns des autres.
- La création d'un graphe de donnée est simple à représenter visuellement. On parle alors de programmation visuelle, voire no-code.
Railway Oriented Programming
Maintenant nous allons voir le Railway Oriented Programming et son design pattern. Afin d'expliquer le concept, partons sur un exemple. Imaginons un processus constitués de différentes étapes : Il faut récupérer une requête utilisateur, puis vérifier que les données reçue sont valide, ensuite mettre à jour la base de données d'utilisateur avec ces nouvelles données. Lorsque la base de donnée est à jour, nous envoyons un email de vérification. Enfin on retourne le résultat de la requête à l'utilisateur qui en a fait la demande.
Dans ce cas de figure, en programmation objet classique, nous allons communément écrire une fonction qui fera ce travail. Mais qu'en est-il de la gestion des erreurs, que ce passe-t-il si les données de la requête ne sont pas valides, alors on ajoute une condition pour traiter ce cas de figure. Que ce passe-t-il si il y a un problème de connection à la base de données ? On encapsule le code dans un try-catch. Et si l'utilisateur n'existe pas en base ? Il faut aussi traiter ce cas de figure par l'ajout d'une condition. Est ce que l'email de vérification a bien été envoyé ? Dans ce cas de figure on ajoute une procédure pour logger les résultats. On remarque à travers cet exemple que lorsque le cahier des charges évolue. Ce que l'on avait écrit de simple à la base peut devenir rapidement compliqué et difficile à maintenir et à tester dans le temps.
Le Railway Oriented Programming est une manière fonctionnelle pour gérer les erreurs. En effet, l'idée consiste de créer notre programme à travers une imbrication de différents Rails. Pour chaque Rail, nous allons avoir deux parties : celle fonctionnelle qui va traiter l'information et celle qui gère les cas d'erreurs. Nous allons pouvoir composer notre programme par un assemblage de Rails. Le processus s'execute alors normalement et dès qu'il existe une erreur, on l'envoie directement en fin de chaine. Dans cette approche, il est possible de composer tout programme à travers différents Rails qui peuvent être assemblés les uns les autres et testés de manière indépendante et unitaire.
Railway Flow Based Programming
Enfin, nous allons parler du Railway Flow Based Programming. Qu'est ce que c'est ? Et bien c'est la résultante des deux concepts vu précédemment. Nous allons voir pas à pas, comment le définir.
Toujours avec un exemple, nous reprenons notre assemblage de Rails pour lesquels nous allons définir des opérations simple comme : AddOne, MultByTwo, MinusThree. A ces rails, nous allons ajouter de part et d'autre un Producer qui est un processus charger de produire l'information. Nous concatenons notre assemblage de rails, puis un système d'Error pour la gestion d'erreur. Et enfin un Consumer qui se charge de traiter l'information produite. A cela, nous aurons un Supervisor qui se chargera d'englober tout ce système, c'est à dire d'instancier les différents composants mais aussi de les orquestrer.
Comment cela fonctionne ? Comme vu dans le FBP, nous allons introduire un Client qui va envoyer une Information Packet a Producer. Cette information packet sera alors traitée et transformée dans le système à travers les différents rails, pour enfin être retournée par le client via le Consumer.
Ce qu'il est interessant, c'est de donner la possibilité d'avoir plusieurs Clients qui font une ou plusieurs demandes en instantané. Chaque client va envoyer une ou plusieurs Information Packet dans le réseau et se verra retourner le résultat de leur demande.
Si l'on prend un cas d'usage plus avancée, nous pourrions dire que l'opération MulByTwo est une opération couteuse. Ainsi, si plusieurs Clients venaient à faire trop de demande en envoyant de nombreuses Informations Packets, nous nous retrouvons alors face à un phénomène déjà évoqué dit de back pressure. C'est à dire que le composant MulByTwo se retrouve saturé par le nombre de d'Information Packet à traiter. La circulation de l'information à travers tout le réseau devient bloquante. Dans ce cas là nous allons scaler, c'est à dire augmenter le nombre d'instances possibles pour l'opération MultByTwo afin qu'il n'y ai plus ce phénomène de goulot d'étranglement, et ainsi fluidifier la demande à traiter. A noter qu'il est aussi possible de rendre scalable le système de traitement des erreurs.
Quel est l'apport de cette représentation :
- La structure de donnée décrite dans l'information packet sera explicite.
- Il est facile de savoir où se situe l'avancement du traitement de notre information packet dans un système linéaire.
- La parallélisation du système est native et simple à configurer.
- Le programme est facile à maintenir et réutiliser puisque chaque rail du système est vu comme un composant indépendant et réutilisable.
Implémentation en PHP
Avant de parler de l'implémentation en PHP, vous pourrez noter qu'il existe déjà une implémentation en Elixir par Anton Mishchuk. Ce qui est interessant ici c'est que Elixir est plus approprié que PHP étant donné que c'est un langage fonctionnel et prévu pour être scalable, où chaque processus peut être lancé de manière indépendante. Ici en PHP, les différents Rails partagent la même mémoire et si un processus venais à planter, c'est tout le système qui échoue.
Comment représenter cette notion de message ? Regardons la documentation de Symfony Messenger. Il s'avère que le schema général correspond exactement à ce que l'on cherche à faire. Nous pouvons assimiler une enveloppe à une IP. Le Supervisor est composé du Producer assimilé au Receiver, le Consumer assimilé au Sender et les différents Rails et le rail Error assimilé au Handler. Le Client est l'émetteur et récepteur de l'information finale.
On vient de voir et définir la partie message d'information. Mais comment représenter la partie asynchrone ? En PHP nous allons utiliser les Générateurs. Quel est le rapport entre les Générateurs et l'asynchrone ? Pour résumer, un Générateur, en plus d'être un itérateur, nous allons avoir 3 autres méthodes qui nous permettent d'envoyer une valeur send, lancer une exception throw et récupérer le résultat final getReturn. Dans un très bon article de Nikita Popov il défini ce qu'on appelle les co-routines en PHP. Effectivement les coroutines permettent à l'aide d'un event loop qui sera pris en charge par le Supervisor afin d'attendre finalement les différentes itérations de l'execution d'un générateur lors de la rencontre du mot clef yield pour une opération bloquante. L'event loop va alors prendre la main afin d'attendre que cette opération bloquante se libère, lorsque le résultat est finalement retourné, l'event loop utilisera la méthode send du Générateur afin de continuer l'execution et ainsi accéder à son prochain yield ou la valeur de retour. Ainsi, il est possible de mettre en place une opération de type asynchrone grâce à l'utilisation des générateurs en tant que co-routine. Pour faciliter l'intégration, j'ai utilisé la librairie Amp qui integrè déjà ces notions.
Avec ces deux éléments, nous avons toutes les briques pour construire notre système de Railway Flow Based Programming en PHP. Le plus simple est d'aller consulter le code associé à cet article, d'installer le projet et executer les exemples.
https://github.com/darkwood-fr/flow
Il faut noter cependant qu'il faut différencier le parallélisme et l'asynchrone. Avec le projet en Elixir https://github.com/antonmi/flowex qui implémente le concept, il y aura des différences avec PHP. En effet, Elixir est un langage adapté aux problématiques de threads car il a été construit sur cette approche. A la différence de PHP qui est une execution mono-thread, l'usage des co-routines fera que toute l'execution se fera en mémoire partagée. En PHP, dans notre cas, si un Rail venait à planter suite à une fuite mémoire par exemple, alors c'est tout le système qui plante. Ce ne sera pas le cas en Elixir.
Avantages
Les avantages que l'on peut trouver à l'implémentation du Railway Flow Based Programming en PHP sont diverses :
- On conçoit des systèmes qui utilisent nativement la scalabilité dès la phase de conception.
- Ce peut être une bonne base pour garder une cohérence avec l'équipe en représentant le projet à travers des rails à assembler les uns des autres et en avoir une représentation visuelle.
- Il n'y a pas de problèmes de lock. En effet les co-routines ne sont pas des processus parallèles, mais ils "simulent" ce parallélisme mais l'execution reste complètement séquentielle.
- Ce concept peut très bien être transposé avec une approche dev-ops. C'est à dire en containérisant le concept (par exemple : utiliser RabbitMQ ou construire une approche serverless). Ici, un avantage d'utiliser le langage de programmation c'est de garder la main de cet assemblage au niveau du code sans à avoir a apprendre d'autres notions supplémentaire.
- Il faut aussi comparer différentes approches en terme de performance. S'il on regarde des tests d'execution de code php selon différentes approches tel que décrites dans ce blog : https://divinglaravel.com/asynchronous-php. L'utilisation des co-routines peut être aussi bénéfique. On peut aussi se poser la question de quelle sera le choix de l'architecture à mettre en place en fonction de son projet et des besoins à valider.