diff --git a/plugins/wasm-go/extensions/user-gray/README.md b/plugins/wasm-go/extensions/user-gray/README.md new file mode 100644 index 0000000000..cd34dd4477 --- /dev/null +++ b/plugins/wasm-go/extensions/user-gray/README.md @@ -0,0 +1,112 @@ +# user-gray 前端灰度插件 +## 功能说明 +`user-gray`插件实现了前端用户灰度的的功能,通过此插件,不但可以用于业务`A/B实验`,同时通过`可灰度`配合`可监控`,`可回滚`策略保证系统发布运维的稳定性。 + +## 配置字段 +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|----------------|--------------|------|-----|-----------------------------------------------------------------------------------| +| `uid-key` | string | 必填 | - | 用户ID的唯一标识,可以来自Cookie或者Header中,比如 userid | +| `uid-sub-key` | string | 非必填 | - | 用户身份信息可能以JSON形式透出,比如:`userInfo:{ userCode:"001" }`,当前例子`uid-sub-key`取值为`userCode` | +| `rules` | array of map | 非必填 | - | 用户定义不同的灰度规则,适配不同的灰度场景 | +| `deploy` | map of map | 非必填 | - | 分别配置Base基线和Gary灰度的生效规则,以及生效版本 | + +`rules`字段配置说明: + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|----------------|--------------|------|-----|-----------------------------------------------------------------------------------| +| `name` | string | 必填 | - | 规则名称唯一标识,和`deploy.gray[].name`进行关联生效 | +| `uid-value` | array of string | 非必填 | - | 用户ID 白名单列表 | +| `gray-tag-key` | string | 非必填 | - | 用户分类打标的标签key值,来自Cookie | +| `gray-tag-value` | array of string | 非必填 | - | 用户分类打标的标签value值,来自Cookie | + + +`deploy`字段配置说明: + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|----------------|--------------|------|-----|-----------------------------------------------------------------------------------| +| `base` | map of string | 必填 | - | 定义Base版本,如果匹配不到灰度版本,默认fallback到当前版本 | +| `gray` | array of string | 非必填 | - | 定义Gray版本,如果匹配到灰度规则,则当前的灰度版本生效 | + +`deploy.base`字段配置说明: + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|----------------|--------------|------|-----|-----------------------------------------------------------------------------------| +| `version` | string | 必填 | - | Base版本的版本号,作为兜底的版本 | + +`deploy.gray`字段配置说明: + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|--------|--------|------|-----|----------------------------| +| `version` | string | 必填 | - | Gray版本的版本号,如果命中灰度规则,则使用此版本 | +| `name` | string | 必填 | - | 规则名称和`rules[].name`关联, | +| `enable` | boolean | 必填 | - | 是否启动当前灰度规则 | + +## 配置示例 +### 基础配置 +```yml +uid-key: userid +rules: +- name: inner-user + uid-value: + - '00000001' + - '00000005' +- name: beta-user + uid-value: + - '00000002' + - '00000003' + gray-tag-key: level + gray-tag-value: + - level3 + - level5 +deploy: + base: + version: base + gray: + - name: beta-user + version: gray + enable: true +``` + + +cookie中的用户唯一标识为 `userid`,当前灰度规则配置了`beta-user`的规则。 + +当满足下面调试的时候,会使用`versin: gray`版本 +- cookie中`userid`等于`00000002`或者`00000003` +- cookie中`level`等于`level3`或者`level5`的用户 + +否则使用`versin: base`版本 + +### 用户信息存在JSON中 + +```yml +uid-key: appInfo +uid-sub-key: userId +rules: +- name: inner-user + uid-value: + - '00000001' + - '00000005' +- name: beta-user + uid-value: + - '00000002' + - '00000003' + gray-tag-key: level + gray-tag-value: + - level3 + - level5 +deploy: + base: + version: base + gray: + - name: beta-user + version: gray + enable: true +``` + +cookie存在`appInfo`的JSON数据,其中包含`userId`字段为当前的唯一标识 +当前灰度规则配置了`beta-user`的规则。 +当满足下面调试的时候,会使用`versin: gray`版本 +- cookie中`userid`等于`00000002`或者`00000003` +- cookie中`level`等于`level3`或者`level5`的用户 + +否则使用`versin: base`版本 \ No newline at end of file diff --git a/plugins/wasm-go/extensions/user-gray/config/config.go b/plugins/wasm-go/extensions/user-gray/config/config.go new file mode 100644 index 0000000000..34fd7ed259 --- /dev/null +++ b/plugins/wasm-go/extensions/user-gray/config/config.go @@ -0,0 +1,90 @@ +package config + +import ( + "strconv" + + "github.com/tidwall/gjson" +) + +type GrayRule struct { + Name string + UidValue []interface{} + GrayTagKey string + GrayTagValue []interface{} +} + +type DeployItem struct { + Name string + Version string + Enable bool +} + +type Deploy struct { + Base *DeployItem + Gray []*DeployItem +} + +type UserGrayConfig struct { + UidKey string + UidSubKey string + Rules []*GrayRule + Deploy *Deploy +} + +func interfacesFromJSONResult(results []gjson.Result) []interface{} { + var interfaces []interface{} + for _, result := range results { + switch v := result.Value().(type) { + case float64: + // 当 v 是 float64 时,将其转换为字符串 + interfaces = append(interfaces, strconv.FormatFloat(v, 'f', -1, 64)) + default: + // 其它类型不改变,直接追加 + interfaces = append(interfaces, v) + } + } + return interfaces +} + +func JsonToUserGrayConfig(json gjson.Result, userGrayConfig *UserGrayConfig) { + // 解析 UidKey + userGrayConfig.UidKey = json.Get("uid-key").String() + userGrayConfig.UidSubKey = json.Get("uid-sub-key").String() + + // 解析 Rules + rules := json.Get("rules").Array() + for _, rule := range rules { + grayRule := GrayRule{ + Name: rule.Get("name").String(), + UidValue: interfacesFromJSONResult(rule.Get("uid-value").Array()), // 使用辅助函数将 []gjson.Result 转换为 []interface{} + GrayTagKey: rule.Get("gray-tag-key").String(), + GrayTagValue: interfacesFromJSONResult(rule.Get("gray-tag-value").Array()), + } + userGrayConfig.Rules = append(userGrayConfig.Rules, &grayRule) + } + + // 解析 deploy + deployJSON := json.Get("deploy") + baseItem := deployJSON.Get("base") + grayItems := deployJSON.Get("gray").Array() + + // 分配内存给 release 对象 + userGrayConfig.Deploy = &Deploy{ + Base: &DeployItem{ + Name: baseItem.Get("name").String(), + Version: baseItem.Get("version").String(), + Enable: baseItem.Get("enable").Bool(), + }, + Gray: []*DeployItem{}, + } + + // 解析 Gray 列表 + for _, item := range grayItems { + DeployItem := &DeployItem{ + Name: item.Get("name").String(), + Version: item.Get("version").String(), + Enable: item.Get("enable").Bool(), + } + userGrayConfig.Deploy.Gray = append(userGrayConfig.Deploy.Gray, DeployItem) + } +} diff --git a/plugins/wasm-go/extensions/user-gray/config/config_test.go b/plugins/wasm-go/extensions/user-gray/config/config_test.go new file mode 100644 index 0000000000..7404e233c5 --- /dev/null +++ b/plugins/wasm-go/extensions/user-gray/config/config_test.go @@ -0,0 +1,27 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" +) + +func TestJsonToUserGreyConfig(t *testing.T) { + allConfigData := `{"uid-key":"userid","rules":[{"name":"inner-user","uid-value":["00000001","00000005"]},{"name":"beta-user","uid-value":["00000002","00000003"],"gray-tag-key":"level","gray-tag-value":["level3","level5"]}],"deploy":{"base":{"version":"base"},"gray":[{"name":"beta-user","version":"gray","enable":true}]}}` + var tests = []struct { + testName string + uidKey string + json string + }{ + {"完整的数据", "userid", allConfigData}, + } + for _, test := range tests { + testName := test.testName + t.Run(testName, func(t *testing.T) { + var userGrayConfig = &UserGrayConfig{} + JsonToUserGrayConfig(gjson.Parse(test.json), userGrayConfig) + assert.Equal(t, test.uidKey, userGrayConfig.UidKey) + }) + } +} diff --git a/plugins/wasm-go/extensions/user-gray/go.mod b/plugins/wasm-go/extensions/user-gray/go.mod new file mode 100644 index 0000000000..8953cc0adb --- /dev/null +++ b/plugins/wasm-go/extensions/user-gray/go.mod @@ -0,0 +1,22 @@ +module user-gray + +go 1.18 + +require ( + github.com/alibaba/higress/plugins/wasm-go v0.0.0-20240531060402-2807ddfbb79e + github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc + github.com/stretchr/testify v1.8.4 + github.com/tidwall/gjson v1.17.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 // indirect + github.com/magefile/mage v1.14.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/resp v0.1.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugins/wasm-go/extensions/user-gray/go.sum b/plugins/wasm-go/extensions/user-gray/go.sum new file mode 100644 index 0000000000..d6289a6480 --- /dev/null +++ b/plugins/wasm-go/extensions/user-gray/go.sum @@ -0,0 +1,32 @@ +github.com/alibaba/higress/plugins/wasm-go v0.0.0-20240531060402-2807ddfbb79e h1:0b2UXrEpotHwWgwvgvkXnyKWuxTXtzfKu6c2YpRV+zw= +github.com/alibaba/higress/plugins/wasm-go v0.0.0-20240531060402-2807ddfbb79e/go.mod h1:10jQXKsYFUF7djs+Oy7t82f4dbie9pISfP9FJwpPLuk= +github.com/alibaba/higress/plugins/wasm-go v1.3.5 h1:VOLL3m442IHCSu8mR5AZ4sc6LVT9X0w1hdqDI7oB9jY= +github.com/alibaba/higress/plugins/wasm-go v1.3.5/go.mod h1:kr3V9Ntbspj1eSrX8rgjBsdMXkGupYEf+LM72caGPQc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA= +github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew= +github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240226065437-8f7a0b3c9071 h1:STb5rOHRZOzoiAa+gTz2LFqO1nYj7U/1eIVUJJadU4A= +github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240226065437-8f7a0b3c9071/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo= +github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc h1:t2AT8zb6N/59Y78lyRWedVoVWHNRSCBh0oWCC+bluTQ= +github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo= +github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= +github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= +github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE= +github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/wasm-go/extensions/user-gray/main.go b/plugins/wasm-go/extensions/user-gray/main.go new file mode 100644 index 0000000000..bad7b147f0 --- /dev/null +++ b/plugins/wasm-go/extensions/user-gray/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "user-gray/config" + "user-gray/util" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/tidwall/gjson" +) + +func main() { + wrapper.SetCtx( + "user-gray", + wrapper.ParseConfigBy(parseConfig), + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + ) +} + +func parseConfig(json gjson.Result, userGrayConfig *config.UserGrayConfig, log wrapper.Log) error { + // 解析json 为UserGrayConfig + config.JsonToUserGrayConfig(json, userGrayConfig) + return nil +} + +// OmitGrayRule 过滤灰度规则 +func OmitGrayRule(userGrayConfig *config.UserGrayConfig, userId string) *config.DeployItem { + for _, grayItem := range userGrayConfig.Deploy.Gray { + if !grayItem.Enable { + // 跳过Enable=false + continue + } + grayRule := util.ContainsRule(userGrayConfig.Rules, grayItem.Name) + // 首先:先校验用户名单ID + if grayRule.UidValue != nil && len(grayRule.UidValue) > 0 && userId != "" { + if util.Contains(grayRule.UidValue, userId) { + return grayItem + } + } + // 第二:校验Cookie中的 GrayTagKey + if grayRule.GrayTagKey != "" && grayRule.GrayTagValue != nil && len(grayRule.GrayTagValue) > 0 { + cookieStr, _ := proxywasm.GetHttpRequestHeader("cookie") + grayTagValue := util.GetValueByCookie(cookieStr, grayRule.GrayTagKey) + if util.Contains(grayRule.GrayTagValue, grayTagValue) { + return grayItem + } + } + } + return nil +} + +func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.UserGrayConfig, log wrapper.Log) types.Action { + // 优先从cookie中获取,如果拿不到再从header中获取 + cookieStr, _ := proxywasm.GetHttpRequestHeader("cookie") + uidHeaderKey, _ := proxywasm.GetHttpRequestHeader(grayConfig.UidKey) + uidKeyValue := util.GetValueByCookie(cookieStr, grayConfig.UidKey) + proxywasm.RemoveHttpRequestHeader("Accept-Encoding") + // 优先从Cookie中获取,否则从header中获取 + if uidKeyValue == "" { + uidKeyValue = uidHeaderKey + } + // 如果有子key, 尝试从子key中获取值 + if grayConfig.UidSubKey != "" { + uidSubKeyValue := util.GetUidFromSubKey(uidKeyValue, grayConfig.UidSubKey) + if uidSubKeyValue != "" { + uidKeyValue = uidSubKeyValue + } + } + grayDeployItem := OmitGrayRule(&grayConfig, uidKeyValue) + if grayDeployItem != nil { + log.Infof("x-mse-tag: %s, userCode: %s", grayDeployItem.Version, uidKeyValue) + proxywasm.AddHttpRequestHeader("x-mse-tag", grayDeployItem.Version) + } else { + log.Infof("x-mse-tag: %s, userCode: %s", grayConfig.Deploy.Base.Version, uidKeyValue) + } + + return types.ActionContinue +} diff --git a/plugins/wasm-go/extensions/user-gray/util/utils.go b/plugins/wasm-go/extensions/user-gray/util/utils.go new file mode 100644 index 0000000000..d7697fc036 --- /dev/null +++ b/plugins/wasm-go/extensions/user-gray/util/utils.go @@ -0,0 +1,71 @@ +package util + +import ( + "encoding/json" + "net/url" + "strings" + "user-gray/config" +) + +// GetValueByCookie 根据 cookieStr 和 cookieName 获取 cookie 值 +func GetValueByCookie(cookieStr string, cookieName string) string { + if cookieStr == "" { + return "" + } + + cookies := strings.Split(cookieStr, ";") + curCookieName := cookieName + "=" + var foundCookieValue string + var found bool + // 遍历找到 cookie 对并处理 + for _, cookie := range cookies { + cookie = strings.TrimSpace(cookie) // 清理空白符 + if strings.HasPrefix(cookie, curCookieName) { + foundCookieValue = strings.TrimPrefix(cookie, curCookieName) + found = true + break + } + } + + if !found { + return "" + } + return foundCookieValue +} + +// contains 检查切片 slice 中是否含有元素 value。 +func Contains(slice []interface{}, value string) bool { + for _, item := range slice { + if item == value { + return true + } + } + return false +} + +func ContainsRule(rules []*config.GrayRule, name string) *config.GrayRule { + for _, rule := range rules { + if rule.Name == name { + return rule + } + } + return nil +} + +func GetUidFromSubKey(userInfoStr string, uidSubKey string) string { + jsonStr, err := url.QueryUnescape(userInfoStr) + if err != nil { + return "" + } + var result map[string]interface{} + err = json.Unmarshal([]byte(jsonStr), &result) + if err != nil { + return "" + } + // 从 map 中获取 userCode 的值 + real, ok := result[uidSubKey].(string) + if !ok { + return "" + } + return real +} diff --git a/plugins/wasm-go/extensions/user-gray/util/utils_test.go b/plugins/wasm-go/extensions/user-gray/util/utils_test.go new file mode 100644 index 0000000000..7c555680da --- /dev/null +++ b/plugins/wasm-go/extensions/user-gray/util/utils_test.go @@ -0,0 +1,41 @@ +package util + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGetValueByCookie(t *testing.T) { + var tests = []struct { + cookie, cookieKey, output string + }{ + {"", "uid", ""}, + {`cna=pf_9be76347560439f3b87daede1b485e37; uid=111`, "uid", "111"}, + {`cna=pf_9be76347560439f3b87daede1b485e37; userid=222`, "userid", "222"}, + {`uid=333`, "uid", "333"}, + {`cna=pf_9be76347560439f3b87daede1b485e37;`, "uid", ""}, + } + for _, test := range tests { + testName := test.cookie + t.Run(testName, func(t *testing.T) { + output := GetValueByCookie(test.cookie, test.cookieKey) + assert.Equal(t, test.output, output) + }) + } +} + +func TestDecodeJsonCookie(t *testing.T) { + var tests = []struct { + userInfoStr, uidJsonKey, output string + }{ + {"{%22password%22:%22$2a$10$YAvYjA6783YeCi44/M395udIZ4Ll2iyKkQCzePaYx5NNG/aIWgICG%22%2C%22username%22:%22%E8%B0%A2%E6%99%AE%E8%80%80%22%2C%22authorities%22:[]%2C%22accountNonExpired%22:true%2C%22accountNonLocked%22:true%2C%22credentialsNonExpired%22:true%2C%22enabled%22:true%2C%22id%22:838925798835720200%2C%22mobile%22:%22%22%2C%22userCode%22:%22noah%22%2C%22userName%22:%22%E8%B0%A2%E6%99%AE%E8%80%80%22%2C%22orgId%22:10%2C%22ocId%22:87%2C%22userType%22:%22OWN%22%2C%22firstLogin%22:false%2C%22ownOrgId%22:null%2C%22clientCode%22:%22%22%2C%22clientType%22:null%2C%22country%22:%22UAE%22%2C%22isGuide%22:null%2C%22acctId%22:null%2C%22userToken%22:null%2C%22deviceId%22:%223a47fec00a59d140%22%2C%22ocCode%22:%2299990002%22%2C%22secondType%22:%22dtl%22%2C%22vendorCode%22:%2210000001%22%2C%22status%22:%22ACTIVE%22%2C%22isDelete%22:false%2C%22email%22:%22%22%2C%22deleteStatus%22:null%2C%22deleteRequestDate%22:null%2C%22wechatId%22:null%2C%22userMfaInfoDTO%22:{%22checkMfa%22:false%2C%22checkSuccess%22:false%2C%22mobile%22:null%2C%22email%22:null%2C%22wechatId%22:null%2C%22totpSecret%22:null}}", + "userCode", "noah"}, + } + for _, test := range tests { + testName := test.userInfoStr + t.Run(testName, func(t *testing.T) { + output := GetUidFromSubKey(test.userInfoStr, test.uidJsonKey) + assert.Equal(t, test.output, output) + }) + } +}