多对多、自关联都能审计:EntityAuditBundle复杂关系版本化实现机制全解析
【免费下载链接】EntityAuditBundleAudit for Doctrine Entities项目地址: https://gitcode.com/gh_mirrors/en/EntityAuditBundle
EntityAuditBundle 是 Symfony + Doctrine ORM 的实体审计组件,支持对实体及其多对多(ManyToMany)、自关联(Self-Referencing)等复杂关系做完整的历史版本化:每次保存都会生成一条全局 revision,实体表和连接表同步写入带_audit后缀的镜像表,从而在任意历史时间点还原"当时谁关联了谁"。
一、先搞懂:审计表是怎么自动建出来的 🏗️
组件的设计灵感来自 Hibernate Envers:为每个被审计实体生成一张"影子表",表名 = 原表名 +_audit(后缀由配置项table_suffix决定)。影子表比原表多两个字段:
| 字段 | 含义 |
|---|---|
rev | 全局修订号,来自公共的revisions表(含 id、时间戳、操作人 username、备注) |
revtype | 修订类型:INS(插入)/UPD(更新)/DEL(删除) |
关键在于——它不止克隆实体表,连多对多的连接表(join table)也会被克隆成连接表名_audit,主键为"两端实体 ID + rev"。这一整套 DDL 由 src/EventListener/CreateSchemaListener.php 中的CreateSchemaListener监听 Doctrine SchemaTool 的postGenerateSchemaTable/postGenerateSchema事件自动完成,所以你执行doctrine:schema:update时,审计表会"顺路"一起建好,无需手写 SQL。
二、写入侧:一次 flush 里到底发生了什么 ✍️
核心写入逻辑全部在 src/EventListener/LogRevisionsListener.php 的LogRevisionsListener中,它订阅了 Doctrine 的 5 个事件:onFlush、postPersist、postUpdate、postFlush、onClear。一次flush()的完整流程是:
- onFlush:拿到 UnitOfWork 的待删除 / 待插入 / 待更新实体清单。删除类实体直接在此记录
DEL修订(含完整字段快照);新增 / 更新的实体先登记进extraUpdates,等主表落库后再补。 - postPersist / postUpdate:为新增(
INS)和更新(UPD)实体写一行实体影子表记录。注意postUpdate会先剔除global_ignore_columns(如created_at)中的变更,没有任何有效变更就不产生新版本,避免"时间戳抖动"产生噪声修订。 - postFlush:处理"额外更新"——比如新建实体插入后才有自增 ID,此时才把真实的 ID 回填到
INS修订行中;同时执行所有"延迟登记"的多对多关系修订(下一节细讲)。
同一个 flush 内所有变更共享同一个全局 rev 号(getRevisionId()首次调用时向revisions表插入一条含当前时间戳和操作人的记录并缓存),因此"这一批操作发生在同一时刻、由同一人完成"的语义天然成立。
三、多对多审计核心:连接表也必须版本化 🔗
这是 EntityAuditBundle 与"只备份字段值"的简单审计方案最大的区别。
3.1 关系变化也写入影子连接表
在saveRevisionEntityData()中,组件遍历实体的所有关联映射:
- 一对多 / 一对一(属主方 FK 列):外键值直接作为列写进实体影子表,所以"改了归属"这种变化在实体
_audit行里就能看到; - 多对多属主方(ManyToMany OwningSide):不写列,而是通过
recordRevisionForManyToManyEntity()向连接表的_audit影子表插入一行(本端 ID + 对端 ID + rev + revtype),SQL 模板由getInsertJoinTableRevisionSQL()按类名.对端类名.连接表名缓存复用(见 src/Utils/ORMCompatibilityTrait.php 中的映射解析工具)。
于是"文章 A 在 rev=10 关联了标签 X、Y,在 rev=15 换成了 X、Z"这种集合级变化,就完整沉淀在文章_标签连接表_audit里,可精确回放。
3.2 延迟提交:解决"新实体还没 ID"的死锁问题
经典难题:同一批 flush 中,父实体要关联一个刚新建、尚未分配自增 ID的子实体,此时无法写入连接表影子行。组件的解法是 src/DeferredChangedManyToManyEntityRevisionToPersist.php——一个纯数据的"待办信封":检测到$uow->getSingleIdentifierValue($relatedEntity)为空时,把(关联实体、revType、实体快照、双方 ClassMetadata)打包入队,等到postFlush 事件(此时子实体必已落库、ID 就绪)再统一recordRevisionForManyToManyEntity()补写,随后清空队列。这个小而优雅的设计是多对多审计正确性的基石。
四、自关联(Self-Referencing)为什么也能审计 🧬
自关联指"同一实体关联自身",典型场景:员工的manager、标签树的children、用户互相关注等。测试夹具 tests/Fixtures/Relation/SelfReferencingManyToManyEntity.php(继承BaseSelfReferencingManyToManyEntity)就是"实体关联自己"的多对多,对应功能测试在 tests/Issue/IssueSelfReferencingManyToManyEntityTest.php。
自关联之所以"免费"可用,是因为组件对属主方 / 被属方(mappedBy)的处理是对称的:
- 写入侧:无论目标实体是不是自己,
saveRevisionEntityData()都按属主方映射走同一套连接表影子表插入逻辑,本端与对端 ID 都来自同一张表,天然无歧义; - 读取侧(见 src/AuditReader.php 末尾集合加载段):
AuditReader加载历史实体时,若映射是isManyToManyOwningSideMapping就从本实体一侧查连接表影子表;若走的是mappedBy反向侧,则自动换算到目标实体(此处即自己)的属主方映射,用反向的relationToSourceKeyColumns / relationToTargetKeyColumns查同一张影子连接表。两条分支覆盖后,自关联的"正着看"和"反着看"都能取到同一份历史数据,不会重复也不会丢失。
五、读取侧:AuditedCollection 如何精确还原"当时的那组关系" 🔍
给定某个 rev 时,多对多集合的还原发生在两处,共同目标是回答:"截至 rev=N,哪些成员仍然有效?"
5.1 AuditReader 的快速路径
find()拿到实体快照后,对属主方多对多直接执行:SELECT rev, revtype, 对端列... FROM 连接表_audit WHERE rev = N AND 本端ID = ?,再对每一行调用find(对端类, 对端ID, N)取对端的历史版本;若对端尚无修订(NoRevisionFoundException),则回退到主表现状。反向侧同理(见上一节)。
5.2 惰性只读集合 AuditedCollection
对于按需加载的场景,src/Collection/AuditedCollection.php 实现了一个只读、懒加载的Collection(任何add/set/remove都会抛出AuditedCollectionException,历史不可篡改)。它的initialize()里那句 SQL 是全文档最精华的一段,逐条排除两种"已失效"成员:
- 排除"被改绑到别的实体"的成员:
NOT EXISTS子查询检查影子表中是否存在比该成员更晚(但 ≤ 目标 rev)的、指向另一主实体的记录——即该成员在 rev=N 前已经"改嫁"; - 排除"已被删除"的成员:
NOT EXISTS检查是否存在revtype = 'DEL'且落在 (成员 rev, 目标 rev] 区间内的记录; - 最后
GROUP BY 成员主键+MAX(rev),取每个成员截至目标 rev 的最新归属行。
这套"时间旅行集合"的语义与 Hibernate Envers 完全对齐,保证你在 rev=5 看到的关系,与当初在 rev=5 时页面渲染出来的一模一样。
六、动手配置与验证 🧪
配置极简,在 src/DependencyInjection/Configuration.php 定义、由 src/DependencyInjection/SimpleThingsEntityAuditExtension.php 注册服务:
simple_things_entity_audit: audited_entities: # 需要版本化的实体 - App\Entity\Article - App\Entity\Tag global_ignore_columns: # 这些列的变化不触发新修订 - created_at disable_foreign_keys: true # 可选:不推断外键约束启用后执行./bin/console doctrine:schema:update --dump-sql,即可在 SQL 中看到xxx_audit与xxx_yyy_audit(连接表)的影子表 DDL。若想让连接表影子行不带外键约束(便于历史回放与迁移),就打开disable_foreign_keys,其专项测试见 tests/NoForeignKeysTest.php。
七、总结:复杂关系版本化的三条设计主线 ✅
- 影子表全覆盖:实体表 + 多对多连接表统一
_audit镜像,rev+revtype构成统一时间轴(CreateSchemaListener); - 事件驱动 + 延迟补偿:5 个生命周期事件各司其职,"无 ID 关系"用延迟信封在 postFlush 兜底,保证同批 flush 数据完整一致(
LogRevisionsListener+DeferredChangedManyToManyEntityRevisionToPersist); - 时间语义严谨的读取:属主 / 反向双分支 +
NOT EXISTS排除规则,让多对多与自关联都能在任意历史 rev 上精确回放(AuditReader+AuditedCollection)。
理解这三条主线后你会发现:所谓"多对多、自关联都能审计",本质是把关系视为连接表上的普通数据来版本化,再用严格的区间查询还原历史——这正是 EntityAuditBundle 复杂关系审计能力的全部秘密。
【免费下载链接】EntityAuditBundleAudit for Doctrine Entities项目地址: https://gitcode.com/gh_mirrors/en/EntityAuditBundle
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考