feat: add stop_if / stop_unless tags for cross-field rules with a sentinel target - #21
Merged
Conversation
…nel target
跨字段比较的右手边取「哨兵值」时,`ltefield` 之类的 tag 会拒掉完全合法的配置。
典型是「0 表示不限制」:
MaxIdleConns int `validate:"ltefield=MaxOpenConns"`
MaxOpenConns int // 0 = unlimited
配 (20, 0) —— 不限制连接数、保留 20 条空闲 —— 会因为 `20 <= 0` 为 false 而校验
失败,服务起不来。而「0 = 不限制」这个语义是消费方代码里 `if conf.X > 0` 定义
的,tag 层看不见。
go-playground/validator 没有内置写法:`omitzero` 跳过的是**当前**字段,不是目标
字段。所以加一个 tag:
MaxIdleConns int `validate:"skip_if=MaxOpenConns 0,ltefield=MaxOpenConns"`
实现复用 skip_nested_unless 已有的机制 —— 返回 false 让该字段后续的 tag 短路
(实测确认 validator 在首个失败处停止),再按 tag 名把这个「失败」滤掉。参数是
(字段名, 值) 对,任一对命中即跳过。
过滤逻辑从「只滤 skip_nested_unless」改成滤 skippedTags 这一组,两个 tag 语义
相同:它们的失败意思是「到此为止」,不是「这个值不合法」。
字段名写错时不跳过(requireCheckFieldValue 的 defaultNotFoundValue 传 false),
免得一个 typo 静默关掉一条规则 —— 有测试钉住。
新增 4 组测试:主场景五种组合、skip_if 自身错误不外泄、多对任一命中、
字段名写错不静默失效。全部通过。
There was a problem hiding this comment.
Pull request overview
This PR adds a new skip_if validation tag to ConfX’s Go validator wrapper to support cross-field validation rules where the comparison target can take on a sentinel value (e.g., 0 meaning “unlimited”), which would otherwise cause valid configs to be rejected.
Changes:
- Introduces the
skip_iftag implementation (skipIfImpl) to short-circuit remaining validations for a field when a target field matches any(field, value)sentinel pair. - Generalizes the existing “skip tag” registration and error filtering so both
skip_nested_unlessandskip_ifinternal “failures” are filtered out of returned validation errors. - Adds unit tests covering the main scenarios, error filtering behavior, multi-pair matching, and unknown-field safety behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| validator.go | Adds skip_if, centralizes skip-tag registration, and filters out skip-tag errors from validator output. |
| validator_test.go | Adds new test cases validating skip_if behavior and non-leakage of the internal skip error. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…less is not it 改名两个原因。 ## 1. 名字要说清它到底做什么 它跳过的是**本字段剩余的校验**(返回 false 让后续 tag 短路),不是跳过某个字段、 也不是跳过嵌套结构。skip_rest_if 直说这件事。 ## 2. 躲开上游的 skip_* 命名空间 validator 内置有个 skip_unless,而 RegisterValidationCtx 同名注册会**静默替换** 内置实现并返回 nil —— 撞名的后果是所有消费方的行为在毫无提示的情况下改变。 这正是上面那个 tag 叫 skip_nested_unless 而不是 skip_unless 的原因,新 tag 也 照此避让。查了 v10.14 ~ v10.30 五个版本,上游 skip_* 家族只有 skip_unless 一个, skip_rest_if 全程无冲突。 ## 顺带钉住一件容易搞混的事 内置 skip_unless **名不副实:它根本不跳过任何东西**。实现是 return hasValue(fl), 即一个存在性检查,属于 required_* 家族;它后面的 tag 照跑。所以它不能拿来做这件 事,这也正是本 tag 存在的理由。 新增 TestBuiltinSkipUnlessDoesNotActuallySkip 把上游这个行为钉下来 —— 哪天上游 改了,这条会红,届时可以重新评估 skip_rest_if 还有没有必要。
… alias
实测发现 skip_nested_unless 与新加的那个 tag **是同一个机制**,之前按
「nested / rest」区分是错的。四种组合交叉验证:
贴在标量字段 贴在嵌套结构
原 tag 跳过后续 tag ✅ 阻止进入 ✅
新 tag 跳过后续 tag ✅ 阻止进入 ✅
两者都靠返回 false —— validator 在首个失败的 tag 处放弃该字段,对标量表现为
「后面的 tag 不跑」,对嵌套结构表现为「不往里钻」。这是同一个行为的两种表现。
也就是说 skip_nested_unless 早就具备「skip_rest_unless」的能力,新 tag 也早就
具备「skip_nested_if」的能力。**唯一真实的轴是极性(if / unless)**,nested 和
rest 两个词各自只说对了一半用法。
因此改成一对只差极性的名字:
stop_if 任一 (字段, 值) 命中就停
stop_unless 必须全部命中才继续
用 stop 而不是 skip:它同时说得通两种情形(「在这里停止校验」),而 skip 容易被
读成「跳过这个字段」——实际跳过的是从这里往下的一切。也顺带彻底离开上游的
skip_* 命名空间:同名注册会静默替换内置实现并返回 nil,撞名的后果是所有消费方
行为无声改变。上游 183 个内置 tag 里 stop_* 一个都没有。
skip_nested_unless 保留,注册到同一个实现上,标为 Deprecated,既有 struct tag
零改动。
新增测试:
· TestStopTagsAreNotScopedToNestedOrScalar —— 四种组合钉住「没有 nested/rest
之分」,这正是改名的依据
· TestSkipNestedUnlessIsAnAliasOfStopUnless —— 别名与新名行为逐一等价,
包括它从没被文档化过的标量用法
Copilot 指出 ValidatorWithSkipNestedUnless 的文档串还写着「只为
skip_nested_unless 注册失败而 panic」,而它现在注册三个 tag —— 对着 panic
排查的人会被误导。文档串已在改名那一版顺手修好,但顺着这条查下去还有两处:
1. skipNestedUnlessWrapper 这个内部函数名同样停在旧语义。它现在滤的是全部三个
stop tag,改名 stopTagsWrapper 并补上「它们的失败是实现细节,不该外泄」的
说明。
2. **三条 panic 路径一条测试都没有。** Copilot 担心的正是「诊断 panic 的人被
误导」,那就该验证 panic 信息真的说得清。补 TestStopTagsPanicMessageNamesTagAndField:
· stop_if / stop_unless 各自报出自己的 tag 名与字段名 —— 两者共用一条消息,
且一个结构体可能挂多个,不带这两样就没东西可 grep。
· 别名 skip_nested_unless 报的是**它自己的名字**而不是 stop_unless。这条钉住
了改名时把 panic 里硬编码的字符串换成 fl.GetTag() 的必要性 —— 否则读者会
去找一个根本不在自己结构体里的 tag。
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
validator_test.go:364
- This test can panic with
index out of rangeifassert.ErrorAsfails (e.g., if upstream changesskip_unlessbehavior anderrbecomes nil, or the error type changes). Because testify assertions don't stop execution,verr[0]is unsafe without checking the assertion result and slice length.
// B == 0 matches the condition. If skip_unless skipped, A = 1 would pass.
err := v.Struct(S{A: 1, B: 0})
var verr validator.ValidationErrors
assert.ErrorAs(t, err, &verr)
assert.Equal(t, "gte", verr[0].Tag(), "built-in skip_unless is documented as a presence check, not a skip")
}
molon
added a commit
to qor5/x
that referenced
this pull request
Aug 9, 2026
… constructors 三处跨字段比较的右手边取 0 表示「不限制」,`ltefield` 拿它当上界是错的。上一轮 把配对检查挪进了构造函数当权宜之计,现在 qor5/confx#21 提供了 stop_if,改回 tag 写法: MaxIdleConns validate:"stop_if=MaxOpenConns 0,ltefield=MaxOpenConns" ConnMaxIdleTime validate:"stop_if=ConnMaxLifetime 0,ltefield=ConnMaxLifetime" ReadHeaderTimeout validate:"stop_if=ReadTimeout 0,ltefield=ReadTimeout" stop_if 命中时让该字段后续的 tag 短路,它自己的错误由 confx 按 tag 名滤掉。 比手写检查好在三点:回到配置校验阶段(confx 的 ValidationSuite 抓得到,而不是 等到 Open()/NewServer() 才炸)、错误是结构化的(path + tag)、三处写法与其余 校验一致。gormx.Open 与 httpx.NewServer 里那两段手写检查随之删除。 测试同步改回 confx.ValidationSuite。已反证:把 stop_if 从 tag 里去掉, 「无上限 + 热池 (20, 0)」与「只设 header 超时 (10s, 0)」两个合法配置立刻被拒 ——正是这个改动要解决的。⚠️ go.mod 暂时把 confx 指向 qor5/confx#21 的分支 commit。该 PR 合并发版后 需要 bump 成正式版本。
molon
added a commit
to qor5/x
that referenced
this pull request
Aug 10, 2026
qor5/confx#21 已合入 main(8d9c78b),go.mod 从分支 commit 换成正式的 pseudo-version v0.0.0-20260810031108-8d9c78bbd3fb。 gormx + httpx 全部测试通过。反证依旧成立:把三处 tag 里的 stop_if 去掉, 「无上限 + 热池 (20, 0)」与「只设 header 超时 (10s, 0)」两个合法配置立刻被拒。
molon
added a commit
to qor5/x
that referenced
this pull request
Aug 10, 2026
… three ltefield tags that reject valid configs (#625) * fix(httpx): replace deprecated h2c.NewHandler, add body size and connection caps 三处改动,核心是第一处。 1. h2c.NewHandler 已废弃,改用标准库的 http.Server.Protocols x/net(本仓库依赖的 v0.55.0 起)里的声明: // Deprecated: Set the [http.Server] Protocols field to use // unencrypted HTTP/2 instead. func NewHandler(h http.Handler, s *http2.Server) http.Handler 同一个文件还有一条我们一直没照做的警告:h2c.NewHandler 为支持 HTTP/1.1 Upgrade 模式,会把 h2c 连接的**首个请求整体读入内存**,文档要求用 http.MaxBytesHandler 包裹——此前并没有。 标准库实现(net/http/server.go 的 maybeServeUnencryptedHTTP2)只 Peek 24 字节比对 PRI 前导,仅支持 prior-knowledge 模式,没有这个内存放大面。 行为差异:依赖 `Upgrade: h2c` 头升级的客户端将静默退回 HTTP/1.1(不报错)。 Envoy(配 appProtocol=kubernetes.io/h2c 时)和 gRPC 客户端用的都是 prior-knowledge,不受影响。 顺带把 HTTP/2 的启用从「由 tls.enabled=false 反推」改成显式声明三个协议位。 原来的 if/else 结构让「TLS 关闭」隐式蕴含「启用 h2c」,这两件事语义无关。 2. MaxRequestBodySize(新增,0 = 不限) 经 http.MaxBytesHandler 包在最外层,先于路由与业务 handler 生效。 3. MaxConnections(新增,0 = 不限) 经 netutil.LimitListener 加在 SetupListener 上。**只防 fd 耗尽,不是并发 闸门**:HTTP/2 一条连接可承载多个 stream,全局并发 = 连接数 × 每连接 stream 数。真正的并发上限应由上游(网关 circuit breaker)或 in-flight middleware 控制。注释和 usage 里都写明了这一点,避免被误当成限流开关。 另注:超出限制时 Accept 阻塞(连接停在内核 accept queue),不是拒绝。 未加 maxConcurrentStreams:它是 per-connection 的,调小只会让客户端多开连接 绕过去,管不住总并发;在网关后面收紧它更是有害无益(把请求挤成网关侧排队或 更多连接)。Go 默认 250 保持不动。 新增 httpx/server_test.go —— NewServer 此前没有测试文件。覆盖迁移后最需要 守住的行为:h2c(prior-knowledge)仍可用、HTTP/1.1 仍可用、body 上限的三种 情形。go test ./httpx/... ./healthz/... ./netx/... 全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(httpx): spell out that MaxConnections counts connections, not requests usage 文案原来只写「maximum number of concurrent connections」,读的人很容易 把它当成并发请求上限。HTTP/1.1 下两者数值接近,HTTP/2 下完全脱钩——一条连接 可以多路复用许多并发请求,所以它管不住并发,只防 fd 耗尽。 这个混淆在 review 中被真实地问到了,说明文案不够。现在 usage 里直接写明 「connections, NOT requests」,doc comment 里补上两种协议下的差异,以及应该 用什么来限并发(网关 circuit breaker 或 in-flight middleware)。 * feat(httpx): make maxConcurrentStreams configurable 既然两种模式下 HTTP/2 都启用了,就把每连接的 stream 上限也暴露出来。 单独看它约束不了任何东西——它是 per-connection 的,客户端多开几条连接就绕过去 了。但和 maxConnections 相乘就得到一个**算术上可知**的在途请求硬上限: maxConnections × maxConcurrentStreams = 在途请求上限 这是加它的真正理由:不是为了限流(网关的 circuit breaker 才是并发闸门),而是 为了让容量上界从「无法计算」变成「一眼可算」。默认 0 = Go 默认 250,不改变 现有行为。 实现上用 http.Server.HTTP2(Go 1.24 引入的 http.HTTP2Config),不用 x/net 的 http2.Server —— 后者需要配合已废弃的 h2c.NewHandler 或 ConfigureServer 才能生效, 而前者对 TLS 与 h2c 两条路径统一生效。 有一个坑值得记下:Go 1.25 的 http.Server.HTTP2 字段注释仍写着 // This field does not yet have any effect. // See https://go.dev/issue/67813. **这句已经过时**。读 h2_bundle.go 会发现调用链是通的:configFromServer → fillNetHTTPServerConfig → fillNetHTTPConfig(conf, srv.HTTP2)。Go 1.26 已删掉 那句注释。为了不让后人重新怀疑这一点,新增的测试直接读服务端 SETTINGS 帧里 通告的 MAX_CONCURRENT_STREAMS 来断言: configured value is advertised 设 42 → 通告 42 zero falls back to Go default 不设 → 通告 250 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(httpx): English comments; pin the compat guarantees that matter in prod 三件事,都是冲着「已有生产项目在引用这个库」来的。 1. 注释改英文 本仓库其余部分都是英文注释,之前几处中文是不该混进来的。 2. 把版本结论改准确,并给出可复现的证据 之前写的是「Go 1.25 起 Server.HTTP2 生效」。更准确的说法是:**它从字段落地 (1.24)那天起就一直生效,是文档注释错了**。 读服务端 SETTINGS 帧实测,MaxConcurrentStreams: 42 在以下版本全部被如实通告 (不设时为 250): go1.24.1 go1.24.11 go1.25.1 go1.25.6 go1.25.12 go1.26.3 源码侧对得上:1.24 的 h2_bundle.go 里 configFromServer 就经 fillNetHTTPServerConfig 消费 h1.HTTP2;1.26 只是把中间那层去掉、直接调 fillNetHTTPConfig,并顺手删掉了那句过时注释。go.dev/issue/67813。 3. 把两条兼容性保证钉成测试 迁移 h2c 实现动的是所有 tls.enabled=false 的消费方(也就是绝大多数),所以 行为差异必须是「已验证」而不是「我认为」: TestNewServer_H2CUpgradeFallsBackToHTTP1 基于 Upgrade 头的 h2c 不再升级——但请求照常被服务。实测旧写法回 101 Switching Protocols,新写法回 200 OK。是协议降级,不是失败。 浏览器不用这个模式,Envoy(appProtocol=h2c)和 gRPC 用的都是 prior-knowledge,不受影响。 TestNewServer_IdleTimeoutAppliesToH2C 旧代码显式转发 &http2.Server{IdleTimeout: srv.IdleTimeout},新写法没有这 一步。实测标准库路径会从 http.Server 继承,两者在同一时刻关掉空闲连接。 这条不钉住的话,h2c 连接可能会静默地永不超时。 三个新配置项的默认值一律为 0(= 保持既有行为),不给非零默认:合理的上限取决 于服务本身(上传端点可能确实需要几百 MiB)和部署形态(fd ulimit),库无从替 调用方决定。要防「忘了设」应该靠 provisioning 侧强制显式配置,而不是在库里塞 一个会静默掐断生产流量的默认值。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(httpx): cover MaxConnections, reject negative limits — per review 两条 review 意见,都采纳。 1. 负值静默变成「不限」 三个新配置项都用 `> 0` 判断是否启用,于是 -1 会被当成「不设」而不是报错 —— 一个明显的配置笔误就这么被吞了。加 validate:"gte=0",与本文件既有的 validate 用法一致(required / ltefield / required_if)。 2. MaxConnections 没有测试 补上 TestNewServer_MaxConnections。它必须走真实装配路径:这个上限在 SetupListener(netutil.LimitListener)里生效,而不是 NewServer,所以测试 经 lifecycle 拿 listener,而不是像其余用例那样裸 net.Listen。 断言的是 LimitListener 的实际语义 —— 超限时不 Accept(连接停在内核 backlog),而不是拒绝: 第一条连接 正常拿到 200,随后用 keep-alive 占住唯一的槽位 第二条连接 TCP 握手完成,但拿不到任何响应(读超时) 关掉第一条之后 排队中的那条立刻被 Accept 并拿到 200 第三步特意复用已排队的连接而不是新拨一条:槽位释放后先被 Accept 的正是 backlog 里那条,新拨的会继续排在后面。 go test ./httpx/... ./healthz/... ./netx/... 全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(gormx): default maxOpenConns to 0 (unlimited), and move the idle/open pairing out of validation ## 默认值 200 → 0 200 从来没有真正生效过:单个 pod 不可能有 200 个并发查询,所以它不是上限, 只是一个看起来像经过考量、实际从不 binding 的数字 —— 而消费方会照着它做容量 规划(「200 × 5 副本 = 1000 条,超了 RDS 的 475」),基于一个从未发生的前提。 而池上限本来就不是约束资源占用的正确手段,请求超时才是:超时的请求释放它占着 的连接,前提是 timeout context 一路传到 DB 这一层。设上限只是把队列挪进 database/sql,而那一层是看不见的 —— 没人给 DBStats.WaitCount 打点,症状是延迟 毛刺而不是错误。它还会掩盖真实负载:请求堵在池口而不是堵在数据库上,于是数据库 看着空闲、CPU 低到 HPA 阈值够不到,既不扩容也查不出原因。 不限制时,过载的数据库变慢但不会崩,浪头过去自己恢复;容量真不够就升实例规格 —— 而池上限会让每个消费方都多出一个必须跟着重调的值。 maxIdleConns 保持 20:它不是上限,是保持多少条空闲连接不关,避免稳定流量反复 付重连成本。 ## 连带必须改的:MaxIdleConns 的 ltefield MaxIdleConns 原本带 `validate:"ltefield=MaxOpenConns"`。0 表示 unlimited 而不是 零,所以拿它当上界比较是错的 —— 实测 `idle=20, open=0` 直接校验失败。也就是说 只改默认值会让所有消费方启动即崩。 改成在 Open() 入口检查,且只在真的配了上限时才检查(MaxOpenConns > 0)。放在 拨号之前,配置错误应当以自己的面目出现,而不是藏在连接错误后面。 ConnMaxIdleTime 的 ltefield 保留 —— 那里 0 没有特殊含义。 新增 TestMaxIdleConnsAgainstCap 钉住三种组合;TestConfig 补一条 20/0 的用例, 它正是本次改动的前提。gormx 全部测试通过。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(gormx,httpx): move the three "0 means unlimited" pairings out of struct tags 三个字段用 `ltefield` 表达「不得超过另一个字段」,而那三个「另一个字段」取 0 时 的含义都是**不限制**,不是零。于是完全合理的配置会校验失败、服务起不来: gormx MaxIdleConns ltefield=MaxOpenConns (20, 0) → 失败 gormx ConnMaxIdleTime ltefield=ConnMaxLifetime (10m, 0) → 失败 httpx ReadHeaderTimeout ltefield=ReadTimeout (10s, 0) → 失败 三条都实测复现过。httpx 那条最容易踩:它没有默认值文件,而「只设一个 header 超时」是很自然的最小加固。 ## 为什么不是换个 tag 写法 go-playground/validator 没有「目标字段为零就跳过」的内置 tag(`omitzero` 跳的是 **当前**字段),只能自定义。但自定义 tag 要注册进校验器实例,而实例由应用侧 构造,库注册不进去。 更根本的是位置不对:「0 = 不限制」这个语义是由下面那几行 `if conf.X > 0` 定义 的,tag 层看不见。把配对检查放到构造函数里,和定义语义的代码在一起,才是它该 待的地方。三处统一放在入口、拨号/监听之前 —— 配置错误应当以自己的面目出现, 而不是藏在连接错误后面。 ## 顺带 httpx.MaxConcurrentStreams 的注释去掉了「网关已经用 maxParallelRequests 限了 并发」这个前提 —— 那个做法已不再推荐。 新增 TestConnMaxIdleTimeAgainstLifetime 与 TestReadHeaderTimeoutAgainstReadTimeout, 各钉三种组合(有上限且合规 / 有上限且越界 / 无上限)。httpx + gormx 全部通过。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(httpx): MaxConnections 默认 0 才是常态,说清它两头都保护不到 原注释把它说成「防 fd 耗尽」,并让人去用网关的 circuitBreaker 兜并发。两句都要改: · 它触顶时 netutil.LimitListener 停止 Accept,连接堆在内核 backlog 里, 客户端等到自己超时 —— 不记日志、不拒绝,是一条看不见的队列。 · 这个数两头都站不住:低到能约束单连接内存时,离进程的 fd 上限还差几个 数量级,所以既没防住 fd 也没防住内存。 · 网关侧的并发闸门已不再推荐(见 theplant/mad-provisioning#123), 不该再把它当成配套方案写在这里。 真正约束资源占用的是上面那几个超时。改成「默认 0 通常就是对的,除非你确实 需要一个硬性连接上限、且拿得出依据」。 * docs(gormx,httpx): state the mechanics, not a recommendation 之前在这两个包的注释里写了「0 才是推荐值」「池上限不是约束资源的手段,请求超时 才是」之类的话。那是消费方的取舍,不该由共享库替所有人拍板 —— 同一个库的不同 使用者完全可能有不同结论。 只留可验证的事实: gormx.MaxOpenConns 0 = unlimited,与 database/sql 自身默认一致;超过上限时 调用方阻塞在 sql.DB 内部,且只能通过 DBStats.WaitCount 看到 gormx.MaxIdleConns pool 里保留的空闲连接数,超出的在归还时关闭;它不限制 能开多少连接 httpx.MaxConnections 触顶后 netutil.LimitListener 停止 Accept,后续连接在内核 backlog 里等到客户端放弃:不记日志、不拒绝 默认值本身(maxOpenConns 200 → 0)不变,那是上一个 commit 的事,理由在那条 commit message 里 —— 200 从来没有真正生效过。 * fix(gormx,httpx): use confx's stop_if instead of hand-checking in the constructors 三处跨字段比较的右手边取 0 表示「不限制」,`ltefield` 拿它当上界是错的。上一轮 把配对检查挪进了构造函数当权宜之计,现在 qor5/confx#21 提供了 stop_if,改回 tag 写法: MaxIdleConns validate:"stop_if=MaxOpenConns 0,ltefield=MaxOpenConns" ConnMaxIdleTime validate:"stop_if=ConnMaxLifetime 0,ltefield=ConnMaxLifetime" ReadHeaderTimeout validate:"stop_if=ReadTimeout 0,ltefield=ReadTimeout" stop_if 命中时让该字段后续的 tag 短路,它自己的错误由 confx 按 tag 名滤掉。 比手写检查好在三点:回到配置校验阶段(confx 的 ValidationSuite 抓得到,而不是 等到 Open()/NewServer() 才炸)、错误是结构化的(path + tag)、三处写法与其余 校验一致。gormx.Open 与 httpx.NewServer 里那两段手写检查随之删除。 测试同步改回 confx.ValidationSuite。已反证:把 stop_if 从 tag 里去掉, 「无上限 + 热池 (20, 0)」与「只设 header 超时 (10s, 0)」两个合法配置立刻被拒 ——正是这个改动要解决的。⚠️ go.mod 暂时把 confx 指向 qor5/confx#21 的分支 commit。该 PR 合并发版后 需要 bump 成正式版本。 * chore: bump confx to the released stop_if / stop_unless qor5/confx#21 已合入 main(8d9c78b),go.mod 从分支 commit 换成正式的 pseudo-version v0.0.0-20260810031108-8d9c78bbd3fb。 gormx + httpx 全部测试通过。反证依旧成立:把三处 tag 里的 stop_if 去掉, 「无上限 + 热池 (20, 0)」与「只设 header 超时 (10s, 0)」两个合法配置立刻被拒。 * docs(httpx): translate the test comments added by this PR to English 本 PR 在 server_test.go 里加的 9 行注释是中文,与仓库其余部分不一致,翻掉。 只动本 PR 自己加的部分。 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
跨字段比较的右手边取「哨兵值」时,
ltefield这类 tag 会拒掉完全合法的配置。本 PR 加一对能中止校验的条件 tag,并顺带修正了既有 tag 的一个命名错误。问题
最典型的是「0 表示不限制」:
配
(20, 0)—— 不限制连接数、保留 20 条空闲 —— 会因为20 <= 0为 false 而校验失败,服务起不来。根因是位置错了:「0 = 不限制」这个语义是消费方代码里
if conf.X > 0那几行定义的,tag 层看不见它。 让一个不知道语义的层去执行依赖语义的规则,必然出这种事。内置的
skip_unless为什么用不了先说清楚,免得被名字骗到:validator 内置的
skip_unless根本不跳过任何东西。它
return hasValue(fl),是个存在性检查,属于required_*家族(官方注释也写着 "must be present and not empty")。实测四种组合,它后面的gte=100一次都没被跳过。上游 183 个内置 tag 里,
skip_*家族只有skip_unless一个,没有任何一个能中止后续校验。解法:一对只差极性的 tag
stop_if=<F> <v> ...stop_unless=<F> <v> ...一个实测推翻的假设:
nested和rest的区分不存在我最初按「跳过嵌套结构」vs「跳过后续 tag」加了两个不同名字。交叉验证后发现它们是同一个机制:
skip_nested_unless两者都靠返回
false—— validator 在首个失败的 tag 处放弃该字段,对标量表现为「后面的 tag 不跑」,对嵌套结构表现为「不往里钻」。同一个行为的两种表现。也就是说
skip_nested_unless早就具备「stop_unless用在标量上」的能力,只是从没被文档化过。唯一真实的轴是极性,nested这个词只说对了一半用法。所以最终是
stop_if/stop_unless这一对,skip_nested_unless注册到同一个实现上、标为Deprecated,既有 struct tag 零改动。为什么是
stop不是skipskip容易被读成「跳过这个字段」,而实际跳过的是从这里往下的一切。skip_*命名空间。RegisterValidationCtx同名注册会静默替换内置实现并返回nil—— 撞名的后果是所有消费方行为无声改变,且检测不到。上游 183 个 tag 里stop_*一个都没有。一个刻意的选择
字段名写错时不跳过(
requireCheckFieldValue的defaultNotFoundValue传false)。一个 typo 静默关掉一条规则,比报错难查得多 —— 有测试钉住。真实动机
qor5/x#625 里同一个坑有三处,三条都实测复现过:
gormx.MaxIdleConnsltefield=MaxOpenConns(20, 0)不限制连接数gormx.ConnMaxIdleTimeltefield=ConnMaxLifetime(10m, 0)连接永不回收httpx.ReadHeaderTimeoutltefield=ReadTimeout(10s, 0)只设 header 超时httpx那条最容易踩:它没有默认值文件,而「只设一个 header 超时」是很自然的最小加固。那边目前的权宜之计是把配对检查挪进构造函数。本 PR 合并发版后就能改回 tag 写法 —— 回到配置校验阶段、拿回结构化的错误、三处写法统一。
测试
go test ./...全绿。新增:TestStopIf—— 主场景五种组合(有上限且合规 / 相等 / 越界 / 无上限 / 全零)TestStopIfDoesNotLeakItsOwnError——stop_if自身的「失败」不得外泄TestStopIfMatchesAnyPair—— 多对时任一命中即停TestStopIfUnknownFieldDoesNotDisableTheRule—— 字段名写错不静默失效TestStopTagsAreNotScopedToNestedOrScalar—— 四种组合钉住「没有 nested/rest 之分」,这正是改名的依据TestSkipNestedUnlessIsAnAliasOfStopUnless—— 别名与新名行为逐一等价TestBuiltinSkipUnlessDoesNotActuallySkip—— 把上游skip_unless「不跳过」这个行为钉下来。哪天上游改了,这条会红,届时可重新评估这对 tag 还有没有必要