腾讯文档前端工程架构改造实践
共 23004字,需浏览 47分钟
·
2024-07-15 08:22
👉目录
1 老旧的工程架构让业务开发走得越来越慢
2 上百个 npm 包仓库的自动化发布系统
3 优化组件库的构建体积与速度
4 多仓库带来困境重重,决心尝试大仓脱困
5 如何阻止代码劣化
6 总结
01
-
多 npm 包手动发布效率低下且不安全。 -
基建分散且老旧,构建速度和效果都不尽如人意。 -
npm 包体积庞大,一个小功能引入 300kb,被接入方频繁吐槽。 -
上下游依赖涉及项目多,更新依赖部署环境流程繁琐。 -
体积持续增长,在天然熵增的世界中,如何阻止软件持续劣化。
-
上百个 npm 包的仓库如何实现全自动发布流。 -
如何将 170+ 组件仓库的构建时间降低到 2min。 -
真正现代的前端大仓实践经验,如何进行大仓依赖管理,大仓中如何搭建持续集成系统。 -
如何优雅的将体积检查与 CI 流程结合,持续监控站点大小。
02
Workspace protocol
可以让仓库内的 npm 包之间的依赖直接使用本地代码,而不是从源上下载,确保仓库内的依赖始终为最新的代码,极大的降低了依赖关系的复杂度。
workspace:
之后才会启用 Workspace protocol 使用本地的依赖,降低黑盒逻辑。(没有设置此字段时,只要版本匹配都会软链到本地)
node_modules
结构设计,也禁止了使用子依赖,实现彻底避免幽灵依赖问题。
-
通过 depcheck
检查子包内使用到的依赖。 -
然后查询其在根目录 lock 文件中的实际安装版本。 -
再写回子包的 package .json
中。 -
删除掉根目录下的依赖。
Workspace protocol
改造之后,包间依赖使用的始终为本地代码,不再需要关心依赖版本变更的问题,但是这样也带来了一个新的问题,需要进行依赖的级联构建。
entry
单 webpack
实例的构建模式,拆分成了多 webpack 实例的模式,每个子包都可以独立的进行构建。
{
...
"targetDefaults": {
"build": {
"dependsOn": ["^build"] // ^ 代表其子依赖的任务
},
"publish": {
"dependsOn": ["build"]
}
}
}
nx affected publish
」,Nx 就会自动的运行一系列任务来进行发布:
nx affected publish
到底是什么意思呢,核心是 **affected,**其意义就是「受影响的」,Nx 会通过检查代码变更,来分析出有代码变更的子包和其上游依赖包,比如我们更改了 lib10,那么受影响的范围如下:
Nx affected publish
的意思就是所有受影响的子包中运行 publish
命令,我们修改了 A 包的代码,其依赖链路上游所有包都受到了影响,所以全部发布了一次。
pnpm i && pnpm nx affected publish
,即可完成之前可能需要人工操作一小时的任务,但是还有一小块没有考虑完善,那就是版本的变更。
publish:beta
来自动的进行版本更新与发布,其核心逻辑就是取当前分支和时间戳更新 version,然后运行 pnpm publish
进行发布,值得注意的是,因为使用了 Workspace protocol
,所以只能使用 pnpm 进行 publish,它会在发布的时候把 workspace:
替换成真实的版本号。
-
当进行不兼容的 API 更改时,升级 MAJOR 版本号。 -
在以向后兼容的方式添加功能时,更新 MINOR 版本号。 -
在进行向后兼容的错误修复时,更新 PATCH 版本。
changeset version
消费这份 md 文件,更新包版本,这包括 A 包自己的版本,与依赖了 A 包的 C 包中依赖的版本(当然如果是使用 workspace 协议,则无需 changeset 来更新版本,因为 pnpm publish
的时候总会使用当前仓库中的版本来进行替换),然后更新 changelog
文件,最后将代码变更合回主干。
pnpm i && pnpm nx affected publish:beta
,而正式包发布也就多了一个命令 pnpm i && pnpm changeset version && pnpm nx affected publish:latest
。从此开发同学再也无需关心发布,只需要专注于代码开发,同时由于流程上的管控和权限收归,只有代码合入主干之后才能发布 latest
版本,从根本上保证了多版本之间的代码同源。总体的流程如下图:
在开发过程中我们发现,如果依赖关系的层级太深,不只是级联发布比较麻烦,开发起来同样也是,因为彼此之间依赖的都是产物,所以需要一个一个地进行构建,即使有 Nx 的加持(dev 任务依赖子依赖的 build 任务),也需要等待全链路的依赖跑完一遍构建,dev 模式形同虚设,如何才能打造更丝滑的开发体验呢。
其实问题的关键在于引用的都是产物,所以需要等待构建,但是在开发模式下真的需要使用构建产物吗?答案是否定的,在开发阶段我们完全可以直接引用源码,这样就只需要启动 C 包的 dev 模式,通过源码直接引用到 A B 包需要的代码,享受最为快速的开发体验,但是我们同时也需要保证其在 publish 之后,引用关系是正确的。
publish
之后可以替换部分 package.json
中的字段。所以 package.json
中的 main
字段可以直接写上 src/index.ts
,通过 publishConfig
配置 main
字段为 dist/index.js
,这样就可以实现在开发阶段引用源码,而发布之后是引用产物了。相比与直接在构建中通过 alias 改写引用路径,这样能够让所有维护同学一眼就看到实际的代码引用关系,避免出现问题不好排查。
{
"name": "foo",
"version": "1.0.0",
"main": "src/index.ts",
"publishConfig": {
"main": "dist/index.js",
"typings": "dist/index.d.ts"
}
}
03
package.json
与其 lock 文件,将这两个文件 copy 到 docker 中,进行依赖安装后上传到云端,在下次流水线运行的时候,判断 package.json
与 lock 文件是否有变更,没有的话直接复用上次打包出来的 docker 镜像即可。
pnpm workspace
的架构下行不通了,因为此时每个子包下都有独立的 package.json
,也就是说我们仓库 100 多个组件就有 100 多个 package.json
文件,好像没有办法仅通过根目录下的 package.json
来完成依赖安装。
pnpm-store
安装在项目根目录下(pnpm 所有的依赖都会储存在这里,依赖安装的时候只需要进行 copy 与软链),所以我们选择使用 docker volumn
直接缓存 pnpm-store
与 node_modules/.pnpm
,流水线中再手动执行一次 pnpm install
,由于 pnpm-store
中已经有上次流水线中下载下来的依赖了,部分情况下只需要几秒钟就能够安装完依赖。
docker volumn
有个问题就是并不能跨构建机 ,我们构建机器是一个集群,还是有一些情况下没有完整的缓存,需要进行下载,继续调研下来发现了 pnpm fetch 这个神奇的命令,它可以根据单个 lock 文件,把所有依赖下载到 node_modules/.pnpm
中,然后再执行 pnpm install –offline,重建依赖树,结合之前提到的 docker + npm 缓存方案可以得出这样一套方案:
-
将 pnpm-lock.yaml 文件 cp 到 docker 中。 -
执行 pnpm fetch,下载 node_modules/.pnpm
。 -
上传 docker 镜像。 -
每次流水线运行的时候,只要 lock 文件没有变更就下载这个 docker,将 node_modules/.pnpm
copy 到对应的目录。 -
执行离线 install 重建依赖树。
-
因为此仓库需要走发布流程发布这份 json 文件,所以存在 feature 分支,release 分支与 hotfix 分支,不同性质的分支就需要区分对比分支了,在特性分支中很容易想到与主干分支进行对比即可,但是发布分支还与主干分支对比合适吗? -
在仓库改在之前,这个 json 文件是通过单个 webpack 构建时直接基于中间产物信息生成的,改造之后每个组件都有了独立的构建流程,所以变成了每个组件只负责产出自己的一部分 json 信息,构建流程结束之后再获取所有组件的 json 信息进行一次拼接,那么在按需构建之后,如何获取这部分没有构建的组件 json 信息呢?
-
特性分支 -
新分支:与主干进行对比,取主干的 json 信息进行拼接,将当前分支 json 信息保存起来。 -
已存分支:与上一次 push 推送的节点进行对比,取当前分支的 json 信息进行拼接。 -
发布分支 -
新分支:与现网分支进行对比,取现网的 json 信息进行拼接,将当前分支 json 信息保存起来。 -
已存分支:与上一次 push 推送的节点进行对比,取当前分支的 json 信息进行拼接。
-
第一次 push:a->b->A。 -
第二次 push:a->b->c->B。
计算缓存
node_modules/.cache/Nx-cache下
,通过上文提到的使用 docker volumn 挂载上理论上就可以实现在 ci 中复用缓存。
webpack
jest
等任务的缓存都是保存在本地的,判断缓存是否命中只能从本地获取信息,而 Nx 支持将缓存信息上传到云端,在任务开始的时候优先从云端获取信息判断是否命中缓存,如果命中的话从云端获取产物信息,没有话再走本地缓存的判断逻辑,计算完成之后再将产物信息上传到云端和本地缓存中。
04
大仓是不是就是简单的将多仓的代码都 copy 在一起呢,答案肯定是否定的,如果只是单纯的复制代码到一起,那可能并享受不到大仓带来的好处,反而被不断扩大的仓库规模拖累降低开发效率,所以我们需要使用一系列的基础设施能力来支持大仓下的开发部署。
有了前面几个组件仓库的实践经验,大仓的基础设施毫不犹豫的选择了 pnpm workspace + nx +changeset,还有 changeset 是因为仓库内的 npm 包目前还需要发布给外部使用。其实上文提到的几个组件仓库在改造之后已经是某种意义上的大仓了,有很多大仓中需要解决的问题我们都已经有过经验,但是同样遇到了一些新的问题。
但是其设计针对的都是单一服务的仓库,没办法很好的支持大仓,难道我们迁移了大仓之后需要自己单独维护一套 ci 代码吗?这样会有很高的维护成本并不划算,能不能继续基于这套设计来实现大仓的持续集成呢,答案是可以的:
将原先处于单仓根目录的配置文件下降到每个 APP 的子包中,包括一系列的 CI 所需文件,其路径都在配置文件中进行修改,每个服务都有独立的配置。
开发一个 Nx 执行器(可以理解为一个 script 命令),核心逻辑就是通过
oci open api
触发引用的模版仓库对外暴露的各种流水线启动自定义事件,同时读取当前服务的配置文件当做环境变量传递过去,覆盖掉默认的配置文件读取行为这样一来,对于运行中的流水线来说它感知到的 Monorepo 仓库就是一个单服务仓库,因为它收到的构建命令和产物路径等都是某个服务单独配置好的,无需模板仓库任何修改即可完成接入。
在大仓的 push 流水线中执行类似
pnpm nx affected oci-feature
的命令,Nx 会分析有代码更改的子仓,然后运行其oci-feature
命令,而 oci-* 命令就是 2 提到的执行器,负责触发各种类型的流水线
总体流程如下:
还有很重要的一点就是,大仓下的流水线部署一定不能每次都全服务部署,即使我们现在的设计中是多流水线并行跑,我们需要能够实现按需部署服务,没有跳着看的同学可能就会发现,这个问题我们在 CDN 组件仓库中已经有过解法,在特性分支中:
第一次推送的分支与主干进行对比,获取需要部署的服务。
非首次推送的分支与上次推送的 commit 进行对比,获取需要部署的服务。
但是作为需要部署发布的子服务与单纯的组件还略有不同,我们还需要关心在发布分支中的服务部署表现:
每周拉起的 C 端 release 分支。
稳妥起见,我们选择手动控制,分支添加服务后缀,显式的控制需要部署的服务(所以会有多服务多发布分支)。
每三个月拉起的私有化 release 分支 (所以只有一条发布分支)。
新分支全量部署。
非首次推送与上次推送的 commit 进行对比,获取需要部署的服务。
通过 Nx,我们极大的降低了上层流水线的复杂程度,不需要繁杂的去取 diff 文件进行手动对比,然后区分各种类型的子仓触发不同的流水线。核心逻辑非常统一,在受影响的项目中执行一个命令而已,基于此逻辑,我们的上层流水线中逻辑非常的薄,流水线完全不需要关心到底有多少个子仓,其各是什么类型,有什么功能,只需要一条命令即可完成所需要做的事情:
pnpm nx affected oci-feature publish:beta
这个命令的意义很容易理解,就是在我们对比策略下得出有变更的项目,然后「并行的执行」它们的 「oci-feature」「publish-beta」。这样描述可能不太准确,因为具有 oci-feature 的子仓就是前端服务,而有 publish:beta 的子仓则是一个个 npm 包,两个命令不会共存,Nx 会在受影响的项目中寻找这两个命令,只执行其存在的,这就是为什么流水线不需要关心子仓的类型。
大仓下的另一个难点在于如何进行依赖的管理,由于仓库内的服务数量增多,依赖数量也变得更加的庞大,如何设计一套合理的依赖管理系统,可以有效避免多实例问题与重复打包问题。
统一版本策略
将所有的依赖安装到的子仓的 package.json
中之后,那么根目录的依赖是否能够完全删除呢,其实也还有其作用。因为确实有一些依赖需要所有子仓进行版本统一,比如内部的组件库,或者 react 这种,那么这类需要统一版本的包我们会提升到根目录,同时在子仓的 package.json 中保留一份版本为 * 的引用,在根目录使用 pnpm overrides 覆写成需要的版本。
{
"dependencies": {
"foo": "^1.0.0"
},
"pnpm": {
"overrides": {
"foo": "$foo"
}
}
}
{
"dependencies": {
"foo": "*"
}
}
值得注意的是,只有 APP 类型的子仓能够使用这种策略,因为 npm 包需要保持发布后可用,需要在 package.json 中写明白依赖版本,这时候就得依靠在 CI 中使用 syncpack 来检查版本统一了。
依赖使用规范
同时通过一系列的 lint 插件来规范依赖的使用:
@Nx/eslint-plugin :禁止子包之间直接通过源码引用。
eslint-plugin-import(no-extraneous-dependencies):禁止使用没有在当前 package.json 中声明的依赖,防止直接在根目录写依赖。
no-restricted-imports: 禁止使用某个依赖,比如:
"no-restricted-imports": ["error", {
"paths": [{
"name": "lodash",
"message": "请使用 lodash-es,更有利于 tree shaking"
}, {
"name": "moment",
"message": "请使用 dayjs 替换~"
}]
}]
大仓搭建起来了,还有个问题就是要如何迁移代码呢,如果只是简单的 copy 文件夹,那么所有的历史提交记录都丢失了,最可怕的是所有 bug 都会算在你的头上,那么如何保持 git 记录迁移仓库到大仓里面呢,按照如下操作即可。
# 1.筛选源仓库需要的目录与git记录
git-filter-repo --path packages/ --to-subdirectory-filter ark-module --tag-rename '':'ark-' --force
# 2.修改文件夹名字
cd packages && for dir in */; do git mv "$dir" "ark-${dir}"; done
# 3.clone 代码到大仓中
git remote add "ark" ../ark &&
git fetch "ark" &&
git merge "ark"/feature/ark_to_mono_1009 --allow-unrelated-histories --no-ff -m "update ark repo"
# 4.合并目录
git mv ark-module/packages/* packages/
05
站点体积很大程度上的影响了网站加载的速度,所以很有必要控制整站的体积,只是经过一两次站点减包是远远不够的,没有监控的话过一段时间体积又很容易增长上去了,我们需要手段来监控每次 MR 带来的体积变化,严重时阻断合入,避免体积的持续膨胀。一开始为了快速可用,我们的方案很简单:
主干中扫描 js 体积,将 js 体积上传到某平台,作为基线体积。
mr 的部署流水线中顺带检查产物 js 体积,与平台上的产物体积进行对比,超出红线设置则报错。
但是跑了一段时间发现遇到的问题太多了。
1. 扫描 js 体积得到的数据并不准确。
我们需要关心的数据实际是首屏加载的所有资源体积变更,比如 dynmic import
的 js 实际上是可以一定程度的上放过的,而首屏的图片资源或者意外引入了某个样式库的全量 CSS 则是需要着重关注的。
2. 检查很容易有误差。
因为我们的开发同学很多,而主干只有一份基线体积数据, A 同学提交 MR 检查的时候,已经有 BCD 同学把代码合入主干,更新了基线体积,那么检查出来的体积变更就并不实际是 A 同学的 MR 造成的,因为基线体积此时已经有别人的代码导致变更了,每次都得一遍遍告诉开发同学 rebase 主干后再重试,非常的浪费时间。
3. 部署流水线实际无法阻断合入,效果有限。
有很多经验证明,MR 流水线的运行时间可能会大幅的增加代码合入的时间,因为开发自己也会因为等待流水线而忘记合入代码,更别说点开帮 CR 代码的同事了。而体积检查一定需要走一遍构建,所以我们为了降低整体的流水线耗时,直接把体积检查内置在构建流程之后,部署测试环境的流水线中,而不是单独的在 MR 流水线中。
但是工蜂的特性决定了只有 MR 流水线挂了的时候才能够阻断合入,所以即使部署流水线报错了体积异常增长也有很多同学因为各种问题不进行处理而合入主干,并没有达到我们的目的,怎么才能兼顾流水线速度和检查效果呢?
4. 体积增长的来源很难分析,开发同学被阻断了合入只能来找我?
因为只是简单的对比了 js 体积,在遇到体积增长报错的时候,开发同学并不知道是什么原因导致的,于是每一个报错的 MR 最终都会找到我,开发没有办法自查问题所在,我的人力都被消耗在了帮助大家分析体积问题中。
而且并不是所有的体积增长都需要阻拦,只有意外的大体积依赖引入或者意外情况导致的大面积重复打包问题才是需要处理的,正常的需求开发引入的体积增长又得允许合入。我们是用一个 curl 命令来控制红线体积,所以经常需要判断允许合入之后,本地跑一个命令之后,让开发同学 rebuild 流水线,非常低效。
如何才能实现一套真正好用的体积检查系统,解放人力,同时最大程度的降低流程与开发同学的负担?经过调研,发现了这个工具很符合我们的需求,bundle-status 是一个进行产物分析的工具,可以通过与某次基线体积数据对比,得出详细的变更分析图:
通过看这个页面,很容易就能看得出体积变更的原因是什么,包括重复打包问题也能够辅助进行分析,同时它能够区分首屏 js,媒体资源,CSS 等。但是他们官方只提供了插件与 ci 用于在本地运行,总不可能每个人遇到问题要他在本地切换几次分支,跑几次构建,再得出这个图吧,那样效率也太低了,如何才能与流程结合提供丝滑流畅的体验呢?我们是这样设计的。
优化对比基线体积数据
针对上文提到的误差问题,要如何避免他人提交的 commit 的干扰?其实核心是如何获取到准确的基线体积,优化方案是在每次代码合入主干之后,运行 bundle-status
的 baseline 模式, 将当前 commit 的 baseline.json
重命名为 {commit hash}.json
储存到 cos 中,这样每个 commit 都有一份对应的体积数据。
git merge-rebase
命令查找当前分支与主干的共同祖先节点,也就是当前分支拉出来的那个 commit 节点。
通过其 commit hash
获取这个 commit 节点的体积数据作为基线体积,这样对比出来的体积变化就是始终复合预期的。
通过对比,超出红线限制的就会把其对比产出的的 html 上传到 CDN 中,然后通过流水线插件输出到 MR 评论区和体积检查群中,方便开发同学自助排查问题。
远程缓存助力 MR 流水线提速
在上文提到过的 Nx 远程缓存的能力帮助下,我们将体积检查流程移到了 MR 流水线中,体积检查依赖于构建任务,而绝大多数情况下构建在提 MR 之前就已经跑过很多次了,也就已经缓存到了我们的远程缓存中。
MR 流水线中只需要 NX 从远程缓存中获取构建输出的产物,然后跑体积检查的命令就好,实际所需时间少于一分钟,而 mr 流水线就能够真正的阻断合入。
同时通过调研发现,工蜂能够通过配置直接在页面上跳过未成功的流水线检查,很适合作为体积检查的逃生舱,再也不需要一次一次的让开发 rebuild 流水线了。
06
这是一篇很长的文章,初心就是向大家介绍一下我们基础开发中心在前端大仓的一些实践,但是可能大家也注意到了,这篇长文中篇幅最少的反而是前端大仓这一节,最重要的原因就是大仓中很多需要解决的问题已经在前文中写过了,这篇文章的脉络其实也和我们的实践经验是完全符合的,一开始只是想解决自动化发布的问题,但是过程中就遇到了很多大仓中会遇到的问题。遇山开山,遇水搭桥,到了某个节点突然发现,心中一直想做的那个大仓好像有能力搭建出来了,也是一个渐进式的过程。之前两个仓库的实践经验为后面真正的 docs-monorepo 仓库建设助力不少,我也希望这些经验能分享出来,帮助到每一位遇到过这些问题的前端团队。
好了,不说废话了,来一次脱水版的总结,我知道不少同学是直接拉到最后只看总结的:
依赖管理:
pnpm 解决彻底幽灵依赖问题,配合 syncpack 通过 pnpm overrides 来进行依赖的统一版本管理,Workspace protocol
软链仓库间依赖,始终使用最新的本地代码。使用 docker voluem 提速 ci 中的依赖安装。
使用构建系统进行任务编排:
基于 Nx 自动编排任务依赖关系,使用 Nx 的「按需构建」和「远程缓存」能力,永远不运行重复和多余的任务,这里的任务包括发布npm 包、触发流水线、构建、单测、lint 检查等。
使用 oci 的 open api 与原有的流水线模板结合,通过 nx 触发不同子仓流水线,实现大仓的流水线设计。
防止代码劣化:
使用 bundle-status 进行体积检查防止意外体积增长:主干中每个 commit 储存一份体积数据,mr 中获取源节点的体积数据进行对比分析,分析 HTML 上传 CDN 辅助排查问题。
前置使用 lint 阻止出现不符合预期的代码,配合在流水线中检查不允许不符合要求的代码合入主干。
同时回看全文会发现,基本上没有什么所谓自研工具,一方面是人力所限,另一方面就是我认同所有的代码本质都是技术债,都是需要维护成本的,所以我的理念就是尽量基于开源的代码,使用社区先进的工具,用尽量少的代码实现我们的目的,从而降低系统的复杂度,工程化的代码不应该是自研的黑盒,而是可以最大程度的可以让每位开发同学一起共建的阳光玻璃房。希望这些实践经验能够帮助到每一位读到这的同学,同时也感谢在这个过程中帮助到我的每一位同事,给于我机会与支持的 leader 们。
📢📢欢迎加入腾讯云开发者社群,享前沿资讯、大咖干货,找兴趣搭子,交同城好友,更有鹅厂招聘机会、限量周边好礼等你来~
(长按图片立即扫码)