|
| 1 | +### Async Local Storage |
| 2 | + |
| 3 | +`AsyncLocalStorage` is a [Node.js API](https://nodejs.org/api/async_context.html#async_context_class_asynclocalstorage) (based on the `async_hooks` API) that provides an alternative way of propagating local state through the application without the need to explicitly pass it as a function parameter. It is similar to a thread-local storage in other languages. |
| 4 | + |
| 5 | +The main idea of Async Local Storage is that we can _wrap_ some function call with the `AsyncLocalStorage#run` call. All code that is invoked within the wrapped call gets access to the same `store`, which will be unique to each call chain. |
| 6 | + |
| 7 | +In the context of NestJS, that means if we can find a place within the request's lifecycle where we can wrap the rest of the request's code, we will be able to access and modify state visible only to that request, which may serve as an alternative to REQUEST-scoped providers and some of their limitations. |
| 8 | + |
| 9 | +Alternatively, we can use ALS to propagate context for only a part of the system (for example the _transaction_ object) without passing it around explicitly across services, which can increase isolation and encapsulation. |
| 10 | + |
| 11 | +#### Custom implementation |
| 12 | + |
| 13 | +NestJS itself does not provide any built-in abstraction for `AsyncLocalStorage`, so let's walk through how we could implement it ourselves for the simplest HTTP case to get a better understanding of the whole concept: |
| 14 | + |
| 15 | +> info **info** For a ready-made [dedicated package](recipes/async-local-storage#nestjs-cls), continue reading below. |
| 16 | +
|
| 17 | +1. First, create a new instance of the `AsyncLocalStorage` in some shared source file. Since we're using NestJS, let's also turn it into a module with a custom provider. |
| 18 | + |
| 19 | +```ts |
| 20 | +@@filename(als.module) |
| 21 | +@Module({ |
| 22 | + providers: [ |
| 23 | + { |
| 24 | + provide: AsyncLocalStorage, |
| 25 | + useValue: new AsyncLocalStorage(), |
| 26 | + }, |
| 27 | + ], |
| 28 | + exports: [AsyncLocalStorage], |
| 29 | +}) |
| 30 | +export class AlsModule {} |
| 31 | +``` |
| 32 | +> info **Hint** `AsyncLocalStorage` is imported from `async_hooks`. |
| 33 | +
|
| 34 | +2. We're only concerned with HTTP, so let's use a middleware to wrap the `next` function with `AsyncLocalStorage#run`. Since a middleware is the first thing that the request hits, this will make the `store` available in all enhancers and the rest of the system. |
| 35 | + |
| 36 | +```ts |
| 37 | +@@filename(app.module) |
| 38 | +@Module({ |
| 39 | + imports: [AlsModule] |
| 40 | + providers: [CatService], |
| 41 | + controllers: [CatController], |
| 42 | +}) |
| 43 | +export class AppModule implements NestModule { |
| 44 | + constructor( |
| 45 | + // inject the AsyncLocalStorage in the module constructor, |
| 46 | + private readonly als: AsyncLocalStorage |
| 47 | + ) {} |
| 48 | + |
| 49 | + configure(consumer: MiddlewareConsumer) { |
| 50 | + // bind the middleware, |
| 51 | + consumer |
| 52 | + .apply((req, res, next) => { |
| 53 | + // populate the store with some default values |
| 54 | + // based on the request, |
| 55 | + const store = { |
| 56 | + userId: req.headers['x-user-id'], |
| 57 | + }; |
| 58 | + // and and pass the "next" function as callback |
| 59 | + // to the "als.run" method together with the store. |
| 60 | + als.run(store, () => next()); |
| 61 | + }) |
| 62 | + // and register it for all routes (in case of Fastify use '(.*)') |
| 63 | + .forRoutes('*'); |
| 64 | + } |
| 65 | +} |
| 66 | +@@switch |
| 67 | +@Module({ |
| 68 | + imports: [AlsModule] |
| 69 | + providers: [CatService], |
| 70 | + controllers: [CatController], |
| 71 | +}) |
| 72 | +@Dependencies(AsyncLocalStorage) |
| 73 | +export class AppModule { |
| 74 | + constructor(als) { |
| 75 | + // inject the AsyncLocalStorage in the module constructor, |
| 76 | + this.als = als |
| 77 | + } |
| 78 | + |
| 79 | + configure(consumer) { |
| 80 | + // bind the middleware, |
| 81 | + consumer |
| 82 | + .apply((req, res, next) => { |
| 83 | + // populate the store with some default values |
| 84 | + // based on the request, |
| 85 | + const store = { |
| 86 | + userId: req.headers['x-user-id'], |
| 87 | + }; |
| 88 | + // and and pass the "next" function as callback |
| 89 | + // to the "als.run" method together with the store. |
| 90 | + als.run(store, () => next()); |
| 91 | + }) |
| 92 | + // and register it for all routes (in case of Fastify use '(.*)') |
| 93 | + .forRoutes('*'); |
| 94 | + } |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +3. Now, anywhere within the lifecycle of a request, we can access the local store instance. |
| 99 | + |
| 100 | +```ts |
| 101 | +@@filename(cat.service) |
| 102 | +@Injectable() |
| 103 | +export class CatService { |
| 104 | + constructor( |
| 105 | + // We can inject the provided ALS instance. |
| 106 | + private readonly als: AsyncLocalStorage, |
| 107 | + private readonly catRepository: CatRepository, |
| 108 | + ) {} |
| 109 | + |
| 110 | + getCatForUser() { |
| 111 | + // The "getStore" method will always return the |
| 112 | + // store instance associated with the given request. |
| 113 | + const userId = this.als.getStore()["userId"] as number; |
| 114 | + return this.catRepository.getForUser(userId); |
| 115 | + } |
| 116 | +} |
| 117 | +@@switch |
| 118 | +@Injectable() |
| 119 | +@Dependencies(AsyncLocalStorage, CatRepository) |
| 120 | +export class CatService { |
| 121 | + constructor(als, catRepository) { |
| 122 | + // We can inject the provided ALS instance. |
| 123 | + this.als = als |
| 124 | + this.catRepository = catRepository |
| 125 | + } |
| 126 | + |
| 127 | + getCatForUser() { |
| 128 | + // The "getStore" method will always return the |
| 129 | + // store instance associated with the given request. |
| 130 | + const userId = this.als.getStore()["userId"] as number; |
| 131 | + return this.catRepository.getForUser(userId); |
| 132 | + } |
| 133 | +} |
| 134 | +``` |
| 135 | + |
| 136 | +4. That's it. Now we have a way to share request related state without needing to inject the whole `REQUEST` object. |
| 137 | + |
| 138 | +> warning **warning** Please be aware that while the technique is useful for many use-cases, it inherently obfuscates the code flow (creating implicit context), so use it responsibly and especially avoid creating contextual "[God objects](https://en.wikipedia.org/wiki/God_object)". |
| 139 | +
|
| 140 | +### NestJS CLS |
| 141 | + |
| 142 | +The [nestjs-cls](https://github.com/Papooch/nestjs-cls) package provides several DX improvements over using plain `AsyncLocalStorage` (`CLS` is an abbreviation of the term _continuation-local storage_). It abstracts the implementation into a `ClsModule` that offers various ways of initializing the `store` for different transports (not only HTTP), as well as a strong-typing support. |
| 143 | + |
| 144 | +The store can then be accessed with an injectable `ClsService`, or entirely abstracted away from the business logic by using [Proxy Providers](https://www.npmjs.com/package/nestjs-cls#proxy-providers). |
| 145 | + |
| 146 | +> info **info** `nestjs-cls` is a third party package and is not managed by the NestJS core team. Please, report any issues found with the library in the [appropriate repository](https://github.com/Papooch/nestjs-cls/issues). |
| 147 | +
|
| 148 | +#### Installation |
| 149 | + |
| 150 | +Apart from a peer dependency on the `@nestjs` libs, it only uses the built-in Node.js API. Install it as any other package. |
| 151 | + |
| 152 | +```bash |
| 153 | +npm i nestjs-cls |
| 154 | +``` |
| 155 | + |
| 156 | +#### Usage |
| 157 | + |
| 158 | +A similar functionality as described [above](#custom-implementation) can be implemented using `nestjs-cls` as follows: |
| 159 | + |
| 160 | +1. Import the `ClsModule` in the root module. |
| 161 | + |
| 162 | +```ts |
| 163 | +@@filename(app.module) |
| 164 | +@Module({ |
| 165 | + imports: [ |
| 166 | + // Register the ClsModule, |
| 167 | + ClsModule.forRoot({ |
| 168 | + middleware: { |
| 169 | + // automatically mount the |
| 170 | + // ClsMiddleware for all routes |
| 171 | + mount: true, |
| 172 | + // and use the setup method to |
| 173 | + // provide default store values. |
| 174 | + setup: (cls, req) => { |
| 175 | + cls.set('userId', req.headers['x-user-id']); |
| 176 | + }, |
| 177 | + }, |
| 178 | + }), |
| 179 | + ], |
| 180 | + providers: [CatService], |
| 181 | + controllers: [CatController], |
| 182 | +}) |
| 183 | +export class AppModule {} |
| 184 | +``` |
| 185 | + |
| 186 | +2. And then can use the `ClsService` to access the store values. |
| 187 | + |
| 188 | +```ts |
| 189 | +@@filename(cat.service) |
| 190 | +@Injectable() |
| 191 | +export class CatService { |
| 192 | + constructor( |
| 193 | + // We can inject the provided ClsService instance, |
| 194 | + private readonly cls: ClsService, |
| 195 | + private readonly catRepository: CatRepository, |
| 196 | + ) {} |
| 197 | + |
| 198 | + getCatForUser() { |
| 199 | + // and use the "get" method to retrieve any stored value. |
| 200 | + const userId = this.cls.get('userId'); |
| 201 | + return this.catRepository.getForUser(userId); |
| 202 | + } |
| 203 | +} |
| 204 | +@@switch |
| 205 | +@Injectable() |
| 206 | +@Dependencies(AsyncLocalStorage, CatRepository) |
| 207 | +export class CatService { |
| 208 | + constructor(als, catRepository) { |
| 209 | + // We can inject the provided ClsService instance, |
| 210 | + this.als = als |
| 211 | + this.catRepository = catRepository |
| 212 | + } |
| 213 | + |
| 214 | + getCatForUser() { |
| 215 | + // and use the "get" method to retrieve any stored value. |
| 216 | + const userId = this.cls.get('userId'); |
| 217 | + return this.catRepository.getForUser(userId); |
| 218 | + } |
| 219 | +} |
| 220 | +``` |
| 221 | + |
| 222 | +3. To get strong typing of the store values managed by the `ClsService` (and also get auto-suggestions of the string keys), we can use an optional type parameter `ClsService<MyClsStore>` when injecting it. |
| 223 | + |
| 224 | +```ts |
| 225 | +export interface MyClsStore extends ClsStore { |
| 226 | + userId: number |
| 227 | +} |
| 228 | +``` |
| 229 | + |
| 230 | +> info **hint** It it also possible to let the package automatically generate a Request ID and access it later with `cls.getId()`, or to get the whole Request object using `cls.get(CLS_REQ)`. |
| 231 | +#### Testing |
| 232 | + |
| 233 | +Since the `ClsService` is just another injectable provider, it can be entirely mocked out in unit tests. |
| 234 | + |
| 235 | +However, in certain integration tests, we might still want to use the real `ClsService` implementation. In that case, we will need to wrap the context-aware piece of code with a call to `ClsService#run` or `ClsService#runWith`. |
| 236 | + |
| 237 | +```ts |
| 238 | +describe('CatService', () => { |
| 239 | + let service: CatService |
| 240 | + let cls: ClsService |
| 241 | + const mockCatRepository = createMock<CatRepository>() |
| 242 | + |
| 243 | + beforeEach(async () => { |
| 244 | + const module = await Test.createTestingModule({ |
| 245 | + // Set up most of the testing module as we normally would. |
| 246 | + providers: [ |
| 247 | + CatService, |
| 248 | + { |
| 249 | + provide: CatRepository |
| 250 | + useValue: mockCatRepository |
| 251 | + } |
| 252 | + ], |
| 253 | + imports: [ |
| 254 | + // Import the static version of ClsModule which only provides |
| 255 | + // the ClsService, but does not set up the store in any way. |
| 256 | + ClsModule |
| 257 | + ], |
| 258 | + }).compile() |
| 259 | + |
| 260 | + service = module.get(CatService) |
| 261 | + |
| 262 | + // Also retrieve the ClsService for later use. |
| 263 | + cls = module.get(ClsService) |
| 264 | + }) |
| 265 | + |
| 266 | + describe('getCatForUser', () => { |
| 267 | + it('retrieves cat based on user id', async () => { |
| 268 | + const expectedUserId = 42 |
| 269 | + mockCatRepository.getForUser.mockImplementationOnce( |
| 270 | + (id) => ({ userId: id }) |
| 271 | + ) |
| 272 | + |
| 273 | + // Wrap the test call in the `runWith` method |
| 274 | + // in which we can pass hand-crafted store values. |
| 275 | + const cat = await cls.runWith( |
| 276 | + { userId: expectedUserId }, |
| 277 | + () => service.getCatForUser() |
| 278 | + ) |
| 279 | + |
| 280 | + expect(cat.userId).toEqual(expectedUserId) |
| 281 | + }) |
| 282 | + }) |
| 283 | +}) |
| 284 | +``` |
| 285 | + |
| 286 | +#### More information |
| 287 | + |
| 288 | +Visit the [NestJS CLS GitHub Page](https://github.com/Papooch/nestjs-cls) for the full API documentation and more code examples. |
0 commit comments