Du Dart dans votre backend ? C’est possible !

le 11/07/2023 par Fabien VallÉe
Tags: Software Engineering, Back

Pour bon nombre de codeur•euse•s, le langage Dart est étroitement lié à Flutter, le framework de Google permettant de développer des apps mobile, web et desktop. Mais si Flutter ne pourrait se passer de Dart, ce dernier peut très bien vivre sa vie sans Flutter, dans la console de votre terminal par exemple.

Dart est un langage de programmation moderne, orienté objet, et nécessitant une runtime pour pouvoir être exécuté. Néanmoins son compilateur permet aussi de générer un binaire embarquant sa propre VM, facilitant le déploiement de son code sur tous les OS/architectures compatibles. C’est tout ce dont nous avons besoin pour aborder la suite de cet article : comment développer son serveur en Dart ?

En plus du langage lui-même, le SDK Dart inclut pub, un outil de gestion de packages pétri de librairies en tout genre et à la communauté bouillonnante. Parmi ces librairies, une en particulier va retenir notre attention : shelf et son extension shelf_router.

Graphe illustrant le taux de satisfaction de certains langages versus le taux d'insatisfaction. Kotlin : 63,29% de satisfaction Swift : 62,88% Dart : 62,16% HTML/CSS : 62,09% Javascript : 61,46%

Taux de satisfaction en bleu, d'insatisfaction en violet. Dart fait jeu égal avec Kotlin et Swift, deux langages relativement récents. Il se permet même de dépasser Javascript d’un poil (source : survey stackoverflow 2022)

Shelf in a nutshell

Concrètement, shelf est une librairie permettant de recevoir des requêtes HTTP et de renvoyer des réponses. Pour ce faire, shelf se base sur le concept de handler : une lambda prenant en entrée un objet Request et renvoyant une Response. La librairie permet de composer différents handlers entre eux à l’aide d’autres concepts plus haut niveau :

  • La cascade permet de chaîner une collection de handlers. Pour une requête donnée, elle va déclencher chaque handler à la suite, en s’arrêtant à la première réponse acceptable reçue. Par défaut, une réponse est considérée comme acceptable si son statut est différent de 404 et 405.

  • Le middleware permet de wrapper un handler afin d’effectuer un pré-traitement sur une requête.

  • La pipeline est encore un cran au-dessus et permet de composer plusieurs middlewares

Une fois nos différents éléments configurés, on peut les encapsuler dans un objet HTTPServer grâce à la sous-librairie shelf_io, incluse dans shelf. La classe HTTPServer fait partie de dart:io, un core package du SDK Dart. shelf_io sert à faire le pont entre shelf et les classes du core SDK permettant d’instancier un serveur.

import 'dart:io';


import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';


Response helloWorldHandler(Request request) {
    return Response.ok("Hello world!");
}


void main(List args) async {
    final ip = InternetAddress.anyIPv4;
    final port = 8080;
    final server = await serve(helloWorldHandler, ip, port);
    print("Server listening on port ${server.port}");
}

Le minimum syndical pour exposer un serveur avec shelf - largement inspiré du template IntelliJ

Et le routage dans tout ça ?

À ce stade, si votre but est de développer un serveur restful, la question de la gestion des routes vous trotte sûrement déjà en tête. En effet, les concepts expliqués plus haut n’introduisent pas de notion de routes dédiées. L’utilisation de la cascade pourrait paraître une bonne idée, mais son fonctionnement en mode séquentiel n’est pas optimal et impliquerait beaucoup de boilerplate pour s'assurer que chaque requête soit dispatchée au bon handler.

C’est là qu’entre en jeu la librairie shelf_router. Comme son nom l’indique, elle permet d’ajouter le concept de routage des requêtes au sein de shelf, et de mapper une route à un handler défini.

import 'dart:io';


import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_router/shelf_router.dart';


Response _loginHandler(Request req) {
 return Response.ok("Let's say login went through");
}


Response _helloHandler(Request request) {
 final message = request.params["message"];
 return Response.ok("Hello $message!");
}


final _router = Router()
 ..post("/login", _loginHandler)
 ..get("/hello", _helloHandler);


void main(List args) async {
 final ip = InternetAddress.anyIPv4;
 final port = 8080;
 final server = await serve(_router, ip, port);
 print("Server listening on port ${server.port}");
}

Exemple de routeur qui accepte la route login en POST, et hello en GET avec un urlparam

Oui mais dans la vraie vie, il y a une couche d’authent !

Vous comptez sécuriser votre api super secrète de recettes à base de choux de Bruxelles ? Pas de problème, les middlewares présentés plus haut sont faits pour ça !

Un middleware permet d’intercepter une requête et de la pré-traiter avant de la transmettre au handler qu’il encapsule. Bien sûr, le middleware peut aussi court-circuiter son handler et renvoyer lui-même une réponse si la situation l’exige. C’est le mécanisme idéal pour ajouter une couche vérifiant la présence d’un header d’authentification avant tout traitement de requête. En cas de mauvais header le middleware pourra directement renvoyer une réponse avec un statut 401 et le traitement s’arrêtera là. Si tout est OK,  il passera la main au handler initialement prévu.

On peut créer facilement un middleware avec la fonction createMiddleware. Cette fonction reçoit en paramètres 3 lambda, toutes optionnelles : requestHandler, responseHandler et errorHandler.

  • requestHandler est le pré-processeur de requête. Il reçoit en entrée la requête et renvoie soit une réponse, soit null. Si le résultat est non null, la requête est considérée comme court-circuitée et ne sera pas transmise au handler encapsulé. Si la réponse est null (ou si requestHandler est non défini), alors la requête est transmise au handler.

  • responseHandler peut être considéré comme un post-processeur de réponse. S’il est défini, il recevra en entrée la réponse calculée précédemment (provenant soit du pré-processeur, soit du handler encapsulé). Le responseHandler doit lui-même renvoyer une réponse. La plupart du temps il fait passe-plat et renvoie directement celle qu’il a reçue, mais il peut aussi en forger une nouvelle.

  • errorHandler est invoqué si le handler encapsulé lève une erreur. Il peut renvoyer une réponse ou lui-même lever une nouvelle erreur

Schéma décisionnel expliquant le fonctionnement d’un middleware. Simple, non ?

Une fois le middleware instancié, il suffit d’utiliser sa fonction addHandler pour y encapsuler un handler. Le résultat de cette fonction produit une nouvelle instance de handler, représentant l’orchestration entre le middleware et son handler. Il est donc possible d’associer plusieurs handlers à une même instance de middleware sans perturbation. Il suffit pour cela d’appeler la fonction addHandler pour chaque handler à encapsuler.

import 'dart:io';


import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_router/shelf_router.dart';


final String _HEADER_TOKEN = 'TOKEN';


final Middleware _SIMPLE_TOKEN_CHECKER = createMiddleware(
 requestHandler: (request) {
   final headerValue = request.headers[_HEADER_TOKEN];
   if (headerValue == null) {
     return Response.unauthorized("$_HEADER_TOKEN header missing");
   }
   print("$_HEADER_TOKEN header present with value ${request.headers[_HEADER_TOKEN]}");
   return null;
 },
);


Response _loginHandler(Request req) {
 return Response.ok("Let's say login went through");
}


Response _helloHandler(Request request) {
 return Response.ok("Hello ${request.headers[_HEADER_TOKEN]}!");
}


final _router = Router()
 ..post("/login", _loginHandler)
 ..get("/hello", _SIMPLE_TOKEN_CHECKER.addHandler(_helloHandler));


void main(List args) async {
 final ip = InternetAddress.anyIPv4;
 final port = 8080;
 final server = await serve(_router, ip, port);
 print("Server listening on port ${server.port}");
}

Dans cet exemple, la route hello ne sera exécutée que si le middleware valide le pré-traitement

capture d'écran du logiciel postman, appelant la route hello de l'exemple de code précédent. La requête ne contient pas le header TOKEN nécessaire au bon traitement de celle-ci

Appel à la route hello sans le header nécessaire - code 401

capture d'écran du logiciel postman, appelant la route hello de l'exemple de code précédent. La requête contient bien le header TOKEN, avec la valeur "world".

Appel à la route hello avec le header nécessaire - code 200

Oui mais dans la vraie vie, il y a aussi [insert feature/tech]

Bien entendu, on est souvent amené à effectuer des traitements plus exotiques que du simple CRUD lorsqu’on développe un backend. Voici une sélection de librairies Dart permettant de répondre aux cas d’usage les plus courants :

La liste des possibilités est longue, et chaque jour pub.dev reçoit de nouvelles librairies et mises à jour étendant le champ des possibles. Si vous ne trouvez pas votre bonheur, rien de vous empêche de mettre la main à la pâte et de participer à l'essor de ce qui deviendra peut-être le nouveau JavaScript !

D'autres façons de faire du backend en Dart

Cet article fait le focus sur la librairie shelf, développée par la Dart Team elle-même. D’autres frameworks permettent d’exposer des backends en Dart, développés par des équipes tierces :

  • Angel3 - Inspiré de FeatherJS. Il intègre notamment un mécanisme d’ORM pour mapper facilement des routes GET/PUT/PATCH/DELETE directement avec sa base de données, ou encore une implémentation de GraphQL. Inclut une CLI dédiée.

  • Conduit - Comme Angel3, il intègre un ORM et une CLI dédiée.

  • Alfred - Inspiré d’ExpressJS.

  • Frog - surcouche à shelf à la popularité grandissante, offrant aussi une CLI dédiée. Sa particularité est de se baser sur l’arborescence et le nommage des fichiers source pour définir facilement ses routes, middlewares et même l’injection de dépendance 👌

Node.JS, Spring et consorts en sueur ?

Nous savons maintenant comment mettre en place une API REST en Dart, capable de pré-traiter les requêtes qu’elle reçoit avant de les exécuter. Nous avons même une liste de libs Dart permettant d’adresser des problématiques standards rencontrées côté backend. Mais alors, quelle équipe va vouloir abandonner son Spring ou son Node chéri pour migrer vers le langage de Google ? À l’heure actuelle, très peu ou presque, et ce pour plusieurs raisons : refus de votre SSI à intégrer une nouvelle plateforme, efficacité en performances/stabilité/sécurité pas encore évaluée dans votre contexte… Dart est encore dans la phase où il n’est connu que d’une petite partie de la communauté : principalement celles et ceux qui font du Flutter.

photo de gumppy cat, un chat célèbre sur internet pour son expression faciale constament renfrognée.

photo d’un responsable de la sécurité du SI à l’annonce d’une nouvelle plateforme logicielle à gérer (source : grumpy cat sur twitter)

Cependant, comme le dévoile la roadmap publiée récemment par Google, la cote de popularité de Flutter est sur la pente ascendante. Dart ne peut que bénéficier de l’attrait grandissant de Flutter, qui lui apportera mécaniquement de plus en plus de développeur•euse•s. En témoigne le graphe suivant issu de l’analyse des tendances stackoverflow, où l’on découvre que Dart dépasse désormais Spring en volume de questions posées. Et en 2010, quand Javascript était considéré comme un langage exclusif au front web, qui aurait pu prédire qu’il occuperait autant de place côté back de nos jours ? Dart pourrait bien être le prochain langage à emprunter cette voie. Certains ont déjà franchi le pas, comme par exemple Datagrok, dont le serveur Datlas est développé en Dart. Citons aussi la plateforme d’API serverless Genezio, qui inclut désormais Dart parmi les langages supportés.

graphe extrait des tendances stackoverflow. il compare le pourcentage des sujets node, dart, kotlin et spring parmi le volume de questions posées sur le site, de 2008 à nos jours. En Juin 2023, node était devant avec 3% du volume, puis dart et kotlin avec environ 1,25% chacun, et enfin spring avec 1%.

Depuis Q2 2020 stackoverflow reçoit plus de questions portant sur Dart que sur Spring, faisant jeu égal avec Kotlin. Aussi, on constate que la courbe de Dart a la même progression que node.js à ses débuts

La question à poser est plutôt la suivante : quelle équipe va bien vouloir se mettre à développer son API web en Dart ? À l’heure actuelle, celle qui a déjà les compétences sans forcément le savoir : la team qui développe une application Flutter. Elle maîtrise déjà le langage, l’écosystème et même le gestionnaire de package. Les templates de leur IDE (qui ont fortement inspiré les extraits de code de cet article) vont même jusqu’à proposer des fichiers de configuration Docker prêts à l’emploi, facilitant le déploiement de votre serveur Dart. Et ce ne serait pas le seul langage à s'aventurer en dehors du front pour s’étendre à toute la stack : Javascript l’a fait avec l’apparition de Node.JS, et souvenons-nous de Kotlin qui a d’abord séduit les développeurs Android avant de rapidement déborder dans tout le monde Java et même au-delà.

Le mot de la fin

Du Dart dans votre backend ? Disons que pour le moment et en conditions réelles, il faut que les planètes soient alignées : mieux vaut tenter cette approche sur un projet mineur afin de se faire les dents et déterminer si la solution est viable dans son propre contexte. Mais si d’aventure vous avez un prototype d’app Flutter dans les cartons (mobile, web et/ou desktop), l’occasion d’unifier votre codebase dans un mono-repo Dart est toute trouvée. Confier les clefs du back for front, voire carrément du backend tout court à votre équipe de dev permettrait d’accélérer les développements et d’accroître son autonomie. On pourrait même donner un nom à cette pratique : le développement fullstack 🤯.