Lean Code: codez efficacement – Exemple avec ExpressJS

Lorsqu’une équipe de développement se lance dans un projet, ce qu’elle cherche à faire en réalité, c’est matérialiser le besoin de son client par le biais d’un support informatique: l’application. Pour réaliser l’application, l’équipe va devoir effectuer un certain nombre de tâches. Certaines de ces tâches permettent d’exprimer le besoin métier tandis que d’autres sont purement techniques. Ainsi, tout développement logiciel comporte une certaine part de complexité essentielle inévitable, liée au problème à résoudre et une part de complexité dite accidentelle introduite en raison de choix de développement non pertinents. Comment minimiser cette dernière complexité et donc la dette technique dès le départ en les réduisant à leur strict minimum ?

Dans cet article, je vous propose de répondre à cette question en 4 étapes clés. C’est parti !

Contexte

La méthode présentée ci-dessous est tirée d’un retour d’expérience du développement de l’application drepakin.com. Cette application permet aux patients atteints de la drépanocytose de retrouver les centres de santé experts à proximité. Vous trouverez le code complet de l’application Express Ici: https://github.com/tutanck/DrepakinApi et celui du client React ici: https://github.com/tutanck/Drepakin.

Définition

Esprit

Un moteur ne transforme pas la totalité de l’énergie qu’il consomme en énergie mécanique (l’énergie utile au mouvement d’une voiture par exemple). Il produit également de la chaleur. La chaleur (ou gaspillage), correspond à l’énergie perdue, inutile au mouvement.

Ici, nous chercherons à réduire le gaspillage (notre temps et nos efforts) tout en maximisant notre rendement, notre productivité et notre résultat (l’application).


Méthode

Pour la suite, nous ferons la distinction entre les tâches utiles et le gaspillage. Les tâches utiles correspondent exclusivement aux tâches qui nous permettent d’exprimer le besoin métier du client. Le gaspillage correspond aux tâches purement techniques sans valeur ajoutée pour le métier mais nécessaires au fonctionnement et à la maintenabilité de l’application.

Considérons le développement d’une application Express classique. Celui-ci se fait généralement en 3 principales étapes : 

  • la définition des routes (les points d’entrée permettant d’exposer chaque fonctionnalité au monde extérieur).
  • l’écriture des services (il s’agit des fonctionnalités intrinsèques de l’application).
  • la définition des modèles de données (qui serviront à persister les données générées par les services dans une base de données). 

À cela s’ajoute généralement, la gestion d’erreurs, l’envoi de mails, etc.

De manière triviale, on peut classer l’écriture des services et la définition des modèles dans la catégorie des tâches utiles car elles permettent d’exprimer directement le métier.

La définition des routes, la gestion d’erreur et l’envoi de mails appartiennent à la catégorie des tâches techniques ou “non métier” (Non-Functional Requirements en anglais) car elles ne participent pas à matérialiser intrinsèquement le besoin métier. Elles sont néanmoins nécessaires au bon fonctionnement de l’application. 

Pour coder efficacement une application, je propose la méthode suivante : 

  • cadrer, structurer et uniformiser au maximum les tâches techniques en ne laissant pas ou peu de liberté de décision aux développeurs. Ces tâches, souvent répétitives doivent être exécutées sans que le développeur n’ait trop besoin de réfléchir à leur réalisation. Cela ne veut pas dire qu’il ne faut pas du tout y avoir réfléchi. Au contraire, il faut les avoir suffisamment mûries, pensées et simplifiées en amont de sorte qu’elles deviennent quelque chose de standard que l’on répète sans trop y penser ou bien qu’elles soient externalisées ou centralisées dans un module générique.
  • passer beaucoup plus de temps à la réalisation des services, leur modélisation, leur découpage (peut-être en micro services), leur interopérabilité, leur robustesse et les optimisations qu’il est possible d’y faire. Ceci devra monopoliser presque toute l’attention des développeurs.

Détaillons cette méthode en 4 étapes simples et faciles à mettre en oeuvre.


Etape 1 : Simplification 

« La première règle pour toute technologie utilisée dans une entreprise, c’est que l’automatisation appliquée à une opération efficace va amplifier son efficacité.  La seconde règle, c’est que l’automatisation appliquée à une opération inefficace va amplifier son inefficacité. » Bill Gates.

La simplification est de loin l’étape la plus importante de toute la méthode.

Cette étape consiste à passer en revue, l’ensemble des opérations habituellement effectuées pour une tâche et d’en éliminer drastiquement le superflu. Il faut n’avoir gardé à la fin de cette étape que l’indispensable nécessaire au fonctionnement de la tâche. Ce mode de pensée est dans la lignée du Système de Production Toyota (TPS pour « Toyota Production System » en anglais).

Par exemple pour simplifier une route, il faut élaguer tout ce qui n’est pas intrinsèquement nécessaire à l’exposition d’un service. Il faut donc déjà en éliminer toute trace du métier et ne laisser que les opérations ciblées, concises et précises, indispensables au fonctionnement d’une route.

Réduite à son strict nécessaire, une route doit : 

  1. vérifier la conformité des paramètres reçus 
  2. en cas de conformité des paramètres, les récupérer
  3. puis les transmettre au service concerné 
  4. renvoyer le résultat de l’exécution du service sous-jacent au client ou une erreur en cas de problème.

Nous avons nos 4 opérations indispensables au fonctionnement d’une route. Nous pouvons maintenant passer à l’étape suivante: la standardisation de ces opérations.


Etape 2 : Standardisation 

La standardisation consiste à uniformiser le fonctionnement de toutes les parties de notre code qui ont un rôle commun selon le même modèle et ce dans toute l’application. Ce modèle doit être tiré des opérations indispensables que nous avons révélées lors de l’étape de simplification.    

En reprenant l’exemple des routes, ça donne ceci: 

router.get(
  '/list/by/center/:centerId',  // URL de la route

// définition des contraintes de validité des paramètres reçus
  [
    param('centerId').isMongoId(),
    query('page').optional().isInt({ min: 1 }),
    query('perPage').optional().isInt({ min: 1 }),
  ],

  async function(req, res) {
    validateRequest(validationResult(req));  // validation des paramètres de la requête

    const { centerId } = req.params;  // récupération des paramètres de requête
    const { page, perPage } = req.query; // récupération des paramètres de requête

    const result = await listByCenter(centerId, page, perPage); // appel à un unique service dédié

    return res.status(200).json(result); // renvoi du résultat au client
  },
);

Dans ce code snippet, la route définit pour une URL donnée les paramètres et les contraintes de validité de chaque paramètre dans un tableau de middlewares, puis dans sa fonction associée, elle s’occupe de : 

  1. vérifier que les paramètres reçus sont bien valides à l’aide de la fonction validationResult du module de vérification (j’y reviendrai)
  2. récupérer les paramètres une fois leur validité confirmée 
  3. appeler le service exposé par la route avec les paramètres récupérés. Je préfère ne faire appel qu’à un unique service par route afin d’éviter toute dispersion du modèle d’opérations établi. De plus, cette organisation favorise un meilleur découpage de nos services
  4. renvoyer le résultat au client. Rien de plus rien de moins. 

Ce même schéma doit se répéter à l’infini pour chaque nouvelle route que nous voudrons définir. 

De cette manière, nous standardisons la création de nos routes en 4 opérations simples et surtout identiques. Si l’on souhaite dans le futur faire évoluer les opérations réalisées par une route, il faudra les uniformiser partout dans l’application afin que le fonctionnement des routes reste homogène. Cela a essentiellement pour but de limiter la nature imprévisible du code associé à la gestion des routes et améliorer la maintenabilité de celui-ci. Puisqu’on effectue toujours les quelques mêmes opérations simples pour chaque route, s’il y’a un problème, il est facile de le corriger partout d’une seule et même manière.

Voyons ensuite comment la phase de vérification des paramètres est déléguée à un module externe à l’étape suivante.


Etape 3 : Masquer la complexité : délégation / modularisation.

Le but de cette étape est de cacher la complexité de certaines opérations en les externalisant dans des modules maintenus soit par des équipes externes, des packages npm par exemple (c’est la délégation) soit par soi-même (c’est la modularisation).

Etape 3.1 : La délégation

La délégation permet de “déplacer le risque” au niveau des modules externes. Quand je parle de “risque”, il s’agit : des bugs éventuels et du coût en maintenance induits lors de l’écriture de code fait maison. Moins il y aura à écrire de code fait maison plus facile sera la maintenance et moins il y aura de bugs. Chaque nouvelle ligne de code écrite introduit théoriquement une ligne d’instabilité et un coût additionnel en maintenance: « le meilleur code, c’est celui que je n’écris pas”.

Il faut cependant éviter de tomber dans le piège du “je délègue tout, tout le temps”. En déléguant tout et n’importe quoi à tout va, il peut très vite devenir difficile de maîtriser l‘interopérabilité entre les modules externes auxquels vous déléguez. Les modules auxquels on délègue doivent être des bibliothèques externes de confiance, régulièrement maintenues, largement utilisées et approuvées par la communauté. Par exemple, j’utilise express-validator pour gérer la validation des requêtes. De cette manière je n’ai presque pas besoin d’écrire de code pour vérifier la validité des paramètres reçus par mes routes. Je n’ai qu’à configurer les validateurs fournis par express-validator pour chaque route. 

Reprenons l’exemple utilisé à l’étape de la standardisation. La configuration de express-validator se résume à ça:

[
    param('centerId').isMongoId(),
    query('page').optional().isInt({ min: 1 }),
    query('perPage').optional().isInt({ min: 1 }),
]

et à une ligne de code de validation que je compose avec la fonction faite maison validateRequest de mon module de gestion centralisée des erreurs (j’en parlerai juste après):

    validateRequest(validationResult(req));  

Voici le code de la fonction validateRequest :

const validateRequest = function (validationResult, options) {
  try {
    validationResult.throw();
  } catch (errors) {
    throw new BadRequest({ errors: errors.array(options) });
  }
}


On se contente simplement, d’encapsuler la fonction validationResult.throw afin de convertir les erreurs de validation en erreurs http.

Prenons un second exemple de délégation, cette fois-ci au niveau du modèle de données. En appliquant l’étape de délégation au niveau du modèle de données mongoose, on est purement et simplement réduit à une configuration de mongoose avec 0 code custom. Au lieu de requêter à la main 2 ou plusieurs collections puis de les matcher, les jointures entre les différentes collections sont gérées à l’aide des fonctions populate et virtual de mongoose : https://mongoosejs.com/docs/populate.html.

L’exemple suivant l’illustre bien. Dans cet exemple, on cherche à récupérer l’ensemble des commentaires postés sur un centre de santé ainsi que la note que chaque auteur d’un commentaire a attribué à ce même centre:

/* Au niveau du modèle */

// Définition du schema de base de la collection `Comments`
const ExpertCenterCommentSchema = new Schema(
  {
    center_id: { required, type: Types.ObjectId, ref: 'ExpertCenter' },
    author: { required, type: Types.ObjectId, ref: 'User' },
    updated_at: { required, type: Date, default: Date.now },
    text: { required, type: String, trim: true, minlength: 1 },
  },
  {
    toJSON: { virtuals: true },
  },
);

// Configuration de la jointure entre la collection 'Comments' et la collection `Rates`
ExpertCenterCommentSchema.virtual('author_center_rate', {
  ref: 'ExpertCenter_Rate',
  localField: 'author',
  foreignField: 'user_id',
  justOne: true,
});
/* Au niveau du service */

// Fetch des commentaires depuis la collection `Comments`
const baseQuery = ExpertCenterComment.find({
    center_id: centerId,
  });

// Enrichissement implicite des résultats par jointure en utilisant `populate`
  const comments = await baseQuery
    .populate({ path: 'author', select: 'name picture' })
    .populate({
      path: 'author_center_rate',
      match: { center_id: centerId },
      select: 'value -_id',
    });

Grâce au populate, on enrichit chaque commentaire retrouvé avec les informations sur son auteur depuis la collection ‘Users’ et la note que cet auteur a attribué au centre qu’il a commenté depuis la collection `Rates` sans écrire une seule ligne de code pour gérer le modèle de données.

Lorsqu’il n’est pas possible de déléguer, la modularisation peut être une option intéressante. 

Etape 3.2 : La modularisation

L’étape de modularisation est dans le même esprit que celui de la délégation. Il s’agit de centraliser dans un ou plusieurs modules la gestion d’un aspect purement technique du développement de notre application et ainsi d’en masquer la complexité.

Pour illustrer cette étape, voici comment la gestion de toutes les erreurs de notre application est centralisée à un seul et même endroit. Chaque erreur possible est associée à une fonction du module error-handlers qui retourne un code erreur http qui sera envoyé au client:  

const {
  BadRequest,
  Forbidden,
  NotFound,
} = require('http-errors');
const DocumentNotFoundError = require('mongoose/lib/error/notFound');
const { isInstanceOfSome } = require('../utils/toolbox');

module.exports = {
  // 400  BadRequest
  handleBadRequestError(error, req, res, next) {
    if (isInstanceOfSome([BadRequest])(error)) {
      return res.status(400).json({
        error,
      });
    } else {
      next(error);
    }
  },

  // 404 NotFound
  handleNotFoundError(error, req, res, next) {
    if (isInstanceOfSome([NotFound, DocumentNotFoundError])(error)) {
      return res.status(404).json({
        error,
      });
    } else {
      next(error);
    }
  },

// ….
 
  // 500 InternalServerError
  // eslint-disable-next-line no-unused-vars
  handleInternalServerError(error, req, res, next) {
    return res.status(500).json({
      error,
    });
  },
};

Chaque fonction du module est ensuite ajoutée en tant que middleware de notre app express :

Object.values(error_handlers).map(fn => app.use(fn));

Ce module est lui-même facilement maintenable. Pour gérer une nouvelle erreur il suffit de l’ajouter dans le tableau passé à la fonction isInstanceOfSome et voilà!

La fonction isInstanceOfSome est une petite fonction maison qui applique un instanceof à tous les éléments du tableau qui lui est passé: 

const isInstanceOfSome = typesList => tobeMatched =>
  typesList.some(oneType => tobeMatched instanceof oneType);

Cette technique de gestion des erreurs est inspirée de cet article: https://thecodebarbarian.com/80-20-guide-to-express-error-handling.

Je vous invite à y jeter un oeil.

À ce niveau, la plupart de nos tâches techniques sont soit standardisées, déléguées ou modularisées. Nous allons enfin pouvoir coder notre métier avec la plus grande des libertés dans les services.


Etape 4 : Centralisation du métier dans les services

Honnêtement, je n’ai pas grand-chose à dire pour cette étape sinon que c’est ici que tous vos neurones disponibles doivent dépenser leur énergie. Si les étapes précédentes ont été bien réalisées, tout le code métier doit se retrouver dans vos services et nulle part ailleurs. 

Si vous ressentez le besoin de faire appel à deux services dans une route, il faut se poser la question de créer un service intermédiaire qui fera appel à ces deux services et qui sera lui-même l’unique service appelé par la route qui lui est associée.

Enfin, cette organisation centralisée du code métier dans les services facilite grandement sa testabilité. Puisqu’il n’y a pas de code à tester au niveau des modèles de bases de données et que les routes n’effectuent que des opérations très basiques, nous pouvons concentrer nos tests sur les services et les modules internes lors des TU (Tests Unitaires). Les modules externes eux sont censés être testés par les équipes qui les maintiennent. Cela nous laisse le temps de faire également des TI (Test d’Intégrations) pour chaque service exposé.

Ah ! Un conseil, qui n’est pas de moi, mais d’un ex de Google : “Good code should read as a sentence.” Voyons voir si mes services font l’affaire. Prenons le même exemple que dans l’étape de la délégation. Le service listByCenter liste l’ensemble des commentaires pour un centre. 

En voici le code complet: 

const listByCenter = async function(centerId, page = 1, perPage = 5) {
  const ec = await ExpertCenter.findById(centerId).orFail();

  const baseQuery = ExpertCenterComment.find({
    center_id: centerId,
  });

  const { metas, query } = await paginate(baseQuery, page, perPage, {
    updated_at: -1,
  });

  const comments = await query
    .populate({ path: ‘author’, select: ‘name picture’ })
    .populate({
      path: ‘author_center_rate’,
      match: { center_id: centerId },
      select: ‘value -_id’,
    });

  return {
    …metas,
    items: comments,
  };
};

Si je m’y essaye, ça devrait donner quelque chose comme ça : 

Le service listByCenter trouve le centre expert identifié par centerId; puis trouve les commentaires sur le centre identifié par centerId, les pagine avant d’enrichir chaque commentaire avec le nom et la photo de son auteur ainsi que la valeur de la note que celui-ci a attribuée au centre identifié par centerId.


Bonus : Minimisation du coût des opérations hautement répétitives

Un développeur est appelé à taper quotidiennement des centaines de commandes shell, souvent les mêmes. Dans un souci d’optimisation, j’ai réduit le temps accordé à chaque commande tapée en créant un raccourci pour chacune des commandes que j’utilise fréquemment :

# cmd
alias cmd='cat ~/.bash_profile' #command list

# docker
alias dk='docker '
alias dkc='docker-compose '

# git
alias ga='git add '
alias gacm='git add . && git commit -m $1'
alias gb='git branch '
alias gck='git checkout '
alias gcl='git clone '
alias gcm='git commit -m $1'
alias gdif='git diff '
alias gf='git fetch '
alias gk='gitk --all&'
alias gpl='git pull '
alias gpsh='git push '
alias grh='git reset --hard '
alias gpop='git stash pop'
alias gst='git branch && git status '
alias gsta='git stash '
alias gtree='git log --graph --oneline --all '
alias gx='gitx --all'

# ls
alias l='ls -a'
alias ll='ls -al'

# node/npm
alias n='node '
alias nd='npm run dev '
alias ni='npm install '
alias nr='npm run '
alias ns='npm start '
alias nt='npm run test '

# touch
alias tdk='touch Dockerfile'
alias tdkc='touch docker-compose.yml'
alias tdki='touch .dockerignore'
alias tgi='touch .gitignore'
alias tmd='touch README.md'
alias ti='touch index.js'
alias tapp='touch app.js'

# yarn
alias y='yarn '
alias ya='yarn add '
alias yi='yarn install '
alias ys='yarn start:'
alias ysd='yarn start:dev'
alias yt='yarn test'
alias ytu='yarn test:unit'
alias ylj='yarn lint && jest'

# npx (npm pkg)
alias cra='npx create-react-app '   # create react app
alias hps='heroku ps -a '           # heroku remaining dynos
alias kp='npx kill-port '           # kp 'port' --ex : kp 3000
alias md='npx readme-md-generator ' # generate new readme
alias ng='ngrok http '              # ng 'port' --ex : ng 3000



En conclusion, pour gagner en efficacité, il faut commencer par dissocier le code métier du code purement technique. Puis, passer par une phase importante de simplification de votre code technique. Ensuite, il faut chercher à déléguer tout ce qui peut l’être à des modules externes de confiance. Lorsque cela n’est pas possible ou non souhaitable, il faut mutualiser les bouts de codes qui ont un même rôle dans un module central. Lorsque ce n’est pas possible de mutualiser ou que ce n’est pas souhaitable de le faire, il ne vous reste plus qu’à standardiser ce pan technique partout dans votre application. Les services doivent rester le seul endroit où vous devriez consacrer du temps et des efforts. La solution que je viens d’exposer se veut être une proposition et laisse la porte ouverte à la discussion. À vos commentaires !


Découvrez nos formations Crafting Software

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

%d blogueurs aiment cette page :