当前的前端代码部署流程
前端工程的开发流程大概如下图:
- 本地进行业务开发、打包构建配置(webpack);
- 线上构建机进行代码打包构建;
- 部署系统将新的代码部署到线上服务器;
流程比较中规中矩,和现在的大部分团队应该是同一个模式,前端代码经过重新构建打包后,文件名中的 hash 发生了变化,从而让客户端不受缓存影响,可以获取到最新的代码。
寻找痛点
前端是一种技术问题较少、工程问题较多的软件开发领域。
单从一个应用的迭代维护来看,上面的流程并没有什么不妥,但是在云音乐,除了主站应用外,还有各业务线应用和各种 H5 应用。这些全部加起来可能有上百个。
现在,我们做个简单的数学:假设一次上线从审批到构建打包再到部署上线所需要花费的最短时间为 30 分钟,那 100 个应用都上线所要花费的时间:
100 * 30 = 3000 分钟 = 50 小时 = 2.08 天
然而其中很多时候并不值得花这么多时间来上线,例如:更改配置中心的某个配置、针对 Web APM 的 SDK 的升级、修改帮助手册文案等。
如果有个办法可以进行快速上线,而不是全部走严格的上线流程,那么日积月累的节省的成本将是一个可观的收益。
新模式:直接下发代码
面对痛点,我们尝试了一种新的模式:让某个脚本拥有动态的特性,开发人员通过界面操作可以对这份脚本的内容进行更改,而从直接更新线上代码。
我们看看下面的两张图片,它们是利用下发系统接入错误上发平台 sentry 的步骤图:
- 界面上针对某个应用填写一些 sentry 平台所需要的表单,这些值用于 sentry 的 SDK 初始化;
- 平台上点击发布代码按钮直接更新代码。
在之后如果有版本、配置的更新,直接在系统上重新配置并下发即可。
该模式操作起来简单明了,最重要的是,代码更新的时间大大缩减,可以缩短至 5 分钟,甚至实时更新。
5分钟是目前我们的一个实践值,这个时间是自己决定的,具体细节后面解释。现在我们就来为下发 web 前端代码这个魔术进行揭密。
踏出勇敢的一步:脚本 API 化
浏览器在向服务端请求一个脚本的时候,浏览器拿到的只是一份文本而已,只是,这时服务端会在 response 里面告诉浏览器,这是个脚本,所以浏览器就会把返回结果当作脚本进行编译执行。
根据这一点,我们可以在 script 标签引用一个脚本的时候,直接填写 API 的 url:
1. <script src="/api/script/<This is an appId>"></script>
然后,在后端的接口路由中定义如下:
1. // 这是 koa | eggjs 的写法
2.
3. router.get('/api/script/:appid', controller.script.get);
最后,在 controller 中组织脚本内容并返回:
1. module.exports = class extends Egg.Controller {
2. async get() {
3. const { ctx } = this;
4. // 获取到应用的ID
5. const appid = ctx.params.appid;
6. // 不要缓存
7. ctx.set('cache-control', 'max-age=0');
8. ctx.code = 200;
9. // 脚本内容自由组织
10. ctx.body = 'console.log("Hello Code Puzzle");';
11. // 告诉浏览器这是个脚本
12. ctx.type = 'application/javascript';
13. }
14. }
这里有几个点需要理解:
ctx.set('cache-control', 'max-age=0'):为了浏览器每次访问都拿到最新的代码,需要告诉浏览器不要对返回结果进行缓存;
ctx.body = 'console.log("Hello Code Puzzle");':这个是你组织代码的地方,实际业务中,代码不会这么简单,后面也会简单讲解下我们的做法;
ctx.type = 'application/javascript':有了这个,浏览器才会把返回结果当作是脚本,而不是普通的字符串。
原理很简单,实现起来也不难,但是实际生产中的情况往往没这么简单,这里就有个被我们忽视的问题:稳定性问题。
上述的方法在把脚本当作一个 API 的同时,也直接将服务暴露在用户面前,在面对大流量访问或者恶意攻击的时候,你的服务就会显得弱小无助,甚至直接崩溃。
所以,我们需要一个稳定性方案。
稳定性方案
为了不让服务直面客户端,我们可以在服务前面设立一道防线,现在常见的做法有:
- API:接入 Gateway 网关;
- 静态资源:放入 CDN。
这里虽然我们将脚本 API 化,但是其本质还是返回脚本,访问是不需要登录或者权限的,所以这里采取 CDN 更合适,架构如下:
CDN 的两大特性为缓存和回源,利用回源机制,我们可以在脚本服务面前嵌入 CDN,主流程主要分为 3 大部分:
客户端访问脚本:脚本地址不再是 API,而是 CDN 域名的一个脚本地址,当访问 CDN 上的脚本时,命中缓存则直接返回给客户端,如果没有命中缓存,则触发回源;
回源机制:事先配置 CDN 回源到某个服务,我们就可以在这个服务中去组织脚本内容,和上述的 controller 代码一致;
脚本内容获取:根据用户在平台的操作,组织代码,并将脚本内容存入 MySQL 数据库中。为了 CDN 回源时提升读写速度,我们会将脚本内容备份在 Redis 中,MySQL 相当于一个 fallback。
在这套方案中,需要注意的一个点是,客户端访问的脚本地址仍然是一个不变的地址,所以返回脚本不能是永久缓存,需要设置一个较短的时间。
这就是上述所说的 5 分钟的由来,5 分钟是一个可接受的实践值。
接入了 CDN 后,我们也就直接地享受了 CDN 的好处:
- 根据缓存和回源特性,拦截住了大部分流量、缓解了源站的压力;
- 因为 CDN 服务在全国各地都有节点,可以有效解决跨地域访问问题,降低访问延时;
- 利用 CDN 强大的计算能力,可以拦截恶意攻击、降低“广播风暴”的影响。
管理组织下发的代码
界面操作 = 操作 schema
在界面上操作配置一个模块的时候,在背后相当于操作一份 schema,如下图:
在应用引入动态脚本
最终所有模块的 schema 会整合到一个 SDK 中,这个 SDK 就可以根据不同的模块数据进行特定的逻辑处理,SDK 的代码结构如下:
1. (function(modules) {
2. modules.forEach((module = {}) => {
3. const { name, vars } = module;
4. // 逻辑
5. });
6. })([
7. { name: 'sentry', vars: [ ... ] },
8. { name: 'wapm', vars: [ ... ] }
9. ])
代码中,所有的应用 SDK 的函数主体都是一样的,只有传入的 modules 数据根据用户操作而发生更改。
最后在应用中引入这个 SDK 脚本即可:
1. <script src="https://cdn.net/appid.js"></script>
不同的处理类型
在 SDK 中,我们会根据 vars 中的不同处理类型进行不同的操作,例如:插入脚本、插入 HTML、定义变量等,可以根据不同的业务需求进行定制。
插入脚本:
schema 为:
1. {
2. "fn": "script",
3. "url": "https://music.163.com/hello.js",
4. "async": true,
5. "onload": "console.log('script onloaded')"
6. }
SDK 对应的处理函数为:
1. function execScript(params) {
2. const oElem = document.createElement('script');
3. oElem.src = params.url;
4. oElem.async = params.async;
5. document.body.appendChild(oElem);
6.
7. if (param.onload) {
8. oElem.onload = function() {
9. const oScript = document.createElement('script');
10. oScript.innerHTML = params.onload;
11. document.body.appendChild(oScript);
12. }
13. }
14. }
当然,其中还省略了 onload 函数中的一些用户填写的变量的替换,这样就可以在启动脚本中初始化一些公共技术模块的 SDK。
定义变量:
schema 为:
1. {
2. "fn": "var",
3. "name": "key",
4. "value": "2bc5b9f43eaf407fa80f4309082a44eb"
5. }
SDK 对应的处理函数为:
1. function execVar(params) {
2. variables[params.name] = params.value;
3. }
插入 HTML
1. {
2. "fn": "html",
3. "selector": "body",
4. "content": "<div>container</div>"
5. }
SDK 对应的处理函数为:
1. function execHtml(params) {
2. const container = document.querySelector(params.selector) || document.body;
3. const oElem = document.createElement('div');
4. oElem.innerHTML = params.content;
5. container.appendChild(oElem);
6. }
通过对不同的行为进行抽象,我们就可以根据一份 schema 来完成各式各样的操作,从而达到我们的目标。
系统优化
到这里,我们已经做到了代码的动态下发,但是针对当前的设计,我们仍有优化空间。
层级配置
现在我们已经可以不用每次更改都走发布上线流程了,但是当我们的通用模块发生更改,例如 sentry 的域名发生变更,那么对于每个应用我们都需要在下发系统中进行配置操作,100 个应用就有100 次操作,而这些操作都是一模一样的,是冗余的。
针对这种情况,我们增加了层级配置的能力:
在系统中,增加了项目这个概念,可以把它当作是文件夹,项目下有着许多的应用,当我们需要配置公共配置的时候,只需要在项目级别进行操作,那么系统就会自动将配置合并到应用中并完成代码下发操作,合并配置时可以想成是:
Object.assign({}, projectConfig, appConfig);
连接部署系统,整合公技模块
即使有了这套下发体系,但是我们在使用不同的公技平台的时候仍然存在一些不方便,在云音乐公共技术平台主要有:
- sentry:错误上发平台
- wapm:前端监控平台
- 鹰眼:代码检测平台
- 前端的部署系统
- 下发系统
每种公技平台都是相互独立的,所以在使用上也是有着不同的方法,这也就导致了开发者在创建一个新应用的时候会出现:
- 在部署系统创建一个应用
- 在 sentry 平台创建一个应用,获取密钥
- 在 wamp 平台创建一个应用,获取密钥
- 在下发系统创建一个应用并配置 sentry、wapm 等模块
对于这种繁琐、重复的操作我们经过抱持着零容忍的态度,而在云音乐,部署系统可以说是一切应用的集合点,所以我们可以利用它来做一些事情:
- 在用户创建应用的时候自动在各个公技平台创建相同的应用;
- 部署系统将各公技平台的初始化配置发送给下发系统;
- 部署系统自动注入动态脚本。