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 个基准。
381 lines
14 KiB
Go
381 lines
14 KiB
Go
// Package resx 提供 req.Resource 接口的实现。
|
||
//
|
||
// 角色权限过滤与写操作后的处理(变更留痕、ResWatcher 事件发布等)依赖更上层的 contracts 包
|
||
// (NewOrm/NewOrmJoin/GetOrmConditions/Queue 等),而 contracts 包本身依赖 req,为避免循环
|
||
// import,这几块能力通过 DataInterceptor/ResChangeEventFunc/ResChangeRowFunc 三个函数类型传入:
|
||
// 上层(如 framework)用 do.Provide 把它们分别注册到 DI 容器即可获得完整能力;容器里没有注册
|
||
// 时优雅降级为空操作,因此这个包在测试或不需要这些能力的场景下(如 orm 包的单元测试)也可以
|
||
// 直接使用。
|
||
//
|
||
// 这几个回调是 per-container(per-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"
|
||
}
|