Skip to content

Async Local Storage

AsyncLocalStorage 是一个 Node.js API(基于 async_hooks API),它提供了一种替代方式,可以在应用中传播本地状态,而无需显式地通过函数参数进行传递。它类似于其他语言中的线程本地存储(thread-local storage)。

Async Local Storage 的核心思想是:我们可以用 AsyncLocalStorage#run 调用来_包裹_某个函数调用。所有在这个被包裹调用中触发的代码,都可以访问同一个 store,并且这个 store 对每条调用链都是唯一的。

在 NestJS 场景下,这意味着如果我们能在请求生命周期中的某个位置包裹住该请求后续的全部代码,就可以访问并修改仅对该请求可见的状态。这可以作为 REQUEST 作用域 provider 及其某些局限性的替代方案。

或者,我们也可以使用 ALS 在系统的某一部分中传播上下文(例如 transaction 对象),而无需在各个 service 之间显式传递它,从而提升隔离性与封装性。

自定义实现

NestJS 本身并没有为 AsyncLocalStorage 提供内建抽象,因此我们来看一下,在最简单的 HTTP 场景中,如何自己实现它,以更好地理解整个概念:

提示

如果你想使用现成的专用包,请继续阅读下文。

  1. 首先,在某个共享源码文件中创建一个新的 AsyncLocalStorage 实例。既然我们在使用 NestJS,那就把它也封装成一个带自定义 provider 的模块。
ts
// als.module.ts
@Module({
  providers: [
    {
      provide: AsyncLocalStorage,
      useValue: new AsyncLocalStorage(),
    },
  ],
  exports: [AsyncLocalStorage],
})
export class AlsModule {}

提示

AsyncLocalStorageasync_hooks 中导入。

  1. 我们这里只关心 HTTP,所以可以用一个中间件,通过 AsyncLocalStorage#run 来包裹 next 函数。由于中间件是请求命中的第一个环节,这样就能让 store 在所有 enhancer 以及系统其余部分中都可用。
ts
// app.module.ts
@Module({
  imports: [AlsModule],
  providers: [CatsService],
  controllers: [CatsController],
})
export class AppModule implements NestModule {
  constructor(
    // inject the AsyncLocalStorage in the module constructor,
    private readonly als: AsyncLocalStorage
  ) {}

  configure(consumer: MiddlewareConsumer) {
    // bind the middleware,
    consumer
      .apply((req, res, next) => {
        // populate the store with some default values
        // based on the request,
        const store = {
          userId: req.headers['x-user-id'],
        };
        // and pass the "next" function as callback
        // to the "als.run" method together with the store.
        this.als.run(store, () => next());
      })
      .forRoutes('*path');
  }
}
  1. 现在,在请求生命周期中的任意位置,我们都可以访问这个本地 store 实例。
ts
// cats.service.ts
@Injectable()
export class CatsService {
  constructor(
    // We can inject the provided ALS instance.
    private readonly als: AsyncLocalStorage,
    private readonly catsRepository: CatsRepository,
  ) {}

  getCatForUser() {
    // The "getStore" method will always return the
    // store instance associated with the given request.
    const userId = this.als.getStore()["userId"] as number;
    return this.catsRepository.getForUser(userId);
  }
}
  1. 就这样。现在我们拥有了一种共享请求相关状态的方式,而不需要注入整个 REQUEST 对象。

警告

请注意,虽然这种技术在许多场景下都很有用,但它天然会让代码流变得更隐式(创建了隐式上下文),因此请谨慎使用,尤其要避免构建出这种上下文式的 “God objects”。

NestJS CLS

nestjs-cls 包相比直接使用原始 AsyncLocalStorage 提供了多项 DX 改进(CLScontinuation-local storage 的缩写)。它将实现抽象进了一个 ClsModule,为不同传输层(不只是 HTTP)提供了多种初始化 store 的方式,同时也支持强类型。

随后,你可以通过可注入的 ClsService 访问 store,也可以使用 Proxy Providers 将其从业务逻辑中完全抽象出去。

提示

nestjs-cls 是第三方包,并不由 NestJS 核心团队维护。如果你在该库中发现问题,请在其对应仓库中反馈。

安装

除了对 @nestjs 系列库的 peer dependency 之外,它只依赖 Node.js 内置 API。像安装其他包一样安装它即可。

bash
npm i nestjs-cls

用法

上面自定义实现中描述的类似功能,可以借助 nestjs-cls 按如下方式实现:

  1. 在根模块中导入 ClsModule
ts
// 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: [CatsService],
  controllers: [CatsController],
})
export class AppModule {}
  1. 然后,你就可以使用 ClsService 来访问 store 中的值。
ts
// cats.service.ts
@Injectable()
export class CatsService {
  constructor(
    // We can inject the provided ClsService instance,
    private readonly cls: ClsService,
    private readonly catsRepository: CatsRepository,
  ) {}

  getCatForUser() {
    // and use the "get" method to retrieve any stored value.
    const userId = this.cls.get('userId');
    return this.catsRepository.getForUser(userId);
  }
}
  1. 为了给 ClsService 管理的 store 值提供强类型(同时获得字符串 key 的自动补全),我们在注入它时可以使用可选的类型参数 ClsService<MyClsStore>
ts
export interface MyClsStore extends ClsStore {
  userId: number;
}

提示

该包还支持自动生成 Request ID,并可通过 cls.getId() 获取;也可以通过 cls.get(CLS_REQ) 获取整个 Request 对象。

测试

由于 ClsService 本身也只是一个普通的可注入 provider,因此在单元测试中可以完全将它 mock 掉。

不过,在某些集成测试中,我们可能仍然希望使用真实的 ClsService 实现。这时,我们就需要用 ClsService#runClsService#runWith 来包裹那段具备上下文感知能力的代码。

ts
describe('CatsService', () => {
  let service: CatsService
  let cls: ClsService
  const mockCatsRepository = createMock<CatsRepository>()

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      // Set up most of the testing module as we normally would.
      providers: [
        CatsService,
        {
          provide: CatsRepository
          useValue: mockCatsRepository
        }
      ],
      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(CatsService)

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

  describe('getCatForUser', () => {
    it('retrieves cat based on user id', async () => {
      const expectedUserId = 42
      mocksCatsRepository.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)
    })
  })
})

更多信息

完整 API 文档和更多代码示例,请访问 NestJS CLS GitHub 页面

基于 NestJS 官方文档翻译