Files
req/resx/res_struct_cache_test.go
T
what b81faafd18 perf: 缓存 GetStruct 构造的 struct 类型
reflect.StructOf 即使命中 runtime 自己的类型缓存, 仍然要重新构造
[]StructField、算签名、加锁查表。实测 12 字段的 struct:

  reflect.StructOf(相同字段, 已缓存)   2349 ns / 2549 B / 31 allocs
  reflect.TypeOf(已知类型)                2.3 ns /    0 B /  0 allocs

相差三个数量级。而 fields 在资源构造之后不再变化, 相同的 extends 必然得到
同一个类型, 完全可以缓存复用。

GetStruct/GetSliceStruct 抽出共用的 structType(), 结果按 extends 缓存:

  GetStruct()          1399 ns / 2352 B / 28 allocs  ->  22.2 ns /  48 B / 1 alloc   63x
  GetStruct(extends)   1673 ns / 3592 B / 31 allocs  ->  47.1 ns / 176 B / 2 allocs  36x
  GetSliceStruct()     1457 ns / 2376 B / 29 allocs  ->  31.5 ns /  24 B / 1 alloc   46x

缓存用切片线性扫描而不是 map: 键是 []reflect.StructField, 要做 map key 得先
拼字符串, 那笔开销反而可能盖过 StructOf 本身; 而 extends 的取值组合来自关联
配置, 数量很少, 逐项比对最省。条目数设了上限, 超出后退回每次现算, 保证内存有界。

一个容易踩的坑: 缓存键会复制一份再存。调用方常把 extends 当临时缓冲区复用
(append 到同一个底层数组上), 直接持有会让键在之后被改写, 变成命中错误类型的
隐蔽 bug。TestGetStructExtendsAliasing 专门守这个。

行为不变: GetStruct 仍然每次返回新实例, 只是复用类型。
新增 4 个测试(类型稳定性/extends 隔离/别名安全/并发)与 3 个基准。
2026-08-28 19:50:54 +08:00

176 lines
5.0 KiB
Go

//go:build sqlite_vtable || vtable
package resx_test
import (
"reflect"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/samber/do/v2"
"git.fsdpf.net/go/db/engine"
"git.fsdpf.net/go/req"
"git.fsdpf.net/go/req/resx"
)
func newCacheTestResource(t testing.TB) req.Resource {
t.Helper()
mockDB, _, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
app := do.New()
do.Provide(app, func(i do.Injector) (engine.Engine, error) {
return engine.Mock(map[string]engine.MockDBConfig{
"default": {Driver: "mysql", Mock: mockDB},
}), nil
})
return resx.New(app, "User", "users",
resx.WithConn("default"),
resx.WithFields(
resx.NewResField("id", "User", resx.FieldWithName("Id"), resx.FieldWithDataType(req.ResInteger)),
resx.NewResField("name", "User", resx.FieldWithName("Name"), resx.FieldWithDataType(req.ResString)),
resx.NewResField("email", "User", resx.FieldWithName("Email"), resx.FieldWithDataType(req.ResString)),
resx.NewResField("status", "User", resx.FieldWithName("Status"), resx.FieldWithDataType(req.ResInteger)),
),
)
}
// 缓存只应影响速度, 不影响结果: 多次调用必须拿到同一个类型, 但仍是新实例。
func TestGetStructTypeIsStable(t *testing.T) {
res := newCacheTestResource(t)
t1 := reflect.TypeOf(res.GetStruct())
t2 := reflect.TypeOf(res.GetStruct())
if t1 != t2 {
t.Fatalf("无 extends 时两次 GetStruct 类型不一致: %v vs %v", t1, t2)
}
if t1.Kind() != reflect.Ptr || t1.Elem().Kind() != reflect.Struct {
t.Fatalf("GetStruct 应返回 *struct, 得到 %v", t1)
}
if res.GetStruct() == res.GetStruct() {
t.Fatal("GetStruct 应该每次返回新实例, 只复用类型")
}
// 带 extends
ext := reflect.StructField{Name: "Extra", Type: reflect.TypeOf("")}
e1 := reflect.TypeOf(res.GetStruct(ext))
e2 := reflect.TypeOf(res.GetStruct(ext))
if e1 != e2 {
t.Fatalf("带 extends 时两次类型不一致: %v vs %v", e1, e2)
}
if e1 == t1 {
t.Fatal("带 extends 的类型不应与不带 extends 的相同")
}
if _, ok := e1.Elem().FieldByName("Extra"); !ok {
t.Fatal("extends 字段没有出现在结果类型里")
}
// 不同的 extends 必须得到不同的类型, 且互不污染
ext2 := reflect.StructField{Name: "Other", Type: reflect.TypeOf(0)}
o1 := reflect.TypeOf(res.GetStruct(ext2))
if o1 == e1 {
t.Fatal("不同 extends 却拿到同一个类型, 缓存键有问题")
}
if _, ok := o1.Elem().FieldByName("Other"); !ok {
t.Fatal("第二组 extends 的字段没有出现")
}
if reflect.TypeOf(res.GetStruct(ext)) != e1 {
t.Fatal("缓存被后续调用污染了")
}
}
// 调用方复用 extends 底层数组时, 缓存键不能跟着被改写。
func TestGetStructExtendsAliasing(t *testing.T) {
res := newCacheTestResource(t)
buf := make([]reflect.StructField, 0, 4)
buf = append(buf, reflect.StructField{Name: "A", Type: reflect.TypeOf("")})
tA := reflect.TypeOf(res.GetStruct(buf...))
// 复用同一个底层数组换成另一组字段
buf = buf[:0]
buf = append(buf, reflect.StructField{Name: "B", Type: reflect.TypeOf("")})
tB := reflect.TypeOf(res.GetStruct(buf...))
if tA == tB {
t.Fatal("复用底层数组导致缓存键被改写, 拿到了错误的类型")
}
if _, ok := tA.Elem().FieldByName("A"); !ok {
t.Fatal("第一次的类型被污染了")
}
if _, ok := tB.Elem().FieldByName("B"); !ok {
t.Fatal("第二次的类型不对")
}
}
func TestGetSliceStructType(t *testing.T) {
res := newCacheTestResource(t)
st := reflect.TypeOf(res.GetSliceStruct())
if st.Kind() != reflect.Ptr || st.Elem().Kind() != reflect.Slice {
t.Fatalf("GetSliceStruct 应返回 *[]struct, 得到 %v", st)
}
if st.Elem().Elem() != reflect.TypeOf(res.GetStruct()).Elem() {
t.Fatal("GetSliceStruct 的元素类型应与 GetStruct 的一致")
}
if reflect.TypeOf(res.GetSliceStruct()) != st {
t.Fatal("两次 GetSliceStruct 类型不一致")
}
}
// 并发调用不能出现数据竞争, 且拿到的类型一致(配合 -race 跑)。
func TestGetStructConcurrent(t *testing.T) {
res := newCacheTestResource(t)
want := reflect.TypeOf(res.GetStruct())
const n = 16
done := make(chan reflect.Type, n)
for i := 0; i < n; i++ {
go func() {
var got reflect.Type
for j := 0; j < 100; j++ {
got = reflect.TypeOf(res.GetStruct())
}
done <- got
}()
}
for i := 0; i < n; i++ {
if got := <-done; got != want {
t.Fatalf("并发下拿到了不同的类型: %v vs %v", got, want)
}
}
}
func BenchmarkGetStruct(b *testing.B) {
res := newCacheTestResource(b)
res.GetStruct() // 预热
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = res.GetStruct()
}
}
func BenchmarkGetStructWithExtends(b *testing.B) {
res := newCacheTestResource(b)
ext := reflect.StructField{Name: "Extra", Type: reflect.TypeOf("")}
res.GetStruct(ext)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = res.GetStruct(ext)
}
}
func BenchmarkGetSliceStruct(b *testing.B) {
res := newCacheTestResource(b)
res.GetSliceStruct()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = res.GetSliceStruct()
}
}