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
2024-05-08 21:35:26 +08:00

git.fsdpf.net/go/reqmaster

资源系统的核心抽象与默认实现,配合 framework-v2/contracts-v2/orm-v2 等 "-v2" 系列项目使用。

核心接口(仓库根目录)

  • Resource:一个业务资源的完整描述——字段列表、主键、变更留痕角色、数据库连接、以及 GetDBTable(u User, opts ...ResOption) *db.SelectDataset 这个统一的查询入口。
  • ResField:单个字段的描述(code、数据类型、默认值、是否虚拟计算列、GetRoles() 权限角色等)。
  • QueryFieldResField 之上再包一层查询专用信息(别名、是否表达式、omitempty、忽略), 给 API 层按请求参数动态拼查询列表用。
  • User:当前操作者(角色列表、匿名判断等),贯穿权限判断的所有环节。
  • GetResource/MustResource:按 code/table/uuid 查资源的 DI 注入函数类型,由上层 (framework-v2)注册实现,resx 包只依赖类型,不关心具体实现。
  • ResFlagsResRow/ResRowRelations/ResMask/ResMaskRelations/ResAll):GetDBTableWithPermission 选项用的位标志,见下文"权限开关"。

resx 包:默认实现

构造资源

  • resx.New(container do.Injector, code, table string, opts ...Option) req.Resource:构造一个 绑定物理表的资源。containersamber/do/v2 的 DI 容器,资源自己的读写钩子 (DataInterceptor/ResChangeEventFunc/MaskFunc)都是运行时从这个容器里解析的,容器里没 注册时优雅降级为空操作,所以 resx 在测试或不需要这些能力的场景下也能独立使用。可选项: WithUuid/WithName/WithDescription/WithConn/WithPrimarykey/WithHistoryRoles/ WithFields
  • resx.NewVirtualResource(parent req.Resource, code string, table exp.SQLExpression, opts ...Option) req.Resource 基于一个已有资源(复用它的 container/conn/historyRoles)和一段查询表达式(子查询/窗口 函数等)构造只读虚拟资源,table 不是物理表名,而是 GetTableExpr() 直接拿来拼 FROM 子句的 表达式。虚拟资源会跳过行级权限过滤和写操作相关的钩子编排(本来就是只读的)。
  • resx.NewResField(code, codeResource string, opts ...ResFieldOption):构造字段,ResField 内部字段全私有,只能通过 FieldWith*FieldWithName/FieldWithDataType/FieldWithRoles/ FieldWithVirtual 等)设置。
  • resx.NewQueryField(rField req.ResField, t req.RouteParamType, opts ...QueryFieldOption) QueryField 嵌入的是 req.ResField 接口而不是具体结构体,调用方手上已有的 req.ResField 可以直接传入,不用现造一个。

字段级权限脱敏

  • resx.MaskField("table.col") / resx.MaskField("col"):在 SELECT 列表里显式标记一列需要按 权限脱敏。只有标记过的列才会被处理,未标记的列即使字段本身配置了 Roles 也不会被动过 ——标记是显式的,dataProcessor 不会替调用方去猜哪一列对应哪个字段。
  • 标记的列在查询真正执行前(exec.Hooks.Before,这时候完整的 FROM/JOIN 子句才拼好)才懒解析: 裸列名或者别名就是当前资源自己时直接用当前资源,不碰 DI;别名对应 JOIN 进来的另一个资源时, 先在 FROM/JOIN 子句里把别名换成真实表名,再用 req.GetResource 按真实表名查到对应资源。 按角色(ResField.GetRoles())判断当前用户有没有权限看到真实值,没有的话换成哨兵值——数字类 默认 resx.DefaultMaskInt-999999999),字符串类默认 resx.DefaultMaskString(三个不可见 字符拼的短横线,跟真实短横线区分得开),也可以在容器里注册 resx.MaskFunc 自定义脱敏值。
  • SELECT *(或者显式选中当前资源自己别名的全部列)会先判断有没有任何字段配置了 Roles,有 的话才展开成显式字段列表逐个按权限脱敏,没有的话原样保留 SELECT *,避免不必要的改写。
  • 写操作(INSERT/UPDATE)里,没有写权限的字段会被 normalizeSaveValue 静默丢弃,不会出现在 最终的 SQL 里。

权限开关(req.WithPermission

GetDBTable(u, opts...) 默认(不传 WithPermission行级过滤和字段脱敏都跳过——这是零值 语义,两个开关相互独立:

res.GetDBTable(u, req.WithPermission(req.ResRow))            // 只开行级过滤
res.GetDBTable(u, req.WithPermission(req.ResMask))            // 只开字段脱敏
res.GetDBTable(u, req.WithPermission(req.ResAll))             // 两个都开(ResRow|ResMask

读写钩子编排

三个函数类型,由上层用 do.Provide 注册到容器,resx 只依赖类型本身:

  • resx.DataInterceptor:每次读写前调用,返回行级权限过滤条件(sub 子查询或 cond 表达式,二选一)、以及这次写操作要不要在写完后回调(onChange,只对 INSERT/UPDATE/DELETE 有意义,SELECT 恒为 nil)。
  • resx.ResChangeEventFunc:写操作完成后无条件调用一次(典型用途:发布"资源变了"的粗粒度事件, 比如清缓存)。
  • resx.ResChangeRowFunc:由 DataInterceptor 返回的 onChange 提供,需要抓取变更快照时才 调用(典型用途:写变更日志、发布 ResWatcher 事件)。onChange 非 nil 时写操作会自动包一层 事务,保证写入和 onChange 本身是同一个事务。

容器里没注册对应钩子时,三个都优雅降级为空操作,不影响基本读写。

分支状态

持续开发中,不保证跟旧 API 兼容。老项目(还没迁移到上面这些新能力的)不要直接跟着这个分支 走,参见 v1-legacy 分支。

S
Description
No description provided
Readme
212 KiB
Languages
Go 100%