腾讯文档前端工程架构改造实践

0183d9ffedfdd55aedca2017a1cf7343.png

0295974b6167919028dddbffa3fef33e.gif

👉目录

1 老旧的工程架构让业务开发走得越来越慢

2 上百个 npm 包仓库的自动化发布系统

3 优化组件库的构建体积与速度

4 多仓库带来困境重重,决心尝试大仓脱困

5 如何阻止代码劣化

6 总结

腾讯基础开发中心负责维护着腾讯文档除编辑器外的大部分业务, 包括 90+ npm 包与 170+ 的 CDN 组件,还有六个 application 服务,散落在七个业务仓库中。随着业务量和开发同学的逐渐增多,基础设施的不完善导致导致开发效率越来越低,一个业务需求需要横跨两三个仓库是常事。代码只需要写一行,发布测试包,更新版本,部署环境这些反而需要一小时,大伙苦不堪言。而且多仓库的基础设施维护也成本越来越高,如何提高多项目的开发效率,降低维护成本,成为了急需解决的问题。

01

老旧的工程架构让业务开发走得越来越慢

需要治理的地方在哪里,只有弄清楚病症才能够有效对症下药,我们通过 review 开发全流程,发现问题主要是这几个方向:

  • 多 npm 包手动发布效率低下且不安全。

  • 基建分散且老旧,构建速度和效果都不尽如人意。

  • npm 包体积庞大,一个小功能引入 300kb,被接入方频繁吐槽。

  • 上下游依赖涉及项目多,更新依赖部署环境流程繁琐。

  • 体积持续增长,在天然熵增的世界中,如何阻止软件持续劣化。

下面聊聊我们是如何解决这些问题,阅读完全文你能了解到:

  • 上百个 npm 包的仓库如何实现全自动发布流。

  • 如何将 170+ 组件仓库的构建时间降低到 2min。

  • 真正现代的前端大仓实践经验,如何进行大仓依赖管理,大仓中如何搭建持续集成系统。

  • 如何优雅的将体积检查与 CI 流程结合,持续监控站点大小。

02

上百个 npm 包仓库的自动化发布系统

   2.1 为什么这么难

我们有个仓库维护着将近 140 个 npm 包,但是其基建却几乎无人维护,采取的还是多年前的一套老旧 webpack 构建,发布繁琐且混乱。每位开发在改 bug 改到这个仓库的时候都苦不堪言,即使有文档的存在,一行小小的改动却动辄需要半天,典型的半天是这样度过的:

c77939abbfac8798d224de3e34ee5bba.png

相比流程繁琐更为严峻的是流程不规范导致的代码不同源问题,有同学为了快速验证,在自己的特性分支直接发 latest 版本而没有合入主干,导致别人再次发布 latest 版本的时候丢失了代码:

d701a00c1a8978e446184a50f82787b0.png

甚至遇到过一次这种情况下该同学离职了,只保存在他本地的那部分代码永远的消失了,只能按照业务逻辑重写一遍代码。

依赖混乱脆弱,所有的 npm 包共用根目录下的依赖,升级依赖牵一发而动全身,很难评估依赖升级的影响范围,充满不安全感。幽灵依赖(由于 npm 会将整个依赖树打平放在一起,所以代码可以用到很多没有实际写入 package.json 中的依赖,很容易有预期之外的依赖变更)问题频出,莫名其妙的依赖变更导致现网白屏也不是没有出现过,很需要一个安全可控的依赖环境

   2.2 现代科技助力,发布安心又快速

我们的目标其实很明确,需要一套自动化发布的系统。开发只需要专注于业务代码开发,而权限,发布等等都交给系统。经过调研和对比分析,我们选用了 pnpm + Nx + changeset + oci 这套技术栈来落地 npm 包的自动发布流。

pnpm 打造安全稳定的依赖环境

2024 年选用 pnpm 作为包管理工具应该无需多言,除了远超 npm 的依赖安装速度,其开箱可用的 workspace 特性做到了对多包仓库最好的支持, Workspace protocol 可以让仓库内的 npm 包之间的依赖直接使用本地代码,而不是从源上下载,确保仓库内的依赖始终为最新的代码,极大的降低了依赖关系的复杂度。

同时我们认为应该把 link-workspace-packages 设置为 false,此时只有显式的把依赖版本设置成 workspace: 之后才会启用 Workspace protocol 使用本地的依赖,降低黑盒逻辑。(没有设置此字段时,只要版本匹配都会软链到本地)

而更为重要的一点是,与 npm 的 workspace 特性不同,pnpm workspce天然就是依赖隔离的,每个子包中都会基于其 package.json 安装一个 node_modules, 代码只能只能使用自己和其父目录 package.json 中列出来的依赖,而无法访问到别的子包的依赖,结合 pnpm 的特殊 node_modules 结构设计,也禁止了使用子依赖,实现彻底避免幽灵依赖问题。

在切换包管理工具过程中,我们使用一个脚本来进行依赖的迁移:

  1. 通过 depcheck 检查子包内使用到的依赖。

  2. 然后查询其在根目录 lock 文件中的实际安装版本。

  3. 再写回子包的 package .json 中。

  4. 删除掉根目录下的依赖。

每个 npm 包只需要关心自己子目录下的依赖变更。依赖变更安全又可控。

构建系统支持级联构建发布

大家都知道只是单包仓库做自动化发布很简单,接入 CI 之后在流水线里面构建完成之后使用公共账户 publish 一次即可,但是多包仓库没那么简单。难点在于需要关心包之间的依赖关系,比如这是实际业务开发中的一个很常见的一个依赖关系:

1b5ad480a2b3de1d9ccf7658e869f4de.png

在以前的架构中,包之间依赖都是直接写版本,然后从源中下载,我们修改 A 的代码,需要进行构建,发布,然后再更新 A 的新版本到 B 中,再重复一次更新 B 的新版本到 C。在经过 Workspace protocol 改造之后,包间依赖使用的始终为本地代码,不再需要关心依赖版本变更的问题,但是这样也带来了一个新的问题,需要进行依赖的级联构建。

因为每个包都依赖其依赖的产物,所以需要按顺序的构建包 A->B ->C,这样能确保所有代码都是最新的,然后进行发布

ce29e1d7ff93fe1dfdb581900bde431e.png

在 CI 中我们需要一个工具,自动化的分析出包之间的依赖关系,进行任务的编排。这就需要引入一个新的概念「build system」。构建系统顾名思义就是在构建工具之上层的,负责任务调度的系统,前端比较流行的就有 Nx,Turborepo,Rush,Lage 等,综合考虑社区生态、接入成本、使用心智负担之后,我们选择了 Nx。

通过使用 Nx,在依赖隔离之后,我们将构建也完全独立了出来。把原来的多 entrywebpack 实例的构建模式,拆分成了多 webpack 实例的模式,每个子包都可以独立的进行构建。

只需要简单的配置上命令之间的依赖关系,告诉 Nx publish 任务依赖于 build 任务,而 build 任务又依赖于子依赖的 build 任务:

{
  ...
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"] // ^ 代表其子依赖的任务
    },
     "publish": {
      "dependsOn": ["build"]
    }
  }
}

Nx 通过分析整个仓库的依赖拓扑图就可以编排出整个发布任务链。当我们修改了 A 包的代码之后,只需要简单的运行一条命令「nx affected publish」,Nx 就会自动的运行一系列任务来进行发布:

526cc0ba0e92bf3efd8879da8f771f8a.png

可以看到基本和我们上面画的流程图一致,顺序的进行依赖的构建,同时并行地进行发布,不过它是在一个包构建完成之后立即进行发布,而不是如上文流程图一样最后再进行发布,这明显效率更高,这是实际的任务执行情况:

c7964c36e9deea68d196628511779ee3.png

那么上文提到的神秘代码 nx affected publish 到底是什么意思呢,核心是 **affected,**其意义就是「受影响的」,Nx 会通过检查代码变更,来分析出有代码变更的子包和其上游依赖包,比如我们更改了 lib10,那么受影响的范围如下:

ae680d228d61ab1b80f9c8cce6217e74.png

Nx affected publish 的意思就是所有受影响的子包中运行 publish 命令,我们修改了 A 包的代码,其依赖链路上游所有包都受到了影响,所以全部发布了一次。

看到这里如何进行自动化发布其实已经呼之欲出了,我们只需要在流水线中运行 pnpm i && pnpm nx affected publish ,即可完成之前可能需要人工操作一小时的任务,但是还有一小块没有考虑完善,那就是版本的变更。

测试版本的发布

对于测试版本的发布,我们的期望是开发完全无感知,同时能够避免多分支开发时的版本冲突,所以我们通过一个命令 publish:beta 来自动的进行版本更新与发布,其核心逻辑就是取当前分支和时间戳更新 version,然后运行 pnpm publish 进行发布,值得注意的是,因为使用了 Workspace protocol,所以只能使用 pnpm 进行 publish,它会在发布的时候把 workspace: 替换成真实的版本号。

正式版本的发布

对于正式版本的发布,预期则不同,不像测试包只需要无脑更新一个测试版本号,正式包的发布需要遵循 semver 规范,给定一个版本号 MAJOR.MINOR.PATCH

  • 当进行不兼容的 API 更改时,升级 MAJOR 版本号。

  • 在以向后兼容的方式添加功能时,更新 MINOR 版本号。

  • 在进行向后兼容的错误修复时,更新 PATCH 版本。

也就是说流水线需要开发同学来提供这个信息辅助发布,业界主要有两种方式,第一种是通过解析 commit msg 来分析需要发布的版本,代表是 lerna,还有一类就是我们选用的 changeset, 简单来说就是开发同学在本地运行命令生成一份文件说明每个包需要更新的版本和 changelog,在流水线里面消费这份文件来进行版本更新和更新 changelog 文件。

为什么选择了 changeset,除了是 pnpm 与 turborepo 官方推荐的版本管理工具以外,我们认为版本发布这个事情应该更透明,让开发同学更有掌控感。Lerna 是如何通过 commit 信息解析出需要发布的版本是绝大多数开发同学不理解的,而且在多包的场景下,每个 commit 可能对应多个包,开发同学可能很长一段时间都搞不清楚我此次合入会发布哪些包,而 changeset 通过本地运行命令显式的生成 changeset 文件,让流程更加可控,手动输入的 changelog 也一定程度上比 git 中的提交信息更有意义,流程如下:

2c1c88f9c39f93c7aa75764a7f606481.png

生成的临时文件就是一份 md 文档,当然你也可以手动进行更改:

57e371b93bcef622694fb819c13edbe1.png

在 mr 流水线中我们会检查每个受影响的每个包是否都具有对应的 changeset 文件,没有则会阻断流水线不允许合入。

在合入主干之后运行 changeset version 消费这份 md 文件,更新包版本,这包括 A 包自己的版本,与依赖了 A 包的 C 包中依赖的版本(当然如果是使用 workspace 协议,则无需 changeset 来更新版本,因为 pnpm publish 的时候总会使用当前仓库中的版本来进行替换),然后更新 changelog 文件,最后将代码变更合回主干。

至此,完整的自动发布流水线就完成了,在 Nx 与 changeset 的支持下,CI 流程变得异常简单,测试包发布就一行命令 pnpm i && pnpm nx affected publish:beta,而正式包发布也就多了一个命令 pnpm i && pnpm changeset version && pnpm nx affected publish:latest。从此开发同学再也无需关心发布,只需要专注于代码开发,同时由于流程上的管控和权限收归,只有代码合入主干之后才能发布 latest 版本,从根本上保证了多版本之间的代码同源。总体的流程如下图:

4454074edd7da97c23b366907d25ec0f.png

其中只有蓝色部分是开发需要关心的部分,其余都是 CI 自动完成的,绝大部分的复杂度都封装在 Nx 内部,仓库 CI 只需要关心版本与调用 Nx 命令。与改造前的流程对比,开发同学需要关心的事情从 16 个节点降低到了 2 个!

e1f778880b6fc00cbf889d05c66ef389.png

优化后:

228fb56cd21d156ade1644d60de8ce02.png

   2.3 进一步优化 npm 开发体验

在开发过程中我们发现,如果依赖关系的层级太深,不只是级联发布比较麻烦,开发起来同样也是,因为彼此之间依赖的都是产物,所以需要一个一个地进行构建,即使有 Nx 的加持(dev 任务依赖子依赖的 build 任务),也需要等待全链路的依赖跑完一遍构建,dev 模式形同虚设,如何才能打造更丝滑的开发体验呢。

其实问题的关键在于引用的都是产物,所以需要等待构建,但是在开发模式下真的需要使用构建产物吗?答案是否定的,在开发阶段我们完全可以直接引用源码,这样就只需要启动 C 包的 dev 模式,通过源码直接引用到 A B 包需要的代码,享受最为快速的开发体验,但是我们同时也需要保证其在 publish 之后,引用关系是正确的。

使用 pnpm 的 publishConfig 可以实现这个目的,其效果是在 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

优化组件库的构建体积与速度

   3.1 npm 打包的最佳实践

经过改造之后,大家发布都爽了,但是却发现发布产物却是不尽如人意,经常有品类同学找过来说,为什么这个 npm 包这么大?经过分析产物发现,由于老旧过时的 webpack 构建设置,npm 包只会产出 cjs 格式的 bundle 后产物,而且几乎没有进行任何 external,单 js 体积经常能达到几百 kb。经过一系列的研究,最终成功的让 npm 包接入品类带来的体积增长从 300kb 降低到接近 0,详细的过程可以阅读:我是如何把统一顶部栏接入 ppt 的体积影响降低到 0 kb(https://docs.qq.com/aio/DSmR5a0RKUVVFVlFT?p=MRJrw32LpHTT3RhSuZoebH)。

在 external 了所有的依赖之后,构建就能跳过依赖只需要处理其源码,大大降低了单包编译时间,加持上 Nx 真正的多线程并发构建,全量构建时间从 7min 降低到了 2min。而在流水线中始终只需要按需构建受影响的包,从 push 到发布测试包大多数情况下只需要1min 以内。

通过这一系列预研与实践,总结出发布一个 npm 包的最佳实践:如何打包一个现代化的 npm 包(https://docs.qq.com/doc/DSlhYdnJMbk1vandi),基于此最佳实践,搭建了仓库脚手架,开发同学无需阅读冗长的文档,只需要使用一条命令就能新建出符合最佳实践的 npm 包。

然而仓库中一百多个 npm 包,并不是所有都是我们在维护的,如果贸然全量的替换了全仓库的构建,由于不熟悉业务场景都没办法进行功能验证,所以我们采取了渐进式的构建升级。在上文 pnpm + nx 的改造基础上,每个 npm 包已经可以独立拥有自己的依赖和构建配置,所以我们继续提供了两套不同的构建器在仓库中,负责维护的开发同学可以使用命令一键实现构建的替换升级,进行功能验证之后才合入主干,保证稳定性。

   3.2 快,要再快一点!

docs-component 仓库也是文管维护着的一个组件仓库,与 npm 仓库不同的点在于,其使用 CDN 的方式加载组件资源,因为不经过品类的构建,所以很难复用依赖,无法进行 external,导致了其构建的压力天然就比 npm 包仓库的更大。随着组件数量上升,构建的速度和成功率越来越低,直到某一天,单 webpack 构建 170+ 组件的 OOM 问题导致构建再也无法成功了。

多线程并发提速构建

在有了 sc 仓库的经验后,我们同样选用了Nx 与 pnpm 来进行仓库的改造,将每个组件都视为一个独立的 lib 包,拥有独立的构建脚本与依赖关系,这样一来单个 node 线程只需要负责一个组件的构建,彻底的解决了构建 OOM 的问题,即使未来200,300 组件也没有任何问题,使用 Nx 的能力,可以并发使用真正多线程进行组件的构建,但是此时全量构建速度仅有小幅度的优化,推测可能与 common 目录重复编译和多 webpack 实例本身也有一定的性能损耗有关。

提速依赖安装

在之前的仓库架构下,使用 npm 安装依赖,整个组件仓库就只有一组 package.json 与其 lock 文件,将这两个文件 copy 到 docker 中,进行依赖安装后上传到云端,在下次流水线运行的时候,判断 package.json 与 lock 文件是否有变更,没有的话直接复用上次打包出来的 docker 镜像即可。

但是在 pnpm workspace 的架构下行不通了,因为此时每个子包下都有独立的 package.json,也就是说我们仓库 100 多个组件就有 100 多个 package.json文件,好像没有办法仅通过根目录下的 package.json 来完成依赖安装。

在调试过程中偶然发现,在流水线中,pnpm 会将 pnpm-store 安装在项目根目录下(pnpm 所有的依赖都会储存在这里,依赖安装的时候只需要进行 copy 与软链),所以我们选择使用 docker volumn 直接缓存 pnpm-storenode_modules/.pnpm ,流水线中再手动执行一次 pnpm install,由于 pnpm-store 中已经有上次流水线中下载下来的依赖了,部分情况下只需要几秒钟就能够安装完依赖。

但是 docker volumn 有个问题就是并不能跨构建机 ,我们构建机器是一个集群,还是有一些情况下没有完整的缓存,需要进行下载,继续调研下来发现了 pnpm fetch 这个神奇的命令,它可以根据单个 lock 文件,把所有依赖下载到 node_modules/.pnpm 中,然后再执行 pnpm install –offline,重建依赖树,结合之前提到的 docker + npm 缓存方案可以得出这样一套方案:

  1. 将 pnpm-lock.yaml 文件 cp 到 docker 中。

  2. 执行 pnpm fetch,下载 node_modules/.pnpm

  3. 上传 docker 镜像。

  4. 每次流水线运行的时候,只要 lock 文件没有变更就下载这个 docker,将 node_modules/.pnpm copy 到对应的目录。

  5. 执行离线 install 重建依赖树。

这套方案下来,流水线中的依赖安装时间大部分时间可以降低到 20s 以内

进一步提速 CI

在 DC 这个组件仓库中,组件之间其实并没有依赖关系,大多数情况下开发同学修改的其实只是某一个组件,在经过 Nx 改造之后,具备了单组件构建的能力,所以很自然就想到能不能降低每次流水线中需要构建的数量呢,如果每次都只构建开发同学修改了的那一个组件,速度自然就上来了,主要的思路就是两个方向:按需和缓存。

按需构建:

如果有认真阅读上文的话,就还会记得 Nx 的一项能力叫做 affected,在 npm 仓库中的流程非常简单,每次和主干进行对比即可得出有变更的 npm 包然后进行发布。但是 DC 仓库中要稍微复杂一点,因为它是一个把组件发布到 CDN 的组件仓库,所以在构建的同时生成了一份包含所有组件 js 的 json 文件用于提供给加载器加载组件,这就导致了两个问题:

  1. 因为此仓库需要走发布流程发布这份 json 文件,所以存在 feature 分支,release 分支与 hotfix 分支,不同性质的分支就需要区分对比分支了,在特性分支中很容易想到与主干分支进行对比即可,但是发布分支还与主干分支对比合适吗?

  2. 在仓库改在之前,这个 json 文件是通过单个 webpack 构建时直接基于中间产物信息生成的,改造之后每个组件都有了独立的构建流程,所以变成了每个组件只负责产出自己的一部分 json 信息,构建流程结束之后再获取所有组件的 json 信息进行一次拼接,那么在按需构建之后,如何获取这部分没有构建的组件 json 信息呢?

其实这两个问题解决的关键就只有一句话,想想这次代码是需要更新那个环境的这份 json 文件。

根据这句话分析,特性分支的代码最终是为了合入主干,发布分支的代码最终是为了发到现网,所以就能够得出结论 :

特性分支与主干进行对比,发布分支与现网分支进行对比。

那是否每次推送都需要和目标分支进行对比呢,答案是否定的。因为如果始终与目标分支进行对比的话,也是存在重复构建的。比如上次 commit 我修改了A 组件,这次 commit 我修改了 B 组件,预期应该是上次推送代码的时候已经更新过 json 信息中 A 组件的 js 信息了,此次只需要构建 B 组件更新其 json 信息,但是每次都与主干对比的话会发现这次推送依然触发了 AB 两个组件的构建,实际上是不必要的。所以当前分支已经存在的话,直接与上次 push 的 commit 对比即可得到最小的变更范围,这样就能得出最终的对比方案:

  • 特性分支

    • 新分支:与主干进行对比,取主干的 json 信息进行拼接,将当前分支 json 信息保存起来。

    • 已存分支:与上一次 push 推送的节点进行对比,取当前分支的 json 信息进行拼接。

  • 发布分支

    • 新分支:与现网分支进行对比,取现网的 json 信息进行拼接,将当前分支 json 信息保存起来。

    • 已存分支:与上一次 push 推送的节点进行对比,取当前分支的 json 信息进行拼接。

这样的策略下就实现了每次最小的构建范围吗,其实还有优化空间。在这套策略下确实每次都只构建了当前 push 所修改的组件,但是其实仓库内还有一部分 common lib 包,组件的构建会依赖于这些 lib 包的构建。

存在以下构建依赖关系。

  • 第一次 push:a->b->A。

  • 第二次 push:a->b->c->B。

在前文的策略下,避免了重复构建 A 包,但是ab 两个 lib 包并没有代码变更而重复构建了多次,如何解决这个问题呢,答案就是「远程缓存」

计算缓存

Nx 可以在子包的粒度上进行缓存,也就是说每个 lib 包和组件包都拥有独立的缓存,通过简单的配置即可实现 lib 包 a,b 复用缓存,只需要真正构建组件 A。查阅官方文档发现 Nx 的缓存配置储存在 node_modules/.cache/Nx-cache下,通过上文提到的使用 docker volumn 挂载上理论上就可以实现在 ci 中复用缓存。

但是实践下来发现缓存的命中率并没有很高,通过分析发现我们的CI 是运行在一个构建集群上,每次启动流水线的时候按照负载分配一台机器执行任务,但是 docker volumn 只能在本机的多次 ci 中复用,而无法跨构建机。

7a4c7d832ec62ef7c2761282212ca92c.png

要怎么样实现跨构建机的缓存呢,那就得提到 Nx 的另一项能力「远程缓存」。

例如 webpackjest 等任务的缓存都是保存在本地的,判断缓存是否命中只能从本地获取信息,而 Nx 支持将缓存信息上传到云端,在任务开始的时候优先从云端获取信息判断是否命中缓存,如果命中的话从云端获取产物信息,没有话再走本地缓存的判断逻辑,计算完成之后再将产物信息上传到云端和本地缓存中。

然而 Nx 官网是推荐使用 Nx-cloud 作为远程缓存的云端(也是其付费点)出于信息安全考虑,司内的业务肯定是不能使用的。经过调研我们发现 nx-remotecache-s3 可以用来在内网搭建远程缓存,简单来说它可以使用腾讯云 cos 作为云端存储,从而实现安全的远程缓存。实现了跨构建机的缓存系统,甚至未来可以实现 ci 与本地开发复用缓存,团队间复用缓存,新分支编译冷启动也能达到秒级。

be1c67867fb912b30bef43bff53e6ddb.png

只要在 CI 构建过组件,在本地开发时都不需要再次重复构建,同事 A 构建过的组件,全团队都可以复用到!做到不止是提速 CI,真正的全开发流程提速。

在远程缓存的加入下,基本实现了按需构建,绝大多数 CI 中构建都能够降低到 3min 以内,与全量构建动辄十几分钟优化了 80% 以上。而且这个数据不会随着组件数量增长而降低,现在 170 个组件是两分钟,未来 1700 个组件也是两分钟!

04

多仓库带来困境重重,决心尝试大仓脱困

随着一些优化手段的落地,大家的开发体验已经提速了非常多,但是最根本的问题还是没有解决,那就是仓库太多了!在那个时候,我们这边维护着两个 CDN 组件仓库,两个 npm 包仓库,三个 APPLICAtion 仓库一共七个仓库,为何会造成如此混乱的历史背景不谈,要同时维护这么多仓库实在让开发同学不堪重负。在极限情况下,一个小需求就会要涉及到三四个仓库,开发流程如下:

eae11a462b8da29a298856732d81364f.png

一次 npm 包的代码更新需要涉及到四个仓库,这明显是低效且不合理的。不光是部署环境,开发的时候更加痛苦,各个仓库开发命令各有各的风格,光是记住四个仓库的 dev 命令就需要消耗不少脑细胞,而在开发模式下将 npm A 的代码更新更新到 APP 仓库 A 的开发环境更是难于登天。每个仓库各自都是两三年前的基建老旧不堪,即使是有心维护,这么多仓库同步代码也是一件难事 这些本质都是由于多仓库带来的问题,于是我们尝试使用大仓(Monorepo)来解决。

   4.1 大仓之路也没有那么简单,问题逐个击破

大仓是不是就是简单的将多仓的代码都 copy 在一起呢,答案肯定是否定的,如果只是单纯的复制代码到一起,那可能并享受不到大仓带来的好处,反而被不断扩大的仓库规模拖累降低开发效率,所以我们需要使用一系列的基础设施能力来支持大仓下的开发部署。

有了前面几个组件仓库的实践经验,大仓的基础设施毫不犹豫的选择了 pnpm workspace + nx +changeset,还有 changeset 是因为仓库内的 npm 包目前还需要发布给外部使用。其实上文提到的几个组件仓库在改造之后已经是某种意义上的大仓了,有很多大仓中需要解决的问题我们都已经有过经验,但是同样遇到了一些新的问题。

   4.2 大仓中的持续集成设计

遇到的第一个新问题便是大仓下的流水线到底应该如何设计呢?这里有一个前提就是腾讯文档前端服务都是接入了统一的流水线模板(通过 include 语法引用),然后使用一份配置文件来个性化定制功能,真正的流水线代码维护在一个统一仓库里面,如下图:

ad683df5dedfdad8f63ba11128f975ff.png

但是其设计针对的都是单一服务的仓库,没办法很好的支持大仓,难道我们迁移了大仓之后需要自己单独维护一套 ci 代码吗?这样会有很高的维护成本并不划算,能不能继续基于这套设计来实现大仓的持续集成呢,答案是可以的:

  1. 将原先处于单仓根目录的配置文件下降到每个 APP 的子包中,包括一系列的 CI 所需文件,其路径都在配置文件中进行修改,每个服务都有独立的配置。

  2. 开发一个 Nx 执行器(可以理解为一个 script 命令),核心逻辑就是通过 oci open api 触发引用的模版仓库对外暴露的各种流水线启动自定义事件,同时读取当前服务的配置文件当做环境变量传递过去,覆盖掉默认的配置文件读取行为

  3. 这样一来,对于运行中的流水线来说它感知到的 Monorepo 仓库就是一个单服务仓库,因为它收到的构建命令和产物路径等都是某个服务单独配置好的,无需模板仓库任何修改即可完成接入。

  4. 在大仓的 push 流水线中执行类似 pnpm nx affected oci-feature 的命令,Nx 会分析有代码更改的子仓,然后运行其 oci-feature 命令,而 oci-* 命令就是 2 提到的执行器,负责触发各种类型的流水线

总体流程如下:

337a99eb2ae74d342b79fdb24dd842b3.png

还有很重要的一点就是,大仓下的流水线部署一定不能每次都全服务部署,即使我们现在的设计中是多流水线并行跑,我们需要能够实现按需部署服务,没有跳着看的同学可能就会发现,这个问题我们在 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 会在受影响的项目中寻找这两个命令,只执行其存在的,这就是为什么流水线不需要关心子仓的类型。

   4.3 依赖管理

大仓下的另一个难点在于如何进行依赖的管理,由于仓库内的服务数量增多,依赖数量也变得更加的庞大,如何设计一套合理的依赖管理系统,可以有效避免多实例问题与重复打包问题。

统一版本策略

将所有的依赖安装到的子仓的 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 替换~"
    }]
}]

   4.4 如何无损迁移仓库

大仓搭建起来了,还有个问题就是要如何迁移代码呢,如果只是简单的 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

如何阻止代码劣化

每次进行的优化专项就像一次 ICU,能够短暂的修复好很多问题,但是这个世界天然就是熵增的,代码仓库一定会随着业务代码的膨胀而不断劣化,我们需要一些「日常体检」,来持续维护仓库的健康,最好能够实现再也不需要走进 ICU。

   5.1 粗糙的体积检查问题多多

站点体积很大程度上的影响了网站加载的速度,所以很有必要控制整站的体积,只是经过一两次站点减包是远远不够的,没有监控的话过一段时间体积又很容易增长上去了,我们需要手段来监控每次 MR 带来的体积变化,严重时阻断合入,避免体积的持续膨胀。一开始为了快速可用,我们的方案很简单:

  1. 主干中扫描 js 体积,将 js 体积上传到某平台,作为基线体积。

  2. 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 流水线,非常低效。

   5.2 如何实现更优雅的体积检查

如何才能实现一套真正好用的体积检查系统,解放人力,同时最大程度的降低流程与开发同学的负担?经过调研,发现了这个工具很符合我们的需求,bundle-status 是一个进行产物分析的工具,可以通过与某次基线体积数据对比,得出详细的变更分析图:

424111f70991ba82bb0e3460c1d5d57d.png

通过看这个页面,很容易就能看得出体积变更的原因是什么,包括重复打包问题也能够辅助进行分析,同时它能够区分首屏 js,媒体资源,CSS 等。但是他们官方只提供了插件与 ci 用于在本地运行,总不可能每个人遇到问题要他在本地切换几次分支,跑几次构建,再得出这个图吧,那样效率也太低了,如何才能与流程结合提供丝滑流畅的体验呢?我们是这样设计的。

优化对比基线体积数据

针对上文提到的误差问题,要如何避免他人提交的 commit 的干扰?其实核心是如何获取到准确的基线体积,优化方案是在每次代码合入主干之后,运行 bundle-status 的 baseline 模式, 将当前 commit 的 baseline.json 重命名为 {commit hash}.json 储存到 cos 中,这样每个 commit 都有一份对应的体积数据。

0b5f17ba6ac588cf5634e3bdff0fab5c.png

在流水线中进行检查之前,通过 git merge-rebase 命令查找当前分支与主干的共同祖先节点,也就是当前分支拉出来的那个 commit 节点。

ad4abe50b7962f78fcbba96822004f1f.png

通过其 commit hash 获取这个 commit 节点的体积数据作为基线体积,这样对比出来的体积变化就是始终复合预期的。

通过对比,超出红线限制的就会把其对比产出的的 html 上传到 CDN 中,然后通过流水线插件输出到 MR 评论区和体积检查群中,方便开发同学自助排查问题。

远程缓存助力 MR 流水线提速

在上文提到过的 Nx 远程缓存的能力帮助下,我们将体积检查流程移到了 MR 流水线中,体积检查依赖于构建任务,而绝大多数情况下构建在提 MR 之前就已经跑过很多次了,也就已经缓存到了我们的远程缓存中。

MR 流水线中只需要 NX 从远程缓存中获取构建输出的产物,然后跑体积检查的命令就好,实际所需时间少于一分钟,而 mr 流水线就能够真正的阻断合入。

同时通过调研发现,工蜂能够通过配置直接在页面上跳过未成功的流水线检查,很适合作为体积检查的逃生舱,再也不需要一次一次的让开发 rebuild 流水线了。

65db36bc72fc64ea1d689a56743b68a1.png

整体的流程设计如图:

7d14bf47638a0d273aceee349b37cd1f.png

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 们。

-End-

原创作者|刘固

735740dfade110d827fa1f8b45db7b05.png

你觉得前端程序员还应该掌握哪些技能呢?欢迎评论留言。我们将选取1则评论,送出腾讯云开发者定制眼罩1个(见下图)。7月10日中午12点开奖。

5ee8df91cfac9941a1ac0fb7bf5e6912.png

📢📢欢迎加入腾讯云开发者社群,享前沿资讯、大咖干货,找兴趣搭子,交同城好友,更有鹅厂招聘机会、限量周边好礼等你来~

f443b43a381886c12c14b27234c96620.jpeg

(长按图片立即扫码)

fc1c612d6b8b8ebe2d344a56660dde24.png

2b85a69612563820ef69147794070ff3.png

796be32d20507acaf768ed64b2d2790c.png

ba20b295203d14c39979317e13b2e5f9.png

c23eadcd717b6df38c4fb4302cbbacdb.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值