React微前端实战教程(A/B测试与灰度发布篇)
在SaaS或其它互联网软件中,新版本即使在开发/测试环境验证OK,也不敢保证上到生产环境就万无一失、用户是否喜欢,要可靠、稳定上线,最好要经过A/B测试与灰度发布。
新版本部署到生产环境之后,先不让普通用户使用,而是让测试人员或自动化到生产环境验证OK,再逐步放开部分用户使用新版本。如果发现问题,影响范围比较小,同时,可立即停用新版本与回滚。
在微前端中,整个软件由很多小块微前端动态拼装出来的,版本的多样化更加明显,也不能使用负载均衡控制(会造成长期缓存失效);如果写路由控制,微前端的个数足够多时就要写到手抖,经常的变更也更加容易出现问题。
在react-micro-frontend-server-go中,我们使用“用户分组隔离”与“激活百分比”来控制每个APP版本的用户服务范围,满足A/B测试与灰度发布的需求。在每个APP版本的元信息中,使用这两个字段(extra.userGroup、extra.activationPercent)标记其服务的范围。本文也主要讲解这两点的设计与体验。
1. 用户分组隔离
在App的每个版本中,标记其服务哪些用户组,如: App.Extra.UserGroup = [A,B,C]。假设用户所在的分组有 UserGroup = [C,D,F],可以发现App.Extra.UserGroup与UserGroup有交集,说明当前APP版本对该用户可见。
1.1. 给用户添加分组
用户分组,我们使用Session来保存用户的分组信息。使用go-session
来构建一个Gin中间件,代码如下:
// session.go
// NewSessionMiddleware create a session middleware
func NewSessionMiddleware(opt ...session.Option) gin.HandlerFunc {
manager := session.NewManager(opt...)
return func(ctx *gin.Context) {
ctx.Set(sessionManagerKey, manager)
store, err := manager.Start(context.Background(), ctx.Writer, ctx.Request)
if err != nil {
// reset cookie and restart session (such as in case: err == session.ErrInvalidSessionID)
ctx.Request.Header.Del("Cookie")
store, err = manager.Start(context.Background(), ctx.Writer, ctx.Request)
}
if err != nil {
log.Printf("[ERROR] Session start: %+v\n", err)
}
if store != nil {
ctx.Set(sessionStoreKey, store)
}
ctx.Next()
}
}
func getUserGroups(c *gin.Context) []string {
store := sessionStoreFromContext(c)
if store == nil {
return []string{defaultUserGroup}
}
userGroup, ok := store.Get(userGroupKey)
if ok {
return userGroup.([]string)
}
return []string{defaultUserGroup}
}
func setUserGroups(c *gin.Context, groups []string) error {
store := sessionStoreFromContext(c)
if store == nil {
return fmt.Errorf("No session for the user")
}
store.Set(userGroupKey, groups)
return store.Save()
}
在Server-go中,HTTP API中我们只允许用户加入或离开tester
组,按照“最小权限原则”满足Demo网站的需求。在用户加入或离开tester
组时,相关的Session被保存起来。
需要特别注意的是:如果配置文件中的sessionSign字段值发生变化,go-session解析以前的Cookies将失败,我们把请求头中的Cookies删除,再创建新的SessionID。
1.2. 过滤保留用户相关的APP版本
主要代码在求两个数组的交集,有交集是保留当前APP版本。同时,也返回默认用户组的APP版本,在用户无匹配版本可用时,还可选默认用户组的版本。
stringSliceContainsAny()在用户组比较少的情况下,性能可以得到保证。如果有大量的用户组情况,用户组需使用整型ID表示,在加载元信息时,就排序成Ordered Set,此处只需求两个Ordered Set是否有交集。
filterUserManifests()函数中,我们同时把激活百分比为0的版本去除。
// manifest_cache.go
func stringSliceContainsAny(a, b []string) bool {
for i := 0; i < len(a); i++ {
for j := 0; j < len(b); j++ {
if a[i] == b[j] {
return true
}
}
}
return false
}
func filterUserManifests(manifests AppVersionMap, userGroups []string) (
matches []AppFilterItem, defaults []AppFilterItem) {
matches = []AppFilterItem{}
defaults = []AppFilterItem{}
defaultGroups := []string{defaultUserGroup}
for _, manifest := range manifests {
groupsInExtra := defaultGroups
if value, ok := manifest.Extra[userGroupKey]; ok {
groupsInExtra = strings.Split(value, userGroupsSplitSep)
}
activationPercent := calcActivationPercent(manifest)
if activationPercent < 1 {
continue
}
item := AppFilterItem{App: manifest, ActivationPercent: activationPercent}
if stringSliceContainsAny(groupsInExtra, userGroups) {
matches = append(matches, item)
} else if stringSliceContainsAny(groupsInExtra, defaultGroups) {
defaults = append(defaults, item)
}
}
return matches, defaults
}
2. 激活百分比
激活百分比主要使用到阶梯随机算法,入参是已匹配用户组的APP版本集合。从代码实现上可以看出,一个APP的激活百分比之和不需要为100。
// manifest_cache.go
func selectAppByActivationPercent(r *rand.Rand, manifests []AppFilterItem) int {
selIdx := 0
mLen := len(manifests)
if mLen > 1 {
sum := 0
steps := make([]int, mLen)
for i := 0; i < mLen; i++ {
sum += manifests[i].ActivationPercent
steps[i] = sum
}
selSum := r.Intn(sum)
for i := 0; i < mLen; i++ {
if selSum < steps[i] {
return i
}
}
}
return selIdx
}
调用流程,见GenerateMetadata()函数片段:
// filter app versions for the user
matches, defaults := filterUserManifests(value.(AppVersionMap), param.UserGroups)
manifests := defaults
if len(matches) > 0 {
manifests = matches
}
// guard for defaults when it is empty
if len(manifests) == 0 {
return true
}
selIdx := selectAppByActivationPercent(r, manifests)
app := manifests[selIdx].App.ConvertToMetadataApp()
3. 更新接口
为了支持A/B测试与灰度发布,我们添加更新接口/api/metadata/update-app-extra
。有了该接口,就能动态控制每个版本的用户范围、激活百分比。同时,该接口为批量接口,管理员可以同时调整多个APP版本。主要函数见 UpdateAppExtra()、UpdateOneAppExtra():
// manifest_cache.go
// UpdateAppExtra Update multi deployed Apps' Extra
func (cache *AppManifestCache) UpdateAppExtra(params []AppUpdateExtraParam) bool {
// group params by service name (as App ID)
serviceMap := map[string][]*AppUpdateExtraParam{}
for _, p := range params {
slice, ok := serviceMap[p.ServiceName]
if !ok {
slice = []*AppUpdateExtraParam{}
}
slice = append(slice, &p)
serviceMap[p.ServiceName] = slice
}
// update each App's Extra
hasOK := false
for serviceName, params := range serviceMap {
if cache.UpdateOneAppExtra(serviceName, params) {
hasOK = true
}
}
return hasOK
}
// UpdateOneAppExtra Update one deployed App's Extra
func (cache *AppManifestCache) UpdateOneAppExtra(serviceName string, params []*AppUpdateExtraParam) bool {
value, ok := cache.ServiceManifests.Load(serviceName)
if !ok {
return false
}
appVersionMap := value.(AppVersionMap)
// lock AppVersionMap when changing it's content
mtxValue, _ := cache.ServiceMutexes.LoadOrStore(serviceName, &sync.RWMutex{})
mtx := mtxValue.(*sync.RWMutex)
mtx.Lock()
defer mtx.Unlock()
// update each version in params
hasOK := false
for _, param := range params {
version := param.GitRevision.GetVersionKey()
if app, ok := appVersionMap[version]; ok {
// Merge each K-V, not replace all
for key, value := range param.Extra {
app.Extra[key] = value
}
hasOK = true
}
}
return hasOK
}
4. 查询接口
添加查询接口/api/metadata/query-app-versions?id={APP_ID}
,可以让管理员了解当前的APP服务情况。在版本发布、A/B测试管控平台上,这是一个信息来源接口。
// QueryAppVersions query app versions by admin. We lock AppVersionMap until json.Marshal() finished
func (cache *AppManifestCache) QueryAppVersions(ctx *gin.Context, serviceName string) {
value, ok := cache.ServiceManifests.Load(serviceName)
if !ok {
ctx.AbortWithError(http.StatusBadRequest, fmt.Errorf("Invalid APP ID"))
return
}
appVersionMap := value.(AppVersionMap)
// lock AppVersionMap when using it's content
mtxValue, _ := cache.ServiceMutexes.LoadOrStore(serviceName, &sync.RWMutex{})
mtx := mtxValue.(*sync.RWMutex)
mtx.RLock()
defer mtx.RUnlock()
ctx.JSON(http.StatusOK, appVersionMap)
}
5. 效果体验
要体验激活百分比特性,最好下载服务包到本地体验。线上Demo网站对频繁刷新产生的请求做了限流,你可能会遇到503错误;同时,线上Demo网站也禁用了查询与更新接口。
本地服务包,见: https://github.com/kinsprite/react-micro-frontend-server-go/releases/tag/v3.0.0
5.1. 测试组与普通用户组隔离
未点击右上角的“Login”时,作为普通用户,我们可以看到这两个版本:A、Logo有缩放;B、Logo无缩放。多F5刷新几下就能看到两个。
点击右上角的“Login”之后,我们加入测试组,只看到中间后一行红色字体的测试版本,不管你F5刷新多少次,始终是这个版本。同时,到“App Example’s Sub”与“Echarts from CDN”也看到与普通用户不一样的版本。
5.2. 激活百分比
5.2.1. 查询当前激活状态
使用Postman发送查询接口http://127.0.0.1:8080/api/metadata/query-app-versions?id=app-home
,先看看主页的激活百分比状态,如下(只保留我们关注的信息):
{
"_44831cf1": {
"gitRevision": {
"tag": "",
"short": "44831cf1"
},
"extra": {
"activationPercent": "25"
}
},
"_7d1fbf09": {
"gitRevision": {
"tag": "",
"short": "7d1fbf09"
},
"extra": {
"userGroup": "tester"
}
},
"v1.3.0_c7ce48c8": {
"gitRevision": {
"tag": "v1.3.0",
"short": "c7ce48c8"
},
"extra": {
"activationPercent": "75"
}
}
}
5.2.2. 修改主页的激活百分比
未修改激活百分比之前,我们在主页多F5刷新几下页面,发现“Logo有缩放”的版本出现概率比较高。
Postman 发送 POST http://127.0.0.1:8080/api/metadata/update-app-extra
,内容如下:
[{
"gitRevision": {
"tag": "v1.3.0",
"short": "c7ce48c8"
},
"serviceName": "app-home",
"extra": {
"activationPercent": "20"
}
},{
"gitRevision": {
"tag": "",
"short": "44831cf1"
},
"serviceName": "app-home",
"extra": {
"activationPercent": "80"
}
}]
再次多刷新主页几次,发现“Logo有缩放”的版本出现概率明显变小了。
也可以禁用主页的测试页面,即使测试用户登录,在主页也只看到普通页面,在其它页面还能看到测试页面。
[{
"gitRevision": {
"tag": "",
"short": "7d1fbf09"
},
"serviceName": "app-home",
"extra": {
"activationPercent": "0"
}
}]
再次放开时,只需调整为"activationPercent": "100"
。
5.2.3 禁用Example App
Postman 发送 POST http://127.0.0.1:8080/api/metadata/update-app-extra
,内容如下:
[{
"gitRevision": {
"tag": "v1.3.0",
"short": "3086bbb1"
},
"serviceName": "app-example",
"extra": {
"activationPercent": "0"
}
}]
F5刷新页面,再点击App Example
与App Example's Sub
均不会有路由跳转,原因:前端框架根本就没有收到app-example
的任何信息,不知道app-example
的存在。
6. 结束语
至此,Server-go已支持React微前端的运行、APP动态安装/卸载、A/B测试与灰度发布控制等功能,足够支撑Demo或非7x24小时的独立站点;其本身对前端框架无要求,只要求元信息满足规则要求即可,可以支持Vue、Angular等其它框架构建的微前端。
作为一个研究项目,暂未考虑支持集群功能:用户鉴权、多副本、MQS同步。