谈谈一次 Midway Scope 报错:编译通过了,后端却没起来

最近给一个后台自动补标功能调整开发环境的启动项,TypeScript 明明提示 Found 0 errors,后端却在启动时抛出了 MidwaySingletonInjectRequestError。起初我也以为是环境配置搞坏了,毕竟刚把开发启动环境从 local 换成 dev。但日志指向的东西又不像配置错误……于是和另一个 agent 一起把这条依赖链翻了一遍。此 LOG 诞生,记录一下这次「编译通过,但服务根本起不来」的排查过程。

报错说了什么?

当时终端先给了我一句很令人安心的话:

1
Found 0 errors. Watching for file changes.

下一秒就变成了:

1
2
MidwaySingletonInjectRequestError: MainConfiguration with singleton scope
can't implicitly inject AutoTaggingService with request scope directly.

上面把业务类名简化了。重点是 MainConfiguration、singleton scope 和 request scope 这三个词:TypeScript 检查的是代码能不能编译;Midway 要等运行时创建对象,才会检查依赖注入的生命周期。 两句日志并不矛盾。

出问题的代码大致长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration({ /* ... */ })
export class MainConfiguration {
@Inject()
autoTaggingService: AutoTaggingService;

async onServerReady() {
if (this.autoTaggingService.enabled) {
await this.autoTaggingService.initializeRecovery();
}
}
}

@Provide()
export class AutoTaggingService {
// 处理自动补标,也负责启动时注册恢复任务
}

我的第一反应是:难道 dev 没读到开关?那把 enabled 设成 false,不就不进 if 了吗?

还真不行。@Inject() 不是等访问属性时才临时找对象。Midway 创建 MainConfiguration 的时候,就要解析这个属性;还没运行到 onServerReady(),容器已经因为 Scope 不匹配而抛错了。 开关关掉只能跳过方法里的业务逻辑,不能取消已经声明的依赖。

Singleton 里为什么不能直接塞一个 Request?

MainConfiguration 是应用级的 Singleton,@Provide() 注册的业务服务默认是 Request scope。前者长期被应用共享,后者应在各自的请求或任务 Context 中取得实例。

说白了就是:一个长期待在办公室的对象,不能随手把「只属于这次任务」的对象装进口袋,留着下次继续用。假如 Request service 后来携带了用户身份或请求状态,这样持有它就可能破坏隔离。这里说的是设计风险,不是说这次已经发生了用户数据串用。Midway 看的是 Scope 声明,不会替我分析方法体里到底有没有读 ctx。

于是,启动阶段实际形成了这么一条不合法的依赖:

1
2
3
MainConfiguration(Singleton)
└─ @Inject() AutoTaggingService(Request) ← 启动时在这里报错
└─ 后续的任务级业务依赖

这也解释了另一件让我困惑的事:之前的 tsc 和直接 new AutoTaggingService() 的业务测试都没报错。前者不执行 Midway 的运行时 Scope 检查;后者干脆绕开了 Midway 容器。测试通过是真的,但它们没测到启动时容器能否解析配置类这道门。

是不是切换 dev 惹的祸?

时间上很像:改完启动项,启动后立刻炸了。但「刚改了什么」不等于「错误由什么造成」。

我们用不连接数据库、Redis 或模型的最小 Midway 容器,分别设成 local 和 dev,两边都复现了相同的 MidwaySingletonInjectRequestError。再回头追代码历史,直接注入自动补标服务的那一行,早在之前新增异步补标时就已经进来了。环境切换后的一次重启暴露了旧问题,不是 dev 把一个合法的注入变成了非法注入。

中间还考虑过两个「看起来很快」的修法:

  • 按错误提示给 Request service 加 allowDowngrade: true。这等于明确允许 Scope 降级;但这条服务后面还连着任务级依赖,不能没查清整条链就让它跟着 Singleton 长期共享。
  • 在 onServerReady() 里改用 applicationContext.getAsync()。这能避开属性注入,却不会自动创建一个请求或任务的 Context;它和 ctx.requestContext.getAsync() 不是一回事。只是换个地方取对象,并没有讲清生命周期边界。

其实仓库里已有可以参考的处理方式:长期运行的事件监听器在收到事件时创建独立 Context,再从 requestContext 获取业务 service;任务处理器则交给框架提供的任务 Context。它们并不是「注入不了,就手动 new 一个」——对象依旧由容器管理,只是取得它的时机和 Scope 不一样。

最后怎么改的?

继续往下拆,发现启动时真正要做的事情其实很少:检查开关、准备索引、往 BullMQ 注册一个每 30 秒运行的恢复任务。读取文件、选择标签、写回结果这些事,应该留在任务处理时做。

于是把这部分启动职责单独放进一个显式声明为 Singleton 的入口。下面同样是脱敏后的结构示意,不是可直接复制的完整实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Provide()
@Scope(ScopeEnum.Singleton)
export class AutoTaggingBootstrapService {
@Config('autoTagging')
config: AutoTaggingConfig;

// 注入 repository 与 queue,不注入 Request scope 的业务服务

async initializeRecovery() {
if (this.config?.enabled !== true) return;
// 准备索引,注册周期恢复任务
}
}

@Configuration({ /* ... */ })
export class MainConfiguration {
@Inject()
autoTaggingBootstrapService: AutoTaggingBootstrapService;

async onServerReady() {
await this.autoTaggingBootstrapService.initializeRecovery();
}
}

原来的 AutoTaggingService 不用强行改成 Singleton。应用启动入口是 Singleton,实际处理每个 BullMQ job 的业务链仍留在各自的任务 Context。 两套 Scope 共存,但不再由一个长期存活的对象直接持有 Request service。这个改法我觉得比给整条链开 allowDowngrade 更清楚,也比到处手动 new service 更符合原来的 DI 设计。

怎么确认不是「改完刚好不报错」?

这次补了一个真正经过 MidwayContainer 的回归测试:修复前解析 MainConfiguration,它会因为同一条 Singleton → Request 注入关系变 red;改完后再解析,确认配置类能创建,而且不会顺手实例化 Request 补标业务链。

另外还测了开关关闭时不会触碰数据库或队列、开启时保留原来的索引和恢复 job,以及两个独立的 MidwayRequestContainer 会拿到彼此隔离的业务实例。最终这组容器测试 5/5 通过,TypeScript 检查也通过。

不过得把边界说清楚:测试用的是内存替身,没有完整启动连接真实 MongoDB、Redis 和模型的后端;所以我能说的是已复现并消除了这条 Scope 冲突,容器回归通过,不能把它写成「完整环境启动和所有功能都验证通过」。那次后端全量测试还有 6 项已有失败,后续一次全量运行还遇到 1 项单独重跑通过的 timeout 用例,也不能假装全绿。

再往后,同一轮开发里还遇到了钉钉通知报错、上传后没有自动标签。这是另外两条问题链,不能看到它们都发生在「改启动项之后」,就把原因全塞给这次 Scope 修复。尤其是自动标签那条,任务其实已经到了 worker,后续还有版本 ID 写回的问题,需要另查。

总之 总之,这次给我补上的不是一个装饰器小技巧,而是一个排错习惯:编译、容器启动、任务执行,是三道不同的门。 看见 Found 0 errors 只能说明过了第一道;要确认 DI 的生命周期没写错,还是得让真正的容器来试一次。QAQ

参考:Midway 错误码文档:Singleton 注入 Request scope · Midway 容器与 Scope · 之前写的 diagnosing-bugs 记录。