使用PNPM构建Monorope模式的单仓库BFF项目的实践

1,329 阅读7分钟

前言

公司现在的工程结构是前端工程和 Node 工程放在同一个仓库中,可以在 CICD 和机器上省钱,但是团队并没有对工程结构提出规范,这就导致开发们自由发挥,出现了工程目录混乱、可读性差的问题。

目前这段时间正好是我的业务空窗期,所以想尝试解决这个问题,经过了一番探索最终落地方案选择了 PNPM + Monorope 模式。

由于痛点是在开发背景限制下优化单仓库前后端结构问题,所以说此方案不是一个传统的 Monorope 工程,一是没发挥共享组件功能,二是没充分发挥依赖包共享功能,三是允许幻影依赖。

希望能帮助到与我有相同开发背景的达瓦里氏

背景

公司的 CICD 流程是由运维部门负责的,也就是说开发是不能直接修改 CICD 脚本的。这是出于安全的考虑,但是带来的影响则是 team 间的沟通成本增加了开发的创造成本。

所以我也将在不改动现有CICD脚本的情况下,来实现工程结构的升级。

一方面不增加与运维部门的沟通成本,另一方面在向其他team推行此方案时更顺利。

如果你的开发背景与我类似,那强烈邀请看到最后。

先看目录结构

当前结构

ng + express 为例

.
├── README.md
├── angular.json
├── decorate-angular-cli.js
├── dist
│   ├── app
│   └── server
├── http-client.env.json
├── ngsw-config.json
├── nx.json
├── package-lock.json
├── package.json
├── src
│   ├── app
│   ├── assets
│   ├── design.scss
│   ├── environments
│   ├── favicon.ico
│   ├── index.app.html
│   ├── index.html
│   ├── index.latest.html
│   ├── index.pro.html
│   ├── index.stage.html
│   ├── main.ts
│   ├── server                // node 工程
│   │   ├── ...
│   │   └── server.ts
│   ├── styles.scss
│   └── types.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.spec.json
└── tslint.json

改为 Monorepo 模式后

├── app // ng 工程
|   ├── src
|   ├── README.md
|   ├── angular.json
|   ├── karma.conf.js
|   ├── .browserslistrc
|   ├── .editorconfig
|   ├── package.json
|   ├── tsconfig.app.json
|   └── tsconfig.json
├── server // node 工程
|   ├── src
|   ├── .eslintrc.js
|   ├── .prettierrc
|   ├── nest-cli.json
|   ├── README.md
|   ├── tsconfig.build.json
|   ├── tsconfig.json
|   └── package.json
├── dist
|   ├── app
|   ├── server
|   └── package.json
├── node_modules // 包含了app和server的依赖
├── .npmrc // pnpm config
├── pnpm-workspace.yaml // workspaces 声明
├── package.json // 根目录下的package可以编写一些 script 来聚合app和server中的 script,比如 build 和 start
├── Readme.md

可以看到主要的改动是

  • 提升了 server 的目录层级
  • 使 app 与 server 工程高度内聚
  • 隔离了 app 与 server 工程

与嵌套相比,Monorope 的好处

  • 项目结构更清晰,可读性增强
  • 省去了手动合并 package.json 的任务
  • app与server的隔离等级提升,避免了ts,tslint,eslint等等不必要的互相影响

如何构建

在开始前可以先阅读下一下几篇文章帮助理解

初始化项目

以 new-bff 举例

新建项目文件夹 new-bff

在项目根目录添加 .npmrc

auto-install-peers=true
strict-peer-dependencies=false
shamefully-hoist=true

在项目根目录添加 pnpm-workspace.yaml

packages:
 - 'app'
 - 'server'

初始化 app 和 server

app

app 可以选用你想用的前端框架:Ng,React,Vue,以下使用 Ng 举例

npm install -g @angular/cli

cd new-bff

ng new app

server

与 app 相同,你也可以使用你熟悉的 web 框架,以下使用 nestjs 举例

npm install -g @nestjs/cli

cd new-bff

nest new server

安装项目依赖

安装 pnpm

npm install -g pnpm

安装依赖

pnpm install

pnpm 会遍历 pnpm-workspace.yaml 中声明的工作空间,并执行 install

执行 build

pnpm run -r build

pnpm 会遍历 pnpm-workspace.yaml 中声明的工作空间,并执行 run build

提醒 :bulb:

可以将构建命令与启动命令集成到根目录的 package.json 的 scripts

注意 :warning:

  1. build前请先检查app/angular.json中的projects.app.architect.build.options.outputPath属性是否指向了根目录下的dist文件夹
  2. build前请先检查server/tsconfig.json中的compilerOptions.outDir属性是否指向了根目录下的dist文件夹

小结

至此整个 new-bff 项目的构建就完成了。

如果只是想用 PNPM 来构建 Monorope 模式的单仓库的前后端项目的话,可以说已经结束了。

别忘了点赞哟(づ ̄3 ̄)づ╭❤~

下面的内容,是我在结合公司现有的 CICD 过程中遇到的一些问题和解决过程。

采坑经历

记录了方案的迭代过程和是如何做到兼容 CICD 的

如何兼容现有Jenkins 的 CICD 脚本

现有 CICD 脚本

可以简单概括为以下流程

  1. clone 项目到服务器
  2. 根目录下运行npm install安装依赖
  3. 运行 package.json 中的build命令
  4. 运行npm pack打包并压缩项目源代码(不含node_modules)并缓存
  5. 运行npm prunenode_modules瘦身
  6. 运行tar cvxf命令对根录下的node_modules压缩,并缓存
  7. 执行node dist/server/main.js启动服务

之所以要对源码和node_modules分别做压缩缓存,主要目的是可以将代码与依赖解耦

经过解耦之后,如果我们一个新的版本中package.json没有更新只有代码更新,过程中就可以省略5、6两步了

问题来了

使用 yarn workspaces

yarn workspaces 的使用可以看这篇文章

使用yarn workerspace安装依赖后,在两个子空间中都会产生一个node_modules

├── app
|   ├── package.json
|   └── node_modules
├── server
|   ├── package.json
|   └── node_modules
├── dist
|   ├── app
|   |   ├── package.json
|   ├── server
|   |   ├── package.json
|   └── package.json
├── package.json
├── node_modules

现在我们的项目中一共有三个node_modules了,三个node_modules中的内容是不同的,如果只是这样这个方案是没有问题的。

但是如果我们用这个方案到服务器上部署,那么在 CICD 过程中,就会遇到两个问题

  • 只有根目录下的node_modules会被压缩缓存
  • node dist/server/main.js命令会失败,因为 node 查找依赖的逻辑是从package.json文件的所在目录开始逐级向上查找。dist 文件夹下是不会有node_modules的,根目录下的 app 和 server 的node_modules也是不被访问到的。

在 yarn 的文档中我没有找到可以将子空间中的node_modules的内容提升到根目录的node_modules中,所以 yarn 无法满足我们的需求

也可能是我没有找到,大家可以尝试再看看 yarn 文档

使用 pnpm workspaces

将 yarn 替换为 pnpm 后,上述问题也是存在的,项目中还是存在三个node_modules

├── app
|   ├── package.json
|   └── node_modules
|   |   └── ng@1.0.0 // 依赖并不存在于当前位置,当前位置的依赖包只是一个软链接(一个快捷方式)
├── server
|   ├── package.json
|   └── node_modules
|   |   └── nest@1.0.0 // 依赖并不存在于当前位置,当前位置的依赖包只是一个软链接(一个快捷方式)
├── dist
|   ├── app
|   |   ├── package.json
|   ├── server
|   |   ├── package.json
|   └── package.json
├── package.json
├── node_modules
|   └── .pnpm // 依赖的真实安装位置
|   |   ├── ng@1.0.0 
|   |   └── nest@1.0.0

不同之处在于子空间中的node_modules指向了根目录下的node_modules

当我走到这一步时,我感觉我已经成功了。

既然依赖包是安装在根目录下的node_modules,那即使 dist 中子空间不包含node_modules,那必然可以逐级向上获取到根目录下的依赖,所以我尝试运行node dist/server/main.js,结果

❯ node dist/server/main.js                                                                               system  12:48:47
internal/modules/cjs/loader.js:905
  throw err;
  ^

Error: Cannot find module '@nestjs/core'
Require stack:
- /new-bff/dist/server/main.js

打脸是真的快 :facepalm:

原因是根目录的node_modules下多了一层.pnpm路径

如何取消.pnpm路径

.pnpm的作用可以看这篇文章

简单来说.pnpm的作用就是隔离各个子空间的依赖,避免幻影依赖问题。关于幻影依赖问题我就不在这里解释了上面的文章中有说到。

那我要取消.pnpm不就意味着我会出现幻影依赖问题吗?

经过调查发现,其实不会,因为幻影依赖问题也可以通过eslint来解决。

退一步讲,即使我不解决幻影依赖也可以,因为它并不会对我的代码运行产生影响,因为我们的子空间并不会被发布到npm上,所以即使发生了也无所谓。

好了,那如何取消.pnpm呢?办法就在根目录下的.npmrc文件中

shamefully-hoist=true

使用这个设置来取消.pnpm隔离,关于这个设置可以在pnpm文档中查看。

最后

现在回顾下 CICD 流程,服务器上只缓存根目录的node_modules的情况下,当我们运行node dist/server/main.js,会逐级向上查找依赖,找到根目录的node_modules,CICD 流程不需要任何改动,我们就能收获一个干净整洁的项目工程。

谢谢观看,别忘了点赞哟