Async Local Storage
AsyncLocalStorage
est une API Node.js (basée sur l'API async_hooks
) qui fournit un moyen alternatif de propager l'état local à travers l'application sans avoir besoin de le passer explicitement en tant que paramètre de fonction. Il est similaire au stockage local des threads dans d'autres langages.
L'idée principale du stockage local asynchrone est que nous pouvons envelopper un appel de fonction avec l'appel AsyncLocalStorage#run
. Tout le code invoqué dans l'appel enveloppé a accès au même store
, qui sera unique à chaque chaîne d'appel.
Dans le contexte de NestJS, cela signifie que si nous pouvons trouver un endroit dans le cycle de vie de la requête où nous pouvons envelopper le reste du code de la requête, nous serons en mesure d'accéder et de modifier l'état visible uniquement pour cette requête, ce qui peut servir d'alternative aux fournisseurs à portée de requête et à certaines de leurs limitations.
Par ailleurs, nous pouvons utiliser l'ALS pour propager le contexte pour une partie seulement du système (par exemple l'objet transaction) sans le transmettre explicitement entre les services, ce qui peut améliorer l'isolation et l'encapsulation.
Implémentation personnalisée#
NestJS lui-même ne fournit pas d'abstraction intégrée pour AsyncLocalStorage
, donc nous allons voir comment nous pourrions l'implémenter nous-mêmes pour le cas HTTP le plus simple afin d'avoir une meilleure compréhension de l'ensemble du concept :
Info Pour un package dédié prêt à l'emploi , continuez à lire ci-dessous.
- Tout d'abord, créez une nouvelle instance de
AsyncLocalStorage
dans un fichier source partagé. Puisque nous utilisons NestJS, transformons-le également en module avec un fournisseur personnalisé.
@Module({
providers: [
{
provide: AsyncLocalStorage,
useValue: new AsyncLocalStorage(),
},
],
exports: [AsyncLocalStorage],
})
export class AlsModule {}
AstuceAsyncLocalStorage
est importé deasync_hooks
.
- Nous ne sommes concernés que par HTTP, alors utilisons un middleware pour envelopper la fonction
next
avecAsyncLocalStorage#run
. Puisque le middleware est la première chose que la requête atteint, cela rendra lestore
disponible dans tous les améliorateurs et le reste du système.
@Module({
imports: [AlsModule]
providers: [CatsService],
controllers: [CatsController],
})
export class AppModule implements NestModule {
constructor(
// injecte l'AsyncLocalStorage dans le constructeur du module,
private readonly als: AsyncLocalStorage
) {}
configure(consumer: MiddlewareConsumer) {
// lie le middleware,
consumer
.apply((req, res, next) => {
// remplit le magasin avec des valeurs par défaut
// en fonction de la requête,
const store = {
userId: req.headers['x-user-id'],
};
// et passe la fonction "next" comme callback
// à la méthode "als.run" avec le magasin.
this.als.run(store, () => next());
})
.forRoutes('*path');
}
}
@Module({
imports: [AlsModule]
providers: [CatsService],
controllers: [CatsController],
})
@Dependencies(AsyncLocalStorage)
export class AppModule {
constructor(als) {
// injecte l'AsyncLocalStorage dans le constructeur du module,
this.als = als
}
configure(consumer) {
// lie le middleware,
consumer
.apply((req, res, next) => {
// remplit le magasin avec des valeurs par défaut
// en fonction de la requête,
const store = {
userId: req.headers['x-user-id'],
};
// et passe la fonction "next" comme callback
// à la méthode "als.run" avec le magasin.
this.als.run(store, () => next());
})
.forRoutes('*path');
}
}
- Désormais, à n'importe quel moment du cycle de vie d'une requête, nous pouvons accéder à l'instance du magasin local.
@Injectable()
export class CatsService {
constructor(
// Nous pouvons injecter l'instance ALS fournie.
private readonly als: AsyncLocalStorage,
private readonly catsRepository: CatsRepository,
) {}
getCatForUser() {
// La méthode "getStore" renvoie toujours
// l'instance de magasin associée à la requête donnée.
const userId = this.als.getStore()["userId"] as number;
return this.catsRepository.getForUser(userId);
}
}
@Injectable()
@Dependencies(AsyncLocalStorage, CatsRepository)
export class CatService {
constructor(als, catsRepository) {
// Nous pouvons injecter l'instance ALS fournie.
this.als = als
this.catsRepository = catsRepository
}
getCatForUser() {
// La méthode "getStore" renvoie toujours
// l'instance de magasin associée à la requête donnée.
const userId = this.als.getStore()["userId"] as number;
return this.catsRepository.getForUser(userId);
}
}
- Voilà, c'est fait. Nous avons maintenant un moyen de partager l'état lié à la requête sans avoir besoin d'injecter l'objet
REQUEST
entier.
Attention Sachez que si cette technique est utile dans de nombreux cas, elle obscurcit intrinsèquement le flux de code (en créant un contexte implicite). Il convient donc de l'utiliser de manière responsable et d'éviter tout particulièrement de créer des "objets Dieu" contextuels.
NestJS CLS
Le package nestjs-cls fournit plusieurs améliorations DX par rapport à l'utilisation du simple AsyncLocalStorage
(CLS
est une abréviation du terme continuation-local storage). Il abstrait l'implémentation dans un ClsModule
qui offre différentes manières d'initialiser le store
pour différents transports (pas seulement HTTP), ainsi qu'un support de typage fort.
Il est alors possible d'accéder au magasin à l'aide d'un ClsService
injectable, ou de s'abstraire entièrement de la logique commerciale en utilisant des Proxy Providers.
Infonestjs-cls
est un package tiers et n'est pas géré par l'équipe NestJS. Veuillez rapporter tout problème trouvé avec la bibliothèque dans le dépôt approprié.
Installation#
En dehors d'une dépendance sur les librairies @nestjs
, il n'utilise que l'API intégrée de Node.js. Installez-le comme n'importe quel autre package.
npm i nestjs-cls
Usage#
Une fonctionnalité similaire à celle décrite ci-dessus peut être implémentée en utilisant nestjs-cls
comme suit :
- Importer le
ClsModule
dans le module racine.
@Module({
imports: [
// Enregistre le ClsModule,
ClsModule.forRoot({
middleware: {
// monte automatiquement le module
// ClsMiddleware pour toutes les routes
mount: true,
// et utilise la méthode setup pour
// fournir des valeurs par défaut
setup: (cls, req) => {
cls.set('userId', req.headers['x-user-id']);
},
},
}),
],
providers: [CatsService],
controllers: [CatsController],
})
export class AppModule {}
- On peut ensuite utiliser le
ClsService
pour accéder aux valeurs du magasin.
@Injectable()
export class CatsService {
constructor(
// Nous pouvons injecter l'instance de ClsService fournie,
private readonly cls: ClsService,
private readonly catsRepository: CatsRepository,
) {}
getCatForUser() {
// et utiliser la méthode "get" pour récupérer toute valeur stockée.
const userId = this.cls.get('userId');
return this.catsRepository.getForUser(userId);
}
}
@Injectable()
@Dependencies(AsyncLocalStorage, CatsRepository)
export class CatsService {
constructor(cls, catsRepository) {
// Nous pouvons injecter l'instance de ClsService fournie,
this.cls = cls
this.catsRepository = catsRepository
}
getCatForUser() {
// et utiliser la méthode "get" pour récupérer toute valeur stockée.
const userId = this.cls.get('userId');
return this.catsRepository.getForUser(userId);
}
}
- Pour obtenir un typage fort des valeurs du magasin gérées par le
ClsService
(et également obtenir des suggestions automatiques des clés de chaîne), nous pouvons utiliser un paramètre de type optionnelClsService<MyClsStore>
lors de l'injection.
export interface MyClsStore extends ClsStore {
userId: number;
}
Astuce Il est également possible de laisser le package générer automatiquement un identifiant de requête et d'y accéder plus tard aveccls.getId()
, ou d'obtenir l'objet de requête complet en utilisantcls.get(CLS_REQ)
.
Tester#
Puisque le ClsService
est juste un autre fournisseur injectable, il peut être entièrement simulé dans les tests unitaires.
Cependant, dans certains tests d'intégration, nous pourrions toujours vouloir utiliser l'implémentation réelle de ClsService
. Dans ce cas, nous devrons envelopper le morceau de code contextuel avec un appel à ClsService#run
ou ClsService#runWith
.
describe('CatsService', () => {
let service: CatsService
let cls: ClsService
const mockCatsRepository = createMock<CatsRepository>()
beforeEach(async () => {
const module = await Test.createTestingModule({
// Met en place la majeure partie du module de test comme nous le ferions normalement.
providers: [
CatsService,
{
provide: CatsRepository
useValue: mockCatsRepository
}
],
imports: [
// Importe la version statique de ClsModule qui fournit seulement
// le ClsService, mais ne configure en aucune façon le magasin.
ClsModule
],
}).compile()
service = module.get(CatsService)
// Récupère également le ClsService pour une utilisation ultérieure.
cls = module.get(ClsService)
})
describe('getCatForUser', () => {
it('retrieves cat based on user id', async () => {
const expectedUserId = 42
mockCatsRepository.getForUser.mockImplementationOnce(
(id) => ({ userId: id })
)
// Enveloppe l'appel au test dans la méthode `runWith`.
// dans lequel nous pouvons passer des valeurs de magasin créées à la main.
const cat = await cls.runWith(
{ userId: expectedUserId },
() => service.getCatForUser()
)
expect(cat.userId).toEqual(expectedUserId)
})
})
})
En savoir plus#
Visitez la Page GitHub NestJS CLS pour obtenir la documentation complète de l'API et d'autres exemples de code.