注册
web

一种纯前端的H5灰度方案

什么是灰度发布


在互联网领域,灰度发布是产品质量保障的重要一环,它可以让某次更新的产品,以一种平滑,逐步扩大的方式呈现给用户,在此过程中,产品和技术团队可以对功能进行验证,收集用户反馈,不断优化,从而减少线上问题的影响范围,完善产品功能。


在前端领域,APP和小程序天生就具有灰度的能力,一般基于发布平台来控制。但 H5 却缺少这种天生能力,而且 H5 一旦发布就会影响所有用户,更加需要一套灰度系统,来保证产品的稳定性。


灰度发布的本质


既然要让部分用户先使用新功能,就需要做好两件事情,这也是灰度的本质:



  1. 版本控制 同一个项目需要在线上同时发布至少两套页面,一套针对全量用户,一套针对灰度用户
  2. 分流控制 需要有一套规则,把用户按某种特征划分为不同的群体,可以是用户ID,门店、城市,也可以是年龄,亦或是随机。命中的用户访问灰度页面,未命中的访问全量页面。

Pasted image 20240803093018.png


那么想要实现灰度发布有哪些方案呢?


可选的灰度方案


Nginx+lua+redis


通过使用 Nginx 的反向代理特性,我们可以根据请求的特定属性(例如ip、请求头、cookie)等有选择性的将请求路由到全量或灰度版本。


同时在 Nginx 中嵌入 Lua 脚本,负责根据预定义的灰度发布策略处理请求,Lua 脚本可以从 Redis 中获取灰度配置。从而确定哪些用户可以访问新版本,那些用户应该可以访问旧版本。


Redis 用于存储灰度发布的配置数据。


通过这种方式可以实现基于 Ngnix 的灰度发布,但这种方式并不适合我们,为什么呢?


因为我们的C端H5页面连同HTML文件都是直接投放在 CDN 上,这就意味着我们没有中转服务层,无法使用第一套 Nginx 的方案,而且使用 Nginx 也会响应降低页面加载速度,虽然可能很轻微,但却是对所有用户都会有影响。


采用 Nginx 进行中转:


Pasted image 20240803102717.png


不采用 Nginx 中转:


Pasted image 20240803102758.png


如上两张图,可以很明显的看到,如果采用 Nginx 来作为中转并进行分流控制,将导致我们的 CDN 优势失效,所有的流量都可能回到上海的机房,再流转到上海的 CDN,这显然不是我们想看到的。


这也是我们放弃 Nginx+lua+redis 方案的原因。


基于 SSR 做灰度


如果我们的前端页面是通过服务端来进行渲染,可以把灰度控制继承在服务端渲染中,基于不同的用户放回不同的HTML,这样也就可以做到灰度发布。


Pasted image 20240803093946.png


不过这需要有一套完善的 SSR系统,对于访问量大的产品,维持系统稳定性的难度远大于实现 SSR 本身的技术难度。由于我们是前后端分离,并且没有基于 Node 高可用的运维团队和经验,所以这个方案也就放弃了。


APP拦截灰度


基于APP的方案,是在用户点击H5资源位,创建webview时,拉取灰度配置,如果当前页面有灰度,则拉取灰度配置,判断是否命中灰度,如果命中,替换H5链接即可。


Pasted image 20240803100556.png


看过我其他文章的朋友,应该有了解到我们针对H5秒开有一套配置下发到APP,那么灰度配置,也可以集成到原有配置中,一并下发给APP,这套方案相对而言也比较简单,但是却有如下问题。



  1. 只能支持APP,APP外和小程序内打开的场景无法支持
  2. 依赖APP,公司其他业务线的APP,如果要使用也需要开发,工作量较大。

所以最后该方案也被排除。


纯前端方案


方案概览


基于如上的一些原因,于是我们采用了一套纯前端的方案,来解决灰度发布问题,虽然这套方案也有一点缺点。前面我们提到灰度发布的本质,其实包含两个方面,一是版本控制,二是分流控制。


版本控制比较好做,我们把全量的HTML代码发布到 index.html 文件,把灰度的HTML代码发布到 gray.html 文件,这样就做到了版本控制。


分流控制,可以被拆分为两部分,一部分只管获取配置、判定是否命中灰度并入在本地,另一部只管读取结果并执行跳转,这样整个系统就解耦了。


方案大体思路是:



  1. 在用户首次方式时,静默激活灰度计算逻辑,通过接口或其他条件判断用户是否命中灰度,把结果存储在 localStorage 中。
  2. 有别于全量版本时使用 index.html,灰度时构建并修改html名称为 gray.html,并发布
  3. 当要灰度发布时,下载 index.html ,注入灰度判断代码到 head 中,注入 GRAY_SWITCH 开关并开启
  4. 当用户再次访问时,执行灰度判断代码,如果命中,重定向到 gray.html 页面
  5. 对获取页面点击的地方,进行封装或拦截,确保灰度用户分享出去的链接,是全量链接

流程图:


Pasted image 20240803111309.png


时序图如下:


Pasted image 20240803113658.png


灰度版本控制


对于版本控制,我们通过提供了一个 webpack 插件集成到构建流程中,在构建时生成不同文件名的 html 文件。


通过构建命令参数,来区分各种发布情况


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。


重定向控制代码如下:


// 标记是否打开灰度  
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(nulldocument.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代码是在用户每次打开后,静默运行,所以需要引入到业务代码中。


引入的代码如下:


import grayManager from '@cherry/grayManager'  
import { getMemberId } from '../utils/index'

// 伪代码,说明GrayOptions 的类型
interface GrayOptions {
    // 灰度比例控制 支持固定值和数组阶梯灰度,配置grayScale 后,grayComputeFn无效
    grayScalenumber | [number]
    // 自定义灰度方法,在内可以请求接口等
    grayCompute() => (() => Promise<boolean>) | boolean
    // 获取维护标识,比如以 shopId 为灰度标识,该函数就返回当前用户的 shopId
    getGaryData() => ()=> Promise<string>,
    // 配置灰度白名单,白名单内的用户都会命中灰度
    whiteDatastring[]
}

// 初始化灰度计算逻辑
grayManagerInit({
    grayScale10,
    whiteData: ['123''456']
})

前端计算分流


随机百分比

多数项目,我们一般使用的策略是随机,比如设置10%的用户命中灰度。


我们可以通过生成随机数来判断是否命中灰度,具体步骤如下:



  1. 在 grayManager.init() 时,随机生成一个 uuid,存在用户本地,不做清除,下次 init 时,先从本地取 uuid,存储 key 命名为 __GRAY_UUID__
  2. 当使用预置灰度计算能力时,取 __GRAY_UUID__ 每位转化为 asci 码并相加,除以100 求余数
  3. 用余数+1 和灰度比例(grayScale)对比,当余数 +1 <= grayScale 时命中灰度

这样可以得到一个近似 10% 比例的灰度用户数。


基于门店和城市分流

如果想基于门店或城市分流,我们只需要配置两个参数, 一是如何获取门店和城市ID

另一个是需要灰度的门店和城市ID


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,亦或是不同的容器,都无依赖,各个业务线都可以平滑使用。


作者:思考的Joey
来源:juejin.cn/post/7438840414239326227

0 个评论

要回复文章请先登录注册