软件技术学习笔记

个人博客,记录软件技术与程序员的点点滴滴。

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刷新几下就能看到两个。

Logo有缩放主页 Logo无缩放主页

点击右上角的“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 ExampleApp Example's Sub均不会有路由跳转,原因:前端框架根本就没有收到app-example的任何信息,不知道app-example的存在。

6. 结束语

至此,Server-go已支持React微前端的运行、APP动态安装/卸载、A/B测试与灰度发布控制等功能,足够支撑Demo或非7x24小时的独立站点;其本身对前端框架无要求,只要求元信息满足规则要求即可,可以支持Vue、Angular等其它框架构建的微前端。

作为一个研究项目,暂未考虑支持集群功能:用户鉴权、多副本、MQS同步。