Un guide de la programmation orientée processus dans Elixir et OTP

Publié: 2022-03-11

Les gens aiment catégoriser les langages de programmation en paradigmes. Il existe des langages orientés objet (OO), des langages impératifs, des langages fonctionnels, etc. Cela peut être utile pour déterminer quels langages résolvent des problèmes similaires et quels types de problèmes un langage est censé résoudre.

Dans chaque cas, un paradigme a généralement un objectif "principal" et une technique qui est la force motrice de cette famille de langues :

  • Dans les langages OO, c'est la classe ou l'objet comme moyen d'encapsuler l'état (données) avec manipulation de cet état (méthodes).

  • Dans les langages fonctionnels, il peut s'agir de la manipulation des fonctions elles-mêmes ou des données immuables transmises d'une fonction à l'autre.

Alors qu'Elixir (et Erlang avant lui) sont souvent classés comme langages fonctionnels parce qu'ils présentent les données immuables communes aux langages fonctionnels, je dirais qu'ils représentent un paradigme distinct de nombreux langages fonctionnels . Ils existent et sont adoptés en raison de l'existence d'OTP, et je les classerais donc comme des langages orientés processus .

Dans cet article, nous saisirons la signification de la programmation orientée processus lors de l'utilisation de ces langages, explorerons les différences et les similitudes avec d'autres paradigmes, verrons les implications pour la formation et l'adoption, et terminerons par un court exemple de programmation orientée processus.

Qu'est-ce que la programmation orientée processus ?

Commençons par une définition : la programmation orientée processus est un paradigme basé sur les processus séquentiels de communication, à l'origine d'un article de Tony Hoare en 1977. Ceci est aussi communément appelé le modèle d' acteur de la concurrence. D'autres langues ayant un certain rapport avec cette œuvre originale incluent Occam, Limbo et Go. L'article officiel ne traite que de la communication synchrone ; la plupart des modèles d'acteurs (y compris OTP) utilisent également la communication asynchrone. Il est toujours possible de créer une communication synchrone au-dessus de la communication asynchrone, et OTP prend en charge les deux formes.

Sur cette histoire, OTP a créé un système de calcul tolérant aux pannes en communiquant des processus séquentiels. Les installations tolérantes aux pannes proviennent d'une approche "laisser échouer" avec une récupération d'erreur solide sous la forme de superviseurs et l'utilisation du traitement distribué activé par le modèle d'acteur. Le "laisser échouer" peut être opposé à "l'empêcher d'échouer", car le premier est beaucoup plus facile à gérer et il a été prouvé dans OTP qu'il est beaucoup plus fiable que le second. La raison en est que l'effort de programmation requis pour éviter les échecs (comme indiqué dans le modèle d'exception vérifié Java) est beaucoup plus impliqué et exigeant.

Ainsi, la programmation orientée processus peut être définie comme un paradigme dans lequel la structure des processus et la communication entre les processus d'un système sont les principales préoccupations .

Programmation orientée objet ou orientée processus

Dans la programmation orientée objet, la structure statique des données et des fonctions est la principale préoccupation. Quelles méthodes sont nécessaires pour manipuler les données incluses et quelles devraient être les connexions entre les objets ou les classes. Ainsi, le diagramme de classes d'UML est un excellent exemple de cette orientation, comme le montre la figure 1.

Programmation orientée processus : exemple de diagramme de classes UML

On peut noter qu'une critique courante de la programmation orientée objet est qu'il n'y a pas de flux de contrôle visible. Étant donné que les systèmes sont composés d'un grand nombre de classes/objets définis séparément, il peut être difficile pour une personne moins expérimentée de visualiser le flux de contrôle d'un système. Cela est particulièrement vrai pour les systèmes avec beaucoup d'héritage, qui utilisent des interfaces abstraites ou n'ont pas de typage fort. Dans la plupart des cas, il devient important pour le développeur de mémoriser une grande partie de la structure du système pour être efficace (quelles classes ont quelles méthodes et lesquelles sont utilisées de quelle manière).

La force de l'approche de développement orienté objet est que le système peut être étendu pour prendre en charge de nouveaux types d'objets avec un impact limité sur le code existant, tant que les nouveaux types d'objets sont conformes aux attentes du code existant.

Programmation fonctionnelle ou orientée processus

De nombreux langages de programmation fonctionnels traitent la concurrence de différentes manières, mais leur objectif principal est la transmission de données immuables entre les fonctions ou la création de fonctions à partir d'autres fonctions (fonctions d'ordre supérieur qui génèrent des fonctions). Pour la plupart, le langage se concentre toujours sur un seul espace d'adressage ou exécutable, et les communications entre ces exécutables sont gérées d'une manière spécifique au système d'exploitation.

Par exemple, Scala est un langage fonctionnel construit sur la machine virtuelle Java. Bien qu'il puisse accéder aux fonctionnalités Java pour la communication, ce n'est pas une partie inhérente du langage. Bien qu'il s'agisse d'un langage commun utilisé dans la programmation Spark, il s'agit à nouveau d'une bibliothèque utilisée conjointement avec le langage.

Une force du paradigme fonctionnel est la capacité de visualiser le flux de contrôle d'un système compte tenu de la fonction de niveau supérieur. Le flux de contrôle est explicite en ce que chaque fonction appelle d'autres fonctions et transmet toutes les données de l'une à la suivante. Dans le paradigme fonctionnel, il n'y a pas d'effets secondaires, ce qui facilite la détermination du problème. Le défi avec les systèmes fonctionnels purs est que les «effets secondaires» doivent avoir un état persistant. Dans les systèmes bien architecturés, la persistance de l'état est gérée au niveau supérieur du flux de contrôle, ce qui permet à la majeure partie du système d'être exempte d'effets secondaires.

Elixir/OTP et programmation orientée processus

Dans Elixir/Erlang et OTP, les primitives de communication font partie de la machine virtuelle qui exécute le langage. La capacité de communiquer entre les processus et entre les machines est intégrée et centrale au système de langage. Cela souligne l'importance de la communication dans ce paradigme et dans ces systèmes linguistiques.

Alors que le langage Elixir est principalement fonctionnel en termes de logique exprimée dans le langage, son utilisation est orientée processus .

Que signifie être orienté processus ?

Être orienté processus tel que défini dans cet article consiste à concevoir d'abord un système sous la forme des processus existants et de la manière dont ils communiquent. L'une des principales questions est de savoir quels processus sont statiques et lesquels sont dynamiques, qui sont générés à la demande des requêtes, qui servent un objectif à long terme, qui détiennent un état partagé ou une partie de l'état partagé du système, et quelles caractéristiques de le système sont intrinsèquement concurrents. Tout comme OO a des types d'objets et fonctionnel a des types de fonctions, la programmation orientée processus a des types de processus.

En tant que telle, une conception orientée processus est l'identification de l'ensemble des types de processus requis pour résoudre un problème ou répondre à un besoin .

L'aspect temps entre rapidement dans les efforts de conception et d'exigences. Quel est le cycle de vie du système ? Quels besoins personnalisés sont occasionnels et lesquels sont constants ? Où se trouve la charge dans le système et quels sont la vitesse et le volume attendus ? Ce n'est qu'après avoir compris ces types de considérations qu'une conception orientée processus commence à définir la fonction de chaque processus ou la logique à exécuter.

Implications pour la formation

L'implication de cette catégorisation pour la formation est que la formation ne doit pas commencer par la syntaxe du langage ou des exemples "Hello World", mais par une réflexion sur l'ingénierie des systèmes et une conception axée sur l'allocation des processus .

Les problèmes de codage sont secondaires par rapport à la conception et à l'allocation des processus, qui sont mieux traités à un niveau supérieur, et impliquent une réflexion interfonctionnelle sur le cycle de vie, l'assurance qualité, le DevOps et les exigences commerciales des clients. Tout cours de formation en Elixir ou Erlang doit (et le fait généralement) inclure OTP, et devrait avoir une orientation processus dès le début, et non comme l'approche de type "Maintenant, vous pouvez coder dans Elixir, alors faisons de la simultanéité".

Conséquences de l'adoption

L'implication pour l'adoption est que le langage et le système sont mieux appliqués aux problèmes qui nécessitent la communication et/ou la distribution de l'informatique. Les problèmes qui sont une charge de travail unique sur un seul ordinateur sont moins intéressants dans cet espace et peuvent être mieux traités avec un autre langage. Les systèmes de traitement continu à longue durée de vie sont une cible privilégiée pour ce langage car il intègre une tolérance aux pannes dès le départ.

Pour les travaux de documentation et de conception, il peut être très utile d'utiliser une notation graphique (comme la figure 1 pour les langages OO). La suggestion pour Elixir et la programmation orientée processus d'UML serait le diagramme de séquence (exemple de la figure 2) pour montrer les relations temporelles entre les processus et identifier les processus impliqués dans le traitement d'une demande. Il n'existe pas de type de diagramme UML pour capturer le cycle de vie et la structure des processus, mais il pourrait être représenté par un simple diagramme en forme de boîte et de flèche pour les types de processus et leurs relations. Par exemple, Figure 3 :

Exemple de programmation orientée processus Diagramme de séquence UML

Exemple de diagramme de structure de processus de programmation orientée processus

Un exemple d'orientation processus

Enfin, nous passerons en revue un court exemple d'application de l'orientation processus à un problème. Supposons que nous soyons chargés de fournir un système qui prend en charge les élections mondiales. Ce problème est choisi dans la mesure où de nombreuses activités individuelles sont exécutées en rafales, mais l'agrégation ou la synthèse des résultats est souhaitable en temps réel et peut entraîner une charge importante.

Conception et allocation initiales du processus

Nous pouvons d'abord voir que l'émission de votes par chaque individu est une rafale de trafic vers le système à partir de nombreuses entrées discrètes, n'est pas ordonnée dans le temps et peut avoir une charge élevée. Pour soutenir cette activité, nous voudrions un grand nombre de processus collectant tous ces entrées et les transmettant à un processus plus central pour la tabulation. Ces processus pourraient être situés à proximité des populations de chaque pays qui généreraient des votes, et offrir ainsi une faible latence. Ils conserveraient les résultats locaux, enregistreraient leurs entrées immédiatement et les transmettraient pour tabulation par lots afin de réduire la bande passante et les frais généraux.

Nous pouvons d'abord voir qu'il faudra des processus qui suivent les votes dans chaque juridiction où les résultats doivent être présentés. Supposons pour cet exemple que nous devions suivre les résultats pour chaque pays, et dans chaque pays par province/état. Pour soutenir cette activité, nous voudrions au moins un processus par pays effectuant le calcul et conservant les totaux actuels, et un autre ensemble pour chaque état/province dans chaque pays. Cela suppose que nous devons être en mesure de répondre aux totaux pour le pays et l'état/province en temps réel ou avec une faible latence. Si les résultats peuvent être obtenus à partir d'un système de base de données, nous pouvons choisir une allocation de processus différente où les totaux sont mis à jour par des processus transitoires. L'avantage d'utiliser des processus dédiés pour ces calculs est que les résultats se produisent à la vitesse de la mémoire et peuvent être obtenus avec une faible latence.

Enfin, nous pouvons voir que beaucoup de gens verront les résultats. Ces processus peuvent être partitionnés de plusieurs façons. Nous pouvons vouloir répartir la charge en plaçant des processus dans chaque pays responsables des résultats de ce pays. Les processus pourraient mettre en cache les résultats des processus de calcul pour réduire la charge des requêtes sur les processus de calcul, et/ou les processus de calcul pourraient pousser leurs résultats vers les processus de résultats appropriés sur une base périodique, lorsque les résultats changent de manière significative, ou sur le le processus de calcul devenant inactif indiquant un taux de changement ralenti.

Dans les trois types de processus, nous pouvons faire évoluer les processus indépendamment les uns des autres, les répartir géographiquement et garantir que les résultats ne sont jamais perdus grâce à la reconnaissance active des transferts de données entre les processus.

Comme indiqué, nous avons commencé l'exemple avec une conception de processus indépendante de la logique métier de chaque processus. Dans les cas où la logique métier a des exigences spécifiques pour l'agrégation de données ou la géographie qui peuvent avoir un impact itératif sur l'allocation du processus. Notre conception de processus jusqu'à présent est illustrée à la figure 4.

Exemple de développement orienté processus : conception initiale du processus

L'utilisation de processus distincts pour recevoir des votes permet à chaque vote d'être reçu indépendamment de tout autre vote, consigné à sa réception et regroupé avec le prochain ensemble de processus, ce qui réduit considérablement la charge sur ces systèmes. Pour un système qui consomme une grande quantité de données, la réduction du volume de données par l'utilisation de couches de processus est un modèle courant et utile.

En effectuant le calcul dans un ensemble isolé de processus, nous pouvons gérer la charge de ces processus et garantir leur stabilité et leurs besoins en ressources.

En plaçant la présentation des résultats dans un ensemble isolé de processus, nous contrôlons à la fois la charge sur le reste du système et permettons à l'ensemble de processus d'être mis à l'échelle dynamiquement pour la charge.

Exigences supplémentaires

Maintenant, ajoutons quelques exigences compliquées. Supposons que dans chaque juridiction (pays ou état), la tabulation des votes peut aboutir à un résultat proportionnel, un résultat gagnant-gagnant ou aucun résultat si des votes insuffisants sont exprimés par rapport à la population de cette juridiction. Chaque juridiction a le contrôle sur ces aspects. Avec ce changement, les résultats des pays ne sont pas une simple agrégation des résultats bruts des votes, mais sont une agrégation des résultats des États/provinces. Cela modifie l'attribution des processus par rapport à l'original pour exiger que les résultats des processus de l'État/province soient intégrés aux processus nationaux. Si le protocole utilisé entre la collecte des votes et les processus d'état/province et de province à pays est le même, la logique d'agrégation peut être réutilisée, mais des processus distincts contenant les résultats sont nécessaires et leurs voies de communication sont différentes, comme le montre la figure 5.

Exemple de développement orienté processus : Conception de processus modifiée

Le code

Pour compléter l'exemple, nous allons passer en revue une implémentation de l'exemple dans Elixir OTP. Pour simplifier les choses, cet exemple suppose qu'un serveur Web tel que Phoenix est utilisé pour traiter les demandes Web réelles, et ces services Web adressent des demandes au processus identifié ci-dessus. Cela a l'avantage de simplifier l'exemple et de garder l'accent sur Elixir/OTP. Dans un système de production, le fait que ces processus soient séparés présente certains avantages et sépare les préoccupations, permet un déploiement flexible, distribue la charge et réduit la latence. Le code source complet avec les tests est disponible sur https://github.com/technomage/voting. La source est abrégée dans cet article pour plus de lisibilité. Chaque processus ci-dessous s'intègre dans une arborescence de supervision OTP pour garantir que les processus sont redémarrés en cas d'échec. Voir la source pour plus d'informations sur cet aspect de l'exemple.

Enregistreur de vote

Ce processus reçoit les votes, les enregistre dans un magasin persistant et envoie les résultats aux agrégateurs. Le module VoteRecoder utilise Task.Supervisor pour gérer les tâches de courte durée pour enregistrer chaque vote.

 defmodule Voting.VoteRecorder do @moduledoc """ This module receives votes and sends them to the proper aggregator. This module uses supervised tasks to ensure that any failure is recovered from and the vote is not lost. """ @doc """ Start a task to track the submittal of a vote to an aggregator. This is a supervised task to ensure completion. """ def cast_vote where, who do Task.Supervisor.async_nolink(Voting.VoteTaskSupervisor, fn -> Voting.Aggregator.submit_vote where, who end) |> Task.await end end

Agrégateur de votes

Ce processus regroupe les votes au sein d'une juridiction, calcule le résultat pour cette juridiction et transmet les résumés des votes au processus supérieur suivant (une juridiction de niveau supérieur ou un présentateur de résultats).

 defmodule Voting.Aggregator do use GenStage ... @doc """ Submit a single vote to an aggregator """ def submit_vote id, candidate do pid = __MODULE__.via_tuple(id) :ok = GenStage.call pid, {:submit_vote, candidate} end @doc """ Respond to requests """ def handle_call {:submit_vote, candidate}, _from, state do n = state.votes[candidate] || 0 state = %{state | votes: Map.put(state.votes, candidate, n+1)} {:reply, :ok, [%{state.id => state.votes}], state} end @doc """ Handle events from subordinate aggregators """ def handle_events events, _from, state do votes = Enum.reduce events, state.votes, fn e, votes -> Enum.reduce e, votes, fn {k,v}, votes -> Map.put(votes, k, v) # replace any entries for subordinates end end # Any jurisdiction specific policy would go here # Sum the votes by candidate for the published event merged = Enum.reduce votes, %{}, fn {j, jv}, votes -> # Each jourisdiction is summed for each candidate Enum.reduce jv, votes, fn {candidate, tot}, votes -> Logger.debug "@@@@ Votes in #{inspect j} for #{inspect candidate}: #{inspect tot}" n = votes[candidate] || 0 Map.put(votes, candidate, n + tot) end end # Return the published event and the state which retains # Votes by jourisdiction {:noreply, [%{state.id => merged}], %{state | votes: votes}} end end

Présentateur des résultats

Ce processus reçoit des votes d'un agrégateur et met en cache ces résultats dans les demandes de service pour la présentation des résultats.

 defmodule Voting.ResultPresenter do use GenStage … @doc """ Handle requests for results """ def handle_call :get_votes, _from, state do {:reply, {:ok, state.votes}, [], state} end @doc """ Obtain the results from this presenter """ def get_votes id do pid = Voting.ResultPresenter.via_tuple(id) {:ok, votes} = GenStage.call pid, :get_votes votes end @doc """ Receive votes from aggregator """ def handle_events events, _from, state do Logger.debug "@@@@ Presenter received: #{inspect events}" votes = Enum.reduce events, state.votes, fn v, votes -> Enum.reduce v, votes, fn {k,v}, votes -> Map.put(votes, k, v) end end {:noreply, [], %{state | votes: votes}} end end

Emporter

Cet article a exploré Elixir/OTP à partir de son potentiel en tant que langage orienté processus, l'a comparé aux paradigmes orientés objet et fonctionnels, et a examiné les implications de cela pour la formation et l'adoption.

Le message comprend également un court exemple d'application de cette orientation à un exemple de problème. Au cas où vous souhaiteriez revoir tout le code, voici à nouveau un lien vers notre exemple sur GitHub, juste pour que vous n'ayez pas à revenir en arrière pour le rechercher.

L'essentiel est de considérer les systèmes comme un ensemble de processus communicants. Planifiez le système d'un point de vue de conception de processus d'abord, et d'un point de vue de codage logique ensuite.