diff --git a/plugins/wasm-go/extensions/frontend-gray/README.md b/plugins/wasm-go/extensions/frontend-gray/README.md index 283785807..dce90802c 100644 --- a/plugins/wasm-go/extensions/frontend-gray/README.md +++ b/plugins/wasm-go/extensions/frontend-gray/README.md @@ -3,13 +3,14 @@ `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灰度的生效规则,以及生效版本 | +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|----------------|--------------|----|-----|----------------------------------------------------------------------------------------------------| +| `grayKey` | string | 非必填 | - | 用户ID的唯一标识,可以来自Cookie或者Header中,比如 userid,如果没有填写则使用`rules[].grayTagKey`和`rules[].grayTagValue`过滤灰度规则 | +| `graySubKey` | string | 非必填 | - | 用户身份信息可能以JSON形式透出,比如:`userInfo:{ userCode:"001" }`,当前例子`graySubKey`取值为`userCode` | +| `rules` | array of object | 必填 | - | 用户定义不同的灰度规则,适配不同的灰度场景 | +| `rewrite` | object | 必填 | - | 重写配置,一般用于OSS/CDN前端部署的重写配置 | +| `baseDeployment` | object | 非必填 | - | 配置Base基线规则的配置 | +| `grayDeployments` | array of object | 非必填 | - | 配置Gray灰度的生效规则,以及生效版本 | `rules`字段配置说明: @@ -20,6 +21,19 @@ | `grayTagKey` | string | 非必填 | - | 用户分类打标的标签key值,来自Cookie | | `grayTagValue` | array of string | 非必填 | - | 用户分类打标的标签value值,来自Cookie | +`rewrite`字段配置说明: +> `indexRouting`首页重写和`fileRouting`文件重写,本质都是前缀匹配,比如`/app1`: `/mfe/app1/{version}/index.html`代表/app1为前缀的请求,路由到`/mfe/app1/{version}/index.html`页面上,其中`{version}`代表版本号,在运行过程中会被`baseDeployment.version`或者`grayDeployments[].version`动态替换。 + +> `{version}` 作为保留字段,在执行过程中会被`baseDeployment.version`或者`grayDeployments[].version`动态替换前端版本。 + + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|------------|--------------|------|-----|------------------------------| +| `host` | string | 非必填 | - | host地址,如果是OSS则设置为 VPC 内网访问地址 | +| `notFoundUri` | string | 非必填 | - | 404 页面配置 | +| `indexRouting` | map of string to string | 非必填 | - | 用于定义首页重写路由规则。每个键 (Key) 表示首页的路由路径,值 (Value) 则指向重定向的目标文件。例如,键为 `/app1` 对应的值为 `/mfe/app1/{version}/index.html`。生效version为`0.0.1`, 访问路径为 `/app1`,则重定向到 `/mfe/app1/0.0.1/index.html`。 | +| `fileRouting` | map of string to string | 非必填 | - | 用于定义资源文件重写路由规则。每个键 (Key) 表示资源访问路径,值 (Value) 则指向重定向的目标文件。例如,键为 `/app1/` 对应的值为 `/mfe/app1/{version}`。生效version为`0.0.1`,访问路径为 `/app1/js/a.js`,则重定向到 `/mfe/app1/0.0.1/js/a.js`。 | + `baseDeployment`字段配置说明: | 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | @@ -28,11 +42,12 @@ `grayDeployments`字段配置说明: -| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | -|--------|--------|------|-----|----------------------------| -| `version` | string | 必填 | - | Gray版本的版本号,如果命中灰度规则,则使用此版本 | -| `name` | string | 必填 | - | 规则名称和`rules[].name`关联, | -| `enabled` | boolean | 必填 | - | 是否启动当前灰度规则 | +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|--------|--------|------|-----|-------------------------------------------------| +| `version` | string | 必填 | - | Gray版本的版本号,如果命中灰度规则,则使用此版本。如果是非CDN部署,在header添加`x-higress-tag` | +| `backendVersion` | string | 必填 | - | 后端灰度版本,会在`XHR/Fetch`请求的header头添加 `x-mse-tag`到后端 | +| `name` | string | 必填 | - | 规则名称和`rules[].name`关联, | +| `enabled` | boolean | 必填 | - | 是否启动当前灰度规则 | ## 配置示例 ### 基础配置 @@ -100,4 +115,51 @@ cookie存在`appInfo`的JSON数据,其中包含`userId`字段为当前的唯 - cookie中`userid`等于`00000002`或者`00000003` - cookie中`level`等于`level3`或者`level5`的用户 -否则使用`version: base`版本 \ No newline at end of file +否则使用`version: base`版本 + +### rewrite重写配置 +> 一般用于CDN部署场景 +```yml +grayKey: userid +rules: +- name: inner-user + grayKeyValue: + - '00000001' + - '00000005' +- name: beta-user + grayKeyValue: + - '00000002' + - '00000003' + grayTagKey: level + grayTagValue: + - level3 + - level5 +rewrite: + host: frontend-gray.oss-cn-shanghai-internal.aliyuncs.com + notFoundUri: /mfe/app1/dev/404.html + indexRouting: + /app1: '/mfe/app1/{version}/index.html' + /: '/mfe/app1/{version}/index.html', + fileRouting: + /: '/mfe/app1/{version}' + /app1/: '/mfe/app1/{version}' +baseDeployment: + version: base +grayDeployments: + - name: beta-user + version: gray + enabled: true +``` + +`{version}`会在运行过程中动态替换为真正的版本 + +#### indexRouting:首页路由配置 +访问 `/app1`, `/app123`,`/app1/index.html`, `/app1/xxx`, `/xxxx` 都会路由到'/mfe/app1/{version}/index.html' + +#### fileRouting:文件路由配置 +下面文件映射均生效 +- `/js/a.js` => `/mfe/app1/v1.0.0/js/a.js` +- `/js/template/a.js` => `/mfe/app1/v1.0.0/js/template/a.js` +- `/app1/js/a.js` => `/mfe/app1/v1.0.0/js/a.js` +- `/app1/js/template/a.js` => `/mfe/app1/v1.0.0/js/template/a.js` + diff --git a/plugins/wasm-go/extensions/frontend-gray/config/config.go b/plugins/wasm-go/extensions/frontend-gray/config/config.go index ce87b816d..efadd07b1 100644 --- a/plugins/wasm-go/extensions/frontend-gray/config/config.go +++ b/plugins/wasm-go/extensions/frontend-gray/config/config.go @@ -1,16 +1,25 @@ package config import ( - "strconv" - "github.com/tidwall/gjson" ) +const ( + XHigressTag = "x-higress-tag" + XPreHigressTag = "x-pre-higress-tag" + XMseTag = "x-mse-tag" + IsHTML = "is_html" + IsIndex = "is_index" + NotFound = "not_found" +) + +type LogInfo func(format string, args ...interface{}) + type GrayRule struct { Name string - GrayKeyValue []interface{} + GrayKeyValue []string GrayTagKey string - GrayTagValue []interface{} + GrayTagValue []string } type BaseDeployment struct { @@ -18,35 +27,46 @@ type BaseDeployment struct { Version string } -type GrayDeployments struct { - Name string - Version string - Enabled bool +type GrayDeployment struct { + Name string + Enabled bool + Version string + BackendVersion string +} + +type Rewrite struct { + Host string + NotFound string + Index map[string]string + File map[string]string } type GrayConfig struct { GrayKey string GraySubKey string Rules []*GrayRule + Rewrite *Rewrite BaseDeployment *BaseDeployment - GrayDeployments []*GrayDeployments + GrayDeployments []*GrayDeployment } -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) - } +func convertToStringList(results []gjson.Result) []string { + interfaces := make([]string, len(results)) // 预分配切片容量 + for i, result := range results { + interfaces[i] = result.String() // 使用 String() 方法直接获取字符串 } return interfaces } +func convertToStringMap(result gjson.Result) map[string]string { + m := make(map[string]string) + result.ForEach(func(key, value gjson.Result) bool { + m[key.String()] = value.String() + return true // keep iterating + }) + return m +} + func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) { // 解析 GrayKey grayConfig.GrayKey = json.Get("grayKey").String() @@ -57,14 +77,20 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) { for _, rule := range rules { grayRule := GrayRule{ Name: rule.Get("name").String(), - GrayKeyValue: interfacesFromJSONResult(rule.Get("grayKeyValue").Array()), // 使用辅助函数将 []gjson.Result 转换为 []interface{} + GrayKeyValue: convertToStringList(rule.Get("grayKeyValue").Array()), GrayTagKey: rule.Get("grayTagKey").String(), - GrayTagValue: interfacesFromJSONResult(rule.Get("grayTagValue").Array()), + GrayTagValue: convertToStringList(rule.Get("grayTagValue").Array()), } grayConfig.Rules = append(grayConfig.Rules, &grayRule) } + grayConfig.Rewrite = &Rewrite{ + Host: json.Get("rewrite.host").String(), + NotFound: json.Get("rewrite.notFoundUri").String(), + Index: convertToStringMap(json.Get("rewrite.indexRouting")), + File: convertToStringMap(json.Get("rewrite.fileRouting")), + } - // 解析 deploy + // 解析 deployment baseDeployment := json.Get("baseDeployment") grayDeployments := json.Get("grayDeployments").Array() @@ -73,10 +99,11 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) { 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(), + grayConfig.GrayDeployments = append(grayConfig.GrayDeployments, &GrayDeployment{ + Name: item.Get("name").String(), + Enabled: item.Get("enabled").Bool(), + Version: item.Get("version").String(), + BackendVersion: item.Get("backendVersion").String(), }) } } diff --git a/plugins/wasm-go/extensions/frontend-gray/envoy.yaml b/plugins/wasm-go/extensions/frontend-gray/envoy.yaml index 63c6ec0d2..ec7620bf0 100644 --- a/plugins/wasm-go/extensions/frontend-gray/envoy.yaml +++ b/plugins/wasm-go/extensions/frontend-gray/envoy.yaml @@ -47,8 +47,7 @@ static_resources: "@type": "type.googleapis.com/google.protobuf.StringValue" value: | { - "grayKey": "UserInfo", - "graySubKey": "userCode", + "grayKey": "userId", "rules": [ { "name": "inner-user", @@ -70,13 +69,26 @@ static_resources: ] } ], + "rewrite": { + "host": "frontend-gray-cn-shanghai.oss-cn-shanghai-internal.aliyuncs.com", + "notFoundUri": "/mfe/app1/dev/404.html", + "indexRouting": { + "/app1": "/mfe/app1/{version}/index.html", + "/": "/mfe/app1/{version}/index.html" + }, + "fileRouting": { + "/": "/mfe/app1/{version}", + "/app1": "/mfe/app1/{version}" + } + }, "baseDeployment": { - "version": "base" + "version": "dev" }, "grayDeployments": [ { "name": "beta-user", - "version": "gray", + "version": "0.0.1", + "backendVersion": "beta", "enabled": true } ] @@ -98,5 +110,5 @@ static_resources: - endpoint: address: socket_address: - address: httpbin.org + address: frontend-gray-cn-shanghai.oss-cn-shanghai.aliyuncs.com port_value: 80 \ No newline at end of file diff --git a/plugins/wasm-go/extensions/frontend-gray/go.mod b/plugins/wasm-go/extensions/frontend-gray/go.mod index 835da044f..574fdc4e9 100644 --- a/plugins/wasm-go/extensions/frontend-gray/go.mod +++ b/plugins/wasm-go/extensions/frontend-gray/go.mod @@ -5,9 +5,9 @@ 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/alibaba/higress/plugins/wasm-go v1.4.3-0.20240727022514-bccfbde62188 github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.9.0 github.com/tidwall/gjson v1.17.0 ) diff --git a/plugins/wasm-go/extensions/frontend-gray/go.sum b/plugins/wasm-go/extensions/frontend-gray/go.sum index 11f68bcbf..a14393574 100644 --- a/plugins/wasm-go/extensions/frontend-gray/go.sum +++ b/plugins/wasm-go/extensions/frontend-gray/go.sum @@ -1,24 +1,20 @@ -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+incompatible/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg= github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/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/pmezard/go-difflib v1.0.0+incompatible/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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= @@ -27,7 +23,6 @@ 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/frontend-gray/main.go b/plugins/wasm-go/extensions/frontend-gray/main.go index 5d4ecaed4..365c200f9 100644 --- a/plugins/wasm-go/extensions/frontend-gray/main.go +++ b/plugins/wasm-go/extensions/frontend-gray/main.go @@ -1,6 +1,10 @@ package main import ( + "fmt" + "net/http" + "strings" + "github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config" "github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/util" @@ -15,6 +19,9 @@ func main() { "frontend-gray", wrapper.ParseConfigBy(parseConfig), wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + wrapper.ProcessResponseHeadersBy(onHttpResponseHeader), + wrapper.ProcessResponseBodyBy(onHttpResponseBody), + wrapper.ProcessStreamingResponseBodyBy(onStreamingResponseBody), ) } @@ -24,55 +31,146 @@ func parseConfig(json gjson.Result, grayConfig *config.GrayConfig, log wrapper.L 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 - } - } +func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig, log wrapper.Log) types.Action { + if !util.IsGrayEnabled(grayConfig) { + return types.ActionContinue } - log.Infof("x-mse-tag: %s, grayKeyValue: %s", grayConfig.BaseDeployment.Version, grayKeyValue) - return nil + + cookies, _ := proxywasm.GetHttpRequestHeader("cookie") + path, _ := proxywasm.GetHttpRequestHeader(":path") + fetchMode, _ := proxywasm.GetHttpRequestHeader("sec-fetch-mode") + + isIndex := util.IsIndexRequest(fetchMode, path) + hasRewrite := len(grayConfig.Rewrite.File) > 0 || len(grayConfig.Rewrite.Index) > 0 + grayKeyValue := util.GetGrayKey(util.ExtractCookieValueByKey(cookies, grayConfig.GrayKey), grayConfig.GraySubKey) + + // 如果有重写的配置,则进行重写 + if hasRewrite { + // 禁止重新路由,要在更改Header之前操作,否则会失效 + ctx.DisableReroute() + } + + // 删除Accept-Encoding,避免压缩, 如果是压缩的内容,后续插件就没法处理了 + _ = proxywasm.RemoveHttpRequestHeader("Accept-Encoding") + _ = proxywasm.RemoveHttpRequestHeader("Content-Length") + + grayDeployment := util.FilterGrayRule(&grayConfig, grayKeyValue, log.Infof) + frontendVersion := util.GetVersion(grayConfig.BaseDeployment.Version, cookies, isIndex) + backendVersion := "" + + // 命中灰度规则 + if grayDeployment != nil { + frontendVersion = util.GetVersion(grayDeployment.Version, cookies, isIndex) + backendVersion = grayDeployment.BackendVersion + } + + proxywasm.AddHttpRequestHeader(config.XHigressTag, frontendVersion) + + ctx.SetContext(config.XPreHigressTag, frontendVersion) + ctx.SetContext(config.XMseTag, backendVersion) + ctx.SetContext(config.IsIndex, isIndex) + + rewrite := grayConfig.Rewrite + if rewrite.Host != "" { + proxywasm.ReplaceHttpRequestHeader("HOST", rewrite.Host) + } + + if hasRewrite { + rewritePath := path + if isIndex { + rewritePath = util.IndexRewrite(path, frontendVersion, grayConfig.Rewrite.Index) + } else { + rewritePath = util.PrefixFileRewrite(path, frontendVersion, grayConfig.Rewrite.File) + } + log.Infof("rewrite path: %s %s %v", path, frontendVersion, rewritePath) + proxywasm.ReplaceHttpRequestHeader(":path", rewritePath) + } + + return types.ActionContinue } -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 +func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig, log wrapper.Log) types.Action { + if !util.IsGrayEnabled(grayConfig) { + return types.ActionContinue } - // 如果有子key, 尝试从子key中获取值 - if grayConfig.GraySubKey != "" { - subKeyValue := util.GetBySubKey(grayKeyValue, grayConfig.GraySubKey) - if subKeyValue != "" { - grayKeyValue = subKeyValue + status, err := proxywasm.GetHttpResponseHeader(":status") + contentType, _ := proxywasm.GetHttpResponseHeader("Content-Type") + if err != nil || status != "200" { + isIndex := ctx.GetContext(config.IsIndex) + if status == "404" { + if grayConfig.Rewrite.NotFound != "" && isIndex != nil && isIndex.(bool) { + ctx.SetContext(config.NotFound, true) + responseHeaders, _ := proxywasm.GetHttpResponseHeaders() + headersMap := util.ConvertHeaders(responseHeaders) + headersMap[":status"][0] = "200" + headersMap["content-type"][0] = "text/html" + delete(headersMap, "content-length") + proxywasm.ReplaceHttpResponseHeaders(util.ReconvertHeaders(headersMap)) + ctx.BufferResponseBody() + return types.ActionContinue + } else { + ctx.DontReadResponseBody() + } } + log.Errorf("error status: %s, error message: %v", status, err) + return types.ActionContinue } - grayDeployment := FilterGrayRule(&grayConfig, grayKeyValue, log) - if grayDeployment != nil { - proxywasm.AddHttpRequestHeader("x-mse-tag", grayDeployment.Version) + + // 删除content-length,可能要修改Response返回值 + proxywasm.RemoveHttpResponseHeader("Content-Length") + + // 删除Content-Disposition,避免自动下载文件 + proxywasm.RemoveHttpResponseHeader("Content-Disposition") + + if strings.HasPrefix(contentType, "text/html") { + ctx.SetContext(config.IsHTML, true) + // 不会进去Streaming 的Body处理 + ctx.BufferResponseBody() + + // 添加Cache-Control 头部,禁止缓存 + proxywasm.ReplaceHttpRequestHeader("Cache-Control", "no-cache, no-store") + + frontendVersion := ctx.GetContext(config.XPreHigressTag).(string) + backendVersion := ctx.GetContext(config.XMseTag).(string) + + // 设置当前的前端版本 + proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Path=/;", config.XPreHigressTag, frontendVersion)) + // 设置后端的前端版本 + proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Path=/;", config.XMseTag, backendVersion)) } return types.ActionContinue } + +func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, body []byte, log wrapper.Log) types.Action { + if !util.IsGrayEnabled(grayConfig) { + return types.ActionContinue + } + backendVersion := ctx.GetContext(config.XMseTag) + isHtml := ctx.GetContext(config.IsHTML) + isIndex := ctx.GetContext(config.IsIndex) + notFoundUri := ctx.GetContext(config.NotFound) + if isIndex != nil && isIndex.(bool) && notFoundUri != nil && notFoundUri.(bool) && grayConfig.Rewrite.Host != "" && grayConfig.Rewrite.NotFound != "" { + client := wrapper.NewClusterClient(wrapper.RouteCluster{Host: grayConfig.Rewrite.Host}) + client.Get(grayConfig.Rewrite.NotFound, nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) { + proxywasm.ReplaceHttpResponseBody(responseBody) + proxywasm.ResumeHttpResponse() + }, 1500) + return types.ActionPause + } + + // 以text/html 开头,将 cookie转到cookie + if isHtml != nil && isHtml.(bool) && backendVersion != nil && backendVersion.(string) != "" { + newText := strings.ReplaceAll(string(body), "", ` + `) + if err := proxywasm.ReplaceHttpResponseBody([]byte(newText)); err != nil { + return types.ActionContinue + } + } + return types.ActionContinue +} + +func onStreamingResponseBody(ctx wrapper.HttpContext, pluginConfig config.GrayConfig, chunk []byte, isLastChunk bool, log wrapper.Log) []byte { + return chunk +} diff --git a/plugins/wasm-go/extensions/frontend-gray/util/utils.go b/plugins/wasm-go/extensions/frontend-gray/util/utils.go index 9faa96082..9b0cb5208 100644 --- a/plugins/wasm-go/extensions/frontend-gray/util/utils.go +++ b/plugins/wasm-go/extensions/frontend-gray/util/utils.go @@ -2,39 +2,56 @@ package util import ( "net/url" + "path" + "path/filepath" + "sort" "strings" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + "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 == "" { +func IsGrayEnabled(grayConfig config.GrayConfig) bool { + // 检查是否存在重写主机 + if grayConfig.Rewrite != nil && grayConfig.Rewrite.Host != "" { + return true + } + + // 检查灰度部署是否为 nil 或空 + grayDeployments := grayConfig.GrayDeployments + if grayDeployments != nil && len(grayDeployments) > 0 { + for _, grayDeployment := range grayDeployments { + if grayDeployment.Enabled { + return true + } + } + } + + return false +} + +// ExtractCookieValueByKey 根据 cookie 和 key 获取 cookie 值 +func ExtractCookieValueByKey(cookie string, key string) string { + if cookie == "" { 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 + value := "" + pairs := strings.Split(cookie, ";") + for _, pair := range pairs { + pair = strings.TrimSpace(pair) + kv := strings.Split(pair, "=") + if kv[0] == key { + value = kv[1] break } } - if !found { - return "" - } - return foundCookieValue + return value } -// contains 检查切片 slice 中是否含有元素 value。 -func Contains(slice []interface{}, value string) bool { +func ContainsValue(slice []string, value string) bool { for _, item := range slice { if item == value { return true @@ -43,6 +60,30 @@ func Contains(slice []interface{}, value string) bool { return false } +// headers: [][2]string -> map[string][]string +func ConvertHeaders(hs [][2]string) map[string][]string { + ret := make(map[string][]string) + for _, h := range hs { + k, v := strings.ToLower(h[0]), h[1] + ret[k] = append(ret[k], v) + } + return ret +} + +// headers: map[string][]string -> [][2]string +func ReconvertHeaders(hs map[string][]string) [][2]string { + var ret [][2]string + for k, vs := range hs { + for _, v := range vs { + ret = append(ret, [2]string{k, v}) + } + } + sort.SliceStable(ret, func(i, j int) bool { + return ret[i][0] < ret[j][0] + }) + return ret +} + func GetRule(rules []*config.GrayRule, name string) *config.GrayRule { for _, rule := range rules { if rule.Name == name { @@ -52,7 +93,66 @@ func GetRule(rules []*config.GrayRule, name string) *config.GrayRule { return nil } -func GetBySubKey(grayInfoStr string, graySubKey string) string { +// 检查是否是页面 +var indexSuffixes = []string{ + ".html", ".htm", ".jsp", ".php", ".asp", ".aspx", ".erb", ".ejs", ".twig", +} + +// IsIndexRequest determines if the request is an index request +func IsIndexRequest(fetchMode string, p string) bool { + if fetchMode == "cors" { + return false + } + ext := path.Ext(p) + return ext == "" || ContainsValue(indexSuffixes, ext) +} + +// 首页Rewrite +func IndexRewrite(path, version string, matchRules map[string]string) string { + for prefix, rewrite := range matchRules { + if strings.HasPrefix(path, prefix) { + newPath := strings.Replace(rewrite, "{version}", version, -1) + return newPath + } + } + return path +} + +func PrefixFileRewrite(path, version string, matchRules map[string]string) string { + var matchedPrefix, replacement string + for prefix, template := range matchRules { + if strings.HasPrefix(path, prefix) { + if len(prefix) > len(matchedPrefix) { // 找到更长的前缀 + matchedPrefix = prefix + replacement = strings.Replace(template, "{version}", version, 1) + } + } + } + // 将path 中的前缀部分用 replacement 替换掉 + newPath := strings.Replace(path, matchedPrefix, replacement+"/", 1) + return filepath.Clean(newPath) +} + +func GetVersion(version string, cookies string, isIndex bool) string { + if isIndex { + return version + } + // 来自Cookie中的版本 + cookieVersion := ExtractCookieValueByKey(cookies, config.XPreHigressTag) + // cookie 中为空,返回当前版本 + if cookieVersion == "" { + return version + } + + // cookie 中和当前版本不相同,返回cookie中值 + if cookieVersion != version { + return cookieVersion + } + return version +} + +// 从cookie中解析出灰度信息 +func getBySubKey(grayInfoStr string, graySubKey string) string { // 首先对 URL 编码的字符串进行解码 jsonStr, err := url.QueryUnescape(grayInfoStr) if err != nil { @@ -68,3 +168,43 @@ func GetBySubKey(grayInfoStr string, graySubKey string) string { // 返回字符串形式的值 return value.String() } + +func GetGrayKey(grayKeyValue string, graySubKey string) string { + // 如果有子key, 尝试从子key中获取值 + if graySubKey != "" { + subKeyValue := getBySubKey(grayKeyValue, graySubKey) + if subKeyValue != "" { + grayKeyValue = subKeyValue + } + } + return grayKeyValue +} + +// FilterGrayRule 过滤灰度规则 +func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, logInfof func(format string, args ...interface{})) *config.GrayDeployment { + for _, grayDeployment := range grayConfig.GrayDeployments { + if !grayDeployment.Enabled { + // 跳过Enabled=false + continue + } + grayRule := GetRule(grayConfig.Rules, grayDeployment.Name) + // 首先:先校验用户名单ID + if grayRule.GrayKeyValue != nil && len(grayRule.GrayKeyValue) > 0 && grayKeyValue != "" { + if ContainsValue(grayRule.GrayKeyValue, grayKeyValue) { + logInfof("frontendVersion: %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 := ExtractCookieValueByKey(cookieStr, grayRule.GrayTagKey) + if ContainsValue(grayRule.GrayTagValue, grayTagValue) { + logInfof("frontendVersion: %s, grayTag: %s=%s", grayDeployment.Version, grayRule.GrayTagKey, grayTagValue) + return grayDeployment + } + } + } + logInfof("frontendVersion: %s, grayKeyValue: %s", grayConfig.BaseDeployment.Version, grayKeyValue) + return nil +} diff --git a/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go b/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go index 13e936794..147c32311 100644 --- a/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go +++ b/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGetValueByCookie(t *testing.T) { +func TestExtractCookieValueByKey(t *testing.T) { var tests = []struct { cookie, cookieKey, output string }{ @@ -19,23 +19,85 @@ func TestGetValueByCookie(t *testing.T) { for _, test := range tests { testName := test.cookie t.Run(testName, func(t *testing.T) { - output := GetValueByCookie(test.cookie, test.cookieKey) + output := ExtractCookieValueByKey(test.cookie, test.cookieKey) assert.Equal(t, test.output, output) }) } } -func TestDecodeJsonCookie(t *testing.T) { +// 测试首页Rewrite重写 +func TestIndexRewrite(t *testing.T) { + matchRules := map[string]string{ + "/app1": "/mfe/app1/{version}/index.html", + "/": "/mfe/app1/{version}/index.html", + } + var tests = []struct { - userInfoStr, grayJsonKey, output string + path, 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"}, + {"/app1/", "/mfe/app1/v1.0.0/index.html"}, + {"/app123", "/mfe/app1/v1.0.0/index.html"}, + {"/app1/index.html", "/mfe/app1/v1.0.0/index.html"}, + {"/app1/index.jsp", "/mfe/app1/v1.0.0/index.html"}, + {"/app1/xxx", "/mfe/app1/v1.0.0/index.html"}, + {"/xxxx", "/mfe/app1/v1.0.0/index.html"}, } for _, test := range tests { - testName := test.userInfoStr + testName := test.path t.Run(testName, func(t *testing.T) { - output := GetBySubKey(test.userInfoStr, test.grayJsonKey) + output := IndexRewrite(testName, "v1.0.0", matchRules) + assert.Equal(t, test.output, output) + }) + } +} + +func TestPrefixFileRewrite(t *testing.T) { + matchRules := map[string]string{ + // 前缀匹配 + "/": "/mfe/app1/{version}", + "/app2/": "/mfe/app1/{version}", + "/app1/": "/mfe/app1/{version}", + "/app1/prefix2": "/mfe/app1/{version}", + "/mfe/app1": "/mfe/app1/{version}", + } + + var tests = []struct { + path, output string + }{ + {"/js/a.js", "/mfe/app1/v1.0.0/js/a.js"}, + {"/app2/js/a.js", "/mfe/app1/v1.0.0/js/a.js"}, + {"/app1/js/a.js", "/mfe/app1/v1.0.0/js/a.js"}, + {"/app1/prefix2/js/a.js", "/mfe/app1/v1.0.0/js/a.js"}, + {"/app1/prefix2/js/a.js", "/mfe/app1/v1.0.0/js/a.js"}, + {"/mfe/app1/js/a.js", "/mfe/app1/v1.0.0/js/a.js"}, + } + for _, test := range tests { + testName := test.path + t.Run(testName, func(t *testing.T) { + output := PrefixFileRewrite(testName, "v1.0.0", matchRules) + assert.Equal(t, test.output, output) + }) + } +} + +func TestIsIndexRequest(t *testing.T) { + var tests = []struct { + fetchMode string + p string + output bool + }{ + {"cors", "/js/a.js", false}, + {"no-cors", "/js/a.js", false}, + {"no-cors", "/images/a.png", false}, + {"no-cors", "/index", true}, + {"cors", "/inde", false}, + {"no-cors", "/index.html", true}, + {"no-cors", "/demo.php", true}, + } + for _, test := range tests { + testPath := test.p + t.Run(testPath, func(t *testing.T) { + output := IsIndexRequest(test.fetchMode, testPath) assert.Equal(t, test.output, output) }) }