VKProxy新增CORS设置和http响应缓存

VKProxy 是使用c#开发的基于 Kestrel 实现 L4/L7的代理(感兴趣的同学烦请点个github小赞赞呢)

目前新添加了如下功能

  • http响应缓存
    • Memory
    • Disk
    • Redis
  • CORS设置
  • log 配置随机概率采样
  • A/B 测试和滚动升级

http响应缓存

响应缓存可减少客户端或代理对 Web 服务器发出的请求数。 响应缓存还减少了 Web 服务器为生成响应而执行的工作量。 响应缓存在标头中设置。

客户端和中间代理应遵循 RFC 9111:HTTP 缓存下缓存响应的标头。

目前暂时内置只内存缓存,不过可以通过扩展接口扩展

缓存条件

  • 请求条件

    • 默认条件

      • 请求方法必须是 GETHEAD
      • 不能出现 Authorization 标头。
      • 不能存在 Cache-Control: no-cachePragma: no-cache
      • 不能存在 Cache-Control: no-store
    • 自定义条件

      可以通过在Metadata中设置 CacheWhen 自定义条件(一旦设置,默认条件将不起作用), 格式参见 如何为HTTP配置路由复杂匹配

      不过依然不能存在 Cache-Control: no-store,避免缓存错误时无法处理

  • 响应条件

    • 缓存协商条件

      • 请求必须生成带有 200 (OK) 状态代码的服务器响应。
      • 不能出现 Set-Cookie 标头。
      • Cache-Control 标头参数必须是有效的,并且必须将响应标记为 public 而不是 private。
      • 不能存在 Cache-Control: no-store
      • 不能存在 Cache-Control: no-cache
      • Vary 标头参数必须有效且不等于 *。
      • Content-Length 标头值(若已设置)必须与响应正文的大小匹配。
      • 根据 Expires 标头与 max-age 和 s-maxage 缓存指令所指定,响应不能过时。
      • 响应缓冲必须成功。 响应的大小必须小于配置的或默认的 SizeLimit。 响应的正文大小必须小于配置的或默认的 MaximumBodySize。
      • 响应必须可根据 RFC 9111:HTTP 缓存进行缓存。 例如,no-store 指令不能出现在请求头或响应头字段中。 有关详细信息,请参阅 RFC 9111:HTTP 缓存(第 3 节"在缓存中存储响应")。
    • 强制缓存

      可以通过在Metadata中设置 ForceCachetrue 忽略响应缓存条件标准,不过程序如果遵从响应缓存协商标准还是使用标准最好,以免缓存错误

      强制缓存条件下,只有以下限制:

      • 请求必须生成带有 200 (OK) 状态代码的服务器响应。
      • 不能出现 Set-Cookie 标头。

缓存设置

大家可以在Metadata中设置缓存, 具体设置项如下

  • Cache

    缓存方式, 内置缓存有 (还可以通过扩展接口扩展,然后大家在此设置)

    • Memory

      基于 MemoryCache 实现

    • Disk

      缓存会实际存于磁盘物理文件

    • Redis

      启用 Redis 情况还可以缓存导redis中

  • CacheWhen

    设置自定义条件(一旦设置,默认条件将不起作用,不设置则使用默认条件), 格式参见 如何为HTTP配置路由复杂匹配

  • CacheKey

    通过内置"简略版模板引擎",大家可以灵活设置缓存key,如 "CacheKey": "{Method}_{Path}" 效果等同于 $"{RouteKey}#{Method}_{Path}", 值结果可为 Route1#GET_/FAQ

    具体设置内容后文详细说明,当然如模板效果不够,大家还可以通过扩展替换引入真正更为强大的模板引擎

  • CacheMaximumBodySize

    可以设置允许缓存Body的最大大小,超过则不会缓存,默认 64 * 1024 * 1024 ,即 64MB

  • ForceCache

    设置 ForceCachetrue 忽略响应缓存条件标准,不过程序如果遵从响应缓存协商标准还是使用标准最好,以免缓存错误

    强制缓存条件下,只有以下限制:

    • 请求必须生成带有 200 (OK) 状态代码的服务器响应。
    • 不能出现 Set-Cookie 标头。
  • CacheTime

    缓存时间,TimeSpan 格式,不设置采用标头中缓存协商结果,否则使用设置值

CacheKey内置"简略版模板引擎"

为了方便大家使用,内置实现了一套简单的数据源为 HttpContext的"简略版模板引擎",

格式为 {数据},只要被{}包裹的数据都会在运行时替换为当前实时数据结果, (如需原样{},则可通过双重转义, 如 {{123}}{Path} 结果为 {123}/FAQ)

具体数据可采用列表如下

  • Path

    获取或设置标识所请求资源的请求路径部分。

    如果 PathBase 包含完整路径,则该值可以是 Empty ;对于"OPTIONS *"请求,该值可以是 。 除"%2F"外,服务器将完全解码路径,该路径将解码为"/"并更改路径段的含义。 "%2F"只能在将路径拆分为段后替换。

  • PathBase

    获取或设置请求的基路径。 路径基不应以尾部斜杠结尾。

  • Method

    获取或设置 HTTP 方法。

  • Scheme

    获取或设置 HTTP 请求方案。

  • IsHttps

    如果 RequestScheme 为 https,则返回 true。

  • Protocol

    获取或设置请求协议 (例如 HTTP/1.1) 。

  • ContentType

    获取或设置 Content-Type 标头。

  • ContentLength

    获取或设置 Content-Length 标头。

  • Host

    获取或设置 Host 标头。 可以包含端口。

  • QueryString

    获取或设置用于在 Request.Query 中创建查询集合的原始查询字符串。

  • HasFormContentType

    检查表单类型的 Content-Type 标头。

如下为动态集合字段,需要再指定Key, 格式为 [Http字段]([Key]), 如获取User-Agent则为Header('User-Agent')

  • Header

    获取请求标头。

  • Query

    获取从 Request.QueryString 分析的查询值集合。

  • Cookie

    获取此请求的 Cookie 集合。

  • Form

    获取或设置窗体形式的请求正文。

可通过扩展替换模板引擎

如简单的不足以满足大家所需,大家可以通过扩展替换模板引擎,

只需实现如下接口

csharp 复制代码
public interface ITemplateStatementFactory
{
    public Func<HttpContext, string> Convert(string template);
}

然后通过 di 替换

csharp 复制代码
services.AddSingleton<ITemplateStatementFactory, XXXTemplateStatementFactory>();

可通过扩展替换缓存存储

如内存缓存不足以满足大家所需,大家可以通过扩展替换

只需实现如下接口

csharp 复制代码
public interface IResponseCache
{
    string Name { get; }

    ValueTask<CachedResponse?> GetAsync(string key, CancellationToken cancellationToken);

    ValueTask SetAsync(string key, CachedResponse entry, TimeSpan validFor, CancellationToken cancellationToken);
}

然后通过 di 添加

csharp 复制代码
services.AddSingleton<IResponseCache, xxxResponseCache>();

跨域(CORS)设置

跨源资源共享(CORS,或通俗地译为跨域资源共享)是一种基于 HTTP 头的机制,该机制通过允许服务器标示除了它自己以外的其他源(域、协议或端口),使得浏览器允许这些源访问加载自己的资源。跨源资源共享还通过一种机制来检查服务器是否会允许要发送的真实请求,该机制通过浏览器发起一个到服务器托管的跨源资源的"预检"请求。在预检中,浏览器发送的头中标示有 HTTP 方法和真实请求中会用到的头。

跨源 HTTP 请求的一个例子:运行在 https://domain-a.com 的 JavaScript 代码使用 XMLHttpRequest 来发起一个到 https://domain-b.com/data.json 的请求。

出于安全性,浏览器限制脚本内发起的跨源 HTTP 请求。例如,XMLHttpRequest 和 Fetch API 遵循同源策略。这意味着使用这些 API 的 Web 应用程序只能从加载应用程序的同一个域请求 HTTP 资源,除非响应报文包含了正确 CORS 响应头。

CORS 机制允许 Web 应用服务器进行跨源访问控制,从而使跨源数据传输得以安全进行。现代浏览器支持在 API 容器中(例如 XMLHttpRequest 或 Fetch)使用 CORS,以降低跨源 HTTP 请求所带来的风险。

什么情况下需要 CORS?

这份跨源共享标准允许在下列场景中使用跨站点 HTTP 请求:

  • 前文提到的由 XMLHttpRequest 或 Fetch API 发起的跨源 HTTP 请求。
  • Web 字体(CSS 中通过 @font-face 使用跨源字体资源),因此,网站就可以发布 TrueType 字体资源,并只允许已授权网站进行跨站调用。
  • WebGL 贴图。
  • 使用 drawImage() 将图片或视频画面绘制到 canvas。
  • 来自图像的 CSS 图形。

更详细描述可以参见 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Guides/CORS

如何在 VKProxy 设置 CORS

在某些情况,大家可能不想修改已有程序设置跨域,而像api gateway 就有功能可以在代理设置 CORS

通过appsettings.json配置

json 复制代码
{
  "ReverseProxy": {
    "Routes": {
      "b": {
        "Order": 0, 
        "Match": {
            "Hosts": [ "api.com" ],
            "Paths": [ "*" ]
        },
        "ClusterId": "ClusterB",
        // Metadata 中设置相关 header 值
        "Metadata": {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Methods": "POST,PUT"
        }
      }
    },
  }
}

通过ui配置

具体配置项列举

  • Access-Control-Allow-Origin

    参数指定了单一的源,告诉浏览器允许该源访问资源。或者,对于不需要携带身份凭证的请求,服务器可以指定该字段的值为通配符"*",表示允许来自任意源的请求。

  • Access-Control-Allow-Origin-Regex

    Access-Control-Allow-Origin 作用一致,不过通过正则表达式让大家能有更加灵活或者复杂的控制能力。

  • Access-Control-Allow-Headers

    标头字段用于预检请求的响应。其指明了实际请求中允许携带的标头字段。这个标头是服务器端对浏览器端 Access-Control-Request-Headers 标头的响应。

  • Access-Control-Allow-Methods

    标头字段指定了访问资源时允许使用的请求方法,用于预检请求的响应。其指明了实际请求所允许使用的 HTTP 方法。

  • Access-Control-Allow-Credentials

    指定了当浏览器的 credentials 设置为 true 时是否允许浏览器读取 response 的内容。当用在对 preflight 预检测请求的响应中时,它指定了实际的请求是否可以使用 credentials。请注意:简单 GET 请求不会被预检;如果对此类请求的响应中不包含该字段,这个响应将被忽略掉,并且浏览器也不会将相应内容返回给网页。

  • Access-Control-Max-Age

    指定了 preflight 请求的结果能够被缓存多久,

  • Access-Control-Expose-Headers

    在跨源访问时,XMLHttpRequest 对象的 getResponseHeader() 方法只能拿到一些最基本的响应头,Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma,如果要访问其他头,则需要服务器设置本响应头。

日志采样

比如 有时候同学们遇到一些问题也希望通过log检查问题,有些时候这些log在产线又会被关闭

.NET 提供日志采样功能,使你能够控制应用程序发出的日志量,而不会丢失重要信息。 可以使用以下采样策略:

基于跟踪的采样:基于当前跟踪的采样决策采样日志。

随机概率采样:基于配置的概率规则对日志进行采样。

为了方便大家使用 VKProxy也提供相关的功能配置

日志采样通过更精细地控制应用程序发出的日志来扩展 筛选功能 。 与其简单地启用或禁用日志,您可以通过配置采样比例来仅发出部分日志。

例如,虽然筛选通常使用概率( 0 不发出日志)或 1 (发出所有日志),但采样允许你选择介于两者之间的任何值,例如 0.1 发出 10% 个日志,或 0.25 发出 25% 个。

配置随机概率采样

随机概率采样允许你根据配置的概率规则对日志进行采样。 可以定义以下特定规则:

  • 日志类别
  • 日志级别
  • 事件编号

可以在 appsettings.json 配置文件中修改

json 复制代码
{
  "Logging": {
    "LogLevel": {
      "Default": "Debug"
    }
  },

  "RandomProbabilisticSampler": {
    "Rules": [
      {
        "CategoryName": "Microsoft.AspNetCore.*",
        "Probability": 0.25,
        "LogLevel": "Information"
      },
      {
        "CategoryName": "System.*",
        "Probability": 0.1
      },
      {
        "EventId": 1001,
        "Probability": 0.05
      }
    ]
  }
}

运行可以用

shell 复制代码
vkproxy -c D:\code\test\proxy\config.json --sampler random

配置基于跟踪的采样

基于跟踪的采样可确保日志被与底层Activity一致地采样。 如果要在跟踪和日志之间保持相关性,这非常有用。 可以启用跟踪采样

当启用基于跟踪的采样时,仅当基础 Activity 被采样时才会记录日志。 采样决策来自当前 Recorded 值。

运行可以用

shell 复制代码
vkproxy -c D:\code\test\proxy\config.json --sampler trace

A/B 测试和滚动升级

这里简单介绍一下A/B 测试和滚动升级相关场景在Api Gateway (比如VKProxy) 可以怎么做

A/B 测试

A/B 测试(Split Testing)是一种对比实验方法,用于评估两个(或多个)版本之间的效果差异。其核心思想是将用户流量随机分为两组(或多组),分别体验不同版本(A、B、C...),然后通过关键指标(如转化率、点击率、留存率等)的变化,判断哪个版本更优。

A/B 测试的流程

  • 确定目标(如提升注册率、减少跳出率)。
  • 设计对比版本(A为原始版本,B为新版本)。
  • 随机将用户分配到不同组(常用 cookie、用户 ID、灰度发布工具等实现)。
  • 收集和分析数据,统计每组指标。

得出结论,决定是否全面上线新版本。

通过路由区分A/B

在大部分场景,路由足以满足大家区分A/B环境

举例 app新增或者调整了的一些功能,处于稳妥考虑,领导希望优先找一小部分用户体验测试,收集反馈在全面发布前看看是否做一些调整。负责后台api的你采取了简单的 A/B 策略, 要求 测试app 调用后台api时加一个 header x-env: test, 你调整路由通过此header区分允许测试app 访问尚未正式发布的部分api

配置示例:

json 复制代码
{
  "ReverseProxy": {
    "Routes": {
      "a": {
        "Order": 0,  // 优先级最高,优先尝试匹配 路由 a
        "Match": {
            "Hosts": [ "api.com" ],
            "Paths": [ "*" ],
            "Statement": "Header('x-env') = 'test'"
        },
        "ClusterId": "ClusterA",
      },
      // 通过降低优先级,路由 a 不匹配的请求会走入路由 b
      "b": {
        "Order": 1, 
        "Match": {
            "Hosts": [ "api.com" ],
            "Paths": [ "*" ]
        },
        "ClusterId": "ClusterB",
      }
    },
    "Clusters": {
        "ClusterA": {
            "Destinations": [
                {
                    "Address": "http://127.0.0.1:7930"
                }
            ]
        },
        "ClusterB": {
            "Destinations": [
                {
                    "Address": "http://127.0.0.2:8989"
                }
            ]
        }
    }
  }
}

当然路由区分还可以编写其他复杂条件,可以参见 如何为HTTP配置路由复杂匹配

您还可以通过定制化扩展 发挥创造力,定制化自己的复杂规则

滚动升级

滚动升级(Rolling Update)是一种逐步替换旧版本为新版本的发布策略,常用于后端服务、微服务、容器集群(如 Kubernetes)、云平台等。其特点是"分批次、逐步替换",这样可以保证服务持续可用,降低因上线新版本导致的风险。

滚动升级的流程

  • 将部分实例(如1台、10%节点)升级为新版本,其余保持旧版本运行。
  • 观察新版本运行状况(如健康检查、监控指标、错误率等)。
  • 如果新版本无异常,则继续升级下一批,直至全部替换完成。
  • 若新版本出现异常,可快速回滚,影响面有限。

VKProxy 由于支持运行时配置动态变更,所以可以说通常简单场景的滚动升级

目前暂未集成Kubernetes实现

配置修改api目前只有 支持etcd的UI站点有简单的api

不过可以通过配置变动向大家说明一下通常滚动升级场景配置是如何变动的

假设您有如下 api 配置

json 复制代码
{
  "ReverseProxy": {
    "Routes": {
      "b": {
        "Order": 0, 
        "Match": {
            "Hosts": [ "api.com" ],
            "Paths": [ "*" ]
        },
        "ClusterId": "ClusterB",
      }
    },
    "Clusters": {
        "ClusterB": {
            "LoadBalancingPolicy": "RoundRobin",
            "HealthCheck": {
                "Active": {
                    "Enable": true,
                    "Interval": "00:00:30",
                    "Policy": "Http",
                    "Path": "/health"  // 业务实例暴露有健康检查api, 正常运行会返回 200 ,当下线时会返回 500
                }
            },
            "Destinations": [
                {
                    "Address": "http://127.0.0.2:8989"
                }
            ]
        }
    }
  }
}

当有新版本上线您可以通过类似配置修改达到滚动升级的目的 (已集成Kubernetes的api gateway 也可以干的类似事情,不过这样滚动升级策略会存在一定时间内新旧版本同时提供服务的场景,需要结合业务场景取舍)

首先部署了一部分新实例, 比如 127.0.0.3:8080

然后会将这一部分实例加入 api 配置中 (Kubernetes之类工具通常还会有检查实例正常启动之后再处理的过程)

json 复制代码
{
  "ReverseProxy": {
    "Clusters": {
        "ClusterB": {
            "Destinations": [
                {
                    "Address": "http://127.0.0.2:8989"
                },
                {
                    "Address": "http://127.0.0.3:8080"
                }
            ]
        }
    }
  }
}

接着您检查了一段时间监控,一切运行正常,觉得可以下掉旧实例了

为了稳妥,您并未直接删除127.0.0.2:8989实例, 而是先通过健康检查下线了旧实例 (让 http://127.0.0.2:8989/health 返回 500)

观察一段时间,127.0.0.2:8989 没有任何流量了,您再修改了配置,删除了实例

json 复制代码
{
  "ReverseProxy": {
    "Clusters": {
        "ClusterB": {
            "Destinations": [
                {
                    "Address": "http://127.0.0.3:8080"
                }
            ]
        }
    }
  }
}

多实例场景就是反复执行上述行为,

如遇见新版本实例有问题,就会撤下新实例,恢复旧实例配置,由于滚动进行,出现问题时通常只有部分实例受影响,所以是有效保证上线稳定性的一种策略

ps: 下线旧实例这一步,Kubernetes之类工具还有另外一种简单做法,先从api gateway之类配置移除旧实例,但旧实例不立马删除,而是等待一定时间(足够保证没有访问流量)再直接删除

大家可以根据自己所需实施具体的滚动升级或金丝雀之类策略

当然您还可以通过定制化扩展 发挥创造力,定制化自己的复杂规则