Skip to content

J3 · 依赖注入与模块 ​

第 3 天 · 11 月 12 日周四 · 交付物:service 层、模块

Nest 的容器和 Spring 的很像:启动时创建单例,通过构造函数注入。但有两个区别会改变写代码的方式:TypeScript 的类型在运行时已经不存在了,而且没有任何东西会被自动发现。本章讲如何声明 provider、通过 token 注入接口、 选择 scope,以及把应用拆分成模块。TaskFlow 因此有了 service 层和一个存储抽象,为 J4 接入 TypeORM 做好准备。

目标 ​

  • 理解 Nest 如何解析构造函数的依赖,以及什么时候需要 token。
  • 用 useClass、useValue、useFactory 和 useExisting 声明 provider。
  • 用 @Inject 使用自定义 token,用 @Optional 声明可选依赖。
  • 了解各个 scope(singleton、request、transient)的代价,做出选择。
  • 用模块组织应用:imports、exports、@Global、动态模块(forRoot、forRootAsync)。
  • 把逻辑收进 ProjectsService,放在 ProjectsStore 抽象之后,并创建 CommonModule。

Spring → NestJS ​

SpringNestJS要点
@Service、@Component@Injectable() + 在 providers 里声明只有装饰器不够:没有 component scan
构造函数注入构造函数注入,类型从 design:paramtypes 读取参数类型必须在运行时存在(一个类)
@Qualifier("store")、注入接口@Inject(PROJECTS_STORE)接口在编译后就消失了:必须用 token
@Configuration + @BeanuseFactory(+ inject)工厂通过 inject 拿到自己的依赖
@Profile、@ConditionalOnProperty根据配置做选择的工厂、ConditionalModule启动时决定一次
Bean 别名useExisting同一个实例挂在两个 token 下
@Autowired(required = false)、Optional<T>@Optional()没有匹配的 provider 时为 undefined
Singleton(默认)Scope.DEFAULT(默认)所有请求共享
@RequestScope(通过代理)Scope.REQUEST没有代理:每个请求都会重建整条注入链
@Scope("prototype")Scope.TRANSIENT每个使用它的类各有一个实例
扁平的上下文、@Import模块之间的 imports / exportsprovider 在被导出之前只属于自己的模块
自动配置 + application.yml动态模块 forRoot() / forRootAsync()配置一个模块,就是调用一个静态方法
在循环依赖上加 @LazyforwardRef()尽量避免:重新划分模块

原理 ​

Nest 如何解析依赖 ​

Spring 从字节码里读取构造函数参数的类型,通过反射总能拿到。TypeScript 则会在编译时擦除类型。为了让 Nest 找回这些类型,编译器必须把它们单独写出来:开启 emitDecoratorMetadata(TaskFlow 的 tsconfig.json 里已开启) 后,每个带装饰器的类都会得到一条 design:paramtypes 元数据,即代表其参数类型的那些值组成的列表。对于 constructor(private readonly projects: ProjectsService),这个列表是 [ProjectsService],也就是这个类本身。

启动时,Nest 读取这些列表,把每个类当作 token(在容器里查找时用的键),先在当前模块、再在导入的模块里 查找对应的 provider,按顺序实例化依赖,并缓存实例。参数上的 @Inject(token) 会替换掉该参数的元数据。

编译后不复存在的东西,在 design:paramtypes 里都会变成 Object,Nest 也就无从解析:

参数类型生成的元数据解析方式
正常导入的类这个类自动
接口或类型别名Object必须用 @Inject(TOKEN)
用 import type 导入的类Object改成正常导入,或者用 @Inject
联合类型(例如某个类或 null)Object@Inject

这也是 Nest 使用 TypeScript“实验性”装饰器(experimentalDecorators)的原因:只有它们和 emitDecoratorMetadata 兼容。同一个根源还会引出其他陷阱,比如 J7 里环境变量的类型转换 (类型与元数据)。

Provider:useClass、useValue、useFactory、useExisting ​

一个 provider 把一个 token 和一份配方关联起来。providers: [ProjectsService] 是 { provide: ProjectsService, useClass: ProjectsService } 的简写。四种配方:

形式Nest 做什么在 TaskFlow 里Spring 对应物
useClass实例化这个类并注入它的依赖{ provide: APP_GUARD, useClass: JwtAuthGuard };测试里用内存版 store 代替真实的一个 @Component 类
useValue提供一个已经构建好的值,不做注入{ provide: CLOCK, useValue: () => new Date() }、全局 ValidationPipe返回现成对象的 @Bean
useFactory调用一个函数;参数列在 inject 里;可以是 async根据配置选择 PROJECTS_STORE;所有 forRootAsync 选项@Configuration 类里的 @Bean 方法
useExisting给已有 provider 起第二个名字:同一个实例未使用(示例见下)Bean 别名

async 工厂会先被等待完成,依赖它的任何东西才会被创建:数据库模块就是这样在启动前建立连接的。工厂的依赖 可以是可选的:inject: [{ token: SomeService, optional: true }]。

useExisting 创建的是别名,而 useClass 总会创建新实例:

ts
// TaskFlow 之外的示例
providers: [
  InMemoryProjectsStore,
  // PROJECTS_STORE 和 InMemoryProjectsStore 指向同一个实例。
  { provide: PROJECTS_STORE, useExisting: InMemoryProjectsStore },
  // 如果用 useClass,PROJECTS_STORE 会是第二个实例,有自己的 Map。
],

测试用同样的机制替换依赖,类似 @MockBean:

ts
describe('ProjectsService', () => {
  let service: ProjectsService;

  beforeEach(async () => {
    // Like a @SpringBootTest slice: only the providers this test needs, with
    // the real in-memory store standing in for the database.
    const moduleRef = await Test.createTestingModule({
      providers: [ProjectsService, { provide: PROJECTS_STORE, useClass: InMemoryProjectsStore }],
    }).compile();
    service = moduleRef.get(ProjectsService);
  });

  it('makes the creator a member who can read the project', async () => {
    const project = await service.create({ name: 'Website' }, alice);
    await expect(service.findOne(project.id, alice)).resolves.toMatchObject({ name: 'Website' });
  });

  it('answers 404, not 403, to a user outside the project', async () => {
    const project = await service.create({ name: 'Website' }, alice);
    await expect(service.findOne(project.id, bob)).rejects.toBeInstanceOf(NotFoundException);
  });

mock 和 Test.createTestingModule 详见 J10。

自定义 token、@Inject 与 @Optional ​

token 可以是类(默认情况)、字符串或 Symbol。凡是不是类的东西(接口、函数、配置对象),都声明一个常量, 再用 @Inject 注入:

  • ProjectsService 通过 @Inject(PROJECTS_STORE) private readonly store: ProjectsStore 拿到存储 (TaskFlow 案例)。ProjectsStore 接口只给编译器用;给容器用的是 token。这就是 Spring 里 @Qualifier 的角色,但有一个区别:Spring 里只要只有一个实现,类型就够了;这里只要类型是接口,token 就 必不可少。
  • TasksService 通过 @Inject(CLOCK) private readonly clock: Clock 拿到时钟(CommonModule)。
  • 各个库的装饰器其实是换了个样子的 @Inject:@InjectRepository(Task) 注入的 token 是 getRepositoryToken(Task),它的值是字符串 "TaskRepository"(J4)。

优先用 Symbol 而不是字符串:Symbol('PROJECTS_STORE') 是唯一的,而两个库可能选中同一个字符串。把常量和 接口放在一起导出,就像 projects.store.ts 那样。

@Optional() 让依赖变成可选:没有匹配的 provider 时,参数值为 undefined,而不是让启动失败。TaskFlow 在 NotificationsListener 里用到了它: @Optional() @InjectQueue(NOTIFICATIONS_QUEUE) private readonly queue?: Queue<NotificationJob>。BullMQ 队列只有 在定义了 REDIS_URL 时才存在;没有队列时,监听器只模拟发送(J9)。它相当于 @Autowired(required = false)。

Scope:singleton、request、transient ​

Scope实例典型用途代价
Scope.DEFAULT只有一个,启动时创建,整个应用共享几乎所有东西:service、repository、guard无
Scope.REQUEST每个 HTTP 请求一个,响应后丢弃请求专属的上下文(租户、用户)每个请求都要重建这个实例及其整条使用者链
Scope.TRANSIENT每个注入它的类一个状态取决于使用者的工具(以类名命名的 logger)每个使用者一个实例
ts
// TaskFlow 之外的示例:一个 request scope 的 provider
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import type { Request } from 'express';

@Injectable({ scope: Scope.REQUEST })
export class RequestContext {
  constructor(@Inject(REQUEST) private readonly request: Request) {}

  get correlationId(): string | undefined {
    return this.request.headers['x-correlation-id'] as string | undefined;
  }
}

和 Spring 类比失效的地方:@RequestScope 会往单例里注入一个代理,由代理转发给当前请求的实例。Nest 没有 代理。scope 会沿注入链向上传播:注入 RequestContext 的 service 也变成 request scope,注入这个 service 的 控制器同样如此,每个请求都要把它们全部重建一遍。代价不只是这个对象本身。transient scope 则不会向上传播:注入 transient provider 的单例仍然是单例。

TaskFlow 里只有单例。请求专属的数据通过参数传递:控制器里的 @CurrentUser() user,再到 service.findOne(id, user)。这样更明确,不需要 HTTP 上下文就能测试,而且没有额外代价。单例绝不能把请求数据 存在自己的属性里:两个并发请求会共享它们(单例里的状态)。

模块:imports、exports、@Global ​

Spring 的上下文是扁平的:每个 bean 对其他所有 bean 都可见。Nest 的模块是一个封装单元,更接近 Java 模块 (JPMS):它的 provider 是私有的,只有列在 exports 里的才对外可见,其他模块要先导入它才能访问。

ts
// No component scan: a provider is only visible to modules that import this
// one, and only because it is listed in `exports`.
@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}
键作用
providers模块要实例化的东西(service、store、工厂)
controllers由它注册路由的控制器
imports它要使用其导出内容的模块
exports它的 provider(或重新导出的模块),对导入它的模块可见

TaskFlow 各模块之间的依赖是单向的:

  • AuthModule 导入 UsersModule,后者导出 UsersService。
  • TasksModule 导入 ProjectsModule,后者导出 ProjectsService(为了 assertAccess),但不导出 store:其他 模块必须经过 service 的规则(模块)。
  • NotificationsQueueModule 重新导出 BullModule,让监听器可以注入队列。

在 TasksModule 这边,只要导入 ProjectsModule,TasksService 的构造函数就能拿到 ProjectsService:

ts
// ProjectsModule exports ProjectsService: importing the module is what makes
// it injectable into TasksService.
@Module({
  imports: [TypeOrmModule.forFeature([Task]), ProjectsModule],
  controllers: [TasksController],
  providers: [TasksService, TasksScheduler],
})
export class TasksModule {}

@Global() 模块只要在根模块导入一次,它的导出内容就在各处都可用,无需再导入。TaskFlow 有两个:CommonModule (时钟)和 ConfigModule.forRoot({ isGlobal: true }) (J7)。只把它留给真正横切的组件:全局模块会隐藏依赖关系,光看 imports 已经不足以理解依赖图。

动态模块:forRoot、forRootAsync ​

静态模块的元数据是固定的。动态模块是一个静态方法,它根据参数构建这些元数据,返回一个 DynamicModule 对象({ module, providers, exports, imports, global })。它承担的正是 Spring Boot 中由 application.yml 驱动的自动配置的角色,只不过是通过显式调用。

命名遵循一个约定:

方法含义TaskFlow 示例
forRoot(options)只配置一次,在根模块ConfigModule.forRoot()、EventEmitterModule.forRoot()、ScheduleModule.forRoot()
forRootAsync({ inject, useFactory })同上,选项由其他 provider 计算得出TypeOrmModule.forRootAsync()、LoggerModule.forRootAsync()、BullModule.forRootAsync()
forFeature(...)每个模块各自的补充配置,建立在根配置之上TypeOrmModule.forFeature([Project, ProjectMember])
register(options) / registerAsync(...)每次导入各自独立的配置JwtModule.registerAsync()、BullModule.registerQueue()

forRootAsync 沿用了 useFactory provider 的形式:工厂通过 inject 拿到依赖,模块等待它的结果。TaskFlow 的 数据库模块就是典型例子:

ts
@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      inject: [databaseConfig.KEY],
      useFactory: (db: DatabaseConfig) => ({
        ...postgresOptions(db),
        // Picks up every entity registered with TypeOrmModule.forFeature().
        autoLoadEntities: true,
      }),
    }),
  ],
})
export class DatabaseModule {}

databaseConfig.KEY 是 J7 里类型化配置的 token。它在这里可见,是 因为 ConfigModule 是全局的;否则就得在选项里加上 imports: [ConfigModule.forFeature(databaseConfig)]。

自己写一个动态模块只需要几行:

ts
// TaskFlow 之外的示例:手写的可配置模块
import { type DynamicModule, Module } from '@nestjs/common';

export const MAILER_OPTIONS = Symbol('MAILER_OPTIONS');
export interface MailerOptions {
  from: string;
}

@Module({})
export class MailerModule {
  static forRoot(options: MailerOptions): DynamicModule {
    return {
      module: MailerModule,
      // MailerService 通过 @Inject(MAILER_OPTIONS) 拿到选项。
      providers: [{ provide: MAILER_OPTIONS, useValue: options }, MailerService],
      exports: [MailerService],
    };
  }
}

如果还想生成 forRootAsync 而不用全部重写,@nestjs/common 提供了 ConfigurableModuleBuilder。要根据环境变量 决定是否加载某个模块,TaskFlow 用的是 @nestjs/config 的 ConditionalModule.registerWhen(),它的字符串形式 有个陷阱(ConditionalModule)。

循环依赖与 forwardRef ​

如果 ProjectsModule 导入 TasksModule,反过来也一样,那么在其中一个被求值时,另一个还是 undefined。 forwardRef() 会推迟解析:

ts
// TaskFlow 之外的示例:应当避免
@Module({ imports: [forwardRef(() => TasksModule)] })
export class ProjectsModule {}

// provider 这边:
constructor(@Inject(forwardRef(() => TasksService)) private readonly tasks: TasksService) {}

计划里说得很清楚:最好重新划分。TaskFlow 没有循环:TasksModule 依赖 ProjectsModule;归档项目需要修改任务 时,TypeOrmProjectsStore 在自己的事务里直接使用 Task 实体,而不导入 TasksModule (J5)。另见循环依赖。

TaskFlow 案例 ​

ProjectsService:控制器只负责路由 ​

J2 的控制器把 HTTP 请求转成方法调用。业务规则放在 service 里:

ts
// Business rules live here; the controller only maps HTTP to these calls.
@Injectable()
export class ProjectsService {
  constructor(@Inject(PROJECTS_STORE) private readonly store: ProjectsStore) {}

  create(dto: CreateProjectDto, user: AuthUser): Promise<Project> {
    return this.store.create(dto, user.id);
  }

  findAll(query: ProjectQueryDto, user: AuthUser): Promise<Page<Project>> {
    // Admins see everything, members only their own projects.
    return this.store.findPage(query, user.role === Role.Admin ? undefined : user.id);
  }

  // A non-member gets 404, not 403: the answer must not reveal that the
  // project exists. A soft-deleted project is not found either, even though
  // its membership rows are kept.
  async findOne(id: string, user: AuthUser): Promise<Project> {
    const project = await this.store.findById(id);
    const allowed = user.role === Role.Admin || (await this.store.isMember(id, user.id));
    if (!project || !allowed) throw new NotFoundException(`Project ${id} not found`);
    return project;
  }

  async update(id: string, dto: UpdateProjectDto, user: AuthUser): Promise<Project> {
    await this.findOne(id, user);
    return (await this.store.update(id, dto))!;
  }

  async remove(id: string, user: AuthUser): Promise<void> {
    await this.findOne(id, user);
    await this.store.softDelete(id);
  }

  async archive(id: string, user: AuthUser): Promise<Project> {
    await this.findOne(id, user);
    return (await this.store.archive(id))!;
  }

  async addMember(id: string, userId: string, user: AuthUser): Promise<void> {
    await this.findOne(id, user);
    await this.store.addMember(id, userId);
  }

  /** Used by TasksService: same rule as findOne(). */
  async assertAccess(projectId: string, user: AuthUser): Promise<void> {
    await this.findOne(projectId, user);
  }
}
  • service 只认识 ProjectsStore 接口,从不认识具体实现。
  • import { PROJECTS_STORE, type ProjectsStore }:只导入类型就够了,因为 token 是显式指定的。
  • 访问规则(管理员看到全部,成员看到自己的项目,非成员得到 404 而不是 403,被软删除的项目对所有人都返回 404)只写一次:findAll() 过滤列表,findOne() 检查对单个项目的访问。update()、remove()、archive() 和 addMember() 都先调用它,TasksService 用到的 assertAccess() 也委托给它:以后加的访问规则对任务同样 生效。user 参数到 J8 才加上(成员);在 J3,这些方法只接收 DTO 和 id。
  • service 抛出 Nest 的 HTTP 异常(NotFoundException)。这样很方便;也有人更喜欢抛业务错误,再由过滤器转换 (J6)。

ProjectsStore:接口、token 与实现 ​

存储抽象及其 token:

ts
// Interfaces disappear at runtime, so the container cannot resolve one by
// type. A token stands in for it: @Inject(PROJECTS_STORE) store: ProjectsStore.
export const PROJECTS_STORE = Symbol('PROJECTS_STORE');

export interface ProjectsStore {
  create(data: CreateProjectDto, ownerId: string): Promise<Project>;
  /** memberId undefined: every project (admin view). */
  findPage(query: ProjectQueryDto, memberId?: string): Promise<Page<Project>>;
  findById(id: string): Promise<Project | null>;
  update(id: string, changes: UpdateProjectDto): Promise<Project | null>;
  softDelete(id: string): Promise<boolean>;
  /** Archives the project and, with TypeORM, all its tasks in one transaction. */
  archive(id: string): Promise<Project | null>;
  isMember(projectId: string, userId: string): Promise<boolean>;
  addMember(projectId: string, userId: string): Promise<void>;
}

J2 的内存实现实现了这个接口(InMemoryProjectsStore implements ProjectsStore,代码见 J2)。在 J3,模块只需把它接上:

ts
// J3 版本(不是 TaskFlow 的最终版本):只有一个实现
providers: [ProjectsService, { provide: PROJECTS_STORE, useClass: InMemoryProjectsStore }],

J4 引入 TypeOrmProjectsStore,选哪个实现就成了配置问题。最终的模块如下:

ts
@Module({
  imports: [TypeOrmModule.forFeature([Project, ProjectMember])],
  controllers: [ProjectsController],
  providers: [
    ProjectsService,
    InMemoryProjectsStore,
    TypeOrmProjectsStore,
    // A @Bean method chosen by configuration, like @ConditionalOnProperty.
    {
      provide: PROJECTS_STORE,
      inject: [ConfigService, InMemoryProjectsStore, TypeOrmProjectsStore],
      useFactory: (config: ConfigService, memory: InMemoryProjectsStore, typeorm: TypeOrmProjectsStore) =>
        config.get('PROJECTS_STORE') === 'memory' ? memory : typeorm,
    },
  ],
  // TasksModule needs assertAccess(): export the service, not the store.
  exports: [ProjectsService],
})
export class ProjectsModule {}
  • 工厂扮演的是带 @ConditionalOnProperty 的 @Bean 方法:它通过 inject 拿到 ConfigService 和两个实现, 返回 PROJECTS_STORE 指定的那个(memory 或 typeorm,这个值在启动时校验,见 J7)。
  • 它返回的是容器已经创建好的实例,而不是 new 出来的:注入到 PROJECTS_STORE 下的实例,就是 providers 里 声明的那一个。
  • 两个实现无论如何都会被实例化;变的只是选择。所以即使在 memory 模式下,TypeORM 版本也需要数据库。
  • exports: [ProjectsService]:store 仍然是模块私有的。

无论换成哪个实现,控制器和 service 都不用改:这正是 token 的意义所在,也是 J4 要验证的 (store 背后的 TypeORM)。

CommonModule ​

计划要求为共享工具建一个 CommonModule。在 TaskFlow 里,它提供一个时钟:

ts
// A custom token for something that is not a class: inject it with
// @Inject(CLOCK). Tests replace it with a fixed date (useValue).
export const CLOCK = Symbol('CLOCK');
export type Clock = () => Date;

@Global()
@Module({
  providers: [{ provide: CLOCK, useValue: (() => new Date()) satisfies Clock }],
  exports: [CLOCK],
})
export class CommonModule {}
  • CLOCK 不是类:用一个 Symbol 作 token,由 useValue 提供这个函数。satisfies Clock 检查类型但不放宽它。
  • @Global():TasksService 可以直接写 @Inject(CLOCK),TasksModule 不必导入 CommonModule。AppModule 导入它一次即可。
  • 测试把时钟换成固定日期 { provide: CLOCK, useValue: () => now },让逾期任务的标记结果可以确定 (J10)。这对应 Spring 里注入 java.time.Clock 而不是直接调用 Instant.now() 的 做法。

src/common/ 里的其他内容(过滤器、拦截器、中间件、装饰器、分页 DTO、time-zone.ts 里的时区)并不由 CommonModule 提供:请求处理链上的组件通过 APP_FILTER、APP_INTERCEPTOR 等在 AppModule 里全局注册 (J6),装饰器、DTO 和工具函数则像普通代码一样直接导入。

场景速查 ​

  • 要注入一个接口 → 和接口一起导出一个 Symbol token,再用 @Inject(TOKEN) (token、ProjectsStore)。
  • 具体实现取决于配置 → 一个 useFactory provider,通过 inject 拿到 ConfigService 和各个实现 (Projects 模块)。
  • 测试要替换某个依赖 → 在 Test.createTestingModule 里用 { provide: TOKEN, useClass: Fake } 或 useValue: mock(provider)。
  • 要注入一个不是类的值(时钟、选项) → 在 Symbol 后面用 useValue(CommonModule)。
  • 某个 service 要被另一个模块使用 → 把它加进所在模块的 exports,再导入这个模块(模块)。
  • 第三方模块需要配置值 → forRootAsync({ inject, useFactory })(动态模块)。
  • 某个依赖只在部分环境里存在 → @Optional(),再判断是否为 undefined(token)。
  • 想在 service 里拿到当前用户 → 作为参数传进去;只有实在做不到时才用 Scope.REQUEST(scope)。

陷阱 ​

“Nest can't resolve dependencies of the ProjectsService (?)”

? 标出的是找不到的那个参数。三个常见原因:

  1. 参数类型是接口、别名或联合类型,又没有 @Inject:元数据是 Object(解析)。加一个 token 和 @Inject(TOKEN)。
  2. 类是用 import type 导入的:效果一样。改成正常导入。Nest 的报错信息也会明确提示这一点。
  3. provider 存在,但在另一个模块里,而那个模块没有导出它,或者当前模块没有导入那个模块。这时报错信息会建议 检查 imports。

单例里存放请求数据

在 service 里写 this.currentUser = user:下一个并发请求会把它覆盖掉。把数据作为参数传递(scope)。

  • 同一个类出现在两个模块的 providers 里,会生成两个独立的实例,各有各的状态。只在一个模块里声明它,导出 它,再导入这个模块。
  • 除了类本身之外又写了 useClass(providers: [Store, { provide: TOKEN, useClass: Store }]),同样会生成 两个实例。用 useExisting,或者像 TaskFlow 那样把实例注入到工厂里。
  • “试试看”地加上 Scope.REQUEST,会让它的所有使用者(包括控制器)都变成 request scope。先测量,并优先用 参数传递。
  • 到处都是 @Global() 模块:依赖图变得难以阅读,测试还得提供一些没有任何地方声明过的依赖。
  • 用字符串作 token 可能发生冲突;优先用导出的 Symbol。
  • 用 forwardRef() 压住循环依赖:循环依然存在,初始化顺序还更脆弱。把共同的部分抽到第三个模块里。

练习 ​

  • [ ] 把逻辑收进 ProjectsService:控制器只做路由。
  • [ ] 引入 ProjectsStore 接口和 PROJECTS_STORE token(一个 Symbol),以 InMemoryProjectsStore 作为实现, 通过 @Inject(PROJECTS_STORE) 注入。
  • [ ] 创建 CommonModule(@Global()),提供一个 useValue 形式的 CLOCK provider,并把它注入到某个 service。
  • [ ] 去掉 ProjectsService 构造函数里的 @Inject(PROJECTS_STORE),阅读启动时的报错,再把它加回去。
  • [ ] 写出四种 provider 形式:useClass(在测试里)、useValue(CLOCK)、useFactory(根据环境变量选择 store)、useExisting(一个别名)。用 app.get(PROJECTS_STORE) === app.get(InMemoryProjectsStore) 验证 useExisting 和 useClass 的区别。
  • [ ] 临时把某个 provider 设为 Scope.REQUEST,在它和使用它的控制器的构造函数里打日志:观察两边都是每个请求 一个实例。然后改回单例。
  • [ ] 使用一个所在模块没有导出的 service,阅读报错,再用 exports 和 imports 修好。
  • [ ] 在 LEARNINGS.md 里记下:@Configuration + @Bean ↔ useFactory,条件化的 profile ↔ 根据配置选择的 provider。

延伸阅读 ​