diff --git a/plugins/wasm-go/extensions/frontend-gray/README.md b/plugins/wasm-go/extensions/frontend-gray/README.md index 6c5db2c3ac..1711969c90 100644 --- a/plugins/wasm-go/extensions/frontend-gray/README.md +++ b/plugins/wasm-go/extensions/frontend-gray/README.md @@ -17,11 +17,15 @@ description: 前端灰度插件配置参考 | 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | |----------------|--------------|----|-----|----------------------------------------------------------------------------------------------------| | `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灰度的生效规则,以及生效版本 | +| `graySubKey` | string | 非必填 | - | 用户身份信息可能以JSON形式透出,比如:`userInfo:{ userCode:"001" }`,当前例子`graySubKey`取值为`userCode` | +| `userStickyMaxAge` | int | 非必填 | 172800 | 用户粘滞的时长:单位为秒,默认为`172800`,2天时间 | +| `rules` | array of object | 必填 | - | 用户定义不同的灰度规则,适配不同的灰度场景 | +| `rewrite` | object | 必填 | - | 重写配置,一般用于OSS/CDN前端部署的重写配置 | +| `baseDeployment` | object | 非必填 | - | 配置Base基线规则的配置 | +| `grayDeployments` | array of object | 非必填 | - | 配置Gray灰度的生效规则,以及生效版本 | +| `backendGrayTag` | string | 非必填 | `x-mse-tag` | 后端灰度版本Tag,如果配置了,cookie中将携带值为`${backendGrayTag}:${grayDeployments[].backendVersion}` | +| `injection` | object | 非必填 | - | 往首页HTML中注入全局信息,比如`` | + `rules`字段配置说明: @@ -56,12 +60,30 @@ description: 前端灰度插件配置参考 | 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | |--------|--------|------|-----|-------------------------------------------------| | `version` | string | 必填 | - | Gray版本的版本号,如果命中灰度规则,则使用此版本。如果是非CDN部署,在header添加`x-higress-tag` | -| `backendVersion` | string | 必填 | - | 后端灰度版本,会在`XHR/Fetch`请求的header头添加 `x-mse-tag`到后端 | +| `backendVersion` | string | 必填 | - | 后端灰度版本,配合`key`为`${backendGrayTag}`,写入cookie中 | | `name` | string | 必填 | - | 规则名称和`rules[].name`关联, | | `enabled` | boolean | 必填 | - | 是否启动当前灰度规则 | +| `weight` | int | 非必填 | - | 按照比例灰度,比如`50`。注意:灰度规则权重总和不能超过100,如果同时配置了`grayKey`以及`grayDeployments[0].weight`按照比例灰度优先生效 | +> 为了实现按比例(weight) 进行灰度发布,并确保用户粘滞,我们需要确认客户端的唯一性。如果配置了 grayKey,则将其用作唯一标识;如果未配置 grayKey,则使用客户端的访问 IP 地址作为唯一标识。 + + +`injection`字段配置说明: + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|--------|--------|------|-----|-------------------------------------------------| +| `head` | array of string | 非必填 | - | 注入head信息,比如`` | +| `body` | object | 非必填 | - | 注入Body | + +`injection.body`字段配置说明: +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|--------|--------|------|-----|-------------------------------------------------| +| `first` | array of string | 非必填 | - | 注入body标签的首部 | +| `after` | array of string | 非必填 | - | 注入body标签的尾部 | + + ## 配置示例 -### 基础配置 +### 基础配置(按用户灰度) ```yml grayKey: userid rules: @@ -94,6 +116,24 @@ cookie中的用户唯一标识为 `userid`,当前灰度规则配置了`beta-us 否则使用`version: base`版本 +### 按比例灰度 +```yml +grayKey: userid +rules: +- name: inner-user + grayKeyValue: + - '00000001' + - '00000005' +baseDeployment: + version: base +grayDeployments: + - name: beta-user + version: gray + enabled: true + weight: 80 +``` +总的灰度规则为100%,其中灰度版本的权重为`80%`,基线版本为`20%`。一旦用户命中了灰度规则,会根据IP固定这个用户的灰度版本(否则会在下次请求时随机选择一个灰度版本)。 + ### 用户信息存在JSON中 ```yml @@ -174,3 +214,31 @@ grayDeployments: - `/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` + +### 往HTML首页注入代码 +```yml +grayKey: userid +rules: +- name: inner-user + grayKeyValue: + - '00000001' + - '00000005' +baseDeployment: + version: base +grayDeployments: + - name: beta-user + version: gray + enabled: true + weight: 80 +injection: + head: + - + body: + first: + - + - + last: + - + - +``` +通过 `injection`往HTML首页注入代码,可以在`head`标签注入代码,也可以在`body`标签的`first`和`last`位置注入代码。 \ No newline at end of file diff --git a/plugins/wasm-go/extensions/frontend-gray/config/config.go b/plugins/wasm-go/extensions/frontend-gray/config/config.go index efadd07b14..fd1c26b154 100644 --- a/plugins/wasm-go/extensions/frontend-gray/config/config.go +++ b/plugins/wasm-go/extensions/frontend-gray/config/config.go @@ -1,16 +1,17 @@ package config import ( + "strings" + "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" + XHigressTag = "x-higress-tag" + XUniqueClientId = "x-unique-client" + XPreHigressTag = "x-pre-higress-tag" + IsPageRequest = "is-page-request" + IsNotFound = "is-not-found" ) type LogInfo func(format string, args ...interface{}) @@ -22,16 +23,12 @@ type GrayRule struct { GrayTagValue []string } -type BaseDeployment struct { - Name string - Version string -} - -type GrayDeployment struct { +type Deployment struct { Name string Enabled bool Version string BackendVersion string + Weight int } type Rewrite struct { @@ -41,13 +38,27 @@ type Rewrite struct { File map[string]string } +type Injection struct { + Head []string + Body *BodyInjection +} + +type BodyInjection struct { + First []string + Last []string +} + type GrayConfig struct { - GrayKey string - GraySubKey string - Rules []*GrayRule - Rewrite *Rewrite - BaseDeployment *BaseDeployment - GrayDeployments []*GrayDeployment + UserStickyMaxAge string + TotalGrayWeight int + GrayKey string + GraySubKey string + Rules []*GrayRule + Rewrite *Rewrite + BaseDeployment *Deployment + GrayDeployments []*Deployment + BackendGrayTag string + Injection *Injection } func convertToStringList(results []gjson.Result) []string { @@ -71,6 +82,17 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) { // 解析 GrayKey grayConfig.GrayKey = json.Get("grayKey").String() grayConfig.GraySubKey = json.Get("graySubKey").String() + grayConfig.BackendGrayTag = json.Get("backendGrayTag").String() + grayConfig.UserStickyMaxAge = json.Get("userStickyMaxAge").String() + + if grayConfig.UserStickyMaxAge == "" { + // 默认值2天 + grayConfig.UserStickyMaxAge = "172800" + } + + if grayConfig.BackendGrayTag == "" { + grayConfig.BackendGrayTag = "x-mse-tag" + } // 解析 Rules rules := json.Get("rules").Array() @@ -94,16 +116,30 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) { baseDeployment := json.Get("baseDeployment") grayDeployments := json.Get("grayDeployments").Array() - grayConfig.BaseDeployment = &BaseDeployment{ + grayConfig.BaseDeployment = &Deployment{ Name: baseDeployment.Get("name").String(), - Version: baseDeployment.Get("version").String(), + Version: strings.Trim(baseDeployment.Get("version").String(), " "), } for _, item := range grayDeployments { - grayConfig.GrayDeployments = append(grayConfig.GrayDeployments, &GrayDeployment{ + if !item.Get("enabled").Bool() { + continue + } + grayWeight := int(item.Get("weight").Int()) + grayConfig.GrayDeployments = append(grayConfig.GrayDeployments, &Deployment{ Name: item.Get("name").String(), Enabled: item.Get("enabled").Bool(), - Version: item.Get("version").String(), + Version: strings.Trim(item.Get("version").String(), " "), BackendVersion: item.Get("backendVersion").String(), + Weight: grayWeight, }) + grayConfig.TotalGrayWeight += grayWeight + } + + grayConfig.Injection = &Injection{ + Head: convertToStringList(json.Get("injection.head").Array()), + Body: &BodyInjection{ + First: convertToStringList(json.Get("injection.body.first").Array()), + Last: convertToStringList(json.Get("injection.body.last").Array()), + }, } } diff --git a/plugins/wasm-go/extensions/frontend-gray/envoy.yaml b/plugins/wasm-go/extensions/frontend-gray/envoy.yaml index ec7620bf06..f454a7f072 100644 --- a/plugins/wasm-go/extensions/frontend-gray/envoy.yaml +++ b/plugins/wasm-go/extensions/frontend-gray/envoy.yaml @@ -48,6 +48,8 @@ static_resources: value: | { "grayKey": "userId", + "backendGrayTag": "x-mse-tag", + "userStickyMaxAge": 172800, "rules": [ { "name": "inner-user", @@ -71,7 +73,7 @@ static_resources: ], "rewrite": { "host": "frontend-gray-cn-shanghai.oss-cn-shanghai-internal.aliyuncs.com", - "notFoundUri": "/mfe/app1/dev/404.html", + "notFoundUri": "/mfe/app1/{version}/333.html", "indexRouting": { "/app1": "/mfe/app1/{version}/index.html", "/": "/mfe/app1/{version}/index.html" @@ -88,10 +90,25 @@ static_resources: { "name": "beta-user", "version": "0.0.1", - "backendVersion": "beta", - "enabled": true + "enabled": true, + "weight": 50 } - ] + ], + "injection": { + "head": [ + "" + ], + "body": { + "first": [ + "", + "" + ], + "last": [ + "", + "" + ] + } + } } - name: envoy.filters.http.router typed_config: diff --git a/plugins/wasm-go/extensions/frontend-gray/main.go b/plugins/wasm-go/extensions/frontend-gray/main.go index 365c200f95..e1fc7a8b83 100644 --- a/plugins/wasm-go/extensions/frontend-gray/main.go +++ b/plugins/wasm-go/extensions/frontend-gray/main.go @@ -28,6 +28,7 @@ func main() { func parseConfig(json gjson.Result, grayConfig *config.GrayConfig, log wrapper.Log) error { // 解析json 为GrayConfig config.JsonToGrayConfig(json, grayConfig) + log.Infof("Rewrite: %v, GrayDeployments: %v", json.Get("rewrite"), json.Get("grayDeployments")) return nil } @@ -40,10 +41,12 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig, path, _ := proxywasm.GetHttpRequestHeader(":path") fetchMode, _ := proxywasm.GetHttpRequestHeader("sec-fetch-mode") - isIndex := util.IsIndexRequest(fetchMode, path) + isPageRequest := util.IsPageRequest(fetchMode, path) hasRewrite := len(grayConfig.Rewrite.File) > 0 || len(grayConfig.Rewrite.Index) > 0 - grayKeyValue := util.GetGrayKey(util.ExtractCookieValueByKey(cookies, grayConfig.GrayKey), grayConfig.GraySubKey) - + grayKeyValueByCookie := util.ExtractCookieValueByKey(cookies, grayConfig.GrayKey) + grayKeyValueByHeader, _ := proxywasm.GetHttpRequestHeader(grayConfig.GrayKey) + // 优先从cookie中获取,否则从header中获取 + grayKeyValue := util.GetGrayKey(grayKeyValueByCookie, grayKeyValueByHeader, grayConfig.GraySubKey) // 如果有重写的配置,则进行重写 if hasRewrite { // 禁止重新路由,要在更改Header之前操作,否则会失效 @@ -53,22 +56,34 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig, // 删除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 + deployment := &config.Deployment{} + + preVersion, preUniqueClientId := util.GetXPreHigressVersion(cookies) + // 客户端唯一ID,用于在按照比率灰度时候 客户访问黏贴 + uniqueClientId := grayKeyValue + if uniqueClientId == "" { + xForwardedFor, _ := proxywasm.GetHttpRequestHeader("X-Forwarded-For") + uniqueClientId = util.GetRealIpFromXff(xForwardedFor) } - proxywasm.AddHttpRequestHeader(config.XHigressTag, frontendVersion) + // 如果没有配置比例,则进行灰度规则匹配 + if isPageRequest { + log.Infof("grayConfig.TotalGrayWeight==== %v", grayConfig.TotalGrayWeight) + if grayConfig.TotalGrayWeight > 0 { + deployment = util.FilterGrayWeight(&grayConfig, preVersion, preUniqueClientId, uniqueClientId) + } else { + deployment = util.FilterGrayRule(&grayConfig, grayKeyValue) + } + log.Infof("index deployment: %v, path: %v, backend: %v, xPreHigressVersion: %s,%s", deployment, path, deployment.BackendVersion, preVersion, preUniqueClientId) + } else { + deployment = util.GetVersion(grayConfig, deployment, preVersion, isPageRequest) + } + proxywasm.AddHttpRequestHeader(config.XHigressTag, deployment.Version) - ctx.SetContext(config.XPreHigressTag, frontendVersion) - ctx.SetContext(config.XMseTag, backendVersion) - ctx.SetContext(config.IsIndex, isIndex) + ctx.SetContext(config.XPreHigressTag, deployment.Version) + ctx.SetContext(grayConfig.BackendGrayTag, deployment.BackendVersion) + ctx.SetContext(config.IsPageRequest, isPageRequest) + ctx.SetContext(config.XUniqueClientId, uniqueClientId) rewrite := grayConfig.Rewrite if rewrite.Host != "" { @@ -77,12 +92,12 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig, if hasRewrite { rewritePath := path - if isIndex { - rewritePath = util.IndexRewrite(path, frontendVersion, grayConfig.Rewrite.Index) + if isPageRequest { + rewritePath = util.IndexRewrite(path, deployment.Version, grayConfig.Rewrite.Index) } else { - rewritePath = util.PrefixFileRewrite(path, frontendVersion, grayConfig.Rewrite.File) + rewritePath = util.PrefixFileRewrite(path, deployment.Version, grayConfig.Rewrite.File) } - log.Infof("rewrite path: %s %s %v", path, frontendVersion, rewritePath) + log.Infof("rewrite path: %s %s %v", path, deployment.Version, rewritePath) proxywasm.ReplaceHttpRequestHeader(":path", rewritePath) } @@ -95,15 +110,34 @@ func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig, } status, err := proxywasm.GetHttpResponseHeader(":status") contentType, _ := proxywasm.GetHttpResponseHeader("Content-Type") + + if grayConfig.Rewrite != nil && grayConfig.Rewrite.Host != "" { + // 删除Content-Disposition,避免自动下载文件 + proxywasm.RemoveHttpResponseHeader("Content-Disposition") + } + + isPageRequest, ok := ctx.GetContext(config.IsPageRequest).(bool) + if !ok { + isPageRequest = false // 默认值 + } + 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) + if grayConfig.Rewrite.NotFound != "" && isPageRequest { + ctx.SetContext(config.IsNotFound, true) responseHeaders, _ := proxywasm.GetHttpResponseHeaders() headersMap := util.ConvertHeaders(responseHeaders) - headersMap[":status"][0] = "200" - headersMap["content-type"][0] = "text/html" + if _, ok := headersMap[":status"]; !ok { + headersMap[":status"] = []string{"200"} // 如果没有初始化,设定默认值 + } else { + headersMap[":status"][0] = "200" // 修改现有值 + } + if _, ok := headersMap["content-type"]; !ok { + headersMap["content-type"] = []string{"text/html"} // 如果没有初始化,设定默认值 + } else { + headersMap["content-type"][0] = "text/html" // 修改现有值 + } + // 删除 content-length 键 delete(headersMap, "content-length") proxywasm.ReplaceHttpResponseHeaders(util.ReconvertHeaders(headersMap)) ctx.BufferResponseBody() @@ -119,24 +153,22 @@ func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig, // 删除content-length,可能要修改Response返回值 proxywasm.RemoveHttpResponseHeader("Content-Length") - // 删除Content-Disposition,避免自动下载文件 - proxywasm.RemoveHttpResponseHeader("Content-Disposition") - - if strings.HasPrefix(contentType, "text/html") { - ctx.SetContext(config.IsHTML, true) + if strings.HasPrefix(contentType, "text/html") || isPageRequest { // 不会进去Streaming 的Body处理 ctx.BufferResponseBody() - // 添加Cache-Control 头部,禁止缓存 - proxywasm.ReplaceHttpRequestHeader("Cache-Control", "no-cache, no-store") + proxywasm.ReplaceHttpResponseHeader("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)) + xUniqueClient := ctx.GetContext(config.XUniqueClientId).(string) + + // 设置前端的版本 + proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s,%s; Max-Age=%s; Path=/;", config.XPreHigressTag, frontendVersion, xUniqueClient, grayConfig.UserStickyMaxAge)) + // 设置后端的版本 + if util.IsBackendGrayEnabled(grayConfig) { + backendVersion := ctx.GetContext(grayConfig.BackendGrayTag).(string) + proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%s; Path=/;", grayConfig.BackendGrayTag, backendVersion, grayConfig.UserStickyMaxAge)) + } } return types.ActionContinue } @@ -145,26 +177,52 @@ func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, b 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 != "" { + isPageRequest, ok := ctx.GetContext(config.IsPageRequest).(bool) + if !ok { + isPageRequest = false // 默认值 + } + frontendVersion := ctx.GetContext(config.XPreHigressTag).(string) + + isNotFound, ok := ctx.GetContext(config.IsNotFound).(bool) + if !ok { + isNotFound = false // 默认值 + } + + if isPageRequest && isNotFound && 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) { + + client.Get(strings.Replace(grayConfig.Rewrite.NotFound, "{version}", frontendVersion, -1), 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 { + if isPageRequest { + // 将原始字节转换为字符串 + newBody := string(body) + + // 收集需要插入的内容 + headInjection := strings.Join(grayConfig.Injection.Head, "\n") + bodyFirstInjection := strings.Join(grayConfig.Injection.Body.First, "\n") + bodyLastInjection := strings.Join(grayConfig.Injection.Body.Last, "\n") + + // 使用 strings.Builder 来提高性能 + var sb strings.Builder + // 预分配内存,避免多次内存分配 + sb.Grow(len(newBody) + len(headInjection) + len(bodyFirstInjection) + len(bodyLastInjection)) + sb.WriteString(newBody) + + // 进行替换 + content := sb.String() + content = strings.ReplaceAll(content, "", fmt.Sprintf("%s\n", headInjection)) + content = strings.ReplaceAll(content, "", fmt.Sprintf("\n%s", bodyFirstInjection)) + content = strings.ReplaceAll(content, "", fmt.Sprintf("%s\n", bodyLastInjection)) + + // 最终结果 + newBody = content + + if err := proxywasm.ReplaceHttpResponseBody([]byte(newBody)); err != nil { return types.ActionContinue } } diff --git a/plugins/wasm-go/extensions/frontend-gray/util/utils.go b/plugins/wasm-go/extensions/frontend-gray/util/utils.go index 9b0cb52080..a8c096816e 100644 --- a/plugins/wasm-go/extensions/frontend-gray/util/utils.go +++ b/plugins/wasm-go/extensions/frontend-gray/util/utils.go @@ -1,11 +1,14 @@ package util import ( + "fmt" + "math/rand" "net/url" "path" "path/filepath" "sort" "strings" + "time" "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" @@ -14,22 +17,53 @@ import ( "github.com/tidwall/gjson" ) +func LogInfof(format string, args ...interface{}) { + format = fmt.Sprintf("[%s] %s", "frontend-gray", format) + proxywasm.LogInfof(format, args...) +} + +func GetXPreHigressVersion(cookies string) (string, string) { + xPreHigressVersion := ExtractCookieValueByKey(cookies, config.XPreHigressTag) + preVersions := strings.Split(xPreHigressVersion, ",") + if len(preVersions) == 0 { + return "", "" + } + if len(preVersions) == 1 { + return preVersions[0], "" + } + + return strings.TrimSpace(preVersions[0]), strings.TrimSpace(preVersions[1]) +} + +// 从xff中获取真实的IP +func GetRealIpFromXff(xff string) string { + if xff != "" { + // 通常客户端的真实 IP 是 XFF 头中的第一个 IP + ips := strings.Split(xff, ",") + if len(ips) > 0 { + return strings.TrimSpace(ips[0]) + } + } + return "" +} + 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 len(grayConfig.GrayDeployments) > 0 +} + +// 是否启用后端的灰度(全链路灰度) +func IsBackendGrayEnabled(grayConfig config.GrayConfig) bool { + for _, deployment := range grayConfig.GrayDeployments { + if deployment.BackendVersion != "" { + return true } } - return false } @@ -98,12 +132,11 @@ 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 { +func IsPageRequest(fetchMode string, myPath string) bool { if fetchMode == "cors" { return false } - ext := path.Ext(p) + ext := path.Ext(myPath) return ext == "" || ContainsValue(indexSuffixes, ext) } @@ -133,22 +166,25 @@ func PrefixFileRewrite(path, version string, matchRules map[string]string) strin return filepath.Clean(newPath) } -func GetVersion(version string, cookies string, isIndex bool) string { - if isIndex { - return version +func GetVersion(grayConfig config.GrayConfig, deployment *config.Deployment, xPreHigressVersion string, isPageRequest bool) *config.Deployment { + if isPageRequest { + return deployment } - // 来自Cookie中的版本 - cookieVersion := ExtractCookieValueByKey(cookies, config.XPreHigressTag) // cookie 中为空,返回当前版本 - if cookieVersion == "" { - return version + if xPreHigressVersion == "" { + return deployment } // cookie 中和当前版本不相同,返回cookie中值 - if cookieVersion != version { - return cookieVersion + if xPreHigressVersion != deployment.Version { + deployments := append(grayConfig.GrayDeployments, grayConfig.BaseDeployment) + for _, curDeployment := range deployments { + if curDeployment.Version == xPreHigressVersion { + return curDeployment + } + } } - return version + return grayConfig.BaseDeployment } // 从cookie中解析出灰度信息 @@ -169,7 +205,12 @@ func getBySubKey(grayInfoStr string, graySubKey string) string { return value.String() } -func GetGrayKey(grayKeyValue string, graySubKey string) string { +func GetGrayKey(grayKeyValueByCookie string, grayKeyValueByHeader string, graySubKey string) string { + grayKeyValue := grayKeyValueByCookie + if grayKeyValueByCookie == "" { + grayKeyValue = grayKeyValueByHeader + } + // 如果有子key, 尝试从子key中获取值 if graySubKey != "" { subKeyValue := getBySubKey(grayKeyValue, graySubKey) @@ -181,18 +222,13 @@ func GetGrayKey(grayKeyValue string, graySubKey string) string { } // 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) +func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string) *config.Deployment { + for _, deployment := range grayConfig.GrayDeployments { + grayRule := GetRule(grayConfig.Rules, deployment.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 + return deployment } } // 第二:校验Cookie中的 GrayTagKey @@ -200,11 +236,45 @@ func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, logInfof 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 + return deployment + } + } + } + return grayConfig.BaseDeployment +} + +func FilterGrayWeight(grayConfig *config.GrayConfig, preVersion string, preUniqueClientId string, uniqueClientId string) *config.Deployment { + // 如果没有灰度权重,直接返回基础版本 + if grayConfig.TotalGrayWeight == 0 { + return grayConfig.BaseDeployment + } + + deployments := append(grayConfig.GrayDeployments, grayConfig.BaseDeployment) + LogInfof("preVersion: %s, preUniqueClientId: %s, uniqueClientId: %s", preVersion, preUniqueClientId, uniqueClientId) + // 用户粘滞,确保每个用户每次访问的都是走同一版本 + if preVersion != "" && uniqueClientId == preUniqueClientId { + for _, deployment := range deployments { + if deployment.Version == preVersion { + return deployment } } } - logInfof("frontendVersion: %s, grayKeyValue: %s", grayConfig.BaseDeployment.Version, grayKeyValue) + + totalWeight := 100 + // 如果总权重小于100,则将基础版本也加入到总版本列表中 + if grayConfig.TotalGrayWeight <= totalWeight { + grayConfig.BaseDeployment.Weight = 100 - grayConfig.TotalGrayWeight + } else { + totalWeight = grayConfig.TotalGrayWeight + } + rand.Seed(time.Now().UnixNano()) + randWeight := rand.Intn(totalWeight) + sumWeight := 0 + for _, deployment := range deployments { + sumWeight += deployment.Weight + if randWeight < sumWeight { + return deployment + } + } 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 147c32311b..7ba014225f 100644 --- a/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go +++ b/plugins/wasm-go/extensions/frontend-gray/util/utils_test.go @@ -3,7 +3,9 @@ package util import ( "testing" + "github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config" "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" ) func TestExtractCookieValueByKey(t *testing.T) { @@ -80,7 +82,7 @@ func TestPrefixFileRewrite(t *testing.T) { } } -func TestIsIndexRequest(t *testing.T) { +func TestIsPageRequest(t *testing.T) { var tests = []struct { fetchMode string p string @@ -97,8 +99,26 @@ func TestIsIndexRequest(t *testing.T) { for _, test := range tests { testPath := test.p t.Run(testPath, func(t *testing.T) { - output := IsIndexRequest(test.fetchMode, testPath) + output := IsPageRequest(test.fetchMode, testPath) assert.Equal(t, test.output, output) }) } } + +func TestFilterGrayWeight(t *testing.T) { + var tests = []struct { + name string + input string + }{ + {"demo", `{"grayKey":"userId","rules":[{"name":"inner-user","grayKeyValue":["00000001","00000005"]},{"name":"beta-user","grayKeyValue":["noah","00000003"],"grayTagKey":"level","grayTagValue":["level3","level5"]}],"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":"dev"},"grayDeployments":[{"name":"beta-user","version":"0.0.1","backendVersion":"beta","enabled":true,"weight":50}]}`}, + } + for _, test := range tests { + testName := test.name + t.Run(testName, func(t *testing.T) { + grayConfig := &config.GrayConfig{} + config.JsonToGrayConfig(gjson.Parse(test.input), grayConfig) + result := FilterGrayWeight(grayConfig, "base", "1.0.1", "192.168.1.1") + t.Logf("result-----: %v", result) + }) + } +}