基于Monorepo + 前后端未完全分离的gitlab CI/CD实践

1,872 阅读7分钟

看到这个标题,如果你做过CI/CD并且对Monorepo有一定的了解,加上前后端未完全分离,你肯定知道我们面临的困难有多大了。

痛点分析

Monorepo的痛

这个项目有很多子项目,对,是很多,多达几十上百个。

几乎所有的CI/CD工具对Monorepo的支持都不友好,执行一次CI/CD就得构建全部子项目,那得花多长时间?开发与QA能接受吗?

目前构建单个子项目大约20秒左右(已进行过webpack优化),构建5个大约1分零10秒。

构建100个至少需要20分钟。

而且这么多业务型的子项目,也不太可能单独去管理它们的版本。

这是要做自动化待解决的首要问题!

前后端未完全分离

由于公司领导层都是.NET后端出身,所以很多项目都是由后端主导的MVC架构。

重构后的页面上其实并没有后端的绘制代码,我们用的React工程构建的项目,将打包结果拷贝到.NET项目中使用,页面引用打包结果,但是版本控制仍然由后端负责。

同事们一再抱怨流程复杂到令人颤抖:在React项目中开发,提交git,推送,提MR;然后构建,将打包结果拷贝到.NET项目中,再次拉分支提交git,推送,提MR。这个拷贝过程很容易导致.NET项目中的csproj文件同步失败导致.NET项目在构建时找不到资源而炸掉(对于chunk文件必须先删掉旧的,再从VS中移除,再Include新的到项目中去,一旦漏掉某项csproj就同步失败,.NET项目的自动化构建就会炸掉,用过VS的童鞋应该深有体会)。

这种前端页面由后端控制器绘制出来的项目要做资源分离,面临最大的难题是版本管理(也就是缓存复用与穿透)!

  • 构建与发布由前端负责
  • 版本管理却由后端负责,因为前端无法更改后端cshtml视图层

你的webpack构建版本处理得再风骚,也会被.NET这个后腿拖得动弹不得。

PS:这不是我的锅,是遗留项目要做重构导致的,这也是我入职以来面临的头疼问题。旧代码视图是用knockout+jquery+.net代码绘制的,重构后是使用React绘制,是独立的项目,但是需要将结果拷贝到遗留项目中运行。

这是要做自动化待解决的第二个难题!

CDN的引入

什么?都2020年了你告诉我还有项目没使用CDN

是的,公司领导层都是.NET后端出身,不太在意前端资源的处理。

所有的脚本和样式都是项目启动时加载到内存里,通过后端代码去引入访问的。

@Script.Src("/page/a.js",
"/page/b.js");
@Style.Src("/css/a.css",
"/css/b.css");

要搞自动化布署,前端资源肯定得分离,那么CDN的引入也是自然的了。

解决方案

既然要做,以上这些问题总是要解决的。经过几天的深思熟虑,我最终得出一个结论:
我们需要一个自己的构建平台

一个个来看:

首先,解决Monorepo的痛

Monorepo之所以构成麻烦,是因为子项目太多,构建效率上不去,实际上也不需要全量构建。

我研究了一下gitlabapi文档,了解了一下trigger的用法,知道通过这个api可以触发gitlabpipeline最重要是它可以携带参数variables

那么解决方案就有了:

  • 定义.gitlab-ci.yml,定义构建阶段的命令,使用预定义变量variables[NERKO_PROJECTS]获取从trigger中拿到的值进行构建(我们的Monorepo在设计时就支持子项目自由组合构建),NERKO_PROJECTS代表要构建的子项目,是字符串,比如”projectA,projectB,projectE“
   - |
      if [ "$BUILD_ALL" == "true" -o "$BUILD_PROJECTS" ]; then
        npm run build ${BUILD_PROJECTS};
      fi
  • 搭建一个构建平台NERKO,列出当前环境所有子项目列表,可选构建,点击确定后发送trigger请求触发gitlabpipeline

  • 最后将打包结果上传到CDN

但是这样虽然解决了第一个问题,第二个问题仍然存在,前后端不分离一直都是最大的难点。

然后,解决前后端未分离的版本控制

上一点中,构建结果有了,上传CDN也有了,但是怎么告诉.NET项目到哪里取哪个版本哪些文件呢?

很多互联网公司都是使用index.html文件充当索引,将当前版本指针指向这个文件,利用这个文件中生成的script引用去渲染页面。

我们这里可以借鉴这个思路,但是要更复杂一些,因为我们的实际视图是.NET渲染的cshtml文件,而不是webpack产出的index.html

解决方案如下:

  • 构建完成之后,会生成projectA.htmlprojectB.html……等。
  • 扫描这些文件,挨个读取,使用正则去匹配scriptlink标签,得到一份js引用列表与css引用列表。
  • 遍历这些jscss,针对相对路径读取webpack构建出的文件(绝对路径大多数是cdn第三方库引用,能直接使用),计算出这些文件的md5值,并进行改名操作,比如将menu.js改为menu.{md5}.js
  • 生成一份manifest.json索引文件,如下所示:
{
  "projects": {
    "allCalls": {
      "js": [
        "/dll/vendor.dll.90c504c0d36899ef24fd43024f0ffad2.js",
        "/public.e107ea21bcd121626fb24fcc6280961e.js",
        "/lib/common-plugins.88cad0d3eeaa5c32057a0d0216f980c4.js",
        "/pages/allCalls.7ba43cc799a7ff5dea331705e713bea0.js"
      ],
      "css": [
        "/css/common.5b78df382b56d09b67371ca2bd785cf2.css",
        "/css/business/allCalls.37c37d31ea0626cedd909a38bdacf501.css"
      ]
    },
    "autoSchedule": {
      "js": [
        "/dll/vendor.dll.90c504c0d36899ef24fd43024f0ffad2.js",
        "/dll/echarts.dll.73360fa76a5ceed9cd8861ea1f1e7acc.js",
        "/dll/fe-toolkit.3eb3dea4259316aa67b70b5e1e966edf.js",
        "/lib/common-plugins.88cad0d3eeaa5c32057a0d0216f980c4.js",
        "/public.e107ea21bcd121626fb24fcc6280961e.js",
        "/pages/autoSchedule.154a78a12b54976c351ebcc1493d8454.js"
      ],
      "css": [
        "/css/common.5b78df382b56d09b67371ca2bd785cf2.css",
        "/css/business/autoSchedule.fb12e319a513cc983279c177156e5f41.css"
      ]
    }
  }
}
  • 将带md5标识的文件上传到CDN上去。
  • 对生成的manifest.json文件生成md5并改名为manifest.{md5}.json,上传到CDN相应目录的version文件夹下,作存档用;并将manifest.json文件上传到CDN相应目录下,作索引用。
  • .NET项目启动时,主动查找manifest.json索引文件,对cshtml进行渲染相应的jscss列表。
  • .NET项目对外提供一个免验证接口,用于重新获取这份索引,刷新版本。NERKO构建平台每次构建完成之后,通过此接口通知.NET更新索引文件。
  • 版本还原,通过将CDNversion目录中相应版本的(带md5标识)索引文件拷贝到manifest.json所在目录进行覆盖,然后通知.NET更新索引文件即可完成一次版本回退,大约7秒,真正做到了秒级回退版本。

后端对接

后端在服务启动时(与前端发完版本之后,后端对外提供一个免登录的刷新接口),需要从源服务器获取manifest文件解析到内存中。

页面里调用 @CDN.Js(projectName)@CDN.Css(projectName)进行资源输出。

好处立马体现出来了:js与css的引用维护已转为前端项目里的index.html负责,后端的视图已经“解耦”了

以上这些是理想中的情况,实际并非如此一帆风顺

CDN的分发时延导致.NET刷新缓存时索引文件未能及时同步

由于CDN的同步时延较大,发版完成后调用.NET项目刷新接口,此时访问的manifest.json文件不是最新的。

解决方案就是直接从源头获取索引文件。

我们知道,CDN分发都是从指定服务器获取文件,那.NET直接从源服务器上取索引文件就行了。

我们用的是AWSS3,所以直接从S3的桶中获取就好。

索引文件公共引用重复比较多

眼尖的或者有优化意识的童鞋应该看出来了,上面这个索引文件的样本,如果扩展到100个子项目,会冗余很多公共引用(特别是MD5的引入导致文件名特别长)。

解决方案就是将公共引用抽离,重新设计manifest的结构。

{
  "projects": {
    "allCalls": {
      "js": [
        "lib:vendor",
        "lib:common-plugins",
        "lib:public",
        "/pages/allCalls.5e2596707a3aa3f3dbde5951ce64f7c6.js"
      ],
      "css": [
        "lib:common-css",
        "/css/business/allCalls.d1af277947613dbc39b5cb24a4229288.css"
      ]
    },
    "autoSchedule": {
      "js": [
        "lib:vendor",
        "lib:echarts",
        "lib:fe-toolkit",
        "lib:common-plugins",
        "lib:public",
        "/pages/autoSchedule.js"
      ],
      "css": [
        "lib:common-css",
        "/css/business/autoSchedule.fdf913542f1ceb74e201a6e820fe42b1.css"
      ]
    }
  },
  "library": {
    "vendor": "/dll/vendor.dll.90c504c0d36899ef24fd43024f0ffad2.js",
    "echarts": "/dll/echarts.dll.73360fa76a5ceed9cd8861ea1f1e7acc.js",
    "fe-toolkit": "/dll/fe-toolkit.3eb3dea4259316aa67b70b5e1e966edf.js",
    "common-plugins": "/lib/common-plugins.88cad0d3eeaa5c32057a0d0216f980c4.js",
    "common-css": "/css/common.1846ca18b2f3b23b38494df63df9eed6.css",
    "public": "/public.690672434e9d4201347f2956ae5773ae.js"
  },
  "host": "http://xx.xx.xx.xx:8080/client",
  "isCdn": false
}

PS: 加入一个host属性用于.NET可以直接拼出完整的访问地址。

结语

实际上我们做的远不止这些。

除了以上这些,我们还做了构建详情界面,与gitlab的流水线详情类似,做了日志轮询,做了版本管理界面支持版本切换。