原文:https://marcin-chwedczuk.github.io/HHH000179-narrowing-proxy-to-class-this-operation-breaks-equality
译者:无意中流逝
本文将会解释为何hibernate会出现HHH000179警告,且无视这个警告会产生什么bug。
为了理解”窄化代理“(Narrowing proxy),首先要理解hibernate的代理原理。当我们读取一个懒加载的属性或者调用EntityManager::getReference
方法时,hibernate会返回一个代理。这个代理运行时生成的类的实例。(如javassit)
例如:
@Entity
@Table(name = "person")
public class Person extends BaseEntity {
@Column(name = "person_name", nullable = false)
private String name;
@ManyToOne(optional = false, fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "house_id")
private House house;
@OneToMany(mappedBy = "owner", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Pet> pets = new HashSet<>(0);
// ...
}
生成的类看起来像这样:
public class Person_$$_jvst5ed_2
extends Person
implements HibernateProxy, ProxyObject {
private MethodHandler handler;
private static Method[] _methods_;
// plenty of other stuff here
public final UUID _d7getId() {
return super.getId();
}
public final UUID getId() {
Method[] var1 = _methods_;
return (UUID)this.handler.invoke(this, var1[14], var1[15], new Object[0]);
}
}
这个类是继承实体类的
如果把代理类和类继承机制混合在一起会发生什么情况?
下面是一个简单继承关系
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "animal_type")
public abstract class Pet extends BaseEntity {
@Column
private String name;
@JoinColumn(name = "owner_id", nullable = false)
@OneToOne(optional = false, fetch = FetchType.LAZY)
private Person owner;
public abstract String makeNoise();
// ...
}
@Entity
@DiscriminatorValue("cat")
public class Cat extends Pet { /* ... */ }
@Entity
@DiscriminatorValue("dog")
public class Dog extends Pet { /* ... */ }
当我们用EntityManager::getReference
去加载一个Pet
,我们会得到继承Pet
的代理类。hibernate还不知道这个是Cat
还是Dog
// 在先前的事务中:
Cat gerard = new Cat("gerard");
entityManager.persist(gerard);
gerardId = gerard.getId();
// 在当前事务中:
Pet pet = entityManager.getReference(Pet.class, gerardId);
assertThat(pet)
.is(hibernateProxy()) //pet是个代理
.is(uninitialized()) //pet未初始化
.isInstanceOf(Pet.class)// pet是Pet的实例
.isNotInstanceOf(Cat.class); // pet不是Cat的实例
我们或许会强制让hibernate去查询数据库以加载实体,但不改变代理的类型
// makeNoise() 访问成员getter方法,导致代理初始化
logger.info("Pet is a cat: " + pet.makeNoise()); // “喵~喵~”
assertThat(pet)
.isNot(uninitialized())//代理已初始化
.isNotInstanceOf(Cat.class);//代理不是Cat的实例
即使现在hibernate已经知道pet其实是Cat
,但也改变不了代理的类型,代理依旧是Pet
。这会导致一些问题,比如pet instanceof Cat
会返回false,尽管pet真真切切是一个Cat
还有另一个问题,如果makeNoise()
直接访问成员变量,代理将不会触发初始化,也就不会从数据库获取数据,导致方法读取了一个未初始化的值。在处理实体状态的时,我们应该始终使用getter和setter
你也许会想,如果重新load一次Pet
(在代理已经初始化之后),hibernate会返回Cat
实体的实例。实际上hibernate更倾向于返回一级缓存中已有的实例而不是创建一个新的
Pet pet2 = entityManager.getReference(Pet.class, gerardId);
assertThat(pet2)
.isNotInstanceOf(Cat.class)//pet2不是Cat的实例
.isSameAs(pet);//pet2是pet本身
当我们明确表示要load一个Cat
// HHH000179: Narrowing proxy to class Cat - this operation breaks ==
Pet pet3 = entityManager.getReference(Cat.class, gerardId);
assertThat(pet3)
.isInstanceOf(Cat.class)//pet3是Cat实例
.isNot(hibernateProxy());//pet3不是hibernateProxy类
这就是HHH000179警告,hibernate解除了Cat
实例的代理。现在Session里有两个不同的类(代理和Cat实例),且都指向同一个实体
因为pet指向的是Cat实例,Cat实例的修改也会反映到代理中
assertThat(pet.getName()).isEqualTo("gerard");//pet.getName()返回"gerard"
assertThat(pet).isNotSameAs(pet3);//pet != pet3
pet3.setName("proton");
assertThat(pet.getName()).isEqualTo("proton");//pet.getName()返回"proton"
你或许会认为,内存中有两个地方指向同一条数据库数没什么不妥,但如果没有重写equals()
和hashCode()
时会导致一些问题。
// 假设Alice是猫主人
Person alice = entityManager.find(Person.class, aliceId);
// 虽然Alice拥有这只猫,但两种判断结果截然不同
assertThat(alice.getPets().contains(pet))
.isFalse();
assertThat(alice.getPets().contains(pet3))
.isTrue();
//使用默认equals()/hashCode()会出现这种情况
重写equals()
方法可以修复此bug,比如通过比较主键是否相等
@MappedSuperclass
public abstract class BaseEntity {
@Id
@Type(type="binary(16)")
private UUID id;
protected BaseEntity() {
this.id = UUID.randomUUID();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || !(o instanceof BaseEntity)) return false;
BaseEntity that = (BaseEntity) o;
// 记得使用getter
return getId().equals(that.getId());
}
@Override
public int hashCode() {
return getId().hashCode();
}
}
我们还可以通过延迟加载来重现上述行为,您可以在文末附带的源代码中找到如何执行此操作的示例。
实际应用的意义
最近我在开发一个基于内部框架的模块,我姑且称这个框架为X。框架包含了一些我无法修改的实体。为了给实体增加属性,我只能使用继承(幸运的是大部分类都只对应单个数据库表)。最后我们手上有一堆父类和一堆简单继承的子类。而且实体里也有大量对这些父类或子类的引用。正如你所预料的,到处都是HHH000179警告。所以我花了几个小时的时间来弄清楚这个警告是什么意思。在我们的例子中,只需要提供适当的equals()
和hashCode()
。但总而言之,我想展示最后一个更真实的例子。
框架X的实体
@Entity
@Table(name = "extensible_user")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "discriminator")
@DiscriminatorValue("NOT_USED")
public class LegacyUser {
@Id
@GeneratedValue
private Long id;
@Column
private String userPreference1;
@Column
private String userPreference2;
// ...
}
@Entity
@Table(name = "document")
public class LegacyDocument {
@Id
@GeneratedValue
private Long id;
@Column
private String contents;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id")
// !!! Entity referes to super class !!!
// 实体引用的是父类
private LegacyUser owner;
// ...
}
我的模块中的实体
@Entity
@DiscriminatorValue("EXTENDED")
public class ExtendedUser extends LegacyUser {
@Column
private String userPreference3;
// ...
}
@Entity
@Table(name = "comment")
public class Comment {
@Id
@GeneratedValue
private Long id;
@ManyToOne(/*...*/)
@JoinColumn(name = "document_id")
private LegacyDocument document;
@ManyToOne(/*...*/)
@JoinColumn(name = "author_id")
// !!! Entity refers to subclass !!!
//实体引用的是子类
private ExtendedUser author;
@Column
private String contents;
// ...
}
原来的类LegacyDocument
使用父类LegacyUser
记录用户,新的类Comment
使用子类ExtendedUser
记录用户
缺少正确的equals()
会导致如下问题:
LegacyDocument document =
entityManager.find(LegacyDocument.class, documentId);
// we load some data from document owner
LegacyUser documentOwner = document.getOwner();
doSomethingWithOwner(documentOwner);
// HHH000179: Narrowing proxy to class ExtendedUser
// - this operation breaks ==
// 当hibernate加载comment时,
// 相同id的ExtendedUser在其他地方以ExtendedUser形式存在现。
// hibernate已经意识到这documentOwner其实是ExtendedUser,那个代理的类型是错的
List<Comment> comments = entityManager.createQuery(
"select c from Comment c where c.document.id = :docId",
Comment.class)
.setParameter("docId", document.getId())
.getResultList();
// Now the most interesting part
ExtendedUser commentAuthor = comments.get(0).getAuthor();
// comment author and doc author is the same user
// 评论的作者id和文档作者id相同
assertThat(commentAuthor.getId())
.isEqualTo(documentOwner.getId());
// 但commentAuthor != documentOwner
assertThat(commentAuthor)
.isNotSameAs(documentOwner);
// 不重写hashCode()/equals()会出现如下问题
Set<LegacyUser> users = new HashSet<>();
users.add(commentAuthor);
users.add(documentOwner);
assertThat(users).hasSize(2);
这就是我关于HHH000179警告的讨论,最重要的是正确重写hashCode()/equals(),这样就可以安心无视这个警告。
源码: https://github.com/marcin-chwedczuk/hibernate_narrowing_proxy_warning_demo