feat: 🎸 add frontend gray plugin (#1120)

Co-authored-by: Kent Dong <ch3cho@qq.com>
This commit is contained in:
mamba
2024-07-15 15:47:04 +08:00
committed by GitHub
parent c00c8827f9
commit 5041277be3
9 changed files with 560 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
# frontend-gray 前端灰度插件
## 功能说明
`frontend-gray`插件实现了前端用户灰度的的功能,通过此插件,不但可以用于业务`A/B实验`,同时通过`可灰度`配合`可监控`,`可回滚`策略保证系统发布运维的稳定性。
## 配置字段
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|----------------|--------------|------|-----|-----------------------------------------------------------------------------------|
| `grayKey` | string | 非必填 | - | 用户ID的唯一标识可以来自Cookie或者Header中比如 userid如果没有填写则使用`rules[].grayTagKey``rules[].grayTagValue`过滤灰度规则 |
| `graySubKey` | string | 非必填 | - | 用户身份信息可能以JSON形式透出比如`userInfo:{ userCode:"001" }`,当前例子`graySubKey`取值为`userCode` |
| `rules` | array of object | 非必填 | - | 用户定义不同的灰度规则,适配不同的灰度场景 |
| `baseDeployment` | object | 非必填 | - | 配置Base基线规则的配置 |
| `grayDeployments` | array of object | 非必填 | - | 配置Gray灰度的生效规则以及生效版本 |
`rules`字段配置说明:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|----------------|--------------|------|-----|-----------------------------------------------------------------------------------|
| `name` | string | 必填 | - | 规则名称唯一标识,和`deploy.gray[].name`进行关联生效 |
| `grayKeyValue` | array of string | 非必填 | - | 用户ID 白名单列表 |
| `grayTagKey` | string | 非必填 | - | 用户分类打标的标签key值来自Cookie |
| `grayTagValue` | array of string | 非必填 | - | 用户分类打标的标签value值来自Cookie |
`baseDeployment`字段配置说明:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|----------------|--------------|------|-----|-----------------------------------------------------------------------------------|
| `version` | string | 必填 | - | Base版本的版本号作为兜底的版本 |
`grayDeployments`字段配置说明:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------|--------|------|-----|----------------------------|
| `version` | string | 必填 | - | Gray版本的版本号如果命中灰度规则则使用此版本 |
| `name` | string | 必填 | - | 规则名称和`rules[].name`关联, |
| `enabled` | boolean | 必填 | - | 是否启动当前灰度规则 |
## 配置示例
### 基础配置
```yml
grayKey: userid
rules:
- name: inner-user
grayKeyValue:
- '00000001'
- '00000005'
- name: beta-user
grayKeyValue:
- '00000002'
- '00000003'
grayTagKey: level
grayTagValue:
- level3
- level5
baseDeployment:
version: base
grayDeployments:
- name: beta-user
version: gray
enabled: true
```
cookie中的用户唯一标识为 `userid`,当前灰度规则配置了`beta-user`的规则。
当满足下面调试的时候,会使用`version: gray`版本
- cookie中`userid`等于`00000002`或者`00000003`
- cookie中`level`等于`level3`或者`level5`的用户
否则使用`version: base`版本
### 用户信息存在JSON中
```yml
grayKey: appInfo
graySubKey: userId
rules:
- name: inner-user
grayKeyValue:
- '00000001'
- '00000005'
- name: beta-user
grayKeyValue:
- '00000002'
- '00000003'
grayTagKey: level
grayTagValue:
- level3
- level5
baseDeployment:
version: base
grayDeployments:
- name: beta-user
version: gray
enabled: true
```
cookie存在`appInfo`的JSON数据其中包含`userId`字段为当前的唯一标识
当前灰度规则配置了`beta-user`的规则。
当满足下面调试的时候,会使用`version: gray`版本
- cookie中`userid`等于`00000002`或者`00000003`
- cookie中`level`等于`level3`或者`level5`的用户
否则使用`version: base`版本

View File

@@ -0,0 +1,82 @@
package config
import (
"strconv"
"github.com/tidwall/gjson"
)
type GrayRule struct {
Name string
GrayKeyValue []interface{}
GrayTagKey string
GrayTagValue []interface{}
}
type BaseDeployment struct {
Name string
Version string
}
type GrayDeployments struct {
Name string
Version string
Enabled bool
}
type GrayConfig struct {
GrayKey string
GraySubKey string
Rules []*GrayRule
BaseDeployment *BaseDeployment
GrayDeployments []*GrayDeployments
}
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 JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
// 解析 GrayKey
grayConfig.GrayKey = json.Get("grayKey").String()
grayConfig.GraySubKey = json.Get("graySubKey").String()
// 解析 Rules
rules := json.Get("rules").Array()
for _, rule := range rules {
grayRule := GrayRule{
Name: rule.Get("name").String(),
GrayKeyValue: interfacesFromJSONResult(rule.Get("grayKeyValue").Array()), // 使用辅助函数将 []gjson.Result 转换为 []interface{}
GrayTagKey: rule.Get("grayTagKey").String(),
GrayTagValue: interfacesFromJSONResult(rule.Get("grayTagValue").Array()),
}
grayConfig.Rules = append(grayConfig.Rules, &grayRule)
}
// 解析 deploy
baseDeployment := json.Get("baseDeployment")
grayDeployments := json.Get("grayDeployments").Array()
grayConfig.BaseDeployment = &BaseDeployment{
Name: baseDeployment.Get("name").String(),
Version: baseDeployment.Get("version").String(),
}
for _, item := range grayDeployments {
grayConfig.GrayDeployments = append(grayConfig.GrayDeployments, &GrayDeployments{
Name: item.Get("name").String(),
Version: item.Get("version").String(),
Enabled: item.Get("enabled").Bool(),
})
}
}

View File

@@ -0,0 +1,27 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
)
func TestJsonToGrayConfig(t *testing.T) {
allConfigData := `{"grayKey":"userid","rules":[{"name":"inner-user","grayKeyValue":["00000001","00000005"]},{"name":"beta-user","grayKeyValue":["00000002","00000003"],"grayTagKey":"level","grayTagValue":["level3","level5"]}],"deploy":{"base":{"version":"base"},"gray":[{"name":"beta-user","version":"gray","enabled":true}]}}`
var tests = []struct {
testName string
grayKey string
json string
}{
{"完整的数据", "userid", allConfigData},
}
for _, test := range tests {
testName := test.testName
t.Run(testName, func(t *testing.T) {
var grayConfig = &GrayConfig{}
JsonToGrayConfig(gjson.Parse(test.json), grayConfig)
assert.Equal(t, test.grayKey, grayConfig.GrayKey)
})
}
}

View File

@@ -0,0 +1,102 @@
admin:
address:
socket_address:
protocol: TCP
address: 0.0.0.0
port_value: 9901
static_resources:
listeners:
- name: listener_0
address:
socket_address:
protocol: TCP
address: 0.0.0.0
port_value: 10000
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
scheme_header_transformation:
scheme_to_overwrite: https
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match:
prefix: "/"
route:
cluster: httpbin
http_filters:
- name: wasmdemo
typed_config:
"@type": type.googleapis.com/udpa.type.v1.TypedStruct
type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
value:
config:
name: wasmdemo
vm_config:
runtime: envoy.wasm.runtime.v8
code:
local:
filename: ./main.wasm
configuration:
"@type": "type.googleapis.com/google.protobuf.StringValue"
value: |
{
"grayKey": "UserInfo",
"graySubKey": "userCode",
"rules": [
{
"name": "inner-user",
"grayKeyValue": [
"00000001",
"00000005"
]
},
{
"name": "beta-user",
"grayKeyValue": [
"noah",
"00000003"
],
"grayTagKey": "level",
"grayTagValue": [
"level3",
"level5"
]
}
],
"baseDeployment": {
"version": "base"
},
"grayDeployments": [
{
"name": "beta-user",
"version": "gray",
"enabled": true
}
]
}
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: httpbin
connect_timeout: 30s
type: LOGICAL_DNS
# Comment out the following line to test on v6 networks
dns_lookup_family: V4_ONLY
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: httpbin
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: httpbin.org
port_value: 80

View File

@@ -0,0 +1,24 @@
module github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray
go 1.18
replace github.com/alibaba/higress/plugins/wasm-go => ../..
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
)

View File

@@ -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=

View File

@@ -0,0 +1,78 @@
package main
import (
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-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(
"frontend-gray",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
func parseConfig(json gjson.Result, grayConfig *config.GrayConfig, log wrapper.Log) error {
// 解析json 为GrayConfig
config.JsonToGrayConfig(json, grayConfig)
return nil
}
// FilterGrayRule 过滤灰度规则
func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, log wrapper.Log) *config.GrayDeployments {
for _, grayDeployment := range grayConfig.GrayDeployments {
if !grayDeployment.Enabled {
// 跳过Enabled=false
continue
}
grayRule := util.GetRule(grayConfig.Rules, grayDeployment.Name)
// 首先先校验用户名单ID
if grayRule.GrayKeyValue != nil && len(grayRule.GrayKeyValue) > 0 && grayKeyValue != "" {
if util.Contains(grayRule.GrayKeyValue, grayKeyValue) {
log.Infof("x-mse-tag: %s, grayKeyValue: %s", grayDeployment.Version, grayKeyValue)
return grayDeployment
}
}
// 第二校验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) {
log.Infof("x-mse-tag: %s, grayTag: %s=%s", grayDeployment.Version, grayRule.GrayTagKey, grayTagValue)
return grayDeployment
}
}
}
log.Infof("x-mse-tag: %s, grayKeyValue: %s", grayConfig.BaseDeployment.Version, grayKeyValue)
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig, log wrapper.Log) types.Action {
// 优先从cookie中获取如果拿不到再从header中获取
cookieStr, _ := proxywasm.GetHttpRequestHeader("cookie")
grayHeaderKey, _ := proxywasm.GetHttpRequestHeader(grayConfig.GrayKey)
grayKeyValue := util.GetValueByCookie(cookieStr, grayConfig.GrayKey)
proxywasm.RemoveHttpRequestHeader("Accept-Encoding")
// 优先从Cookie中获取否则从header中获取
if grayKeyValue == "" {
grayKeyValue = grayHeaderKey
}
// 如果有子key, 尝试从子key中获取值
if grayConfig.GraySubKey != "" {
subKeyValue := util.GetBySubKey(grayKeyValue, grayConfig.GraySubKey)
if subKeyValue != "" {
grayKeyValue = subKeyValue
}
}
grayDeployment := FilterGrayRule(&grayConfig, grayKeyValue, log)
if grayDeployment != nil {
proxywasm.AddHttpRequestHeader("x-mse-tag", grayDeployment.Version)
}
return types.ActionContinue
}

View File

@@ -0,0 +1,70 @@
package util
import (
"net/url"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config"
"github.com/tidwall/gjson"
)
// 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 = cookie[len(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 GetRule(rules []*config.GrayRule, name string) *config.GrayRule {
for _, rule := range rules {
if rule.Name == name {
return rule
}
}
return nil
}
func GetBySubKey(grayInfoStr string, graySubKey string) string {
// 首先对 URL 编码的字符串进行解码
jsonStr, err := url.QueryUnescape(grayInfoStr)
if err != nil {
return ""
}
// 使用 gjson 从 JSON 字符串中提取 graySubKey 对应的值
value := gjson.Get(jsonStr, graySubKey)
// 检查所提取的值是否存在
if !value.Exists() {
return ""
}
// 返回字符串形式的值
return value.String()
}

View File

@@ -0,0 +1,42 @@
package util
import (
"testing"
"github.com/stretchr/testify/assert"
)
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, grayJsonKey, 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%22enabledd%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 := GetBySubKey(test.userInfoStr, test.grayJsonKey)
assert.Equal(t, test.output, output)
})
}
}