Hibernate:orphanRemoval+DtoMapping+@Audited场景下的异常解决问询
场景概述
我有带@Audited注解的父、子实体,采用单向OneToMany映射;同时存在与实体字段完全一致(包括id)的DTO类,以及负责DTO与实体转换的Mapper。当DTO的id字段已设置时,Mapper的map方法会通过id从数据库加载实体而非实例化新对象,子实体的映射逻辑同理。
核心问题
映射子列表时,Mapper会先清空父实体的子列表,此时orphanRemoval=true会触发数据库中子实体的删除,之后又需要重新创建并持久化子实体。为避免这个问题,我在映射前调用session.detach(parent)阻止数据库变更,后续通过session.saveOrUpdate(parent)重新附加,却触发了NonUniqueObjectException。
Mapper实现
@Stateless public class Mapper{ @PersistenceContext(unitName = "core") private EntityManager em; public void map(ParentDto parentDto, Parent parent) { Session session = em.unwrap(Session.class); if(em.contains(parent)) { session.detach(parent); //detach so that orphanRemoval will not delete childs from DB } ... parent.getChilds().clear(); for (ChildDto childDto : parentDto.getChilds()) { parent.getChilds().add(mapToEntity(childDto)); } session.saveOrUpdate(parent); //(re-)attach, so that changed fields or new childs get written to DB } public Parent mapToEntity(ParentDto parentDto) { Parent parentEntity= null; if (parentDto.id != null) { parentEntity= loadParentEntityFromDb(parentDto.id); } if (parentEntity == null) { parentEntity= new Parent(); } map(parentDto, parentEntity); return parentEntity; } public Child mapToEntity(ChildDto childDto) { Child childEntity= null; if (childDto.id != null) { childEntity= loadChildEntityFromDb(childDto.id); } if (childEntity == null) { childEntity= new Child(); } map(childDto, childEntity); return childEntity; } }
父实体类
@Entity @Audited public class Parent implements Serializable { private static final long serialVersionUID = 1L; @Id @GeneratedValue @Getter private Long id; ... @Getter @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @JoinTable(name = "PARENT_TO_CHILD", joinColumns = @JoinColumn(name = "PARENT_ID", referencedColumnName = "ID"), inverseJoinColumns = @JoinColumn(name = "CHILD_ID", referencedColumnName = "ID")) private final List<Child> childs = new ArrayList<>(); }
子实体类
@Entity @Audited public class Child implements Serializable { private static final long serialVersionUID = 1L; @Id @GeneratedValue @Getter private Long id; ... }
DTO类
public class ParentDto implements Serializable { private static final long serialVersionUID = 1L; @Getter @Setter private Long id; ... @Getter private final List<ChildDto> childs = new ArrayList<>(); } public class ChildDto implements Serializable { private static final long serialVersionUID = 1L; @Getter @Setter private Long id; ... }
触发的异常
12:35:17,422 WARN [com.arjuna.ats.arjuna] (default task-5) ARJUNA012125: TwoPhaseCoordinator.beforeCompletion - failed for SynchronizationImple< 0:ffff0a00100f:4b2caaeb:5e4cf8b3:58b38, org.wildfly.transaction.client.AbstractTransaction$AssociatingSynchronization@1149eaa7 >: org.hibernate.NonUniqueObjectException: A different object with the same identifier value was already associated with the session : [PARENT_TO_CHILDS_AUD#{REV=Revision [id=98781, timestamp=1582112117394, username=RUser], Parent_id=885752, childs_id=885754}]
已尝试的方案
使用em.detach(parent)后调用em.merge(parent)可正常工作,但merge会返回持久化的父实体副本,传入Mapper的父实体仍处于detached状态。由于无法修改map方法签名public void map(ParentDto parentDto, Parent parent),因此尝试使用session.saveOrUpdate(parent)以操作传入对象并重新附加。
我的疑问
有没有办法让@Audit注解与session.saveOrUpdate()兼容?或者在不清空子列表的前提下,防止Hibernate在映射期间删除子实体?我使用的是Hibernate 5.3.6.Final版本。
针对你的问题,我整理了几个可行的方向,你可以根据业务场景选择:
方案1:用merge+同步状态到传入对象
虽然不能修改map方法的签名,但可以在方法内部调用merge后,把返回的持久化实体的状态复制回传入的parent对象,既利用merge的兼容性,又保证传入对象最终符合预期状态。
修改map方法的最后一步:
// 替换原来的session.saveOrUpdate(parent); Parent mergedParent = (Parent) session.merge(parent); // 复制基本字段到传入的parent BeanUtils.copyProperties(mergedParent, parent, "childs"); // 排除子列表单独处理 // 手动同步子列表(因为子列表是final的) parent.getChilds().clear(); parent.getChilds().addAll(mergedParent.getChilds());
这种方式避开了saveOrUpdate与@Audited的冲突,同时保持方法签名不变。注意处理集合类型时,手动复制比依赖工具类更稳妥。
方案2:不清空子列表,手动增量更新子实体
既然orphanRemoval=true会在清空列表时误删子实体,那我们可以跳过清空操作,通过对比DTO和现有实体的子列表,做增量更新:
- 找出DTO存在但实体没有的子实体:新增到列表
- 找出实体存在但DTO没有的子实体:从列表移除(此时
orphanRemoval会删除这些子实体,符合业务逻辑) - 找出双方都存在的子实体:调用
map方法更新字段
具体实现示例:
// 替换原来的parent.getChilds().clear();和循环添加逻辑 Map<Long, Child> existingChildMap = parent.getChilds().stream() .collect(Collectors.toMap(Child::getId, child -> child)); for (ChildDto childDto : parentDto.getChilds()) { if (childDto.getId() != null && existingChildMap.containsKey(childDto.getId())) { // 更新已有子实体 map(childDto, existingChildMap.get(childDto.getId())); existingChildMap.remove(childDto.getId()); } else { // 添加新子实体 parent.getChilds().add(mapToEntity(childDto)); } } // 移除DTO中不存在的子实体(orphanRemoval会自动删除这些记录) existingChildMap.values().forEach(child -> parent.getChilds().remove(child));
这个方案从根源上避免了清空列表触发的误删,也不需要detach和重新附加,自然不会出现NonUniqueObjectException,适合子实体数量不多的场景。
方案3:临时关闭orphanRemoval(应急用)
如果不想修改太多代码,可以在detach前临时关闭orphanRemoval行为,但这个方法需要反射修改注解属性,不够优雅,只适合临时应急:
if(em.contains(parent)) { // 反射获取子列表字段的OneToMany注解 Field childsField = Parent.class.getDeclaredField("childs"); OneToMany annotation = childsField.getAnnotation(OneToMany.class); // 借助第三方库(如javassist)修改注解的orphanRemoval值为false // 之后detach parent session.detach(parent); // 映射完成后再把orphanRemoval改回true }
不推荐这个方案,因为反射操作会破坏封装,且不同Hibernate版本可能存在兼容性问题。
为什么saveOrUpdate会和@Audited冲突?
出现NonUniqueObjectException是因为@Audited会生成审计表,Hibernate处理审计数据时,已将对应的审计实体实例加载到session中。当你detach原parent后,用saveOrUpdate重新附加时,Hibernate发现session中已存在相同id的审计关联实体,就会抛出这个异常。而merge是合并状态,不会直接附加传入对象,因此不会触发冲突。
内容的提问来源于stack exchange,提问作者Paul

