Skip to content

perf(util/gvalid,util/gtag): speed up struct validation - #4886

Open
LanceAdd wants to merge 5 commits into
gogf:masterfrom
LanceAdd:fix/gvalid
Open

LanceAdd wants to merge 5 commits into
gogf:masterfrom
LanceAdd:fix/gvalid

Conversation

@LanceAdd

@LanceAdd LanceAdd commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

背景

标准 handler(func(ctx, req) (res, err))的 Req 校验是每个请求都要付的固定开销。对 gvalid 做 profile 后发现,时间大量花在几处"每次都在重复算、但结果本来就不会变"的地方:

现象 占 gvalid CPU
同一个结构体的校验 tag,每次校验都重新用正则解析一遍 ~7%
自定义错误消息本来就是 map[string]string,却每次被 gconv.Map 反射转换 + 拷贝一份 ~6%
每条规则用正则 ^([\w-]+):{0,1}(.*) 拆 规则名:参数 ~10%
读 tag 时每次都跑 gtag.Parse 的正则,而绝大多数 tag 值根本没有 { 占位符 ~7%

改动

util/gvalid(4 处)

  1. 缓存校验 tag 的解析结果(field / rule / message),进程级、默认关闭。
    • 默认关闭的原因:规则串在用户代码里可能是动态生成的(例如每次请求拼不同的 Rules([]string{...})),进程级缓存会随之无界增长;默认关闭保证用户现有代码的行为与旧版完全一致。
    • 规则静态的场景(例如全部来自 struct tag)可显式开启,ghttp 的请求校验路径就是这么做的(见下);用户自己的调用也可以传 gvalid.New(true) 获得同样收益。
    • 公开 API ParseTagValue 保持原有"不缓存"语义,直接调用方不会引入隐藏的内存增长。
  2. 自定义错误消息直通:Messages 为 map[string]string 时直接使用(只读,无拷贝),跳过 gconv.Map 的反射转换与逐项 gconv.String;消息容器改为惰性分配。
  3. 规则项拆分去掉正则:改为"找第一个冒号 + 去空格"。语义与正则一致——只有第一个冒号是分隔符,后面的 :、| 都属于参数(例如 regex:^\d+:\w+$)。顺带修复一个 panic:自定义规则名含非 ASCII 字符(例如注册 中文规则)时,旧实现会 index out of range。
  4. 删除 Clone() 里的无效分配:原先 Clone() 先调用 New() 再整体覆盖,New() 创建的 ruleFuncMap 从未被引用(每次 Clone 白分配一个 map);改为直接做浅拷贝,逐字段行为等价。

util/gtag

Parse 在内容不含 { 时直接返回。正则 \{(.+?)\} 必须先有字面量 { 才可能匹配,所以这是可证明的等价:无占位符时结果必然等于原文(返回的字符串本身,不产生拷贝)。

gtag.Parse 是所有读 struct tag 路径的必经之地(gstructs → gvalid / gconv / gdb / gcmd / goai),所以这个收益不限于 gvalid。

net/ghttp

请求校验的两个点(标准 handler 的单结构体与结构体切片,ghttp_request_param.go)改用 gvalid.New(true) 显式开启上述缓存。这里的校验规则全部来自 struct tag,编译期即固定,缓存条目数与"代码里写过的 tag 条数"一致,不会随时间增长。

结果

口径:Linux 容器(golang:1.25),改动前后交错各跑 6 次取中位数,-benchmem,基线为提交前的 e8d22526a。

请求校验路径(ghttp,缓存开启)

基准以 gvalid.New(true) 构造(与 ghttp 一致);基线侧用等价的 gvalid.New()(基线代码本身没有缓存)。

基准 提升前 提升后 变化
CheckStruct_Data(6 字段) 9.95µs / 8073 B / 100 allocs 5.54µs / 6040 B / 67 allocs 耗时 -44%,内存 -25%,分配数 -33%
CheckStruct_Assoc(标准 handler 的用法) 9.62µs / 7827 B / 94 allocs 5.17µs / 5648 B / 59 allocs 耗时 -46%,内存 -28%,分配数 -37%
CheckStruct_BigReq(24 字段 8 规则,贴近真实 Req) 30.17µs / 29190 B / 262 allocs 20.94µs / 25057 B / 185 allocs 耗时 -31%,内存 -14%,分配数 -29%
ParseTagValue(对照组,公开 API) 817ns / 192 B / 2 allocs 817ns / 192 B / 2 allocs 不变

用户默认路径(gvalid.New(),不开启缓存)

用户现有代码走的路径:缓存之外的优化(消息直通、规则项拆分、Clone、gtag 短路)对所有调用者生效,无需任何改动或选择。

基准 提升前 提升后 变化
CheckStruct_Data 9.68µs / 8091 B / 100 allocs 7.52µs / 6515 B / 71 allocs 耗时 -22%,内存 -19%,分配数 -29%
CheckStruct_Assoc 9.72µs / 7839 B / 94 allocs 7.33µs / 6119 B / 63 allocs 耗时 -25%,内存 -22%,分配数 -33%
CheckStruct_BigReq 29.44µs / 29205 B / 262 allocs 25.53µs / 26615 B / 197 allocs 耗时 -13%,内存 -9%,分配数 -25%

对照组说明:公开 API ParseTagValue 的耗时与内存逐字节不变,说明语义没有被改动,收益全部落在 validator 路径上。

兼容性

  • 没有破坏性变更:gvalid.New() 的调用方式不变(新增的是一个可选参数),ParseTagValue 行为不变。
  • 用户代码默认零行为变化:New() 默认不缓存,用户现有代码的校验行为与旧版一致(同时仍获得上表"默认路径"的 -13%~-25% 提升,因为它来自与缓存无关的那几项优化)。
  • 框架请求路径自动受益、零迁移:ghttp 的两个校验点已显式开启缓存,升级后标准 handler 的请求校验自动获得上表"请求校验路径"的提升,用户无需任何配置。
  • 用户若确定自己的规则是静态的,可主动使用 gvalid.New(true) 获得与请求路径相同的收益。
  • 唯一的行为修复:非 ASCII 自定义规则名由 panic 变为正常工作。
  • 另有一处"无意义写法"的解析路径差异:裸 regex / not-regex(不带参数)与后一项按 | 合并时,新旧实现解析出的规则名不同,但两种实现在该写法下都是恒通过,实际不可观测。

测试

  • 新增单测 6 个:
    • Test_New_Cache:默认构造不写缓存、New(true) 才写(钉住"缓存 opt-in"这一契约);
    • Test_CustomError_MapStringString:map[string]string 消息直通,并断言输入 map 不被校验改写;
    • Test_Rule_PatternContainingColon:regex:^\d+:\w+$ 这类参数含冒号的规则正常;
    • Test_Rule_NonAsciiName:非 ASCII 规则名不再 panic;
    • Test_Parse_NoPlaceholder:gtag.Parse 短路的两条分支(无占位符 / 未注册占位符);
    • Test_Validator_Clone_Independence:链式校验互不污染、基座实例不被派生操作改动(Clone() 改写的契约)。
  • 新增基准文件 util/gvalid/gvalid_z_bench_check_test.go,含 6 字段 / 24 字段 / Assoc 三个场景,以 gvalid.New(true) 构造(与 ghttp 一致),并带 sanity 测试保证基准真实触发校验。
  • 已运行的测试:util/gvalid、util/gtag、os/gstructs、util/gconv、util/gmeta、util/gutil 全部通过;net/ghttp(Request/Params 定向用例)与 os/gcmd 通过。

复现

# 请求校验路径(与 ghttp 一致,缓存开启)
go test ./util/gvalid/ -bench "ParseTagValue|CheckStruct" -benchmem -count=6

# 用户默认路径(不开启缓存):把基准中的 gvalid.New(true) 改为 gvalid.New() 后再运行

- Cache the parsed validation tag values (field/rule/message) in a
  process level cache keyed by the tag value, as the tag values of the
  same struct definitions are static. The cache is enabled in default
  and can be disabled by gvalid.New(false) for dynamically generated
  rules, avoiding useless cache growth.
- Use the messages of type map[string]string directly in doCheckValue
  instead of converting them by gconv.Map, and allocate the message
  containers lazily.
- Replace the regexp based single rule item parsing with plain string
  scanning, which also fixes the panic when the name of a custom rule
  contains non-ASCII characters.
- Add benchmarks for the struct validation path.

Benchmarked on Linux (golang:1.25 container, -benchtime=2s) against
e8d2252, measured at the branch tip where the util/gtag change is
applied as well:

  Benchmark_CheckStruct_Data     8.18us -> 5.86us  ( 97 ->  71 allocs)
  Benchmark_CheckStruct_Assoc    7.78us -> 5.33us  ( 91 ->  65 allocs)
  Benchmark_CheckStruct_BigReq  28.30us -> 21.6us  (251 -> 189 allocs)
…eholder

Parse replaces "{name}" style placeholders using a regexp, and it is a
hot path for struct tag retrieving (gstructs.Field.Tag and
TagPriorityName), while most tag values contain no placeholder at all.

Return the content directly if there is no char '{', which is equivalent
as the regexp `\{(.+?)\}` cannot match an input without it.
Clone created a new Validator through New and then overwrote every field
with a shallow copy of the source, so the ruleFuncMap created by New was
never referenced. Create the shallow copy directly instead: the copy
already carries the source's ruleFuncMap, which keeps the behavior
identical.

Benchmarked on Linux (golang:1.25 container, interleaved, medians of 6
samples) against the previous commit:

  Benchmark_CheckStruct_Data      6.02us -> 5.75us  ( 71 ->  67 allocs)
  Benchmark_CheckStruct_Assoc     5.49us -> 5.20us  ( 65 ->  59 allocs)
  Benchmark_CheckStruct_BigReq   21.96us -> 20.90us (189 -> 185 allocs)
The parsed rule value cache is process level, while validation rules
might be dynamically generated in user code, in which case the cache
would grow without bound. Disable the cache in default so that the
behavior of existing user code keeps unchanged, and enable it explicitly
for the HTTP request handling, of which the validation rules are all from
struct tag and are static and bounded.

Users who are sure their rules are static can enable it with
gvalid.New(true) for the same performance gain.

This branch has not been deployed

No deployments
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.

1 participant