ZIO qu'est-ce que c'est ?

Vous faites du Scala, mais vous ne connaissez pas ZIO ? Pour vous, ZIO n’est qu’un buzzword ? Vous ne comprenez pas à quoi cela sert ?

Personnellement, j’ai eu du mal au début à comprendre en quoi cela pouvait m’être utile. Aujourd’hui, je vois mal comment je pourrais m’en passer. Alors, je vais essayer de vous expliquer exactement pourquoi je pense que vous allez l’adorer.

Pourquoi ZIO existe ?

Quand j’ai commencé à rentrer dans le monde fonctionnel, il y avait une chose que je n’arrivais vraiment pas à assimiler.

Comment gérer les effets ?

J’arrivais à créer des programmes fonctionnels et pures. Mais, je ne pouvais rien faire d’essentiellement utile, puisque je ne savais pas comment communiquer avec une API, une base de données ou autre.

J’ai ensuite commencé à m’intéresser à cette question et je suis tombé sur cet adage : “On ne peut pas faire un programme entièrement pur. L’objectif, c'est de retarder la gestion des effets le plus tard possible dans notre code.”

Pour être honnête, je n’avais pas compris comment faire avec cette phrase non plus… J’avais juste compris qu’il fallait essayer d’avoir le plus de fonctions pures puisqu’elles sont :

  • prévisibles (déterministes)
  • faciles à tester
  • faciles à réutiliser

L’IO monad

J’ai compris ce que cela impliquait réellement avec cette vidéo présentant l’IO monad :

C’est l’origine de ZIO et d’autres bibliothèques à “effets” comme Cats Effect ou ScalaZ. Toutes ces librairies ne sont que des implémentations différentes de cette monade.

Le nom est assez barbare, mais la logique derrière la monade est plus simple à appréhender : “On n’exécute pas nos effets immédiatement, on décrit notre programme qu’on interprètera qu’à la fin de notre code.

Tout commence par cette déclaration :

case class IO[A](unsafeRun: () => A) {
	def map[B](f: A => B): IO[B] = ???

	def flatMap[B](f: A => IO[B]): IO[B] = ???
}

val printHello = IO(() => print("hello"))
val printWorld = IO(() => print(" world"))

printHello.unsafeRun() // Programme qui affiche que "hello"
printHello.flatMap(_ => printWorld).unsafeRun() // Programme qui affiche "hello world"

Ici, printHello et printWorld sont des valeurs qui décrivent un affichage console. Cela veut dire qu’au moment où on les déclare, rien n’est réellement lancé. On peut faire un lien avec les transformations pour Spark, une transformation décrit seulement ce que les workers vont devoir faire, mais rien n’est concrètement lancé au niveau d’une transformation. Pour que cela se lance, il faut une action. Ici notre action, c'est unsafeRun.

ZIO, la monade divine

ZIO, comme on l’a dit auparavant, n’est pas la seule IO monad, mais elle se démarque des autres, car elle a transcendé son être pour devenir ce qu’on appelle une god monad.

ZIO porte ce surnom parce qu'elle est à la fois :

  • Une IO monad
  • Une Either monad
  • Une State monad
  • Une Future monad

Là où les autres ont parié sur la composition et sur l’utilisation massive du tagless final, ZIO a préféré généraliser tous ces concepts dans un seul et même endroit, rendant son utilisation beaucoup plus simple et naturelle.

ZIO dans les grandes lignes

Pour gérer autant d’aspects différents, ZIO ne peut pas se contenter d’un type générique A comme c’est le cas pour une monade IO[A].

ZIO possède pas moins de trois types génériques et s’écrit donc ZIO[R, E, A] :

  • Le type A décrit la valeur de retour de notre effet dans le cas nominal
  • Le type E décrit la valeur de retour de notre effet en cas d’échec
  • Le type R décrit les dépendances que notre fonction a besoin pour être exécutée

ZIO utilise la hiérarchie des types en Scala (https://docs.scala-lang.org/tour/unified-types.html) dans le cas où vous ne voulez pas de dépendances ou d’erreurs.

Vous voulez décrire un effet qui n’a pas de dépendance ? Il suffit de créer un ZIO[Any, E, A]. Parce que ne pas avoir besoin de dépendances signifie que notre fonction peut accepter n’importe quelle dépendance.

Vous voulez un effet qui n’échoue pas ? Il suffit de créer un ZIO[R, Nothing, A]. Parce que ne pas avoir d’erreurs signifie que le type erreur ne peut pas être défini. Or, le type Nothing n’a pas de valeur associée.

Les aspects de ZIO qui vont vous faciliter la vie

Comme nous l’avons expliqué auparavant, le datatype ZIO crée une description de nos effets qui ne sera interprétée qu’à la fin de notre programme.

💡
On fait souvent l’analogie avec une recette de cuisine, et je trouve la comparaison très bonne. Plutôt que d’être la personne qui cuisine, avec ZIO, on est celui qui écrit la recette. On va ensuite passer cette recette au Runtime de ZIO qui va se charger de la réaliser.

Ce qui est intéressant avec ce principe, c'est qu’on peut très facilement augmenter notre effet et lui rajouter certaines capacités automatiquement et sans overhead.

1. La gestion explicite des erreurs

ZIO rend explicite nos erreurs. Lorsqu’on manipule un effet, on sait exactement si notre effet peut rater et si oui, qu’elle est l’erreur en question que l’on va devoir gérer.

Cela nous permet de mieux appréhender les différents cas non prévus de nos applications et de réduire considérablement le nombre de bugs en production.

ZIO vient avec une pléthore de fonctionnalités pour gérer les erreurs :

  • vous pouvez décider de les ignorer
  • vous pouvez décider de les gérer
  • vous pouvez décider de relancer votre fonction jusqu’à ce qu’elle réussisse
  • vous pouvez propager les erreurs
  • vous pouvez transformer les erreurs

Voici, par exemple, comment gérer une erreur :

import zio._

val getUser(id: Int): ZIO[Database, UserNotFound, User] = ???

val printUser: ZIO[Database, Nothing, Unit] = getUser(10).fold(
	failure = _    => Console.printLine("L'utilisateur 10 n'existe pas."),
	success = user => Console.printLine(s"L'utilisateur 10 est ${user.name}.")
)

Ici, on a un nouvel effet printUser qui récupère l’utilisateur numéro 10, puis gère le cas où l'on récupère l’utilisateur de la base de données et le cas où l'on ne le récupère pas :

  • Si on récupère l’utilisateur, on affiche : “L'utilisateur 10 est John Doe.”.
  • Si on a une erreur, on affiche : “L'utilisateur 10 n'existe pas.”.

À noter, qu’ici, notre nouvel effet n’a plus d’erreurs (d’où le type Nothing) puisqu’on a géré le cas d’erreur via la fonction fold.

2. La programmation concurrente

On peut, par exemple, lancer une liste d’effet en parallèle sans avoir à s’en soucier plus que ça.

Ces effets seront lancés dans des fibers (en quelque sorte, des greens threads gérés par ZIO). L’avantage des fibers par rapport aux threads de notre ordinateur, c’est qu’on peut en lancer des dizaines de milliers, voire des millions avec un impact faible sur les ressources de la machine ! De notre côté, on ne s’en soucie pas trop. On ne s’occupe que de la logique de notre application.

Voici un exemple dans lequel on récupère une liste d’utilisateurs depuis une base de données de manière concurrente :

import zio._

val getUser(id: Int): ZIO[Database, UserNotFound, User] = ???

// Récupère les utilisateurs de 10 à 20
val getUsers: ZIO[Database, UserNotFound, List[User]] = 
	ZIO.foreachPar((10 to 20).toList)(getUser)

On peut bien évidemment aller beaucoup plus loin. ZIO est rempli de structures concurrentes à la fois pratiques et suffisamment sûres pour vous aider à mener à bien vos projets. Pour plus d’informations : https://zio.dev/overview/overview_basic_concurrency.

3. L’injection de dépendances

Un autre point important de ZIO, c’est le système de Layers. Cette feature prend tout son sens depuis la version 2.0 de ZIO, car elle est devenue plus confortable à utiliser.

Si on reprend l’analogie de la recette de cuisine, cette dernière a souvent besoin d’ustensiles pour fonctionner qu’il va falloir préparer en amont pour la réaliser (eg. le four à faire chauffer à 200ºC et quel sera le temps de la cuisson). Pour les Layers, c’est exactement pareil. Votre programme va avoir besoin de dépendances pour fonctionner tout au long de sa durée de vie. Cela peut être :

  • Une configuration du programme
  • Un service pour communiquer avec une API tierce
  • Une connexion à une base de données pour pouvoir la requêter

Tout ceci peut être fait avec des Layers et cela va grandement vous améliorer la vie. Ce sujet mérite un article de blog à part entière. Si vous voulez plus d’informations, je vous conseille : https://zio.dev/references/contextual/zlayer.

4. Le scheduling

Le dernier point qui est fréquemment mis en second plan, mais que je trouve très important, c’est la possibilité de planifier nos effets.

Avec ZIO, on peut très bien :

  • répéter un effet 10 fois
  • répéter un effet tous les mardis à 10 heures du matin
  • appliquer une stratégie pour que l’effet se relance en cas d’erreur avec entre chaque essai un délai exponentiel
  • relancer la fonction jusqu’à ce qu’elle réussisse et compter le nombre de fois qu’il a fallu pour qu’elle réussisse

Cette feature est très puissante et permet de remplacer, dans certains cas, des outils d’orchestration un peu trop overkill.

Prenons le cas d’un programme en Scala qui s’assure que github.com est up :

import zio._

def pingGithub: ZIO[Any, ConnectionError, Unit] = ???

val app = 
	pingGithub
		.retry(Schedule.exponential(1.second) && Schedule.recurs(5))
		.schedule(Schedule.spaced(1.minute))
		.orElse(Console.printLine("Github is unreachable"))

Ici l’effet “app”, quand il sera exécuté par le Runtime de ZIO, va ping Github en suivant cette logique :

  • Il va se lancer toutes les minutes sans interruption (schedule).
  • Il va se relancer jusqu’à 5 fois si GitHub ne répond pas (retry), d’abord avec 1 seconde de délais, puis avec un délai exponentiel.
  • Si au bout de 5 fois Github ne répond toujours pas alors, on notifie l’utilisateur (orElse) avec un message dans la console.

Personnellement, la première fois que j’ai écrit ce genre de code, j’ai été impressionné ! En si peu de lignes, on peut écrire un code entièrement fonctionnel, qui gère parfaitement les mauvais cas et qui en plus vient avec un DSL plaisant à lire.

À vous de jouer !

ZIO c’est beaucoup plus que ce que je vous ai présenté. Il y a d’autres fonctionnalités qui sont potentiellement plus pratiques dans vos cas d’usage, comme la gestion des streams, les métriques ou la gestion de la configuration de vos services... Il définit une nouvelle manière d’écrire du code et vous accompagne tout le long du développement de votre application, sans pour autant être clivant comme peuvent l’être les grands frameworks que sont Spring ou Play par exemple.

Si vous avez besoin de plus de ressources, je vous conseille la documentation de ZIO https://zio.dev/ ou encore la chaine YouTube de Ziverge https://www.youtube.com/channel/UCeIg_PnAoyd1w6y8BelLdiQ.

Vous verrez, l’essayer c’est l’adopter !

Je ne suis pas responsable si vous ne pouvez plus vous en passer par la suite ;)