Files
req/resx/resource.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

381 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package resx 提供 req.Resource 接口的实现。
//
// 角色权限过滤与写操作后的处理(变更留痕、ResWatcher 事件发布等)依赖更上层的 contracts 包
// NewOrm/NewOrmJoin/GetOrmConditions/Queue 等),而 contracts 包本身依赖 req,为避免循环
// import,这几块能力通过 DataInterceptor/ResChangeEventFunc/ResChangeRowFunc 三个函数类型传入:
// 上层(如 framework)用 do.Provide 把它们分别注册到 DI 容器即可获得完整能力;容器里没有注册
// 时优雅降级为空操作,因此这个包在测试或不需要这些能力的场景下(如 orm 包的单元测试)也可以
// 直接使用。
//
// 这几个回调是 per-containerper-app)的概念而不是 per-resource 的:回调参数里已经带 res,可以
// 按资源分支处理,同一个容器里的所有资源理应共享同一套实现,所以不作为 resource 的字段通过 Option
// 逐个设置,而是像 DB() 一样在使用时从 container 里解析(见 (*resource).intercept/changeEvent)。
package resx
import (
"fmt"
"reflect"
"sync"
"unicode"
"github.com/samber/do/v2"
"github.com/samber/lo"
"git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/engine"
"git.fsdpf.net/go/db/exp"
"git.fsdpf.net/go/db/schema"
"git.fsdpf.net/go/req"
)
// DataInterceptor 每次读写操作前调用。由上层用 do.Provide 注册到 DI 容器;容器里没有注册时不过滤、
// 不抓取。返回值:
// - sub:权限条件的子查询形式,非 nil 时用于重写 FROM(SELECT)或改写为
// `id` IN (子查询)UPDATE/DELETE);与 cond 互斥,两者都为 nil 表示不过滤。例如
// SELECT 拼接后为:
// SELECT * FROM (SELECT * FROM `users` AS `User` WHERE (`owned_user` = '...')) AS `User`
// UPDATE/DELETE 拼接后为:
// UPDATE `users` AS `User` SET ... WHERE (`id` IN ((SELECT `User`.`id` FROM `users` AS `User` WHERE (`owned_user` = '...'))))
// - cond:权限条件的表达式形式,非 nil 时直接拼进 WHERE;与 sub 互斥。例如拼接后为:
// SELECT * FROM `users` AS `User` WHERE (`owned_user` = '...')
// - onChange:非 nil 表示本次写操作需要抓取快照/收集变更内容,写完后会调用它(写入变更
// 日志、发布 ResWatcher 事件等要用到);nil 表示不需要,也就完全不会有调用。跟
// ResChangeEventFunc 不是一回事:ResChangeEventFunc 不看这个返回值,任何写操作后都会
// 无条件调用。SELECT 场景不会用到 onChange,可以恒返回 nil
type DataInterceptor func(res req.Resource, u req.User, category ResEventType) (sub *db.SelectDataset, cond db.Expression, onChange ResChangeRowFunc)
// ResChangeEventFunc 写操作完成后无条件调用一次,不受 DataInterceptor 返回的 onChange 影响(典型
// 用途:发布粗粒度的"资源发生变更"事件,如清缓存)。event 已经自带 Type()/Res()/User()/
// Result(),不需要额外参数。由上层用 do.Provide 注册到 DI 容器;容器里没有注册时不做任何处理。
type ResChangeEventFunc func(event ResChangeEvent) error
// ResChangeRowFunc 写操作完成后调用,由同一次写操作里 DataInterceptor 的 onChange 返回值直接
// 提供(典型用途:写入变更日志、发布 ResWatcher 事件),不是从 DI 容器解析的,因此可以按
// 资源/类别/用户各返回不同的处理逻辑。rows 里每一行已经自带 Type()/Res()/User()/ID()/Old()/
// New()/Cur()/Tx(),不需要额外参数。
type ResChangeRowFunc func(rows []ResChangeRow) error
// MaskFunc 返回某个字段被脱敏后展示的哨兵值(用户没有权限查看 field 真实值时,SELECT 里这个
// 字段会换成这个值)。由上层用 do.Provide 注册到 DI 容器;容器里没有注册时按 field.GetDataType()
// 走内置默认值:数字类(Integer/SmallInteger/Decimal)给一个明显不合理的负数,字符串类
// String/Text/Enum)给一个"零宽空格-零宽空格"的三字符标记(视觉上只显示一个短横线,但跟真实
// 短横线不是同一个字符串),其它类型给 NULL——都是刻意选的、不会跟真实业务数据混淆的哨兵值,
// 具体见 defaultMask。
type MaskFunc func(field req.ResField) exp.LiteralExpression
// DefaultMaskInt 数字类字段的默认哨兵值:绝大多数业务字段(数量/价格/年龄/次数等)不可能出现
// 这个量级的负数,比 0 更不容易被误当成真实数据
var DefaultMaskInt = -999999999
// DefaultMaskString 字符串类字段的默认哨兵值:零宽空格 + 短横线 + 零宽空格(U+200B, '-', U+200B)。
// 用显式字节而不是直接在源码里放不可见字符,方便 diff/grep 时能看清楚具体是什么;视觉上只显示
// 一个"-",但实际是 3 个字符,跟调用方自己存的真实短横线字符串区分得开
var DefaultMaskString = string([]byte{0xE2, 0x80, 0x8B, '-', 0xE2, 0x80, 0x8B})
// defaultMask 内置的按数据类型分支的脱敏默认值,MaskFunc 未注册时使用
func defaultMask(field req.ResField) exp.LiteralExpression {
switch field.GetDataType() {
case req.ResInteger, req.ResSmallInteger, req.ResDecimal:
return db.V(DefaultMaskInt)
case req.ResString, req.ResText, req.ResEnum:
return db.V(DefaultMaskString)
default:
return db.V(nil)
}
}
type resource struct {
container do.Injector
initOnce *sync.Once
uuid string
code string
name string
description string
conn string
isResVirtual bool
table string
// virtualTable 只有虚拟资源(NewVirtualResource 构造的)才会设置,table 留空。GetTableExpr
// 用它代替 table 拼子查询:db.V(sd.Expression()) 让方言自己生成/加括号,不用手写 "(" + sql + ")"
// 拼字符串。
virtualTable exp.SQLExpression
primarykey string
historyRoles []string
fields []req.ResField
// structCache 缓存 GetStruct 构造出来的类型,见 res_struct_cache.go
structCache resStructCache
}
// intercept 从容器里解析 DataInterceptor 并调用;容器里没有注册时返回 nil, nil, nil(不过滤、不抓取)。
func (this *resource) intercept(u req.User, category ResEventType) (sub *db.SelectDataset, cond db.Expression, onChange ResChangeRowFunc) {
fn, err := do.Invoke[DataInterceptor](this.container)
if err != nil {
return nil, nil, nil
}
return fn(this, u, category)
}
// changeEvent 从容器里解析 ResChangeEventFunc 并调用;容器里没有注册时不做任何处理。
func (this *resource) changeEvent(event ResChangeEvent) error {
fn, err := do.Invoke[ResChangeEventFunc](this.container)
if err != nil {
return nil
}
return fn(event)
}
// mask 从容器里解析 MaskFunc 并调用;容器里没有注册时走 defaultMask。
func (this *resource) mask(field req.ResField) exp.LiteralExpression {
fn, err := do.Invoke[MaskFunc](this.container)
if err != nil {
return defaultMask(field)
}
return fn(field)
}
// Option 用于设置 New 里非必填的字段
type Option func(r *resource)
// WithUuid 设置资源 uuid,不设置则为空字符串
func WithUuid(v string) Option {
return func(r *resource) { r.uuid = v }
}
// WithName 设置资源名,不设置则默认与 code 相同
func WithName(v string) Option {
return func(r *resource) { r.name = v }
}
// WithDescription 设置资源说明,不设置则为空字符串
func WithDescription(v string) Option {
return func(r *resource) { r.description = v }
}
// WithConn 设置数据库连接名,不设置则为空字符串(引擎按空字符串键查找连接)
func WithConn(v string) Option {
return func(r *resource) { r.conn = v }
}
// WithPrimarykey 设置主键字段名,不设置则默认为 "id"
func WithPrimarykey(v string) Option {
return func(r *resource) { r.primarykey = v }
}
// WithHistoryRoles 设置变更留痕角色列表,不设置则不留痕
func WithHistoryRoles(roles ...string) Option {
return func(r *resource) { r.historyRoles = roles }
}
// WithFields 设置资源字段,不设置则为空
func WithFields(fields ...req.ResField) Option {
return func(r *resource) { r.fields = fields }
}
// New 构造一个 req.Resource。code/table 为必填,其余通过 Option 设置。
// 读写钩子(DataInterceptor/ResChangeEventFunc/ResChangeRowFunc)不通过 Option 设置,而是由上层用
// do.Provide 注册到 container,见包注释。
func New(container do.Injector, code, table string, opts ...Option) req.Resource {
r := &resource{
container: container,
uuid: code,
code: code,
name: code,
table: table,
primarykey: "id",
initOnce: &sync.Once{},
}
for _, opt := range opts {
opt(r)
}
return r
}
func (this *resource) GetUuid() string {
return this.uuid
}
func (this *resource) GetCode() string {
return this.code
}
func (this *resource) GetName() string {
return this.name
}
func (this *resource) GetDescription() string {
return this.description
}
func (this *resource) GetConn() string {
return this.conn
}
func (this *resource) GetPrimarykey() string {
return this.primarykey
}
func (this *resource) GetHistoryRoles() []string {
return this.historyRoles
}
func (this *resource) IsVirtual() bool {
return this.isResVirtual
}
func (this *resource) IsSystem() bool {
return this.conn == "service-support"
}
func (this *resource) GetFields() []req.ResField {
return this.fields
}
func (this *resource) GetField(code string) (req.ResField, bool) {
return lo.Find(this.fields, func(v req.ResField) bool {
return v.GetCode() == code
})
}
func (this *resource) HasField(code string) bool {
return lo.SomeBy(this.fields, func(v req.ResField) bool {
return v.GetCode() == code
})
}
func (this *resource) BeginTransaction() (*db.TxDatabase, error) {
return this.DB().Begin()
}
func (this *resource) DB() *db.Database {
dbEngine := do.MustInvoke[engine.Engine](this.container)
conn := dbEngine.Connection(this.conn)
if isLocalDB(conn.Dialect()) && !this.isResVirtual && this.table != "" && this.initOnce != nil {
this.initOnce.Do(func() {
if err := this.autoCreateTable(conn); err != nil {
panic(err)
}
})
}
// vtable 资源:查询走 engine 中的 :memory: 连接("__"+Conn),
// 原文件连接仅用于 _vtab_cache 持久化,两者互不阻塞。
if conn.Dialect() == "vtable" && this.table != "" && this.initOnce != nil {
memConn := dbEngine.Connection("__" + this.conn)
this.initOnce.Do(func() {
if err := this.autoSetupVtab(memConn); err != nil {
panic(err)
}
})
return memConn
}
return conn
}
// autoSetupVtab 执行 CREATE VIRTUAL TABLE;对应的 vtab 模块由上层通过 req.ResVirtualTableSetup
// 提前注册完成(在资源缓存构建时对所有 vtable 连接的资源整体调用一遍,而不是在这里 lazy 触发)
func (this *resource) autoSetupVtab(conn *db.Database) error {
_, err := conn.Exec(fmt.Sprintf(`CREATE VIRTUAL TABLE IF NOT EXISTS %s USING %s_mod()`, this.table, this.table))
return err
}
// autoCreateTable 自动创建数据表
func (this *resource) autoCreateTable(conn *db.Database) error {
sb := schema.New(conn)
return sb.Create(this.table, func(bp *schema.Blueprint) {
bp.Comment = this.name
for _, field := range this.fields {
field.ToBlueprint(bp)
}
})
}
// GetTable 原始表名;虚拟资源没有物理表名,返回空字符串
func (this *resource) GetTable() string {
return this.table
}
// GetTableExpr 获取资源对应的可用于 FROM/JOIN 的表引用表达式
func (this *resource) GetTableExpr() exp.Aliaseable {
if this.isResVirtual {
return db.L("?", this.virtualTable.Clone())
}
return db.T(this.table)
}
// GetDBTable 获取资源对应的数据库连接
func (this *resource) GetDBTable(u req.User, opts ...req.ResOption) (sd *db.SelectDataset) {
if u == nil {
panic("GetDBTable: user cannot be nil")
}
op := &req.ResOptions{}
for _, cb := range opts {
cb(op)
}
alias := this.GetCode()
if op.Alias != "" {
alias = op.Alias
}
sd = this.DB().From(this.GetTableExpr().As(alias))
if op.Tx != nil {
sd = op.Tx.From(this.GetTableExpr().As(alias))
}
sd.WithHook(&dataProcessor{
res: this,
u: u,
tx: op.Tx,
silent: op.Silent,
skipRowFilter: op.Permission&req.ResRow == 0,
skipFieldMask: op.Permission&req.ResMask == 0,
})
return sd
}
func (this *resource) GetStruct(extends ...reflect.StructField) any {
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])) {
fields = append(fields, field.ToStructField())
}
}
fields = lo.UniqBy(append(fields, extends...), func(v reflect.StructField) string {
return v.Name
})
t := reflect.StructOf(fields)
this.structCache.store(extends, t)
return t
}
// isLocalDB 判断是否为本地文件型数据库(LastInsertId 返回最后一条而非第一条)
func isLocalDB(dialect string) bool {
return dialect == "sqlite3" || dialect == "duckdb"
}