J3 · 依赖注入与模块
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
| Spring | NestJS | 要点 |
|---|---|---|
@Service、@Component | @Injectable() + 在 providers 里声明 | 只有装饰器不够:没有 component scan |
| 构造函数注入 | 构造函数注入,类型从 design:paramtypes 读取 | 参数类型必须在运行时存在(一个类) |
@Qualifier("store")、注入接口 | @Inject(PROJECTS_STORE) | 接口在编译后就消失了:必须用 token |
@Configuration + @Bean | useFactory(+ 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 / exports | provider 在被导出之前只属于自己的模块 |
自动配置 + application.yml | 动态模块 forRoot() / forRootAsync() | 配置一个模块,就是调用一个静态方法 |
在循环依赖上加 @Lazy | forwardRef() | 尽量避免:重新划分模块 |
原理
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 总会创建新实例:
// TaskFlow 之外的示例
providers: [
InMemoryProjectsStore,
// PROJECTS_STORE 和 InMemoryProjectsStore 指向同一个实例。
{ provide: PROJECTS_STORE, useExisting: InMemoryProjectsStore },
// 如果用 useClass,PROJECTS_STORE 会是第二个实例,有自己的 Map。
],测试用同样的机制替换依赖,类似 @MockBean:
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) | 每个使用者一个实例 |
// 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 里的才对外可见,其他模块要先导入它才能访问。
// 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:
// 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 的 数据库模块就是典型例子:
@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)]。
自己写一个动态模块只需要几行:
// 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() 会推迟解析:
// 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 里:
// 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:
// 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,模块只需把它接上:
// J3 版本(不是 TaskFlow 的最终版本):只有一个实现
providers: [ProjectsService, { provide: PROJECTS_STORE, useClass: InMemoryProjectsStore }],J4 引入 TypeOrmProjectsStore,选哪个实现就成了配置问题。最终的模块如下:
@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 里,它提供一个时钟:
// 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 和工具函数则像普通代码一样直接导入。
场景速查
- 要注入一个接口 → 和接口一起导出一个
Symboltoken,再用@Inject(TOKEN)(token、ProjectsStore)。 - 具体实现取决于配置 → 一个
useFactoryprovider,通过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 (?)”
? 标出的是找不到的那个参数。三个常见原因:
- 参数类型是接口、别名或联合类型,又没有
@Inject:元数据是Object(解析)。加一个 token 和@Inject(TOKEN)。 - 类是用
import type导入的:效果一样。改成正常导入。Nest 的报错信息也会明确提示这一点。 - 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_STOREtoken(一个Symbol),以InMemoryProjectsStore作为实现, 通过@Inject(PROJECTS_STORE)注入。 - [ ] 创建
CommonModule(@Global()),提供一个useValue形式的CLOCKprovider,并把它注入到某个 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。