Skip to content

feat: add global and per-provider upstream proxy settings#11

Merged
newcodebook merged 3 commits intolansespirit:mainfrom
ThomasX-git:main
Apr 13, 2026
Merged

feat: add global and per-provider upstream proxy settings#11
newcodebook merged 3 commits intolansespirit:mainfrom
ThomasX-git:main

Conversation

@ThomasX-git
Copy link
Copy Markdown

有些中转站IP限制太严了,比如黑与白公益,我所有代理IP都被封了,只能直连;而有些公益站需要代理才能访问,需要单独配置代理。

所以添加了全局代理,以及每个provider设置单独代理:

  • 继承全局
  • 直连
  • 自定义代理

@newcodebook
Copy link
Copy Markdown
Collaborator

[P3] Proxy 配置变更语义不应在 Web API 层重复定义

这次功能的整体目标我认同:把 upstream proxy 建模成“全局默认 + provider 覆盖”,并且用 inherit / direct / custom 表达不同策略,这个方向是合理的。
不过这里这段实现有一个比较明显的架构问题:Web API 层正在重复定义 proxy 配置的变更语义,而不是复用 config 层作为单一事实来源。

我为什么认为这是个问题

applyProviderProxySettings 看起来像是在做 HTTP 请求校验,但它实际承担的职责已经超过了 transport 层校验,开始直接定义配置模型本身的行为,包括:

  • proxy_mode 哪些值是合法的
  • proxy_url 在什么情况下允许为空
  • proxy_url 在什么情况下必须出现
  • create 和 update 的行为差异
  • 字段缺省时是保留旧值、清空旧值,还是直接报错

这些都不是“接口层细节”,而是 provider proxy 配置语义本身 的一部分。

而同一套语义现在已经分散在多个层里:

  • internal/config 负责 normalize / validate
  • YAML 读写依赖 config 层定义的结构和规则
  • runtime 依赖 effective proxy policy 的解析结果
  • 这里的 Web API 又单独实现了一套 mutation 规则

这意味着,系统里关于 proxy 的“真实语义”已经不是只在一个地方定义了,而是开始同时存在于 config、API 和 runtime 多层里。

这样做的直接风险

短期内这段代码当然是可工作的,但长期会有明显的维护风险:

  1. 层间漂移风险

    • 后面只要 proxy 规则再扩展,比如增加新的 mode、修改 patch 语义、允许更细粒度的更新方式,就需要同时修改 config、API、runtime 多处逻辑。
    • 到那时最容易出现的问题不是“代码完全坏掉”,而是:
      • YAML 可以表示
      • runtime 可以解析
      • 但 API 却不允许这样更新
    • 这种问题最难排查,因为每一层单看都“有道理”。
  2. API 开始拥有自己的业务语义

    • 例如当前这里已经在决定 patch 行为:
      • 有些字段缺省会保留旧值
      • 有些组合会直接报错
    • 不管这些规则本身是否合理,问题在于:它们现在是由 handler 决定的,而不是由 config 层统一定义的。
    • 对于一个已经跨越 YAML、Web UI、hot reload、runtime policy resolution 的特性,这个边界不够稳。
  3. 未来扩展成本会越来越高

    • 现在 proxy 还是一个相对简单的能力;
    • 一旦后续增加更多策略或特殊语义,这种“多层平行实现”的方式会迅速放大复杂度。

我建议的最佳方案

更稳妥的实现方式,是把 proxy 的 apply / normalize / validate 逻辑收敛到 internal/config,让 config 层真正成为这块特性的唯一语义中心。

可以考虑把它整理成类似这样的形态:

  • 由 config 层提供一个统一入口,例如:
    • 接收“旧 provider 配置 + patch 字段”
    • 输出“规范化后的新 provider 配置”
    • 并统一负责校验合法性
  • Web handler 只负责:
    • 解析请求
    • 组织 patch 数据
    • 调用 config 层统一入口
    • 把错误映射成 HTTP 响应

也就是说,职责应当是:

  • config 层:定义 proxy 配置语义
  • web 层:传递和展示这些语义
  • runtime 层:消费解析后的最终结果

这么改的收益

如果按这个方向收敛,会有几个明确好处:

  • YAML、Web UI、hot reload、未来 CLI/其他入口都共享同一个事实来源
  • proxy 规则只维护一份,后续扩展成本更低
  • handler 层职责会更清晰,不再持有配置状态机
  • 测试边界更明确:proxy 语义测试集中在 config 层,而不是散落在 API 行为里

结论

所以这条评论不是在说“这段代码现在不能用”,而是在说:

这块逻辑已经从“请求校验”越界到了“配置语义定义”,继续放在 Web API 层会让 proxy 特性的长期维护成本明显上升。

在当前这个阶段,把 proxy 配置语义收敛到 internal/config,比以后在多层之间补齐不一致要便宜得多。

@newcodebook
Copy link
Copy Markdown
Collaborator

[P2] inherit 同时承载了两层不同语义,长期会让 proxy 模型变得含混

这次 proxy 功能的整体建模方向是对的:需要同时支持“全局默认策略”和“provider 级覆盖策略”,并且覆盖层至少要能表达“沿用默认 / 强制直连 / 使用自定义代理”。
不过当前这里有一个比较明显的抽象问题:同一个枚举值 inherit,在全局层和 provider 层实际上表示的是两种不同的语义。

当前语义实际上是分裂的

从实现来看,inherit 在两个层次上的含义并不一致:

  • 全局配置层

    • upstream_proxy_mode = inherit
    • 实际表示的是:沿用环境变量代理设置(也就是走 ProxyFromEnvironment
  • provider 配置层

    • proxy_mode = inherit
    • 实际表示的是:沿用“全局默认 proxy 策略”

也就是说,虽然配置字面量相同,但这两个 inherit 并不是在表达同一个“继承对象”。

一个是在说:

继承环境代理

另一个是在说:

继承全局默认策略

这会让配置模型从一开始就带有歧义。

为什么这会成为问题

短期内,这个设计仍然是能工作的;但从模型清晰度和后续扩展性来看,它会带来几个问题。

  1. 概念本身不自洽

    • 同一个枚举值在不同层级表示不同继承目标,这会让“配置语义”变得依赖上下文解释,而不是自解释。
    • 代码里还可以靠实现细节维持正确,但对使用者、对维护者、对未来的调用入口来说,这个模型并不干净。
  2. UI / 文档会被迫做补偿

    • 一旦模型本身不够清晰,UI 文案和说明文档就只能靠“补充解释”来兜底。
    • 这不是最佳状态,因为这意味着真正的语义并没有被类型系统或配置结构表达出来,而是靠文案在补救。
  3. 未来扩展空间被提前压缩

    • 当前模型下,没有一个自然的方式表达:
      • “provider 明确要求走环境变量代理”
      • 即使全局默认策略已经被设成 directcustom
    • 因为 provider 层的 inherit 已经被定义成“继承全局默认”,而不是“继承环境”。

换句话说,当前模型把两种本来应该区分开的概念提前压成了一个词,后面如果要扩展,会比较别扭。

更稳妥的最佳方案

更清晰的做法,是把“全局默认策略”和“provider 覆盖策略”显式拆成两套语义,而不是共享一个 inherit

例如可以考虑类似这样的建模:

  • 全局层

    • environment | direct | custom
  • provider 层

    • default | direct | custom

这样语义会直接清楚很多:

  • 全局层描述的是:

    • 默认情况下,系统到底使用环境代理、直连,还是自定义代理
  • provider 层描述的是:

    • 该 provider 是沿用默认策略,还是显式覆盖为直连/自定义

这种拆分有几个明显好处:

  • 每个枚举值只有一个含义
  • 配置语义不再依赖上下文猜测
  • UI 和文档不需要额外补偿解释
  • 如果未来要支持更细粒度策略,扩展空间会更自然

结论

所以这条评论不是说当前实现一定有功能 bug,而是在说:

proxy 配置模型里“继承”的目标对象在两个层次上并不一致,但却复用了同一个枚举值,这会让抽象边界变得含混,并压缩未来演进空间。

如果这块功能后续还会继续扩展,我会建议尽早把全局层和 provider 层的 proxy mode 语义拆开,而不是继续让 inherit 在两个层次里承担不同含义。

@newcodebook
Copy link
Copy Markdown
Collaborator

[P2] 相同的 effective proxy policy 没有复用 transport,会带来不必要的连接池割裂

这次 runtime 层的落点整体是对的:不是在每个请求上临时判断 proxy,而是在构建 ClientProxy 时先解析出每个 provider 的 effective proxy policy,再据此决定使用哪个上游 http.Client
这个方向是合理的,也比把 proxy 判断散落到请求路径里要干净很多。

不过当前这里还有一个可以明显改进的实现点:虽然代码已经计算出了每个 provider 的 effective proxy policy,但对相同 policy 的 provider 并没有复用同一个 transport / client。

当前实现的问题在哪里

从这段逻辑来看,流程大致是:

  1. 先根据全局配置和 provider 配置求出每个 provider 的 effective proxy policy
  2. 再为每个 provider 构造对应的 http.Client
  3. 只有回落到默认环境代理时,才复用同一个 shared client

这意味着:

  • 如果全局默认是 direct

    • 所有 proxy_mode=inherit 的 provider 最终都会得到同样的 effective policy:直连
    • 但它们仍然会各自创建独立的 http.Client / Transport
  • 如果全局默认是 custom

    • 多个 inherit provider 最终都会得到同样的 effective policy:同一个自定义代理
    • 但它们同样不会共享连接池

也就是说,当前代码虽然已经识别出“这些 provider 最终走的是同一套代理策略”,但并没有把这个结果真正用于 runtime 资源复用。

为什么这不是单纯的小优化

这条不只是“代码还能更优雅”这么简单,它会影响 runtime 行为质量:

  1. 连接池被不必要地切碎了

    • 对于同一个上游代理策略,本来完全可以共享底层 transport 和空闲连接池
    • 现在却按 provider 切开,导致连接复用能力下降
  2. 资源占用被放大

    • 每个独立的 http.Client / Transport 都会维护自己的连接状态
    • provider 数量一多,这部分开销会线性增长,但并没有换来真正的隔离收益
  3. 抽象层次不够一致

    • 既然前面已经专门引入了 “effective proxy policy” 这个概念,runtime 最自然的做法应该是:
      • 相同 policy 共享同一个 client
      • 不同 policy 再分开
    • 现在实际上还是“按 provider 分 client”,只是多了一层 policy 计算,抽象没有完全落到资源模型上

更稳妥的最佳方案

这里更合理的实现方式,是按 effective proxy policy 复用 http.Client,而不是默认按 provider 创建。

可以把 policy 作为 key 做缓存,例如按以下维度区分:

  • environment
  • direct
  • custom + normalized proxy URL

然后构建阶段变成:

  • 先为每个 provider 解析 effective policy
  • 再从一个 map[policyKey]*http.Client 里取对应 client
  • 如果该 policy 还没有 client,再创建并缓存
  • 最后 provider 只引用对应 policy 的 client

这样会更贴合前面已经建立的抽象,也更符合 runtime 层真正关心的东西:
决定连接行为的不是 provider 名字,而是它最终落到的代理策略。

这么改的收益

如果按 policy 维度复用 client,会有几个明确收益:

  • 相同代理策略的 provider 可以共享连接池
  • runtime 资源模型和“effective proxy policy”这个抽象保持一致
  • provider 数量增加时,不会机械地按数量扩增 transport
  • 同时仍然保留真正需要的隔离:
    • direct 和 custom 会分开
    • 不同 custom URL 也会分开

结论

所以这条评论不是在否定当前实现方向,相反,我认为方向是对的;问题在于它还没有把“effective proxy policy”这个抽象贯彻到 runtime 资源复用层。

更好的形态应该是:

先按 provider 求出 effective proxy policy,再按 policy 复用 client / transport,而不是在 policy 已经相同的情况下仍然按 provider 切分连接池。

这样才能把这次新增的 proxy 模型真正落实成一个更合理的 runtime 实现。

Address PR lansespirit#11 review feedback:
- Split global/proxy mode enums: global uses `environment|direct|custom`,
  provider uses `default|direct|custom` (no shared `inherit` value)
- Move proxy apply/normalize/validate logic from web API into config layer
- Share http.Client by effective proxy policy key for connection pool reuse
@ThomasX-git
Copy link
Copy Markdown
Author

Fixed.

@newcodebook
Copy link
Copy Markdown
Collaborator

[P3] Effective proxy policy 的判等仍然依赖未规范化的 URL 文本

这次重构已经把之前几个更大的结构问题处理掉了:

  • 全局和 provider 的 proxy mode 语义已经拆开
  • proxy 的 apply / normalize / validate 已经收敛进 internal/config
  • runtime 也开始按 effective proxy policy 复用 http.Client

这些方向我都认同,而且我认为是正确的改进。
不过在这套新结构里,还剩下一个比较细但真实存在的问题:effective proxy policy 的 key 目前仍然使用“未规范化的 proxy URL 文本”,这会导致等价配置被误判成不同 policy。

问题具体出在哪里

从当前实现看,policy 的形成和使用大致是这样:

  • NormalizedProxyURL() / NormalizedUpstreamProxyURL() 目前只做了 TrimSpace
  • effectiveProviderProxyPolicy() 直接把这个字符串放进 upstreamProxyPolicyKey
  • 这个 key 随后被用于:
    • policyClients 的 client/transport 复用
    • reload 时 sameProviderRuntimeIdentity() 的 runtime identity 比较

也就是说,这个 key 现在不只是一个内部辅助值,它已经决定了两件非常实际的行为:

  1. 相同 policy 的 provider 是否会共享连接池
  2. reload 后旧的 runtime state 是否会被视为“同一个 provider policy”而继续继承

为什么这会出问题

问题在于,当前 key 不是建立在“规范化后的 URL 语义”上,而是建立在“原始文本是否完全一样”上。

例如下面两种写法:

  • http://PROXY.example:8080
  • http://proxy.example:8080

在网络语义上它们是同一个代理;Transport 最终也会把它们当成同一个目标。
但在当前实现里,这两个字符串仍然会形成两个不同的 upstreamProxyPolicyKey,因为:

  • NormalizedProxyURL() 只 trim,不做 canonicalization
  • host 大小写不会被统一
  • policy key 直接拿字符串做比较

这意味着,系统在“请求实际会发往哪里”和“是否属于同一个 proxy policy”这两件事上,使用了不同粒度的判定标准。

这会带来什么实际影响

这不是功能完全失效的 bug,所以我把它定成 P3,不是 blocker。
但它确实会带来几个不必要的副作用:

  1. 连接池会被无意义地切碎

    • 两个本质相同的 proxy URL,如果只是大小写写法不同,就会各自创建独立的 client / transport
    • 这样会削弱这次“按 effective policy 复用 client”的优化收益
  2. 无害的文本改动会触发 runtime state 重置

    • reload 时 runtime identity 现在把 policy key 也纳入比较
    • 所以即使用户只是把 URL 改成等价写法,例如 host 大小写变化,系统也会认为 policy 变了
    • 结果是旧的 cooldown / breaker / busy state 都不会被继承
  3. effective policy 抽象还没有完全落到“语义判等”层

    • 这次重构最大的提升之一,就是把“按 provider 判等”推进到了“按 effective proxy policy 判等”
    • 但如果 policy 仍然基于原始字符串而不是 canonical form,那么这个抽象还没有完全闭合

为什么我认为应该在 config 层解决

我不建议在 proxy.go 里临时补一个字符串处理,因为这不是 runtime 层特有问题,而是proxy URL 规范化语义的问题。

更自然的落点还是 internal/config,原因有两个:

  • proxy URL 的合法性校验本来就已经在 config 层
  • 这次 patch / normalize / validate 也已经被成功收回 config 层了

所以延续当前重构方向,最一致的做法应该是:

由 config 层负责把 proxy URL 校验并规范化成 canonical form,runtime 只消费 canonical 结果。

我建议的最佳方案

我建议把“canonical proxy URL”变成 config 层的一等能力,然后统一用于:

  • policy key 构造
  • runtime identity 比较
  • 任何后续依赖“proxy 是否语义相同”的逻辑

实现上可以有两种思路:

方案 A:在 apply 时直接存 canonical URL

ApplyProviderProxySettings / ApplyUpstreamProxySettings 中:

  • ParseProxyURL
  • 再把规范化后的结果回写进 provider.ProxyURL / global.UpstreamProxyURL

好处是:

  • 后续所有读取方都天然拿到 canonical 值
  • runtime 不需要额外做 canonicalization

代价是:

  • 会改变最终持久化到配置文件中的文本形式
方案 B:保留用户输入,但额外提供 canonical accessor

例如在 config 层增加类似:

  • CanonicalProxyURL()
  • CanonicalUpstreamProxyURL()

内部通过 ParseProxyURL(raw).String() 或等效方式返回规范化结果。
然后让 runtime 只使用 canonical 结果参与 policy key 和 identity 比较。

我更倾向这个方案,因为它把“显示/持久化的原始输入”和“内部用于判等的规范化值”分开了:

  • 对用户配置文件更温和
  • 改动面更集中
  • 也更符合“config 层提供统一语义服务,runtime 只消费结果”的方向

结论

这条评论不是在否定这次重构,相反,我认为这次改动已经把 proxy 架构推进到了一个明显更好的状态。
现在剩下的问题是:

effective proxy policy 虽然已经成为 runtime 的核心抽象,但它的判等仍然建立在未规范化的 URL 文本之上,导致等价 proxy 配置无法稳定复用连接池,并在 reload 时可能不必要地丢失 runtime state。

如果把 proxy URL 的 canonicalization 再补齐,这套 proxy policy 抽象就会完整很多。

@newcodebook
Copy link
Copy Markdown
Collaborator

这次更新已经把 host 大小写和默认端口的 canonicalization 补上了,这个方向是对的;不过我再顺着 runtime identity / policy key 这条链路看了一遍,感觉这里还留了一个等价性边角问题。

ParseProxyURL 目前接受带 path/query/fragment 的 proxy URL,而 CanonicalProxyURL 只规范化了 host/port,最后仍然直接返回 parsed.String()。这样一来,像下面这些写法:

  • http://proxy.example
  • http://proxy.example/
  • http://proxy.example?x=1

虽然对“代理首跳是谁”这个语义来说是等价的,但现在仍会生成不同的 canonical 字符串。后面 effectiveProviderProxyPolicyproviderProxyPolicies 以及 reload 时的 runtime identity 比较,都会把它们当成不同 proxy policy,结果就是一次无害的文本改动,依然可能打断连接池复用,或者让本该继承的 runtime state 丢失。

如果这里的目标是按“实际 proxy 语义”而不是“配置文本字面量”来做去重,我觉得更稳妥的方案是把 proxy identity 在 config 层明确收敛成 scheme + authority (+userinfo)

  1. 要么在校验阶段直接拒绝带 path/query/fragment 的 proxy URL;
  2. 要么在 canonicalization 阶段统一清空这些字段,只保留真正影响代理身份的部分。

同时建议补几组等价性测试,至少覆盖:

  • http://proxy.example vs http://proxy.example/
  • http://proxy.example:80 vs http://proxy.example
  • http://PROXY.example:80 vs http://proxy.example

这样 canonicalization 的边界会更清晰,后面 policy key、client 复用和 reload state 继承也才能真正共享同一套“proxy identity”定义。

…comparison

Add CanonicalProxyURL in config layer to normalize host case and strip
default ports, so semantically equivalent proxy URLs produce identical
policy keys. This ensures connection pool reuse and runtime state
inheritance work correctly across reload even when URL text differs
(e.g. host case, default port presence).
@newcodebook
Copy link
Copy Markdown
Collaborator

感谢这次持续跟进和多轮迭代修正,这个 PR 目前我这边已经重新完整审过一轮。

这次实现里,前面 review 提到的几个关键问题已经都处理到位了:

  • 全局 proxy 策略和 provider override 策略已经拆分,不再复用同一个语义值。
  • proxy 的 apply / normalize / validate 逻辑已经收敛到 config 层,Web API 不再单独维护一套配置语义。
  • runtime 侧已经按 effective proxy policy 复用 http.Client,连接池复用和 reload 状态继承的整体方向是正确的。
  • 这轮补上的 canonicalization 也解决了 host 大小写、scheme 大小写以及默认端口导致的 policy key 分裂问题。

我这边本地重新跑了 go test ./...,结果通过;PR 当前的 test / smoke 也通过。现在剩下的点,主要是 proxy identity 是否要进一步收敛到 authority 语义(例如 trailing slash / path / query 是否应参与 runtime identity)。我判断这已经属于后续维护层面的设计收敛问题,不影响这个 PR 当前主目标落地,也不适合继续阻塞在这一个变更里。

所以这边决定接受并合并这个 PR。后续更彻底的 runtime identity / proxy policy 语义收敛,我们会作为维护者单独继续推进。

再次感谢你的贡献和配合修改。

@newcodebook newcodebook merged commit 6da60e8 into lansespirit:main Apr 13, 2026
2 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants