排查问题
使用 pnpm workspace 搭建 nestjs 项目,在启动服务时遇到如下报错,意思是 nestjs 的 DI 系统无法找到要注入的依赖:
ERROR [ExceptionHandler] Nest can't resolve dependencies of the FooModule (FooModule, ?). Please make sure that the argument ModuleRef at index [1] is available in the FooModule context.
Nestjs 官方文档在 FAQ 中对该问题作为常见问题进行了解释:
Common errors - FAQ | NestJS - A progressive Node.js framework
If you are in a monorepo setup, you may face the same error as above....
This likely happens when your project end up loading two Node modules of the package@nestjs/core
如果不是真的缺少对应 provider 的话,最常见的可能性就是在 monorepo 中 resolve 到了两个不同的 @nestjs/core
包。
这里我们用的是 pnpm workspace ,并且指定的所有包版本都是匹配的,理论上来说最终都会 link 到同一份 @nestjs/core
代码。
但是打开 node_modules/.pnpm
会发现,里面的 @nestjs/core
有两个 版本完全一致 但是 带有不同后缀 hash 的目录,这就是问题所在了:
寻找问题原因
检索 pnpm discussions ,发现有相关问题的讨论:
- pnpm install duplicate package with same version · Discussion #3897 · pnpm/pnpm (github.com)
- Two copies of the same version of the package · Discussion #4051 · pnpm/pnpm (github.com)
原来这是由 pnpm 处理 peer dependencies 的机制导致的,官方文档详细介绍了这个机制:
简而言之,如果一个 package 声明了 peerDependencies
,那么如果在不同的 workspace 下安装的 peerDependencies
不完全一致,就会导致有两份不同的 hash 被生成。
拿我们这里遇到的 @nestjs/core
的问题为例,其 9.1.2 版本的 package.json 声明的 peerDependencies
和 peerDependenciesMeta
如下:
{
"peerDependencies": {
"@nestjs/common": "^9.0.0",
"@nestjs/microservices": "^9.0.0",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/websockets": "^9.0.0",
"reflect-metadata": "^0.1.12",
"rxjs": "^7.1.0"
},
"peerDependenciesMeta": {
"@nestjs/websockets": {
"optional": true
},
"@nestjs/microservices": {
"optional": true
},
"@nestjs/platform-express": {
"optional": true
}
}
}
可以看到,除了 @nestjs/common
, reflect-metadata
, rxjs
以外,另外三个其实都是可选的,分别在当前服务要提供 ws / microservices / http 时才需要安装。
此时,如果我们有一个 packages/foo
,要提供 http 服务,安装 @nestjs/platform-express
:
# packages/foo
pnpm i @nestjs/core @nestjs/common rxjs reflect-metadata @nestjs/platform-express
我们还有另一个 packages/bar
,要提供微服务,安装 @nestjs/microservices
:
# packages/bar
pnpm i @nestjs/core @nestjs/common rxjs reflect-metadata @nestjs/microservices
这时候,由于 foo
和 bar
安装的 @nestjs/core
的 peerDependencies
并不一致, pnpm 为了严谨起见就会将他们 resolve 到两份不同的 @nestjs/core
中。这看起来也没什么问题,因为两个服务是独立运行的。
但是,此时我们还有一个 packages/common
存放一些公共模块,同样也依赖了 @nestjs/core
,不过那三个可选的依赖在这个包里都不需要:
# packages/common
pnpm i @nestjs/core @nestjs/common rxjs reflect-metadata
显然,这里的依赖和另外两个包都不一样,此时就会出现三个版本相同但 hash 不同的文件夹了:
当 foo
和 bar
引入 common
时,就会出现有两个不同的 @nestjs/core
被 resolve 到,引发上面的问题。
如何解决
首先一种可行的解决方案就是把所有包都装上相同的 peerDependencies
,那么最终解析到的 hash 就都一样了。不过这显然并不是个好办法,毕竟我们每个包所需的依赖就是不一样的。
所以,另一个 workaround 就是在 Discussion #4051 中提到的,通过 .pnpmfile.cjs 提供的 hook 能力,在读取 @nestjs/core
包信息的时候,把其中声明的 peerDependencies
和 peerDependenciesMeta
全部删除,来避免 pnpm 的 peer 机制:
// .pnpmfile.cjs
module.exports = {
hooks: {
readPackage(pkg) {
// @see https://github.com/pnpm/pnpm/discussions/4051
if (pkg.name === '@nestjs/core' || pkg.name === '@nestjs/common') {
delete pkg.peerDependencies;
delete pkg.peerDependenciesMeta;
}
return pkg;
},
},
};
然后删除 node_modules
和 pnpm-lock.yaml
重新安装依赖,pnpm 就会认为 @nestjs/core
并没有声明 peerDependencies
,从而不会生成带 hash 的文件夹了。
这个问题相对比较少见,基本只有同时符合以下条件才会发生:
- 声明了
peerDependencies
- 部分
peerDependencies
是可选安装 - 你需要在同一个 monorepo 的不同 workspace 下安装不同的
peerDependencies
组合 - 解析到两个不同路径的包会导致出错(很多包即使解析到不同路径也没事,比如只提供工具类函数)
而 nestjs 刚好符合这几个特点,于是就踩到了这个坑。
说个题外话,提到同一个依赖被解析到多份,我就想起来了之前在 Vite 踩到的相似的问题:
- [Bug report] importing dependencies by fs paths results in two different copies of the same module · Issue #2503 · vitejs/vite (github.com)
- [Bug report] importing dependencies by fs paths results in two different copies of the same module (Same as #2503) · Issue #7621 · vitejs/vite (github.com)
在一年多之前提出之后一直没解决,前段时间终于因为大客户 @sveltejs/kit
也遇到这个问题才推动解决了,泪目。