Voici un (long) retour sur la présentation “On a porté Rudder sur ZIO – Gestion systématique des erreurs dans vos applications” de François Armand (@fan42), CTO de la société Rudder. La présentation a été donné lors de la conférence Scala.IO à Lyon, en octobre 2019. Vous verrez plus bas quelques principes de programmation à appliquer en Scala, dès lors qu’il s’agit de gérer les Erreurs. Plus largement, François expose son point de vue, sur des pratiques à adopter pour gérer les erreurs. Il s’intéresse au “Pourquoi” de la mise en place de ZIO sur un “vrai” projet, le produit Rudder. Bonne lecture !

Le métier de développeur consiste à modéliser le monde réel sous forme de code. Comme l’explique François, tous ces modèles sont faux car ce sont des abstractions et simplifications du monde réel. Une analyse et donc une interprétation de ce monde réel, pour coder nos applications, s’accompagne aussi de l’analyse des cas d’erreurs. Et si mon utilisateur donne un code postal de 10 chiffres ? Et si son adresse de courrier électronique existe déjà ? Par ailleurs, la modélisation prend à un instant T l’ensemble de ce qui est connu. Ce qui est valable aujourd’hui, sera peut-être obsolète demain. Enfin les utilisateurs ne font pas toujours ce qui est prévu.

L’introduction expose donc que nous, développeurs, allons devoir faire face à des cas d’erreurs. Miam, miam.

Comment gérer les erreurs de façons systématiques dans le cadre de la programmation ? Et plus particulièrement, dans le cadre de l’utilisation de Scala ?

Nous avons la chance d’avoir un certain nombres d’outils à notre disposition. Le langage Scala de base, apporte un ensemble de pratiques et de techniques défensives, pour améliorer la robustesse de son code. Par ailleurs, des librairies complètent le langage, en attendant l’arrivée prochaine de Scala 3. L’utilisation d’Either, les Union types, les Coproducts avec Shapeless, les Interfaces, l’utilisation de librairie comme Refined, etc. Scala gère aussi les exceptions du monde Java, même si cela représente souvent un anti-pattern ou un code smells à ne pas utiliser. Nous avons donc un ensemble d’outils à notre disposition.

En préparant ce talk, François Armand explique qu’il s’est avéré plus compliqué d’expliquer le “pourquoi“, que le “comment”, de la mise en place d’une librairie comme ZIO.

Il part de 4 opinions fortes, sur la façon de voir notre métier de développeur. De ce point de vue, nous comprendrons ensuite la justification de l’utilisation de ZIO, mais plus globalement, de sa stratégie de gestions des erreurs.

  • La 1ère opinion : notre travail c’est de découvrir et comprendre quels sont les cas d’erreurs dans une application. Le développeur se doit de comprendre ce qui peut ne pas aller dans le cadre du développement.
  • 2eme opinion : les erreurs sont une construction sociale. Nous traitons les erreurs dans notre programme, afin de faire réagir l’utilisateur.
  • 3ème opinion : dans une application il y a les utilisateurs finaux, les futurs développeurs et aussi les opérateurs systèmes. Chacun se doit d’être traité et de recevoir la bonne information, lorsqu’une erreur survient ;
  • 4eme : le travail du développeur est de définir ce qui est le cas nominal et de définir les erreurs. Il doit poser la limite du modèle et mettre en oeuvre nos promesses.

En termes concrets ?
La démarche doit donc être la suivante :

  • découvrir les cas d’erreurs
  • mettre en place la capacité à gérer ces erreurs
  • définir et tenir les promesses, grâce aux contrats d’interface et aux types

Pour cela, François propose 5 grands principes :

  • Coder des Fonction pure et total
  • Créer un canal d’erreur dédié
  • Séparer les erreurs, des fautes (failures)
  • Définir les frontières de son modèle
  • Rendre le tout agréable à utiliser avec la composition et l’utilisation d’outils adaptés

Ces points sont transposables dans d’autres domaines que le code.

Principe 1 : utiliser des fonctions pures et totales

Je vois déjà votre visage perplexe… ne bougez pas. Le principe est simple : ne pas mentir à ses utilisateurs dans la définition de votre fonction. Prenez par exemple cette fonction Scala, qui réalise la division de 2 entiers, et qui d’après cette signature, semble retourner un Int. 3 divisé par 5, retournera 0 ici.

 def divide(a: Int, b:Int):Int

La fonction n’est pas une fonction totale. Quid de la division par zéro ? Comment la fonction peut-elle indiquer qu’elle est susceptible de retourner une erreur, si b = 0 ?

Une fonction “pure” est une fonction sans effet de bord, dont le résultat ne dépend que des paramètres. Pas de modification de variable interne, pas de magie. Nous pourrions écrire un article complet sur le concept, mais revenons à la présentation de François.

Un autre exemple : voici une fonction qui recherche un User dans une base de données. Cette fonction n’est pas complète car elle nous ment, d’une certaine façon. Voyez-vous pourquoi ?

getUserFromDB(id: UserId): User

Et si l’utilisateur n’existe pas ?
Et si la DB ne marche plus ou s’il y a une Exception DB ?

Comme nous le voyons dans cet exemple simple, il y a 2 types d’erreurs. François utilise le mot “Failure” et le mot “Error”. Les “Failures” regroupent ce qui est exceptionnel, comme lorsque la base de données ne fonctionne pas, ou que la table User n’existe plus. Le cas “Error” représente plutôt les traitements classiques à prendre en compte. Ici, le fait que l’utilisateur n’existe pas, pour l’id donné, est un cas d’erreur.

Un exemple d’amélioration, dans un premier temps, peut se faire avec Option :

getUserFromDB(id: UserId): Option[User]

En Français, cette fonction prend en argument un id d’utilisateur (UserId peut très bien être un alias pour un String) et retournera (ou pas) un User. Notez comment, d’une façon simple, la fonction devient presque totale.

Mais comment représenter les Failures ? Nous allons voir cela dans quelques lignes plus bas.

Principe 2 : créer un canal d’erreur clair et dédié

L’idée est d’informer au maximum votre utilisateur (ici un autre développeur). Il faut que le signal soit clair et que le développeur sache réagir. Ne pas assumer ce qui est ‘évident’. Il ne faut pas forcer l’autre développeur à analyser votre code pour découvrir par exemple, que votre fonction getUserFromDB peut retourner ou non un résultat.

Principe 3 : distinguer les Erreurs et les Fautes

François introduit la citation suivante :

type system is a syntactic method for automatically checking the absence of certain erroneous behaviors by classifying program phrases according to the kinds of values they compute.

Benjamin C.Pierce, “Types and Programming Languages” ISBN 0262162091

Un système de types est une méthode syntaxique permettant de vérifier automatiquement l’absence de certains comportements erronés, en classifiant les étapes d’un programme selon les types de valeurs qu’il traite/qu’il calcule.

Le principe est d’aller encore plus loin, et d’expliciter plus en avant, le type de retour de votre fonction. Reprenons notre division de 2 entiers, et voyons comment traiter le cas de la division par zéro :

def divide(a:Int, b:Int):PureResult[Int]

trait MyAppError
type PureResult[A] = Either[MyAppError,A]

Observez comment nous avons modifié le type de retour de notre fonction divide, qui retourne un résultat pur. Aucune exception n’est levée, et le type alias nous montre qu’ici, nous utiliserons Either, pour différencier le retour attendu, du cas d’erreur.

Le système de type doit automatiquement classifier le résultat (que ce soit un succès ou une erreur). D’où l’importance de créer un canal dédié pour les erreurs, et un canal différent pour le résultat. Cette approche facilite la composition et l’utilisation du résultat.

Pour notre fonction de recherche d’un utilisateur en base de données :

def getUser(id:User):IOResult[User]

trait MyAppError
type IOResult[A] = IO[MyAppError, A]

Notez l’introduction d’un type alias IOResult. Ce type est un alias pour la Monad IO, appliqué avec notre trait d’erreur et le type A, ici User. Une Monade est une enveloppe permettant d’empaqueter un type, afin d’y ajouter un comportement. (note : on sait aussi que c’est un endo functor dans la catégorie des types, mais on en parlera plus tard).

François Armand explique toujours “pourquoi” et pas “comment”. Cela rend la présentation plus intéressante. Nous ne sommes pas dans la démonstration de connaissances, mais dans la réflexion quant à la “vraie” utilité (et utilisation) de ces types. Vraiment intéressant.

La présentation continue en nous montrant du code, et des principes simples basés sur des trait et la définition d’un type, le tout sur du code de Rudder. Malgré notre vitesse de frappe, il était difficile de retranscrire tout le code ici 🙂

Une autre bonne pratique est d’utiliser un canal dédié pour les erreurs. Pour cela le type Either (et EitherT lorsque vous manipulez des Futures) est adapté pour écrire du code pur.

Either[E,A] où E représente les erreurs et A représente le type attendu. Par exemple notre fonction de recherche d’utilisateurs pourrait s’écrire de cette façon :

def findUserByUUID(id:UserUUID):Either[MyBusinessError, User] 

Ou une autre approche avec une monade IOResult fait maison (similaire au Try de Scala) :

def findUserByUUID(id:UserUUID):IOResult[User] 

Principe 3 : définir les frontières de votre modèle

François Armand disait au début : un modele est forcément faux / une abstraction de la réalité. Il convient donc de définir les frontières de votre modèle dans votre logiciel. Le principe de “Bounded Context” vient du monde du Domain-Design Driven.

Lorsqu’il s’agit de traiter les Erreurs et les Failures, quel est la limite à poser dans votre fonction ? Faut-il prévoir “tous les cas” ? Faut-il prévoir le cas où la base de données ne marche plus ? Le cas où la table User a été effacé ?

Prenez par exemple ce code, expliqué par François durant la présentation :

writeFile(p:String, value:String):IOResult[Unit]

Et si le code lève une exception lié aux permissions sous Unix, d’écriture de fichier ?

java.lang.SecurityEXception ??? jvm restriction 

La réponse est donc “ça dépend”. Parfois vous voudrez tracer et définir les limites. Ici, il semble important de tracer et gérer les exception liés au système de fichier. Dans d’autres cas, non. Il faut définir l’horizon, les limites de votre système. Et ensuite, il est important d’appliquer ces principes sur l’ensemble de votre Périmètre.

Quelles différences fait-on entre les Errors et les Failures ?

Errors : tout ce qui est attendu. Ce sont des signaux pour les autres développeurs. Cela permet de représenter des cas d’erreur à gérer. Ces Error reflètent dans les types, on les rend explicites,
Failures : tout ce qui est non attendu, exceptionnel, qui met notre système dans un état incohérent. En général, François dit que la bonne chose c’est d’arrêter l’app. Par définition, nous sommes dans un état non prévu, non modélisé dans les Types et donc, exceptionnel.

Comment faire pour mettre cette limite ? La bonne ou mauvaise nouvelle, c’est que c’est votre boulot de choisir et correctement le définir. Une des particularités du métier de développeur, c’est que l’on décide ce qui est correct, et ce qui est incorrect. Toujours des décisions et des choix, qui doivent être documentés.

Dans Rudder, les utilisateurs du système peuvent coder en Javascript des fonctions. Dès lors, il est primordial de gérer les SecurityException comme des Errors (des cas attendus, on parle de coder en JS directement ici).

Cela donne alors dans le code ce type de signature de fonction :

execScript(js:String): IOResult[String]

Ici SecurityException c’est géré (car on execute du script)

Lors de la définition d’un système, on vérifie les promesses faites par chaque sous ensemble. Toujours cette notion de Bounded Context.

Sans citer le mot “Architecture Hexagonale” d’Alistair Cockburn (2014), François Armand cependant explique ensuite comment faire cohabiter par exemple la logique métier d’une part, l’interface Web d’autre part, tout en gardant des promesses (ou contrat) d’API REST. Pour pouvoir faire évoluer l’API REST sans impacter le métier, il convient d’isoler le modèle côté vue, de celui du côté métier. Chaque couche est séparée, et cela va alors faciliter le traitement des erreurs. Dans le cas d’une Error attendue (notre utilisateur n’existe pas en base de données), nous pourrons retourner une Error du coté métier, qui deviendra par exemple une Erreur HTTP 404 du coté de l’API REST.

Il est primordial que le coeur du métier soit isolé, et ne dépende pas de frameworks ou librairies extérieures. Il doit être simple, composable et surtout, faire un traitement des Errors (tel que définit ci-dessus) et des Failures.

Oui, chaque domaine a sa responsabilité. Nous pouvons imaginer une API interne, et une API externe, avec des signatures différentes.

Extrait de la présentation

Si ce sujet vous intéresse, nous vous recommandons de lire l’article sur l’architecture hexagonale ainsi que le livre d’Eric Evans sur le Domain-Driven Design.

Dernier principe : rendre le tout facilement utilisable

Dernière partie de la présentation, celle où l’on parle de ZIO, et où l’on comprend la démarche de François.

Le monde Java utilise les Exceptions. Il existe les RunTimeException et les autres, dites “checked exceptions”. Certes, c’est un signal fort à destination de l’utilisateur. Cependant, cela reste un signal ambigu. Les Failures et les Errors sont mélangés, même si une bonne utilisation du typage peut aider.

Les exceptions java sont cependant une solution critiquable, pour plusieurs raisons. Difficile à gérer, on ne peut pas les composer, les manipuler ou les transformer. On perd aussi la transparence référentielle et enfin (peut-être le pire dans la programmation fonctionnelle), on sort du flux de traitement nominal d’une fonction.

Or, nous sommes en 2019 et l’informatique a pas mal évolué, il existe d’autres approches pour gérer les Errors / Failures. Le code doit être lisible, sans pièges, sans signaux compliqués ou inutiles.

Vous pouvez tout à fait utiliser Scala et quelques librairies pour répondre à cela, mais François introduit (enfin) ZIO.

Qu’est-ce que ZIO ?

ZIO est un projet open-source sur Github. C’est une librairie Scala, pour la programmation concurrente et asynchrone, dites “type-safe” et composable. C’est d’abord une librairie qui facilite l’écriture de code asynchrone/concurrents. L’apport du traitement des erreurs n’est qu’un sous-ensemble de la librairie.

ZIO propose une gestion des effets et un canal dédié pour les erreurs. La librairie propose tout ce qui est attendu et pratique, dès lors que l’on souhaite mettre en application ce que François a montré. Par ailleurs, c’est une librairie qui se marie bien et remplace l’utilisation des Futures en Scala.

Selon lui, cela ne nous enlève pas la responsabilité de comprendre et définir les Errors (comme expliqué durant la présentation). Cependant la librairie propose la gestion des effets, avec par exemple :

IO[+E, +A]
val pureCode = IO.effect(effectfulCode)

val effectTotalTask: Task[Long] = IO.effectTotal(System.nanoTime())

Les lettres E et A désignent des Types. E pour Error et A pour AnyVal. Le petit signe + en Scala, placé devant un type, modifie la variance d’une classe, pour l’héritage. Ici E et A sont covariants.

IO est documenté ici, avec des exemples plus avancés : https://zio.dev/docs/datatypes/datatypes_io

ZIO propose donc :

  • gestion des effets, la possibilité de wrapper du code impur
  • canal dédié pour les erreurs
    IO[+E, +A]
  • une capacité à composer et gérer correctement les cas d’erreurs tel que présenté durant la présentation

Composition d’erreur
Gestion d’erreurs tres outillées
create from Option, Either, value
transform : mapError, fold, foldM
recovery: total, partial

François Armand nous propose d’en découvrir plus en regardant la présentation de John De Goes “Error Managment, Future vs ZIO”.

Pour la suite de la présentation, nous avons pu voir le code mis en place sur le projet Rudder. Vous trouverez ce code sur les slides, que François a mis en ligne sur sa page LinkedIn (à partir de la diapo 81). Il a eu l’excellente idée de reprendre les questions du public de Scala.IO, et de les ajouter à la fin de sa présentation.

Conclusion

Le code, la gestion des erreurs, notre métier consiste à capturer le réel, pour écrire des programmes. Nous avons les utilisateurs finaux. Mais aussi les autres développeurs, ou vous-même dans 6 mois. Le code est un contrat social, un espace de dialogue. La gestion des erreurs peut se faire avec des librairies, mais plus globalement avec quelques principes simples et faciles à apprendre.

Nous vous encourageons à tester et à découvrir ZIO, une librairie puissante pour Scala.

Merci à François Armand pour cette très bonne présentation durant Scala.IO 2019.