Le design pattern ReaderT, en Haskell
Voir aussi : video youtube - video peertube - code source
Le design pattern ReaderT est une architecture de code classique en Haskell. Elle permet de séparer la couche métier de l’environnement d’exécution, tout en explicitant les fonctionnalités nécessaires.
Architecture de base, avec ReaderT
Comme son nom l’indique, le design pattern ReaderT
est basé sur le transformateur de monade ReaderT
. Ce transformateur permet de définir :
- un environnement (base de données, authentification, système de log…), auquel on peut accéder avec des fonctions comme
ask
. - une application, que l’on peut exécuter dans un environnement donné.
Pour cet article, on veut manipuler une liste d’utilisateurs prédéfinie et écrire des messages de log. La couche métier se résume donc à définir un type représentant un utilisateur, avec son nom et son age.
data User = User
userName :: String
{ userAge :: Int
,deriving (Show) }
On définit également un type pour l’environnement d’exécution, avec la fonction de log et la liste d’utilisateurs.
data Env1 = Env1
env1Log :: String -> IO ()
{ env1Users :: [User]
, }
On peut ensuite définir quelques fonctions de base, accédant à notre environnement.
readUsers1 :: (MonadReader Env1 m) => m [User]
= asks env1Users
readUsers1
logMsg1 :: (MonadReader Env1 m, MonadIO m) => String -> m ()
= do
logMsg1 msg <- ask
env $ env1Log env msg liftIO
Ici, on utilise le “style MTL” (voir le tutoriel 60). Par exemple, readUsers1
est une action dans une pile de monades m
. Cette action récupère (avec asks
) la liste des utilisateurs (avec env1Users
). Elle peut donc fonctionner dans n’importe quelle pile de monades m
respectant la contrainte de types MonadReader Env1 m
. De même pour logMsg1
, où l’on a également besoin de la contrainte MonadIO
, pour appeler env1Log
(une action de IO
).
On peut ensuite définir une application et un environnement d’exécution.
app1 :: (MonadReader Env1 m, MonadIO m) => m ()
= do
app1 "begin app1"
logMsg1 <- readUsers1
users $ show users
logMsg1 "end app1"
logMsg1
env1 :: Env1
= Env1
env1 = putStrLn
{ env1Log = [User "Pedro" 13, User "John" 42]
, env1Users }
Et enfin lancer l’exécution, avec la fonction runReaderT
.
$ ghci main1.hs
> runReaderT app1 env1
ghci
begin app1User {userName = "Pedro", userAge = 13},User {userName = "John", userAge = 42}]
[ end app1
Cette architecture permet déjà de structurer le code et de le faire évoluer un peu. Par exemple, on peut définir un autre environnement d’exécution, avec une fonction de log prédéfinie et sans gestion d’utilisateur, ainsi qu’une application correspondante.
data Env2 = Env2
logMsg2 :: (MonadReader Env2 m, MonadIO m) => String -> m ()
= liftIO $ putStrLn $ "-> " <> msg
logMsg2 msg
app2 :: (MonadReader Env2 m, MonadIO m) => m ()
= do
app2 "begin app2"
logMsg2 "end app2" logMsg2
Ici, app2
peut être exécutée sur un environnement Env2
mais pas sur un environnement Env1
.
> runReaderT app2 Env2
ghci-> begin app2
-> end app2
> runReaderT app2 env1
ghci<interactive>:11:12: error:
Couldn't match type ‘Env1’ with ‘Env2’
• ...
En effet, les applications sont définies explicitement pour leur environnement respectif. Cependant, comme app2
ne fait que des logs et que Env1
fournit également cette fonctionnalité, on aimerait pouvoir exécuter app2
également sur un Env1
.
Les classes de types Has*
Principe
Une approche classique consiste à définir des classes de types pour les différentes fonctionnalités, plutôt que d’accéder directement à un type bien particulier (un peu comme les interfaces en POO). Par exemple, la classe HasUsers
suivante permet de définir qu’un type propose une fonction getUsers
retournant une liste d’utilisateurs. On peut alors instancier cette classe pour Env1
et écrire la fonction de base readUsers
de façon plus générale.
class HasUsers a where
getUsers :: a -> [User]
instance HasUsers Env1 where
= env1Users
getUsers
readUsers :: (HasUsers e, MonadReader e m) => m [User]
= asks getUsers readUsers
En effet, readUsers
n’est pas limité à des m
de classe MonadReader Env1
mais accepte n’importe quel MonadReader e
où e
est de classe HasUsers
.
De même, on écrit une classe HasLog
pour implémenter la fonctionnalité “récupérer une fonction de log”, on l’instancie pour Env1
et on l’utilise pour écrire la fonction de base logMsg
.
class HasLog a where
getLog :: a -> (String -> IO ())
instance HasLog Env1 where
= env1Log
getLog
logMsg :: (HasLog e, MonadReader e m, MonadIO m) => String -> m ()
= do
logMsg msg <- ask
env $ getLog env msg liftIO
Enfin, on change les fonctions de base dans app1
, qu’on peut exécuter sur env1
comme précédemment.
app1 :: (HasLog e, HasUsers e, MonadReader e m, MonadIO m) => m ()
= do
app1 "begin app1"
logMsg <- readUsers
users $ show users
logMsg "end app1"
logMsg
env1 :: Env1
= ... env1
Mais désormais, on peut également utiliser HasLog
pour Env2
, avec une simple instance.
instance HasLog Env2 where
= putStrLn $ "-> " <> msg
getLog _ msg
app2 :: (HasLog e, MonadReader e m, MonadIO m) => m ()
= do
app2 "begin app2"
logMsg "end app2" logMsg
Ainsi, app2
fonctionne pour un MonadReader e m
où e
n’est plus un type particulier mais n’importe quel type de classe HasLog
, donc ici Env1
et Env2
.
$ ghci main2.hs
> runReaderT app2 Env2
ghci-> begin app2
-> end app2
> runReaderT app2 env1
ghci
begin app2 end app2
On dit parfois que cette approche est évolutive selon deux dimensions car on peut ajouter des environnements sans changer les fonctions de base existantes, et on peut ajouter des fonctionnalités sans changer les environnements existants.
La bibliothèque RIO
RIO est une bibliothèque standard alternative, pour écrire des applications. Elle se base sur le design pattern ReaderT
avec l’approche des “classes de types Has”. Par exemple, on peut réécrire le code précédent de la façon suivante.
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE FlexibleInstances #-}
import RIO
import System.IO (putStrLn)
data User = User
userName :: String
{ userAge :: Int
,deriving (Show)
}
data Env1 = Env1
env1Log :: String -> IO ()
{ env1Users :: [User]
,
}
class HasUsers a where
getUsers :: a -> [User]
instance HasUsers Env1 where
= env1Users
getUsers
readUsers :: (HasUsers e) => RIO e [User]
= asks getUsers
readUsers
class HasLog a where
getLog :: a -> (String -> IO ())
instance HasLog Env1 where
= env1Log
getLog
logMsg :: (HasLog e) => String -> RIO e ()
= do
logMsg msg <- ask
env $ getLog env msg
liftIO
app1 :: (HasLog e, HasUsers e) => RIO e ()
= do
app1 "begin app1"
logMsg <- readUsers
users $ show users
logMsg "end app1"
logMsg
env1 :: Env1
= Env1
env1 = putStrLn
{ env1Log = [User "Pedro" 13, User "John" 42]
, env1Users }
Exemple d’exécution :
$ ghci main2rio.hs
> runRIO env1 app1
ghci
begin app1User {userName = "Pedro", userAge = 13},User {userName = "John", userAge = 42}]
[ end app1
Réduire les dépendances via des monades
L’approche des “classes de types Has” peut introduire des dépendances injustifiées. Par exemple, la classe HasLog
introduit une dépendance à IO
, qu’on indique par la contrainte MonadIO m
.
class HasLog a where
getLog :: a -> (String -> IO ())
app2 :: (HasLog e, MonadReader e m, MonadIO m) => m ()
= do
app2 "begin app2"
logMsg "end app2" logMsg
Cependant, cette contrainte n’est pas toujours justifiée. Par exemple, on pourrait vouloir tester le code en environnement contrôlé (mocking), via un State
sans IO
. Une solution classique consiste à définir et à utiliser une monade sans cette dépendance, ici MonadLog
.
class Monad m => MonadLog m where
logMsg :: String -> m ()
app2 :: (MonadLog m) => m ()
= ... app2
Pour notre application à base de ReaderT
, on peut instancier MonadLog
, avec HasLog
et donc MonadIO
, comme avant.
instance (HasLog e, MonadIO m) => MonadLog (ReaderT e m) where
= do
logMsg msg <- ask
env $ getLog env msg liftIO
Mais désormais, on peut aussi définir un type d’environnement pour faire du mocking. Ce type peut alors instancier MonadLog
sans nécessiter MonadIO
.
type EnvMock = State ([String], [User])
instance MonadLog EnvMock where
= do
logMsg msg <- get
(msgs, users) :msgs, users) put (msg
Par exemple, pour tester app2
et récupérer les messages de log dans une liste :
$ ghci main4.hs
> execState app2 ([]::[String], [User "John" 42])
ghci"end app2","begin app2"],[User {userName = "John", userAge = 42}]) ([
Conclusion
Tout comme la programmation orientée objet, la programmation fonctionnelle a également ses design-patterns. En Haskell, le design pattern ReaderT
est une architecture de code classique qui permet de séparer la couche métier de l’environnement d’exécution, d’expliciter les fonctionnalités avec des classes de types Has*
et de simplifier les dépendances via des monades spécifiques.
Le design pattern ReaderT
est utilisé dans des bibliothèques comme RIO ou dans des architectures plus évoluées comme l’architecture Haskell trois couches.