前端在粗放开发模式下的痛点
前端业务在近几年迎来一个很好的发展,但关于前端的基础设施并没有跟上前端业务的迅速扩展。业务扩张之后,我们不能再像小作坊一样进行粗放的开发:开发前如何快速规范的初始化项目?开发中如何保证多人高效的合作开发?开发完成后如何保证正确快速的上线?上线后如何管理诸多业务稳定的运行?围绕这些问题,笔者列举一些相关的基础设施:完整的构建打包流程/服务(统一的脚手架、上线服务等)、完整的测试环境、前端错误日志管理系统(收集、统计、报警)、前端资源离线化管理、前端资源增量下载服务以及针对Node应用的日志(完整调用链)、性能和错误监控平台等等。
其中,针对前端业务在上线前,我们一直有这样的一个痛点:基于现有项目在继续开发时,本地开发完成后,需要启动本地服务,预览给PM查看检查,但有时PM或者团队其他人员想看下效果,而自己又不方便操作电脑,就总是需要协调时间。如果自己开发完成后,可以直接上线到一个测试环境,将链接丢到群里,会非常方便别人随时预览效果。
所以,本文的目标是: 针对前后端分离的前端项目,git push 之后,能够直接推到测试环境,可以在线预览效果。
gitlab CI 简介
从v 7.1.2 之后,gitlab支持通过配置.gitlab-ci.yaml文件支持CI/CD。
具体配置可参考文档: https://docs.gitlab.com.cn/ee/ci/yaml/
为了使项目能够执行yaml文件中配置的task,还需要我们首先部署安装相应的环境: install runner && register runner, 参考:https://docs.gitlab.com/runner/#using-gitlab-runner。runner的执行方式有很多种, 目前最流行的就是作为一个docker容器,其内部集成了gitlab的一些基础环境, 注册阶段就是将其与gitlab主任务做关联(runner通常不跟gitlab服务器部署在同一台服务器),而yaml中配置的任务,就是在runner中具体执行, 然后将结果发送回gitlab服务器。
最后项目需要在setttings中开启enable shared runner或者specific runner.
基于Node搭建前端业务的预览服务
使用Node搭建服务,托管静态资源,以及代理请求的转发。
基本流程
- git push
- runner中执行yaml中的task
- 资源构建
针对测试环境打包:npm run build -e test
- 上传资源到node 服务器。
将该服务抽离为npm 包, 执行festaging-scripts
命令,上传的资源有两类:- 构建出的静态资源
- 必要的请求代理配置(默认读取根目录下的.festaging.config.js, 下文会解释为何需要这个)
- 资源构建
基本流程比较好理解,但囿于公司现有基础设施的限制,一些问题变得复杂一些:
node服务部署是基于公司现有的容器管理方案,支持动态扩容或者销毁。node服务集群没有固定的IP,需要首先获取所有实例ip地址,然后上传静态资源;
-
公司load balance服务是统一管理,nginx 配置不支持泛域名解析。所以针对不同项目,不能共用二级域名,如(aa.xxx.com, bb.xxx.com),只能共用一个域名,如
festaging.xxx.com
。但是为了区分不同的项目,我们需要增加路径信息, 如festaging.xxx.com/aa/branch1/
,这样会带来两个问题:- 接口代理增加难度: 倘若支持泛域名解析,针对每一个项目的请求,就可以根据域名中的信息进行相应的代理(每个项目会配置其后端接口访问地址的实际域名):
aa.xxx.com/api/
->aa.config.origin/api/
;但现在每个项目请求的接口地址仍然会是/api/**,node端如何区分是哪个项目发出的请求,进而对其进行正确转发? - 多路由项目的支持:node中 配置好静态资源的路径之后,浏览器输入
festaging.xxx.com/aa/branch1/
能够访问到该项目的主页,但是点击按钮,切换路由之后,网址就会变为festaging.xxx.com/tab2
等形式,并且在浏览器中只能通过festaging.xxx.com/tab2访问到该路径,而不是festaging.xxx.com/aa/branch1/tab2
, 不能保证同一个项目在url上的统一。
- 接口代理增加难度: 倘若支持泛域名解析,针对每一个项目的请求,就可以根据域名中的信息进行相应的代理(每个项目会配置其后端接口访问地址的实际域名):
静态资源的上传
上面说到,需要首先获取部署了node服务的所有实例地址,然后进行上传, 如何上传呢?
- scp: 这可能是机器间最普遍的传输方式了, 但首次连接需要ssh 认证,需要明文写密码到脚本中,而部署了node服务的的容器连接密码我们并不知道。
- 基于http的网络服务传输:操作简单,但需要node服务提供上传接口
使用后者作为解决方案:
- 在runner中执行的script负责: 构建-> zip -> post到node服务(这个功能抽离为npm包, yaml配置文件的script中只要执行该npm对应的命令)
- node服务提供接口: 接受post的zip包, 解压, 移动到指定位置。
PS: koa 的async, await与操作文件时的stream配合总觉得有点tricky: 需要将stream的操作形式转为promise, 如:
function pipe(from, to, options) {
return new Promise((resolve, reject) => {
from.pipe(to, options)
from.on('error', reject)
from.on('end', resolve)
})
}
async function processZipFiles(input, output) {
const reader = fs.createReadStream(input);
const upStream = fs.createWriteStream(output);
await pipe(input, output);
}
接口代理的处理
每个项目都需要指定其真实后端的请求域名,这样才能够对项目中的请求进行转发。初次之外,还需要支持将某些接口代理到其他指定地址,如webpack dev server所支持的那样。
所以我们支持两种方式,
- 可以指定proxy target为一个json文件,其内容格式为
{ proxyApi: { '/api/xx': 'https://www.baidu.com', 'default': 'https://v.qq.com' } }
使用这种方式,还可以继续支持以后添加除了proxyApi的配置,为以后业务的扩展提供了余量。
- 执行script 时, 配置参数
--target "https://www.baidu.com''
如何在接口请求中注入项目的相关信息?
因为所有项目公用一个域名,紧靠路径来区分不同项目,但是接口请求时却都是域名+接口进行拼接,所以我们需要针对不同项目,在其接口中添加关于项目信息的前缀:针对测试环境,在打包时,将其请求接口地址由/api/xxx
改为/project1/branch1/api/xxx
。但是实际修改文件中的每个地址是不现实的,我们无法准确识别哪些地方是需要添加前缀的。而前端进行网络请求的方式就两种XMLHttpRequest和fetch, 所有我们只要在html文件最前面对其方法进行改写即可。
function buildUrl(prefix) {}
var originXHROpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
return originXHROpen.call(this, method, buildUrl(url, '${prefix}'), async, user, password);
};
if (window.fetch) {
var originFetch = window.fetch;
window.fetch = function () {
var input = arguments[0];
if (typeof input === 'string') {
arguments[0] = buildUrl(input, '${prefix}');
}
return originFetch.apply(this, arguments);
};
}
该配置如何发送到node服务?
- 作为文件发送,node端也从该文件夹下的所有文件读取转发配置:当该文件夹下内容变化时,重启node服务。
- 作为参数发送,node 端收到请求后,修改内存中的变量: node端维护一个proxy config对象,收到请求后修改其内容,无需node服务重启, config 格式如下:
{ project1: { `branch1`: { proxyAPI: { '/api/xx': 'https://www.baidu.com', 'default': 'https://v.qq.com' } } } }
后者显然为更优方案。
静态资源、proxy config实例化
上文提到静态资源是直接发送到每台实例,proxy config也是发送到每个node实例,然后直接修改内存中的config。倘若node服务重启,docker容器新建,这些东西不就全部丢失了吗?所以需要对其进行静态化存储,当node重启服务时,从此读取初始值。
- 静态资源的实例化: 当做文件, 压缩成
${project}_${branch}.zip
存储到公司统一的文件存储服务上。 - proxy config的实例化: 因为config其实就是个对象,所以将其存到mongodb中。
多路由业务的支持
因为团队现在统一使用react技术栈,所以对于多路由的支持就围绕react-router-dom
进行。通常会使用的路由组件是BrowserRouter
或者StaticRouter
, 而其
basename参数可以用来对url地址添加前缀,这跟上文中我们需要的项目相关信息完全符合,所以我们可以通过修改其basename参数实现对多路由的支持。
- fork一个react-router-dom仓库, 对其basename进行修改,然后针对测试环境构建时,添加alias,将react-router-dom resolve为我们修改后的仓库: 优点是修改足够简单,缺点也很明显:我们需要同步更新fork的仓库,以及可能对低版本支持不足。
- 在webpack构建之后,添加插件, 解析ast,检查如果使用了
BrowserRouter
或StaticRouter
,然后修改basename的值,返回新的code。
选用方案2。 基于webpack4 提供的parser api 来解析被webpack处理过的每个module, 类似 useStrictPlugin.js实现, 只是在得到ast后再利用babel的‘traverse‘和'generate'包生成修改了basename的方法。
【参考】
gitlab CI官方介绍
当谈到 GitLab CI 的时候,我们该聊些什么(上篇)