异步本地存储 Async Local Storage

导读

AsyncLocalStorage 是一个 Node.js API(基于 async_hooks API),它提供了一种在应用程序中传播本地状态的替代方法,而无需将其显式传递为函数参数。它类似于其他语言中的线程本地存储。

异步本地存储的主要思想是我们可以使用 AsyncLocalStorage#run 调用包装一些函数调用。在包装的调用中调用的所有代码都可以访问相同的 store,该 store 对于每个调用链都是唯一的。

在 NestJS 的上下文中,这意味着如果我们可以在请求的生命周期中找到一个可以包装请求其余代码的位置,我们将能够访问和修改仅对该请求可见的状态,这可以作为 REQUEST 范围提供程序及其某些限制的替代方案。

或者,我们可以使用 ALS 来传播系统的一部分上下文(例如 transaction 对象),而无需在服务之间明确传递它,这可以增加隔离性和封装性。

自定义实现

NestJS 本身不提供任何内置的 AsyncLocalStorage 抽象,因此让我们了解如何针对最简单的 HTTP 案例自行实现它,以便更好地理解整个概念:

info

对于现成的专用包,请继续阅读以下内容。

  1. 首先,在某些共享源文件中创建 AsyncLocalStorage 的新实例。由于我们使用 NestJS,因此我们还将其转换为具有自定义提供程序的模块。
als.module
ts
@Module({
  providers: [
    {
      provide: AsyncLocalStorage,
      useValue: new AsyncLocalStorage(),
    },
  ],
  exports: [AsyncLocalStorage],
})
export class AlsModule {}

info Hint AsyncLocalStorage is imported from async_hooks.

  1. 我们只关心 HTTP,因此让我们使用中间件将 next 函数与 AsyncLocalStorage#run 包装在一起。由于中间件是请求到达的第一个对象,因此这将使 store 在所有增强器和系统的其余部分中可用。
ts
TS
ts
// app.module
@Module({
  imports: [AlsModule]
  providers: [CatService],
  controllers: [CatController],
})
export class AppModule implements NestModule {
  constructor(
    // 在模块构造函数中注入 AsyncLocalStorage
    private readonly als: AsyncLocalStorage
  ) {}

  configure(consumer: MiddlewareConsumer) {
    // 绑定中间件
    consumer
      .apply((req, res, next) => {
        // 根据请求使用一些默认值填充存储
        const store = {
          userId: req.headers['x-user-id'],
        };
        // 并将`next`函数作为回调与存储一起传递给`als.run`方法。
        this.als.run(store, () => next());
      })
      // 并将其注册到所有路由(如果使用 Fastify 则使用`(.*)`)
      .forRoutes('*');
  }
}
  1. 现在,在请求生命周期的任何位置,我们都可以访问本地存储实例。
    ts
    TS
    ts
  2. 就是这样。现在我们有一种方法可以共享与请求相关的状态,而无需注入整个 REQUEST 对象。
warning

请注意,虽然该技术对许多用例都很有用,但它本质上会混淆代码流(创建隐式上下文),因此请负责任地使用它,尤其是避免创建上下文[God 对象](https://en.wikipedia.org/wiki/God_object)

NestJS CLS

与使用普通的 AsyncLocalStorage 相比,nestjs-cls 包提供了几项 DX 改进(CLS是术语 continuation-local storage 的缩写)。它将实现抽象为 ClsModule,为不同的传输(不仅仅是 HTTP)提供了各种初始化 store 的方法,以及强类型支持。

然后可以使用可注入的 ClsService 访问存储,或者通过使用 代理提供程序 完全从业务逻辑中抽象出来。

info

nestjs-cls 是第三方包,不受 NestJS 核心团队管理。请在 适当的存储库 中报告发现的与库相关的任何问题。

安装

除了对 @nestjs 库的对等依赖之外,它仅使用内置的 Node.js API。像任何其他包一样安装它。

bash
npm i nestjs-cls

用法

可以使用 nestjs-cls 实现与 上文 类似的功能,如下所示:

  1. 在根模块中导入 ClsModule
app.module
ts
@Module({
  imports: [
    // Register the ClsModule,
    ClsModule.forRoot({
      middleware: {
        // automatically mount the
        // ClsMiddleware for all routes
        mount: true,
        // and use the setup method to
        // provide default store values.
        setup: (cls, req) => {
          cls.set('userId', req.headers['x-user-id'])
        },
      },
    }),
  ],
  providers: [CatService],
  controllers: [CatController],
})
export class AppModule {}
  1. 然后可以使用ClsService来访问存储的值。
    ts
    TS
    ts
  2. 为了获得由 ClsService 管理的存储值的强类型(并且还获得字符串键的自动建议),我们可以在注入时使用可选类型参数 ClsService<MyClsStore>
ts
export interface MyClsStore extends ClsStore {
  userId: number
}
hint

也可以让包自动生成请求 ID,稍后使用 cls.getId() 访问它,或者使用 cls.get(CLS_REQ) 获取整个请求对象。

测试

由于 ClsService 只是另一个可注入的提供程序,因此可以在单元测试中完全模拟它。

但是,在某些集成测试中,我们可能仍希望使用真正的 ClsService 实现。在这种情况下,我们需要使用对 ClsService#runClsService#runWith 的调用来包装上下文感知代码段。

ts
describe('CatService', () => {
  let service: CatService
  let cls: ClsService
  const mockCatRepository = createMock<CatRepository>()

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      // Set up most of the testing module as we normally would.
      providers: [
        CatService,
        {
          provide: CatRepository
          useValue: mockCatRepository
        }
      ],
      imports: [
        // Import the static version of ClsModule which only provides
        // the ClsService, but does not set up the store in any way.
        ClsModule
      ],
    }).compile()

    service = module.get(CatService)

    // Also retrieve the ClsService for later use.
    cls = module.get(ClsService)
  })

  describe('getCatForUser', () => {
    it('retrieves cat based on user id', async () => {
      const expectedUserId = 42
      mockCatRepository.getForUser.mockImplementationOnce(
        (id) => ({ userId: id })
      )

      // Wrap the test call in the `runWith` method
      // in which we can pass hand-crafted store values.
      const cat = await cls.runWith(
        { userId: expectedUserId },
        () => service.getCatForUser()
      )

      expect(cat.userId).toEqual(expectedUserId)
    })
  })
})

更多信息

访问 NestJS CLS GitHub 页面 获取完整的 API 文档和更多代码示例。