看到这个标题,如果你做过
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之所以构成麻烦,是因为子项目太多,构建效率上不去,实际上也不需要全量构建。
我研究了一下gitlab
的api
文档,了解了一下trigger
的用法,知道通过这个api
可以触发gitlab
的pipeline
,最重要是它可以携带参数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
请求触发gitlab
的pipeline
。
- 最后将打包结果上传到
CDN
。
但是这样虽然解决了第一个问题,第二个问题仍然存在,前后端不分离一直都是最大的难点。
然后,解决前后端未分离的版本控制
上一点中,构建结果有了,上传CDN
也有了,但是怎么告诉.NET
项目到哪里取哪个版本哪些文件呢?
很多互联网公司都是使用index.html
文件充当索引,将当前版本指针指向这个文件,利用这个文件中生成的script
引用去渲染页面。
我们这里可以借鉴这个思路,但是要更复杂一些,因为我们的实际视图是.NET
渲染的cshtml
文件,而不是webpack
产出的index.html
!
解决方案如下:
- 构建完成之后,会生成
projectA.html
、projectB.html
……等。 - 扫描这些文件,挨个读取,使用正则去匹配
script
与link
标签,得到一份js
引用列表与css
引用列表。 - 遍历这些
js
与css
,针对相对路径读取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
进行渲染相应的js
与css
列表。.NET
项目对外提供一个免验证接口,用于重新获取这份索引,刷新版本。NERKO
构建平台每次构建完成之后,通过此接口通知.NET
更新索引文件。- 版本还原,通过将
CDN
上version
目录中相应版本的(带md5
标识)索引文件拷贝到manifest.json
所在目录进行覆盖,然后通知.NET
更新索引文件即可完成一次版本回退,大约7秒,真正做到了秒级回退版本。
后端对接
后端在服务启动时(与前端发完版本之后,后端对外提供一个免登录的刷新接口),需要从源服务器获取manifest
文件解析到内存中。
页面里调用
@CDN.Js(projectName)
与@CDN.Css(projectName)
进行资源输出。
好处立马体现出来了:js与css的引用维护已转为前端项目里的index.html
负责,后端的视图已经“解耦”了
以上这些是理想中的情况,实际并非如此一帆风顺
CDN的分发时延导致.NET
刷新缓存时索引文件未能及时同步
由于CDN的同步时延较大,发版完成后调用.NET
项目刷新接口,此时访问的manifest.json
文件不是最新的。
解决方案就是直接从源头获取索引文件。
我们知道,CDN分发都是从指定服务器获取文件,那.NET
直接从源服务器上取索引文件就行了。
我们用的是AWS
的S3
,所以直接从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
的流水线详情类似,做了日志轮询,做了版本管理界面支持版本切换。