简单介绍JPA Attribute Converters
老早之前,在JPA/Hibernate项目里尝试使用了mysql5.7的新特性json字段,个人感觉对于那些结构不稳定而且还啰里八嗦的字段用json应该会挺灵活的。问题在于ORM怎么映射呢?Google后给出的方案是JPA Attribute Converters。下面给出在User表中userSetting使用json类型的代码片段(随便臆想出来的代码):
// 定义entity
@Entity
public class User {
@Column(columnDefinition = "json DEFAULT NULL")
@Convert(converter = UserSettingConverter.class)
private UserSetting userSetting;
// getter() setter()...
}
// json字段映射的pojo
public class UserSetting {
private String item;
// getter() setter()...
}
// 自定义converter实现AttributeConverter接口双向转化的方法
@Converter
public class UserSettingConverter implements AttributeConverter<UserSetting, String> {
// json to object
@Override
public UserSetting convertToEntityAttribute(String attribute) {
if (attribute == null) {
return null;
}
return JsonUtils.parse(attribute, UserSetting.class);
}
// object to json
@Override
public String convertToDatabaseColumn(UserSetting dbData) {
return JsonUtils.toJson(dbData);
}
}
问题早已埋好
正如这篇【史上被复制最多的StackOverflow Java代码段中包含一个Bug】说的那样,搜出来的答案不一定没有bug的,用converter却不看converter的官方文档,活该出bug。该项目用上面converter的写法运行了好久都没有被发现有啥问题,直到最近我开启了druid的spring监控(尽管spring监控有点小bug#2770,需要关闭spring.aop.auto才能统计正常),我发现有个批量处理的方法本来应该只涉及到几千行左右的数据更新,居然统计出来22万行,玩儿呢?一通log之后发现,只有那些用了json converter的entity会时不时地发update语句,尽管没有被修改过。网上看到有位仁兄跟我状况一模一样,里面有个人提到可能跟hibernate的dirty check有关,这引起了我的注意。根据Hibernate的开发者Vlad Mihalcea的这两篇【How do JPA and Hibernate define the AUTO flush mode】【How does AUTO flush strategy work in JPA and Hibernate】,JPA每次查询之前都会尝试flush,flush就意味要做dirty check。假如我那些用了converter的entity每次dirty check都被认为是脏的,被修改过的,确实就会出现成吨的update语句。
为什么dirty check会失败
这时候,我终于想起来去看converter的官方文档了。
再看下我程序的日志,确实部署的时候就出现了文档中说的这个warning,只是一直没注意到!!!另外又翻到了写converter这块hibernate代码实现的开发者的一些讨论【Could not find matching type descriptor for requested Java class】。连蒙带猜地看n遍并且做了一系列的断点debug验证,综合起来,我总结一下这里面的前因后果:为了实现flush的时候hibernate能自动检测出哪些持久化entity被修改过,hibernate会通过deep copy(类似clone)对所有持久化的entity做一个快照,flush的dirty check过程其实就是比对持久化entity和快照是否一致,不一致就去发udpate语句。而dirty check的实现,hibernate对jdk已有的类型都有很好的支持,可是如果是你通过converter自定义的类,很抱歉你得自己去实现JavaTypeDescriptor并注册到JavaTypeDescriptorRegistry中,如果你没有做这一步,hibernate就只好先看下你这个类有没有实现Scerializable接口,如果实现了Hibernate就通过将entity和快照对象序列化成byte array的方式来比对是否一致,如果没有实现Hibernate就只能直接调用equals方法来比对了。我的例子显然进equals了,而没有重写equals的话,实际就是直接比对物理地址,显然持久化entity对象跟快照对象的物理地址是不可能一致的,所以就理所当然dirty check fail了,然后就每dirty check一次就update一次,22万就是这么来的。
解决方案
- 按照官网的推荐,实现JavaTypeDescriptor,并注册到JavaTypeDescriptorRegistry。由于官网没有给出例子,不太会写,看了其他JavaTypeDescriptor的实现又长又臭,而且看到里面有equals和hashcode方法,感觉跟方法3类似,于是我把它作为最后最后不得已的选择。
PS: 写这篇文章的时候才发现今年9月份Vlad Mihalcea的博客给出了json orm的最佳实践 ,理论上应该加个依赖就行,不用自己写了。
-
实现Scerializable接口,很多人都选择这个简单粗暴偷懒的方法。像上面的例子,只需要让UserSetting implements Scerializable就可以了。效率问题暂不考虑,相比22万,这点序列化的效率还不在考虑的范围。简单测试了这个方法是成功的。但是!!!可能是天妒懒人吧,应用在我真实的项目中,不知道为何,transaction内的一个局部变量,我确认没有被任何持久化对象引用,却也被要求要序列化,这么implement Scerializable岂不没完没了了,因为还有option 3所以我也没仔细去研究这个问题的根源。
-
重写equals和hashCode方法。既然不得不选择这个方法,还是有必要看一下,为什么光重写equals不行,非得还要再重写hashCode方法。又是一顿搜索和jdk文档,大概意思是jdk要求equals返回true的两个对象必须hashCode也一致,否则可能导致HashTable/Map等的contains方法失效,因为它们的结构是通过对象的hashCode散列来得到具体放置的位置的。了然,有理,于是我简单粗暴地写加了个BaseJsonEntity,让所有映射json的pojo去继承(again不讨论实现的效率):
按照这篇How to Write an Equality Method in Java的说法,equals方法不能轻易重写,因为你写对的概率几乎为0,包括使用lombok生成的equals和hashCode方法也存在文章说的Pitfall #3: Defining equals in terms of mutable fields问题,所以还是要使用Vlad Mihalcea给出的方案。
public class BaseJsonEntity {
// 地址相等直接返回true,不等就比对json是否一致(原谅我这个实现其实挺笨拙的)
@Override
public boolean equals(Object obj) {
if(super.equals(obj)) {
return true;
} else {
if(obj != null && obj instanceof BaseJsonEntity) {
String objJson = ((BaseJsonEntity)obj).toJson();
return this.toJson().equals(objJson);
}
}
return false;
}
// 转成json直接利用jdk在String类中重写的hashCode()方法,懒惰如初
@Override
public int hashCode() {
return toJson().hashCode();
}
// 定义将对象自己转成json的私有方法
private String toJson() {
String json = JsonUtils.toJson(this);
return json;
}
}
还没完
改完发布测试,druid监控到的更新行数确实大幅下降了。你以为完事了?闲着蛋疼的我决定认真数一数我那个批量处理里面究竟应该更新多少行才是精确的。数完发现还真对不上,我的心哇凉哇凉的,求放过!这次现象是,我所有json entity的dirty check都正常了,除了一个。又是大半天的debug,我发现快照的对象确实比持久化entity对象打出来的json少了一丁点东西,过了好久我才想起来,这少的恰恰是我在实现AttributeConverter#convertToEntityAttribute(String attribute)方法的时候加的一点点额外处理,因为有个字段前端不需要,我偷懒直接在这个方法里面把这个字段去掉了。
这就奇怪了,deep copy出来的快照对象怎么会走converter的逻辑呢? debug了下源代码,发现deep copy的过程居然是把持久化对象的所有属性值全部转成数据库字段的形式,然后把这些字段当作刚从数据库查出来的样子,通过entity配置的映射来创建出entity实例对象(代码复用来说确实妙)。因此converter的双向转化的实现必须要注意一致性,除了json转obj,obj转json,不能有其他别的逻辑,一旦不一致就会导致快照deep copy出不一样的对象,进而导致dirty check失败,然后就又回到了万劫不复的疯狂update。
懒惰出Bug,Bug促进步。
Who knows what is right ! Just be myself !
网友评论