When using database transactions with Strapi, use the query API, NOT the services API.
Explaination
I started using Strapi few month ago to code our backend at Burrowvest.com, a platform we built with my cofounder to buy slices of high yield Real Estate in 3 clics.
Strapi is a headless CMS developed by the eponymous French company that offers an easy-to-use API on top of Koa. It’s plugin system makes development fast as it proposes an admin interface out-of-the-box, a customizable file upload system and a straight-forward database configuration (among others). The main drawback to me was the lack of support of typescript since I got used to it for some years now. Static typing systems efficiency to catch bugs before deployment has been proven over the past decades and was essential to me. But I still decided to give Strapi a try to expand my horizon beyond Express, Koa and Nestjs.
I have to say, coming from Nestjs, Strapi is a breath of fresh air. I felt freed from the strong opinionated architecture principles of Nestjs and rediscovered the joy of developing with bare simple JavaScript. With good practices and project configuration, it is possible to write scalable JavaScript application. Strapi is still under intense development and a first typescript support has been announced with version 4.0.2. It sounds promising and I will pay close attention to it.
That being said, with version 3.6.8, I ran into an issue when developing our system at Burrowvest.com. To ensure the integrity of our data, our endpoint to buy slices of Real Estate is implemented using database transactions. If an issue occurs during the sell, the transaction is rolled back and our system stays in a consistent state. Since we use a Postgres database, database transactions are usable. At first we implemented them this way:
const buySlices = async (ctx) => {
//...
// Create a database transaction.
const trx = await strapi.connections.default.transaction();
try {
// Create a transaction for the user to buy slices of Real Estate.
const transaction = await strapi.services['transactions'].create(
{ buyer: user.id, /** ... */ },
// Have the query be part of a database transaction.
{ transacting: trx },
);
// The database transaction is committed when the program runs as expected.
await trx.commit();
} catch (err) {
// The database transaction is rolled back if an error occured.
await trx.rollback();
}
//...
}
module.exports = buySlices;
The Strapi services API offers a simple way to query the database in a transaction manner.
But during testing, it didn’t work as expected. When an issue occured, the database transaction wasn’t rolled back. The data stayed in an inconsistent state as if the database transaction wasn’t even taken into account. By digging into Strapi’s implementation, I found the cause of the issue:
// Query API
// The query API takes a second parameter for database
// transactions.
async function create(attributes, { transacting } = {}) {
//...
}
// Services API
// The services API calls the query API under the hood
// but the second parameter is not passed along when a
// query is made using this API.
async create(opts, { model }) {
//...
let entry = await db.query(model).create(validData);
//...
}
When querying the database with the services API, Strapi calls the query API under the hood but doesn’t pass all the parameters down. The database transaction gets lost and the query is actually made instead of awaiting to be committed. So I used the query API directly instead:
// Create a transaction for the user to buy slices of Real Estate.
// Use the query API.
const transaction = await strapi.query('transactions').create(
{ buyer: user.id, /** ... */ },
// Have the query be part of a database transaction.
{ transacting: trx },
);
And it worked as expected. The fire was extinguished, at least for now.
Strapi lets developers query the database using the query and services APIs, but the services API is incomplete. That is misleading and error prone, and it won’t be long since a developer introduces the bug again. I want developers to be enforced to use the query API when they need to query the database. Since I can’t rely on typescript, I will explain in another blogpost how I made use of the eslint rule no-restricted-syntax to tackle the issue once for all.
Ça fait maintenant un bon bout de temps que j’utilise NestJs pour des projets persos et pros et je dois dire que je suis assez conquis. Notamment son système d’injection de dépendance chipé à Angular qui simplifie l’architecture de l’application et la mise en place des tests.
Le framework étant très orienté autour de la POO et du DDD, il y a cependant une partie que je n’arrivais pas à craquer : la création rapide et pratique des Domain Models.
La meilleure solution que j’avais trouvée était une adaptation du Simple Factory Pattern qui même si elle fonctionnait bien, me laissant cet arrière goût de Mouais…
Note au passage
Le code est en libre accès sur github et disponible sur npm sous le nom builder-pattern-2 :
npm i builder-pattern-2
# ou
yarn add builder-pattern-2
Plantons le décors
Pour cet article, partons du principe qu’on développe une entreprise de Serviteurs On Demand. Nos clients sont des personnes qui ont un problème précis comme l’incapacité à ouvrir un pot de cornichons. Ils se rendent sur notre plateforme, commandent un serviteur qui leur vient en aide et disparait instantanément une fois la tâche accomplie. Appelons ces serviteurs des Mr Meeseeks.
Mr Meeseeks, toujours prêt à se payer une bonne tranche en aidant son prochain.
Voilà le Domain Model de notre Mr Meeseeks :
class MrMeeseeks {
constructor(
private readonly goal: string,
private readonly lifespan: number = Infinity,
) {}
public getGoal(): string {
return this.goal;
}
public getLifespan(): number {
return this.lifespan;
}
}
Il a un objectif à atteindre (goal) et une durée de vie maximale (lifespan) avant d’avoir le droit au repos.
Le Simple Factory Pattern à l’Arrière Goût de Mouais
Afin de créer un Mr Meeseeks, jusqu’à maintenant j’aurais utilisé une adaptation du Simple Factory Pattern. Plutôt que de passer tous les paramètres du constructeur à la main, je passe par un object de configuration :
interface CreateArgs {
goal: string;
lifespan?: number;
}
class MrMeeseeksSimpleFactory {
public static create(args: CreateArgs): MrMeeseeks {
return new MrMeeseeks(args.goal, args.lifespan);
}
}
L’object de config est 1. extensible, 2. je n’ai pas à passer undefined aux paramètres optionnels qui ne m’intéressent pas et 3. je peux définir des valeurs par défaut pour les paramètres obligatoires. Par exemple, si les analytics de la boite nous permettent de nous apercevoir que l’ouverture de pots de cornichons est un véritable customer pain point, on peut potentiellement le mettre en goal par défaut dans la factory (ça ne ferait pas nécessairement sens de le mettre en valeur par défaut dans le constructeur de la classe).
Là où ça pêche c’est que je dois mettre à jour manuellement l’interface CreateArgs et l’implémentation de la méthode create à chaque fois que j’ajoute un paramètre au constructeur. Si j’oublie l’une des 2 étapes, ça ne fonctionne pas, ce qui alourdi considérablement la charge mentale liée à la modification d’un Domain Model. Pareil pour la création d’un Domain Model et même davantage, puisque je dois implémenter la factory qui n’existe pas encore.
Mon postulat c’est qu’il doit y avoir moyen de générer la factory automatiquement à partir d’un Domain Model plutôt que de le faire à la main.
Le Builder Pattern à l’Élégance Fringante
Comme j’étais insatisfait, j’ai commencé à regarder les solutions existantes à gauche à droite et je me suis souvenu du décorateur @Builder du package Java Lombok.
Dans un monde plein de magie où javascript exécuterait du code java car ils ont leurs 4 premières lettres communes, le décorateur @Builder de Lombok donnerait le résultat suivant :
@Builder
class MrMeeseeks {
constructor(
private readonly goal: string,
private readonly lifespan: number = Infinity,
) {}
public getGoal(): string {
return this.goal;
}
public getLifespan(): number {
return this.lifespan;
}
}
const mr = MrMeeseeks.builder()
// Définition du goal.
.goal("Ouvrir pot de cornichons")
// Définition de la durée de vie.
// On lui met 24h max.
.lifespan(24)
// Construction du MrMeeseeks.
.build();
// log : "Ouvrir pot de cornichons".
console.log(mr.getGoal());
Le Builder Pattern est élégant, pratique et lisible notamment grâce à sa Fluent API qui permet de chaîner les méthodes du builder. De plus, il se plug simplement sur la classe qu’on veut rendre buildable sans autres formes d’intrusivité.
De cette utilisation simple découlent des contraintes non triviales que le système doit respecter :
Génération automatique des méthodes du builder en fonction des paramètres du constructeur : le builder doit exposer la méthode setLifespan comme le constructeur prend le paramètre lifespan.
Auto-complétion des méthodes du builder avec typescript.
Inférence des types des méthodes du builder avec typescript : la méthode setLifespan doit prendre un number en paramètre et retourner un Builder de MrMeeseeks.
Pas de code boilerplate à ajouter dans la classe à rendre buildable : le système doit être le moins invasif possible pour se laisser la possibilité de changer l’implémentation du builder si on le souhaite.
On va s’occuper de l’implémentation en 2 parties : dans un premier temps, on va s’occuper de générer le builder de façon automatique (javascript) et dans un second temps on implémentera le typage (typescript).
Génération du Builder
Le builder sera une classe qui a pour attributs les paramètres du constructeur de la classe qu’il construit. Par exemple, pour créer un MrMeeseeks, on a doit passer 2 paramètres qui sont goal et lifespan. Le builder d’un MrMeeseeks aura donc 2 attributs qui seront goal et lifespan, dont la valeur sera définie par les méthodes setGoal et setLifespan.
Commençons simple et implémentons la fonction qui crée un builder à partir d’une classe. Le builder est une classe instantiable, donc la base de la fonction est :
La première étape est de définir les attributs du builder. Pour ça il nous faut récupérer le nom des paramètres du constructeur de cls.
En javascript, il est possible de récupérer le code d’une classe en la convertissant en string. C’est à la fois weird et très pratique puisqu’avec une regex on va pouvoir extraire la partie constructeur de la classe et ses paramètres :
const CONSTRUCTOR_REGEX = /constructor\s*\((.*)\)/;
const COMMENT_REGEX = /\/\*\*.*\*\//g;
const SPACE_REGEX = /\s*/g;
const extractConstructorParams = (cls): string[] =>
/** exécute la regex `CONSTRUCTOR_REGEX`sur le code source de la classe `cls` et récupère la string qui contient les paramètres du constructeur. */
CONSTRUCTOR_REGEX.exec(cls.toString())[1]
/** Supprime respectivement les commentaires et les espaces. */
.replace(COMMENT_REGEX, '')
.replace(SPACE_REGEX, '')
/** Sépare les paramètres les
uns des autres. */
.split(',')
/** Sépare le nom des paramètresde leur valeur par défaut. */
.map((param) => param.split('=')[0]);
En appliquant extractConstructorParams à la classe MrMeeseeks, on obtient le tableau de string ['goal', 'lifespan'].
On peut maintenant créer les attributs et setters du builder :
const capitalize = (str: string) =>
str.charAt(0).toUpperCase() + str.substring(1);
const createBuilderClass = (cls) => {
const paramNames: string[] = extractConstructorParams(cls);
const builder = function () {
// Remet à zéro les attributs du builder.
this._reset = function () {
for (const param of paramNames) {
this[param] = undefined;
}
};
/** Crée un setter pour chaque paramètre
du constructeur.
Le setter retourne `this` pour avoir
une fluent API. */
for (const param of paramNames) {
this[`set${capitalize(param)}`] = function (value) {
this[param] = value;
return this;
};
}
// Instancie la classe et remet à zéro le builder.
this.build = function () {
const values = [];
for (const param of paramNames) {
values.push(this[param]);
}
const instance = new cls(...values);
this._reset();
return instance;
};
};
return builder;
};
Avec ça on obtient déjà un builder javascript fonctionnel.
Ici on n’a pas spécifié le lifespan de notre MrMeeseeks avec le builder qui pourtant vaut Infinity. Puisque l’attribut lifespan du builder vaut undefined par défaut, l’attribut de la classe prend la valeur par défaut définie par le constructeur.
Typage du Builder
L’objectif est d’obtenir l’auto-complétion des méthodes du builder avec leur typage. Par exemple, si je fais appel au builder, je veux que la méthode setGoal me soit proposée et que je saches qu’elle prend en paramètre une string et retourne un Builder de MrMeeseeks.
Typescript propose un ensemble d’Utility Types qui facilitent la manipulation des types.
Un qui potentiellement nous intéresse est ConstructorParameters. Il extrait le type des paramètres du constructeur d’une classe :
C’est intéressant, on vient de récupérer les types des paramètres du constructeur mais ça s’arrête là parce que typescript ne permet pas de récupérer le nom des paramètres pour le moment.
C’est ici que commence le compromis vers notre builder idéal.
Puisque les noms des setters reposent sur le noms des paramètres du constructeur, une solution est de les répertorier dans une interface. Les clés seront les noms des paramètres et les valeurs leur type :
// L'interface reflète les noms et types
// des paramètres du constructeur.
interface MrMeeseeksCtor {
goal: string;
lifespan: number;
}
class MrMeeseeks {
constructor(
private readonly goal: string,
private readonly lifespan: number,
) {}
}
Et depuis typescript 4.1, la génération des noms des setters devient facile grâce au Template Literal Types. Ils permettent de créer des types string de manière programmatique à partir d’autres types string. Par exemple, le type string "goal" peut être utilisé pour créer le type string "setGoal" sans qu’on ait besoin de le créer à la main.
La manipulation des types string en typescript fonctionne de la même manière qu’en javascript :
/** Template Literal Type.
`SetterName` prend un type string en
paramètre et retourne un nom de setter.
Ex : SetterName<'master'> = 'setMaster'.
Typescript fournit le type `Capitalize`
qui met la 1ère lettre d'un type string
en majuscule. */
type SetterName<Key extends string> = `set${Capitalize<Key>}`;
/** `SetterFunctions` prend en paramètre un type objet.
Les clés de l'object sont transformées en `SetterName`
et le résultat sert de clé au type object généré.
Puisque la propriété créée est un setter, la valeur
associée à la clé correspond au typage du setter
qui s'appuie sur le type du paramètre du constructeur.
Le type `Builder` retourné par le setter est
détaillé par la suite. */
type SetterFunctions<T> = {
[K in keyof T as SetterName<string & K>]: (value: T[K]) => Builder<T>
}
// Exemple d'utilisation :
interface MrMeeseeksCtor {
goal: string;
lifespan: number;
}
/**
{
setGoal: (value: string) => Builder<MrMeeseeksCtor>;
setLifespan: (value: number) => Builder<MrMeeseeksCtor>;
}
*/
type Setters = SetterFunctions<MrMeeseeksCtor>;
Le plus gros du travail est fait. Il reste à définir le type Builder qui doit être constructible par l’opérateur new et exposer une méthode build :
type SetterName<Key extends string> = `set${Capitalize<Key>}`;
type SetterFunctions<T, TC> = {
[K in keyof TC as SetterName<string & K>]: (value: TC[K]) => Builder<T, TC>
}
// Le type Ctor est constructible.
type Ctor<T, TC> = new () => Builder<T, TC>
/** `Builder` prend 2 génériques en paramètres :
- T : Le type retourné par la fonction `build`.
Ex : MrMeeseeks.
- TC : Le type représentant les paramètres du
constructeur.
Ex : MrMeeseeksCtor.
Ces 2 génériques sont passés aux types `Ctor`
et `SetterFunctions` car ces derniers font
référence au type `Builder`. */
export type Builder<T, TC> = Ctor<T, TC> & SetterFunctions<T, TC> & {
build: () => T
};
Tout ça mis bout à bout, la séquence suivante est valide du point de vue du typage :
interface MrMeeseeksCtor {
goal: string;
lifespan: number;
}
class MrMeeseeks {
constructor(
private readonly goal: string,
private readonly lifespan: number = Infinity,
) {}
public getGoal() {
return this.goal;
}
public getLifespan() {
return this.lifespan;
}
}
const MrMeeseeksBuilder: Builder<MrMeeseeks, MrMeeseeksCtor>;
const builder = new MrMeeseeksBuilder();
const mr: MrMeeseeks = builder
.setGoal('Ouvrir pot de C')
.setLifespan(12)
.build();
console.log(mr.getGoal()); // 'Ouvrir pot de C'
console.log(mr.getLifespan()); // Infinity
Dernier Coup de Polish
Dernière étape, il nous reste à typer la fonction qui génère le builder :
const createBuilderClass = <T, TC>(cls): Builder<T, TC> => {
// ...
return builder as unknown as Builder<T, TC>;
};
Nous voilà maintenant avec un builder 100% fonctionnel.
Épilogue
Un dernier snippet de code pour la route histoire d’illustrer son utilisation :
import {
Builder,
createBuilderClass
} from './builder';
interface MrMeeseeksCtor {
goal: string;
lifespan: number;
}
class MrMeeseeks {
constructor(
private readonly goal: string,
private readonly lifespan: number = Infinity
) {}
public getGoal() {
return this.goal;
}
public getLifespan() {
return this.lifespan;
}
}
const MrMeeseeksBuilder: Builder<MrMeeseeks, MrMeeseeksCtor> = createBuilderClass<MrMeeseeks, MrMeeseeksCtor>(MrMeeseeks);
const builder = new MrMeeseeksBuilder();
const mr: MrMeeseeks = builder
.setGoal('Ouvrir pot de C')
.setLifespan(12)
.build();
console.log(mr.getGoal()); // 'Ouvrir pot de C'
console.log(mr.getLifespan()); // 12
En partant de cette base, on va voir comment faciliter l’ajout de nouveaux handlers en automatisant nos injections.
Pour le moment, pour charger les handlers dans le DispatchService, ce que nous faisons est d’injecter les handlers un à un et de les stocker dans la liste de handlers :
@Injectable()
export class DispatchService {
// Liste des handlers disponibles.
private handlers: ActionHandler[] = [];
// On injecte les handlers et on les stocke dans
// la liste de handlers.
constructor(
@InjectCreateDoerHandler createDoerHandler: CreateDoerHandler,
@InjectGetMissionHandler getMissionHandler: GetMissionHandler,
) {
this.handlers = [createDoerHandler, getMissionHandler];
}
// ...
}
Ça se fait, mais est-ce qu’il n’y aurait pas un moyen qui permettrait de le faire automatiquement pour ne pas se casser la nénette ? L’idée c’est de ne plus avoir à toucher la logique du DispatchService seulement pour ajouter un handler dans la liste. Par exemple, est-ce qu’il y’a moyen de charger les handlers automatiquement une fois qu’ils ont été ajoutés dans le module ?
Aïe mais quel suspens, j’espère vraiment que l’auteur de l’article a une astuce pour ça sinon ça n’a aucun intérêt 😰
Tkt frelot(te) à la compote, j’ai tout prévu.
Commençons par
La découverte du DiscoveryModule de NestJs 🛰🔍
Le DiscoveryModule fait partie du core package de NestJs. C’est le module qui permet au framework de trouver les bons services à injecter en se basant sur leurs metadata.
Par exemple, NestJs sait qu’une classe est injectable grâce à l’annotation @Injectable(). En allant voir dans le code source, on s’aperçoit que ce décorateur ne fait qu’ajouter des metadata à la classe annotée :
// Ça se code à une main.
export function Injectable(options?: InjectableOptions): ClassDecorator {
return (target: object) => {
// NestJs se base sur la Metadata Reflection API
// pour ajouter des metadonnées à la classe
// qu'on veut rendre injectable.
Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, options, target);
};
}
Je rentre pas dans les détails de la Metadata Reflection API parce que ça ferait un article bien trop gros.
Partant de là, on peut utiliser la même mécanique pour marquer les classes qui sont des handlers. Il suffit de leurs ajouter une metadata maison puis de les récupérer grâce au DiscoveryService et sa méthode getProviders().
Aparté sur la méthode getProviders()
Comme son nom l’indique, la méthode récupère l’ensemble des providers injectés dans l’application. Et elle fait ça sans distinction de module. ✊🏻✊🏼✊🏽✊🏾✊🏿
Par exemple, si on considère les modules suivants :
@Module({
providers: [ProviderA]
})
export class ModuleA {}
@Module({
providers: [ProviderB]
})
export class ModuleB {}
Et qu’on les injecte dans notre AppModule :
@Module({
imports: [ModuleA, ModuleB]
})
export class AppModule {}
Même si ProviderA et ProviderB n’ont pas été exportés depuis leur module respectif, on les retrouvera quand même dans la liste de handlers retournée par la méthode getProviders().
Pour être précis, la méthode retourne une liste de InstanceWrapper qui possèdent 2 propriétés intéressantes :
metatype : contient les métadonnées de la classe providée. On va utiliser la Metadata Reflection API pour aller chercher la métadonnée qui nous intéresse.
instance : contient une instance de la classe providée. Dans notre cas, ça sera une instance de handler si le provider possède la bonne métadonnée.
Ceci étant dit, on peut faire une première ébauche pour trouver les providers qui sont des handlers en utilisant le DiscoveryService :
@Injectable()
export class DispatchService {
// Liste des handlers disponibles.
private handlers: ActionHandler[] = [];
// Le DiscoveryService de NestJs nous permet
// de trouver des services en se basant sur leurs
// metadata.
// Plus besoin d'injecter les handlers à la main.
constructor(private readonly discoverySvc: DiscoveryService) {}
// On cherche les handlers une fois que le
// ChatbotModule a été initialisé.
onModuleInit(): void {
this.discoverHandlers();
}
// Cherche les handlers grâce à leur metadata.
private discoverHandlers(): void {
this.handlers = this.discoverySvc
// On récupère la liste de l'ensemble des
// providers injectés dans l'application.
.getProviders()
// On ne garde que les providers qui
// possèdent la bonne metadata.
.filter((wrapper) => !!wrapper.metatype && !!Reflect.getMetadata(NOTRE_METADATA_MAISON, wrapper.metatype))
// Enfin, on récupère l'instance du provider.
.map((wrapper: InstanceWrapper<ActionHandler>) => wrapper.instance);
}
// ...
}
L’injection sans pression 💉🍻
Maintenant qu’on a notre stratégie, on peut passer à l’implémentation.
En reprenant la logique plus haut, on va commencer par créer notre propre décorateur pour injecter notre metadata maison :
import { CustomDecorator, SetMetadata } from '@nestjs/common';
// Notre metadata maison.
export const ACTION_HANDLER_METADATA_KEY = 'CHATBOT_HANDLER';
// Et notre décorateur maison qui se contente
// d'associer un simple booléen à notre metadata.
export const ActionHandlerDecorator = (): CustomDecorator => SetMetadata(ACTION_HANDLER_METADATA_KEY, true);
On peut ensuite l’appliquer à chacun de nos handlers.
@Injectable()
@ActionHandlerDecorator()
export class CreateDoerHandler {
// ...
}
@Injectable()
@ActionHandlerDecorator()
export class GetMissionHandler {
// ...
}
Il ne nous reste plus qu’à adapter le DispatchService pour filtrer les providers selon la bonne metadata :
@Injectable()
export class DispatchService {
// ...
// Cherche les handlers grâce à leur metadata.
private discoverHandlers(): void {
this.handlers = this.discoverySvc
.getProviders()
// On ne garde que les providers qui
// possèdent la bonne metadata.
.filter((wrapper) => !!wrapper.metatype && !!Reflect.getMetadata(ACTION_HANDLER_METADATA_KEY, wrapper.metatype))
.map((wrapper: InstanceWrapper<ActionHandler>) => wrapper.instance);
}
}
Épilogue
Pour résumer, au lancement du programme, le DispatchService va parcourir l’ensemble des providers répertoriés dans l’application pour ne garder que ceux qui ont notre metadata maison ACTION_HANDLER_METADATA_KEY et les stocker dans sa liste de handlers.
Créer un nouveau handler est maintenant plus simple que jamais : il suffit de
Créer une nouvelle classe injectable.
Annoter la classe avec notre décorateur ActionHandlerDecorator.
Ajouter la classe dans les providers de notre application.
Le DispatchService se charge du reste, sans qu’on ait à le modifier.
À Hello Doe, on a pour mission d’aider les étudiants à trouver des jobs pendant leurs études. On démarche les entreprises, on obtient des missions qu’on poste sur les réseaux sociaux et les doers (les étudiants) postulent en un clic depuis notre chatbot sur Facebook Messenger.
Le chatbot nous permet de proposer un système sans friction aux doers qui n’ont pas besoin de télécharger d’application supplémentatire pour accéder aux missions et à leur candidature.
L’ajout de nouvelles fonctionnalités et la croissance de l’équipe tech a nécessité de revoir l’implémentation de notre webhook avec pour objectifs :
Garantir la lisibilité du code.
Permettre la maintenabilité et la testabilité.
Respecter l’Open/Close Principle.
Élaborer une architecture qui parle d’elle même pour faciliter sa compréhension.
Je vais décrire notre solution en me focalisant sur l’architecture du code uniquement, sans décrire le fonctionnement d’un chatbot et la manière dont s’interface un webhook avec une plateforme pour créer un chatbot.
Ceci étant dit, on peut commencer sans plus attendre par
Un Aller Simple Droit Dans Le Mur 🧱🏃
La façon la plus simple de coder le webhook est d’écrire tous les handlers dans un seul et même fichier. De cette manière ils sont centralisés et n’importe quel dev sait où aller quand il s’agit d’y toucher.
Ça pourrait ressembler à ça :
@Injectable()
export class Handler {
constructor(
@InjectUserRepository private readonly userRepo: UserRepositiry,
@InjectMissionRepository private readonly missionRepo: MissionRepository
) {}
// La méthode reçoit la requête de la plateforme
// de chatbot et la transforme en une action
// qu'elle traite.
public async handle(request: RequestBody): Promise<void> {
const action = this.createAction(request);
await this.handleAction(action);
}
// handleAction s'occupe de traiter l'action
public async handleAction(action: Action): Promise<void> {
// Selon l'action déclénchée par le doer on branche
// sur telle ou telle méthode.
// Ici on va s'occuper de créer un nouveau doer.
if (action.getName() === ActionNames.CREATE_DOER) {
await this.createDoer(action);
// Et là on va récupérer une mission en base pour
// la retourner au doer.
} else if (action.getName() === ActionNames.GET_MISSION) {
await this.getMission(action);
}
}
// Crée un nouveau doer en base de données.
public async createDoer(action: Action): Promise<void> {
const username = action.getParameter('username');
if (!username) {
throw new Error(`Missing parameter username`);
}
await this.userRepo.create(username);
}
// Cherche une mission et la retourne au doer.
public async getMission(action: Action): Promise<void> {
const missionId = action.getParameter('missionId');
if (!missionId) {
throw new Error(`Missing parameter missionId`);
}
const mission = await this.missionRepo.findById(missionId);
if (mission) {
action.addMessage(`Voilà ta mission servie sur un plateau 🍽️`);
action.addMessage(`${mission.company} recrute pour sa mission de ${mission.category}`);
} else {
action.addMessage(`Nous n'avons pas trouvé la mission que tu cherches`);
action.addMessage(`On te présente les missions du moment ?`, `Oui`, `Non`);
}
}
// Crée l'action à partir de la requête du chatbot.
private createAction(request: RequestBody): Action {
// Extraction des paramètres depuis la requête et
// création de l'action.
// ...
return action;
}
}
J’ai volontairement chargé l’exemple pour me rapprocher d’un webhook réaliste.
Ici notre webhook gère 2 actions :
ActionNames.CREATE_DOER : le chatbot demande au webhook de créer un nouvel utilisateur en base de données.
ActionNames.GET_MISSION : le chatbot demande à récupérer une mission en base pour l’afficher sur Facebook Messenger.
Des sous-fonctions createDoer et getMission ont été créées pour soulager la fonction handleAction qui s’occupe de dispatcher l’action.
Ce qu’on sent déjà, c’est que même si le code est centralisé on va vite ramasser nos dents 🦷 Pour le moment on n’a que deux actions gérées mais on va vite commencer à avoir de la misère à lire le fichier. On va se retrouver avec une classe qui gère toutes les actions de notre chatbot dont les effets et les dépendances sont différents.
Par exemple, le UserRepository est pertinent quand on veut créer un nouveau doer avec l’action ActionNames.CREATE_DOER, mais ne l’est plus quand on veut récupérer une mission. Notre classe Handler va rapidement perdre en cohésion.
Et puis comment ça se teste ? De la même manière, ça va donner un énorme fichier fourre-tout. Sans parler des régressions éventuelles dues au fait qu’un(e) dev touchera à du code partagé par plusieurs fonctionnalités.
L’enfer.
La Consultation Chez Le Dentiste 🩺🍓
Les problèmes mentionnés plus haut viennent du fait que tous les handlers sont écrits dans un seul et même fichier.
En mettant à plat les dépendances, on obtient le graphe suivant :
Carie en cours de formation
Alors que si on place chaque handler dans son propre fichier, ça règle pas mal de problèmes. Ça donnerait le schéma suivant :
Dentition saine
Un telle architecture permettrait de :
Gérer une nouvelle action facilement : il suffit de créer un nouveau handler dans un fichier dédié et c’est plié.
Tester : de nouveau on a un fichier de test par handler et seules les dépendances du handler en question sont à mocker.
Améliorer la lisibilité : on se retrouve avec plusieurs fichiers de 100 lignes plutôt qu’un fichier de 1000.
Avec ça le webhook sera ouvert à l’extension et fermé à la modification. Et ça, ça me botte.
Un Bon Coup De Brosse À Dents 🪥✨
Passons à l’implémentation en commençant par définir l’interface ActionHandler que vont implémenter les handlers :
export interface ActionHandler {
// Indique si le ActionHandler prend
// en charge l'action.
canHandle: (action: Action) => boolean;
// Prend en charge l'action.
// La méthode prend une action déjà créée au
// lieu de la créer lui même.
handle: (action: Action) => Promise<void>;
}
L’idée est que quand on reçoit une action depuis le chatbot, on parcourt chacun des ActionHandler jusqu’à en trouver un qui prenne en charge l’action.
Plaçons nos 2 ActionHandler dans leur propre fichier :
@Injectable()
export class CreateDoerHandler {
constructor(@InjectUserRepository private readonly userRepo: UserRepositiry) {}
// L'action est prise en charge si son nom est
// ActionNames.CREATE_DOER.
public canHandle(action: Action): boolean {
return action.getName() === ActionNames.CREATE_DOER;
}
// Prend en charge l'action de création d'un
// nouveau doer.
public async handler(action: Action): Promise<void> {
const username = action.getParameter('username');
if (!username) {
throw new Error(`Missing parameter username`);
}
await this.userRepo.create(username);
}
}
@Injectable()
export class GetMissionHandler {
constructor(@InjectMissionRepository private readonly missionRepo: MissionRepository) {}
// Ici l'action est prise en charge si son nom est
// ActionNames.GET_MISSION.
public canHandle(action: Action): boolean {
return action.getName() === ActionNames.GET_MISSION;
}
// Cherche une mission en base et la retourne au doer.
public async handle(action: Action): Promise<void> {
const missionId = action.getParameter('missionId');
if (!missionId) {
throw new Error(`Missing parameter missionId`);
}
const mission = await this.missionRepo.findById(missionId);
if (mission) {
action.addMessage(`Voilà ta mission servie sur un plateau 🍽️`);
action.addMessage(`${mission.company} recrute pour sa mission de ${mission.category}`);
} else {
action.addMessage(`Nous n'avons pas trouvé la mission que tu cherches`);
action.addMessage(`On te présente les missions du moment ?`, `Oui`, `Non`);
}
}
}
C’est déjà bien plus lisible. Côté testabilité c’est bien aussi puisque chaque ActionHandler n’a comme services injectés que ceux dont il a réellement besoin. L’idée étant la même pour les fichiers de test, je m’attarde pas sur leur implémentation.
Ensuite, on va avoir besoin du DispatchService. Son rôle est de construire l’action depuis la requête du chatbot et de sélectionner le bon handler pour la gérer. Son code est le suivant :
@Injectable()
export class DispatchService {
// Liste des handlers disponibles.
private handlers: ActionHandler[] = [];
// On injecte les handlers et on les stockes dans
// la liste de handlers.
constructor(
@InjectCreateDoerHandler createDoerHandler: CreateDoerHandler,
@InjectGetMissionHandler getMissionHandler: GetMissionHandler,
) {
this.handlers = [createDoerHandler, getMissionHandler];
}
// Sélectionne le bon handler capable de traiter
// l'action.
public async dispatchAction(request: RequestBody): Promise<void> {
// Création de l'action.
const action = this.createAction(request);
// On manipule un ActionHandler pour s'abstraire
// des implémentations concrètes des handlers.
const handler: ActionHandler = this.getHandler(action);
// On retrouve notre méthode handle.
await handler.handle(action);
}
public getHandler(action: Action): ActionHandler {
for (const handler of this.handlers) {
// On retrouve notre méthode canHandle.
if (handler.canHandle(action)) {
return handler;
}
}
// Si on n'a pas trouvé de handler, c'est que le
// chatbot déclenche une action qui n'est pas
// gérée par l'api. On lève une erreur car on ne
// peut pas aller plus loin.
throw new Error(`No handler found to handle action ${action.getName()}`);
}
// On retrouve notre logique qui crée une action
// à partir d'une requête web.
private createAction(request: RequestBody): Action {
// Extraction des paramètres de la requête et
// création de l'action.
// ...
return action;
}
}
Le DispatchService a simplement à être branché à l’entrée de notre api, on passe la requête à la méthode dispatchAction et le tour est joué 👌 L’avantage c’est qu’une fois cette logique mise en place, on n’a plus besoin d’y toucher. On vient de séparer ce qui change (la gestion des actions par les ActionHandler) de ce qui ne change pas (la sélection du bon handler par le DispatchService), et ça c’est gourmand 🍰
Épilogue
On vient de s’offrir une belle dentition avec cette nouvelle architecture en plus d’atteindre les objectifs qu’on s’était fixé, à savoir :
Garantir la lisibilité du code
Permettre la maintenabilité et la testabilité
Respecter l’Open/Close Principle
Élaborer une architecture qui parle d’elle même pour faciliter sa compréhension
Je vois 2 points principaux à améliorer :
Le premier est le chargement fastidieux des handlers : pour chaque nouveaux handlers, il faut penser à l’ajouter dans le module NestJs ET dans le DispatchService. On verra dans un prochain article comment s’affranchir de cette contrainte.
Le deuxième serait de déplacer la construction de l’action dans un service dédié plutôt que de le faire dans le DispatchService. Ce nouveau service recevrait la requête du chatbot puis construirait une action qu’il passerait au DispatchService. De cette manière, le DispatchService s’occuperait seulement de sélectionner le bon handler. La logique de création de l’action étant dépendante de la structure de la requête http et donc de la plateforme de chatbot utilisée, on poserait la seconde brique d’un système qui protégerait notre logique métier d’une interface sur laquelle on n’a pas la main. La première étant la création de l’interface Action qu’on a utilisée tout au long de l’article.