diff --git a/resx/res_struct_cache.go b/resx/res_struct_cache.go new file mode 100644 index 0000000..4bba303 --- /dev/null +++ b/resx/res_struct_cache.go @@ -0,0 +1,87 @@ +package resx + +import ( + "reflect" + "sync" +) + +// 本文件为 GetStruct/GetSliceStruct 提供结果类型缓存。 +// +// 动机: 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 必然 +// 得到同一个类型, 所以完全可以缓存下来复用。 + +// resStructCache 按 extends 缓存 GetStruct 的结果类型。 +// +// 用切片线性扫描而不是 map: 键是 []reflect.StructField, 要做 map key 得先拼 +// 字符串, 那笔开销反而可能盖过 StructOf 本身。而 extends 的取值组合来自关联 +// 配置, 数量很少(通常个位数), 逐项比对最省 —— reflect.StructField 的 +// Name/Type/Tag 三者都可比较。 +type resStructCache struct { + mu sync.RWMutex + base reflect.Type // extends 为空时的类型(最常见的调用形态) + entries []resStructCacheEntry +} + +type resStructCacheEntry struct { + extends []reflect.StructField + typ reflect.Type +} + +// resStructCacheLimit 限制缓存条目数。正常配置下远远用不满; 万一调用方拿运行时 +// 生成的字段做 extends, 超过上限后退回每次现算, 保证内存有界。 +const resStructCacheLimit = 64 + +// sameExtends 判断两组 extends 是否等价。 +func sameExtends(a, b []reflect.StructField) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Name != b[i].Name || a[i].Type != b[i].Type || a[i].Tag != b[i].Tag { + return false + } + } + return true +} + +// load 查缓存, 未命中返回 nil。 +func (c *resStructCache) load(extends []reflect.StructField) reflect.Type { + c.mu.RLock() + defer c.mu.RUnlock() + + if len(extends) == 0 { + return c.base + } + for i := range c.entries { + if sameExtends(c.entries[i].extends, extends) { + return c.entries[i].typ + } + } + return nil +} + +// store 写入缓存。 +// +// extends 会复制一份再存: 调用方常把它当临时缓冲区复用(append 到同一个底层 +// 数组上), 直接持有会让缓存键在之后被改写, 变成命中错误类型的隐蔽 bug。 +func (c *resStructCache) store(extends []reflect.StructField, t reflect.Type) { + c.mu.Lock() + defer c.mu.Unlock() + + if len(extends) == 0 { + c.base = t + return + } + if len(c.entries) >= resStructCacheLimit { + return + } + key := make([]reflect.StructField, len(extends)) + copy(key, extends) + c.entries = append(c.entries, resStructCacheEntry{extends: key, typ: t}) +} diff --git a/resx/res_struct_cache_test.go b/resx/res_struct_cache_test.go new file mode 100644 index 0000000..bfe1705 --- /dev/null +++ b/resx/res_struct_cache_test.go @@ -0,0 +1,175 @@ +//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() + } +} diff --git a/resx/resource.go b/resx/resource.go index 7566351..fce6dea 100644 --- a/resx/resource.go +++ b/resx/resource.go @@ -102,6 +102,9 @@ type resource struct { primarykey string historyRoles []string fields []req.ResField + + // structCache 缓存 GetStruct 构造出来的类型,见 res_struct_cache.go + structCache resStructCache } // intercept 从容器里解析 DataInterceptor 并调用;容器里没有注册时返回 nil, nil, nil(不过滤、不抓取)。 @@ -337,8 +340,24 @@ func (this *resource) GetDBTable(u req.User, opts ...req.ResOption) (sd *db.Sele } func (this *resource) GetStruct(extends ...reflect.StructField) any { - fields := []reflect.StructField{} + return reflect.New(this.structType(extends)).Interface() +} +func (this *resource) GetSliceStruct(extends ...reflect.StructField) any { + return reflect.New(reflect.SliceOf(this.structType(extends))).Interface() +} + +// structType 返回资源字段(可选叠加 extends)对应的 struct 类型。 +// +// 结果按 extends 缓存: fields 在资源构造之后不再变化, 相同的 extends 必然得到 +// 同一个类型, 没必要每次都走一遍 reflect.StructOf —— 它即使命中 runtime 的类型 +// 缓存也要 2μs 上下并产生几十次分配, 详见 res_struct_cache.go。 +func (this *resource) structType(extends []reflect.StructField) reflect.Type { + if t := this.structCache.load(extends); t != nil { + return t + } + + fields := make([]reflect.StructField, 0, len(this.fields)+len(extends)) for _, field := range this.fields { code := field.GetCode() if unicode.IsLetter(rune(code[0])) { @@ -351,14 +370,8 @@ func (this *resource) GetStruct(extends ...reflect.StructField) any { }) t := reflect.StructOf(fields) - - return reflect.New(t).Interface() -} - -func (this *resource) GetSliceStruct(extends ...reflect.StructField) any { - t := reflect.TypeOf(this.GetStruct(extends...)) - st := reflect.SliceOf(t.Elem()) - return reflect.New(st).Interface() + this.structCache.store(extends, t) + return t } // isLocalDB 判断是否为本地文件型数据库(LastInsertId 返回最后一条而非第一条)