Introduction à la programmation fonctionnelle en JS, partie III
Précédemment …
Dans la première et la seconde partie de cet article, nous avons pu présenter et aborder une grande partie des concepts de la programmation fonctionnelle.
Pour terminer cet article, il est temps pour moi de vous montrer comment ces concepts peuvent être appliqués à travers un exemple simple d’API.
Un exemple simple d’API pour illustrer
Je vais donc vous présenter un petit exemple d’API basique écrite avec Node.js et le framework Express. Je vais d’abord expliquer son fonctionnement puis montrer des parties du programme écrites de manière impérative : cela me permettra ensuite de vous expliquer comment j’ai réécrit ces portions de code de manière fonctionnelle, pour illustrer au mieux les concepts expliqués dans cet article.
Présentation rapide de l’API
Cette API de démonstration est volontairement simple et ne propose qu’une seule route, POST /import
, qui permet d’envoyer un fichier CSV contenant des informations sur différents modèles de voitures.
Voici un exemple de fichier de données que l’API peut consommer :
brand | model | year | energy | engine | transmission | gearbox | power |
---|---|---|---|---|---|---|---|
Tesla | Model S P85 | 2012 | E | electric rear-motor | R | E | 500 |
Déclaration de la route d’import
Version impérative
Voici le code qui déclare la route en version impérative :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
app.post('/import', uploads.single('content'), async (req, res, next) => {
try {
const { file } = req
// throws an error
checkFile(file)
// parse CSV
const lines = await parseCSVFile(file)
const proceededLines = []
// process each lines
for (const line of lines) {
const newLine = await processLine(line)
proceededLines.push(newLine)
}
// IO write
await saveLinesToDb(db, proceededLines)
// Write response to the client
res.status(200).json(proceededLines)
} catch (err) {
next(err)
}
})
On peut voir dans cet extrait de code que la route va effectuer différents traitements :
-
Import du fichier (via une bibliothèque externe, les informations sur le fichier sont récupérées dans le variable
file
). -
Vérification de la présence du fichier et de son extension (
checkFile()
). -
«Parser» le texte du fichier CSV en un objet (
parseCSVFile
) -
Traiter/transformer les valeurs de certaines colonnes, notamment celles contenant des codes (
processLines
). -
Enregistrer les données sous forme de document dans une base MongoDb (
saveLinesToDb
). -
Renvoyer ces données au client
Version fonctionnelle
1
2
3
4
5
6
app.post('/import', uploads.single('content'), (req, res) => {
S.either
( writeHttpErrorResponse(res) ) // ❌ Error case
( R.partialRight(importCSVOperation, [res])) // ✔️ Success case (String)
( tryGetFilepath(req) ) // -> Either Error | String
})
La structure du code est totalement différente ici : on utilise le mécanisme fourni par le type algébrique Either
que l’on a abordé plus haut : la fonction tryGetFilePath
va prendre en paramètre l’objet de la requête req
et tenter de renvoyer le chemin d’accès vers le fichier importé.
Conformément à ce mécanisme, ce conteneur peut renfermer deux contextes différents : une valeur gauche contenant la description de l’erreur ou une valeur droite contenant le chemin du fichier importé.
Ici, on utilise la fonction either
de la bibliothèque Sanctuary pour traiter le contexte renvoyé par tryGetFilepath
:
-
Renvoyer le message d’erreur au client si on a une valeur gauche
-
Appeler
importCSVOperation
avec le chemin du fichier et l’objetreq
si on a une valeur droite
On remarque rapidement l’écriture particulière de either
:
1
S.either (traitementErreur) (traitementValeur) (either)
Vous aurez sans doute compris qu’il s’agit d’une fonction currifiée. Si either
vaut Right(42)
, alors la fonction traitementValeur
sera appelée avec l’argument 42
. Sinon, si elle renvoie Left('Bad value')
, c’est la fonction traitementErreur
qui sera appelée avec la chaîne 'Bad value'
.
La fonction importCSVOperation
reprend ce qui était fait de manière impérative dans le middleware de la route :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const importCSVOperation = (filepath, res) => {
// Process each lines
const processLines = R.map(processLine)
// Write error response to the client and log it
const errorLogAndWriteOnResponse = R.pipe(
R.tap(console.error),
writeErrorResponse(res)
)
parseCSVFile(filepath)
.pipe(F.map ( processLines ))
.pipe(F.chain ( saveLines(db) ))
.pipe(F.fork
( errorLogAndWriteOnResponse ) // ❌ Error case
( writeJSONResponse(res, 200) ) // ✔️ Success case
)
}
On appelle parseCSVFile()
qui renvoie une Future
contenant un tableau d’objet correspondant aux lignes du fichier CSV importé.
Grâce à la méthode .pipe()
, on créé une pipeline qui va chaîner les traitements successifs et les appliquer au résultat du CSV. Ensuite, la fonction fork()
de la bibliothèque fluture nous permet de terminer cette chaîne de traitement en définissant quelle fonction appeler pour traiter le résultat ou une éventuelle erreur, sur le même modèle que la fonction S.either()
vue plus haut.
D’ailleurs, elle a exactement la même signature :
1
F.fork(traitementErreur)(traitementResultat)(future)
C’est parce qu’on l’utilise en combinaison de F.pipe()
ici que l’on n’a pas besoin de préciser le troisième argument, qui est la valeur renvoyée par le pipeline à la fin de l’exécution des traitements.
Parsing du CSV
Dans les deux cas, on s’appuie sur la bibliothèque csv-parse.
Version impérative
1
2
3
4
5
6
7
8
9
10
11
12
13
export async function parseCSVFile(file) {
const csvParser = csv({ separator: ',' })
const parsedLines = []
const { path } = file
const parseStream = createReadStream(`./${path}`).pipe(csvParser)
for await (const data of parseStream) {
parsedLines.push(data)
}
return parsedLines
}
Ce code reste classique : on instancie le parser, puis on créé un Readable pour la lecture du fichier grâce à fs.createReadStream()
. Notez qu’on utilise la construction for await
qui permet d’itérer sur le stream, car chaque Readable fournit un AsyncIterator.
On utilise un tableau pour stocker les données émises par le stream.
Version fonctionnelle
1
2
3
4
5
6
7
8
9
10
11
12
import * as Fn from 'fluture-node'
const relativePath = path => `./${path}`
const pipeCSVParser = stream => stream.pipe(csv({ separator: ',' }))
export const parseCSVFile =
R.pipe(
relativePath,
createReadStream,
pipeCSVParser,
Fn.buffer,
)
On utilise la composition pour créer notre fonction qui reçoit en paramètre le chemin du CSV :
-
relativePath
ajoute la sous-chaîne./
au chemin du fichier -
createReadStream
reçoit le chemin relatif et renvoie un Readable pour lire le fichier -
pipeCSVParser
transforme le stream lisant le fichier pour renvoyer les lignes parsées -
Fn.buffer
réduit le stream et le transforme en une Future qui contient un tableau de valeurs émises (iciobject[]
).
Note : On utilise ici la bibliothèque fluture-node qui ajoute des fonctions utilitaires pour l’utilisation de fluture.js dans un environnement Node.js. La fonction buffer
nous permet justement de reproduire ce que fait le for await
dans la version impérative.
Connexion à la base de données
Pour des raisons de simplicité, on passe par une connexion à MongoDb via le driver Node.js.
Version impérative
1
2
3
4
5
6
7
8
9
10
11
export async function getDatabase(mongoUrl) {
try {
const client = new MongoClient(mongoUrl);
await client.connect()
console.log(`✔️ MongoDb connection to '${mongoUrl}' OK`)
return client.db()
} catch (err) {
console.error(`❌ Unable to connect to MongoDb at url '${mongoUrl}'`)
}
}
Rien d’inhabituel ici, on instancie MongoClient
, on se connecte, puis on récupère l’instance de la base de données via db()
.
Note : on ne transmet pas le nom de la base de données ici car on l’a déjà renseigné dans l’URL de connexion.
Version fonctionnelle
1
2
3
4
5
6
7
8
9
10
export const getDatabase = mongoUrl =>
F.go(function* () {
const client = new MongoClient(mongoUrl)
yield F.encaseP(mongoConnect)(client)
return mongoGetDb(client)
})
.pipe(F.bimap
( R.tap( consoleErr(`❌ Unable to connect to MongoDb at url '${mongoUrl}'`) ))
( R.tap( consoleLog(`✔️ MongoDb connection to '${mongoUrl}' OK`) ))
)
On utilise ici la fonction go
de fluture.js (documentation) : elle prend en paramètre une fonction génératrice qui va émettre des instances de Future
via le mot-clé yield
, ce qui va nous permettre de grouper plusieurs opérations asynchrones dans une seule Future
.
La valeur retournée dans cette fonction correspondra à celle encapsulée par la Future
que va nous retourner go()
.
Ce concept reprend le principe des Promises coroutines, dont certaines bibliothèques fournissent une implémentation (comme Bluebird).
Accès aux sources de l’exemple
Le code source de l’API servant de démonstration est accessible sur mon dépôt Github ici.
La branche main
correspond à la version impérative tandis que fp
correspond à la version fonctionnelle.
J’ai aussi regroupé les différents articles qui m’ont permis d’appréhender les concepts abordés dans l’article dans cette bibliographie.
Conclusion
La programmation fonctionnelle est un paradigme que j’ai trouvé très intéressant : c’est rafraîchissant de voir comment elle aborde différemment les problèmes et y trouve ses propres solutions. De prime abord, elle m’a paru plus complexe et moins accessible, mais cela s’explique en partie par le fait que la majorité d’entre nous sommes davantage habitués à la programmation impérative et à la POO.
Bien qu’imposant une courbe d’apprentissage un peu abrupte et un nombre important de contraintes, la programmation fonctionnelle apporte aussi son lot d’avantages :
-
une meilleure testabilité : les fonctions pures sont déterministes, ne dépendent d’aucun état extérieur, on peut donc facilement les tester sans avoir recours à des frameworks de mock
-
une meilleure réutilisabilité : il est possible de découper le comportement en fonctions très petites que l’on peut composer
-
une meilleure lisibilité : le découpage en fonction permet de mettre des noms explicites sur de petits traitements, et la composition permet de conjuguer aisément ces comportements, rendant l’ensemble plus lisible.
En tant que néophyte de la programmation fonctionnelle, cela me paraît encore compliqué d’envisager de développer un projet de manière 100% fonctionnelle. Toutefois, ce paradigme propose des solutions intéressantes permettant d’adresser des problèmes courants en programmation orientée objet comme la mutation d’objets, ce qui permet d’améliorer la qualité du code et de réduire beaucoup de bugs.