什么是灰度发布
在互联网领域,灰度发布是产品质量保障的重要一环,它可以让某次更新的产品,以一种平滑,逐步扩大的方式呈现给用户,在此过程中,产品和技术团队可以对功能进行验证,收集用户反馈,不断优化,从而减少线上问题的影响范围,完善产品功能。
在前端领域,APP和小程序天生就具有灰度的能力,一般基于发布平台来控制。但 H5 却缺少这种天生能力,而且 H5
一旦发布就会影响所有用户,更加需要一套灰度系统,来保证产品的稳定性。
灰度发布的本质
既然要让部分用户先使用新功能,就需要做好两件事情,这也是灰度的本质:
- 版本控制 同一个项目需要在线上同时发布至少两套页面,一套针对全量用户,一套针对灰度用户
- 分流控制 需要有一套规则,把用户按某种特征划分为不同的群体,可以是用户ID,门店、城市,也可以是年龄,亦或是随机。命中的用户访问灰度页面,未命中的访问全量页面。
那么想要实现灰度发布有哪些方案呢?
可选的灰度方案
Nginx+lua+redis
通过使用 Nginx
的反向代理特性,我们可以根据请求的特定属性(例如ip、请求头、cookie)等有选择性的将请求路由到全量或灰度版本。
同时在 Nginx
中嵌入 Lua
脚本,负责根据预定义的灰度发布策略处理请求,Lua
脚本可以从 Redis
中获取灰度配置。从而确定哪些用户可以访问新版本,那些用户应该可以访问旧版本。
Redis
用于存储灰度发布的配置数据。
通过这种方式可以实现基于 Ngnix
的灰度发布,但这种方式并不适合我们,为什么呢?
因为我们的C端H5页面连同HTML文件都是直接投放在 CDN
上,这就意味着我们没有中转服务层,无法使用第一套 Nginx
的方案,而且使用 Nginx
也会响应降低页面加载速度,虽然可能很轻微,但却是对所有用户都会有影响。
采用 Nginx
进行中转:
不采用 Nginx
中转:
如上两张图,可以很明显的看到,如果采用 Nginx
来作为中转并进行分流控制,将导致我们的 CDN
优势失效,所有的流量都可能回到上海的机房,再流转到上海的 CDN,这显然不是我们想看到的。
这也是我们放弃 Nginx+lua+redis
方案的原因。
基于 SSR 做灰度
如果我们的前端页面是通过服务端来进行渲染,可以把灰度控制继承在服务端渲染中,基于不同的用户放回不同的HTML,这样也就可以做到灰度发布。
不过这需要有一套完善的 SSR
系统,对于访问量大的产品,维持系统稳定性的难度远大于实现 SSR
本身的技术难度。由于我们是前后端分离,并且没有基于 Node
高可用的运维团队和经验,所以这个方案也就放弃了。
APP拦截灰度
基于APP的方案,是在用户点击H5资源位,创建webview时,拉取灰度配置,如果当前页面有灰度,则拉取灰度配置,判断是否命中灰度,如果命中,替换H5链接即可。
看过我其他文章的朋友,应该有了解到我们针对H5秒开有一套配置下发到APP,那么灰度配置,也可以集成到原有配置中,一并下发给APP,这套方案相对而言也比较简单,但是却有如下问题。
- 只能支持APP,APP外和小程序内打开的场景无法支持
- 依赖APP,公司其他业务线的APP,如果要使用也需要开发,工作量较大。
所以最后该方案也被排除。
纯前端方案
方案概览
基于如上的一些原因,于是我们采用了一套纯前端的方案,来解决灰度发布问题,虽然这套方案也有一点缺点。前面我们提到灰度发布的本质,其实包含两个方面,一是版本控制,二是分流控制。
版本控制比较好做,我们把全量的HTML代码发布到 index.html
文件,把灰度的HTML代码发布到 gray.html
文件,这样就做到了版本控制。
分流控制,可以被拆分为两部分,一部分只管获取配置、判定是否命中灰度并入在本地,另一部只管读取结果并执行跳转,这样整个系统就解耦了。
方案大体思路是:
- 在用户首次方式时,静默激活灰度计算逻辑,通过接口或其他条件判断用户是否命中灰度,把结果存储在
localStorage
中。 - 有别于全量版本时使用
index.html
,灰度时构建并修改html名称为gray.html
,并发布 - 当要灰度发布时,下载
index.html
,注入灰度判断代码到 head 中,注入GRAY_SWITCH
开关并开启 - 当用户再次访问时,执行灰度判断代码,如果命中,重定向到 gray.html 页面
- 对获取页面点击的地方,进行封装或拦截,确保灰度用户分享出去的链接,是全量链接
流程图:
时序图如下:
灰度版本控制
对于版本控制,我们通过提供了一个 webpack
插件集成到构建流程中,在构建时生成不同文件名的 html 文件。
通过构建命令参数,来区分各种发布情况
bash
npm run build your_project_name -- --gray=open
# --gray 的值
# --gray=close 不打开灰度,默认值
# --gray=open 打开灰度
# --gray=full 灰度全量
# --gray=unpublish 撤销灰度
可以分为如下情况:
正式发布
构建时生成:
- index.html 全量页面
- index_backup.html 全量备份页面(用来做回归)
灰度发布
构建时生成:
- gray.html 灰度页面
- gray_backup.html 灰度备份页(用来在全量后替换 index_backup.html)
同时下载 index.html ,注入灰度重定向控制JS。
重定向控制代码如下:
js
// 标记是否打开灰度
window.__GRAY_SWITCH__ = 1
let graySwitchName = 'gray_switch_';
// 获取去除html后的pathname
const pathname = window.location.pathname.split('/').slice(0, -1).join('/');
graySwitchName = graySwitchName + pathname
const graySwitch = localStorage.getItem(graySwitchName)
if (graySwitch === '1') {
const grayUrl = window.location.href.replace('index.html', '_gray.html')
if(window.history.replaceState){
// 安卓 app 使用 location.replace 无效
window.history.replaceState(null, document.title, grayUrl);
}else{
window.location.replace(grayUrl);
}
}
修改输出的 HTML
文件名,是通过编写 webpack
的自定义插件来完成。
原理是通过 compiler.hooks.afterEmit.tapAsync
钩子函数,再 "输出" 阶段,对文件名进行修改。
撤销灰度
从云端下载 index_backup.html
重命名为 index.html
放在打包目录,之后再由发布系统上传。
全量发布
从云端下载 gray.html
和 gray_backup.html
,重命名为 index.html
和 index_backup.html
,发布后就会替换原有的全量HTML。
灰度分流控制
分流的重点是如何判断哪些用户能命中灰度。每个项目划分人员的策略都可能不同,比如C端页面更倾向于按useID随机划分。而B端拣货、配送等业务线,更需要按门店来进行划分,这样可以做到同门店员工体验一致,便于管理。所以这块这块必须要足够的灵活性。
我们这里采取了两种方式:
第一种是基于接口来做分流控制:把用户信息传给服务端,接口通过配置的灰度规则,计算是否命中,并返回前端。前端只管把结果存入本地。
第二种是把计算逻辑都放在前端,比较适合C端项目,因为C端项目大部分场景都是随机划分灰度用户。
灰度分流计算的JS代码是在用户每次打开后,静默运行,所以需要引入到业务代码中。
引入的代码如下:
typescript
import grayManager from '@cherry/grayManager'
import { getMemberId } from '../utils/index'
// 伪代码,说明GrayOptions 的类型
interface GrayOptions {
// 灰度比例控制 支持固定值和数组阶梯灰度,配置grayScale 后,grayComputeFn无效
grayScale: number | [number]
// 自定义灰度方法,在内可以请求接口等
grayCompute: () => (() => Promise<boolean>) | boolean
// 获取维护标识,比如以 shopId 为灰度标识,该函数就返回当前用户的 shopId
getGaryData: () => ()=> Promise<string>,
// 配置灰度白名单,白名单内的用户都会命中灰度
whiteData: string[]
}
// 初始化灰度计算逻辑
grayManagerInit({
grayScale: 10,
whiteData: ['123', '456']
})
前端计算分流
随机百分比
多数项目,我们一般使用的策略是随机,比如设置10%的用户命中灰度。
我们可以通过生成随机数来判断是否命中灰度,具体步骤如下:
- 在
grayManager.init()
时,随机生成一个uuid
,存在用户本地,不做清除,下次 init 时,先从本地取uuid
,存储 key 命名为__GRAY_UUID__
。 - 当使用预置灰度计算能力时,取
__GRAY_UUID__
每位转化为 asci 码并相加,除以100 求余数 - 用余数+1 和灰度比例(
grayScale
)对比,当余数+1 <= grayScale
时命中灰度
这样可以得到一个近似 10% 比例的灰度用户数。
基于门店和城市分流
如果想基于门店或城市分流,我们只需要配置两个参数, 一是如何获取门店和城市ID
另一个是需要灰度的门店和城市ID
ts
import grayManager from '@cherry/grayManager'
import { getShopId } from '../utils/index'
grayManagerInit({
getGaryData: () => {
return await getCityId()
},
whiteData: ['123', '456']
})
可以通过 grayScale 配置数组来实现,起始时间为打灰度包构建的时间,我们会把构建时间注入到 HTML
中。
其他注意项
开头讲过,这套方案有一点缺点。可能大家也会发现,灰度时用户需要先进入打 HTML
,执行 head
中注入的重定向控制JS,对命中灰度的用户再次跳转到 gray.html
。
这样其实带来了两个问题:一是对灰度用户来说经过了两个HTML,白屏的时间会更长。二是灰度用户访问的URL变化了,如果此时用户把页面分享出去,被分享用户将直接打开灰度页面。
对于第一条,全量用户是不会被影响,只有灰度用户才会白屏更久,我们目前测试白屏的时长还能接受。
对于第二条,我们最初是系统通过 Object.defineProperty
来拦截 对 window.location.pathname
的获取,返回 index.html
。但window.location.pathname
是一个只读属性不可拦截。
最后只能提供统一的方法,来获取 pathname
。
结语
以上就是我们的灰度核心方案,整个方案会比较简单,几乎不依赖外部部门。无论是对于H5还是pcWeb,亦或是不同的容器,都无依赖,各个业务线都可以平滑使用。