记得前几个月,spring4all 社区刚搞过一次技术话题讨论:如何对 JPA 或者 MyBatis 进行技术选型?传送门:http://www.spring4all.com/article/391 由于平时工作接触较多的是 JPA,所以对其更熟悉一些,这一篇文章记录下个人在使用 JPA 时的一些小技巧。补充说明:JPA 是一个规范,本文所提到的 JPA,特指 spring-data-jpa。
tips:阅读本文之前,建议了解值对象和实体这两个概念的区别和领域驱动设计的基本概念。
使用 @Embedded 关联一对一的值对象
现实世界有很多一对一的关联关系,如人和身份证,订单和购买者...而在 JPA 中表达一对一的关联,通常有三种方式。下面就以订单(Order)和购买者(CustomerVo)为例来介绍这三种方式,这里 CustomerVo 的 Vo 指的是 Value Object。
字段平铺
这可能是最简单的方式了,由于一对一关联的特殊性,完全可以在 Order 类中,使用几个字段记录 CustomerVo的属性。
实际上大多数人就是这么做的,甚至都没有意识到这三个字段其实是属于同一个实体类。这种形式优点是很明显的:简单;缺点也是很明显的,这不符合 OO 的原则,且不利于统一检索和维护 CustomerVo 信息。
使用 @OneToOne
这么做的确更“面向对象”了,但代价似乎太大了,我们需要在数据库中额外维护一张 CustomerVo 表,关联越多,代码处理起来就越麻烦,得不偿失。
使用 @Embedded
那有没有能中和上述矛盾的方案呢?引出 @Embedded 这个注解。分析下初始需求,我们发现:CustomerVo 仅仅是作为一个值对象,并不是一个实体(这里牵扯到一些领域驱动设计的知识,值对象的特点是:作为实体对象的修饰,即 CustomerVo 这个整体是 Order 实体的一个属性;不变性,CustomerVo 一旦生成后便不可被修改,除非被整体替换)
@Embedded 注解便是内嵌值对象最好的表达形式。
Order 拥有 @Entity 注解,表明其是 DDD 中的实体;而 CustomerVo 拥有 @Embeddable 注解,表明其是 DDD 中的值对象。这也是为什么我一直在表达这样一种观点:JPA 是对 DDD 很好的实践的。
关于实体类的设计技巧,在曹祖鹏老师的 github 中可以看到很成熟的方案,可能会颠覆你对实体类设计的认知:https://github.com/JoeCao/qbike/。
使用 @Convert 关联一对多的值对象
说到一对多,第一反应自然是使用 @OneToMany 注解。的确,我自己在项目中也主要使用这个注解来表达一对多的关联,但这里提供另一个思路,来关联一对多的值对象。
以商品和商品组图来举例。
使用 @OneToMany
还是先想想我们原来会怎么做,保存一个 List, 一种方式是这样
使用字符串存储,保存成 JSON 数组的形式,或者以逗号分隔都行。
如果图片还要保存顺序,缩略图,那就必须要得使用一对多的关联了。
我们应当发现这样的劣势是什么,从设计的角度来看:我们并不想单独为 GoodsPicture 单独建立一张表,正如前面使用 String pictures 来表示 List一样,这违反了数据库设计的第一范式,但这对于使用者来说非常方便,这是关系型数据库的表达能力有限而进行的妥协。关于这一点我曾和芋艿,曹大师都进行过讨论,并达成了一致的结论:数据库中可以保存 JSON,使用时在应用层进行转换。
使用 JSON 存储复杂对象
使用 @Convert
上述的 String 使得在数据库层面少了一张表,使得 Goods 和 GoodsPictures 的关联更容易维护,但也有缺点:单纯的 String goodsPictures 对于使用者来说毫无含义,必须经过应用层的转换才可以使用。而 JPA 实际上也提供了自定义的转换器来帮我们自动完成这一转换工作,这便到了 @Convert 注解派上用场的时候了。
1 声明 Convert 类
2 设置转换类 PicturesWrapperConverter
PicturesWrapperConverter 实现了 AttributeConverter接口,它表明了如何将 PicturesWrapper 转换成 String 类型。这样的好处是显而易见的,对于数据库而言,它知道 String 类型如何保存;对于 Goods 的使用者而言,也只关心 PicturesWrapper 的格式,并不关心它如何持久化。
对于 List 的保存,我暂时只找到了这种方式,借助一个 Wrapper 对象去存储一个 List 对象。没有找到直接持久化 List 的方式,如果可以实现这样的方式,会更好一些:
但 converter 无法获取到 List 的泛型参数 GoodsPicture,在实践中没找到方案来解决这一问题,只能退而求其次,使用一个 Wrapper 对象。
与 OneToMany 对比,这样虽然使得维护变得灵活,但也丧失了查找的功能,我们将之保存成了 JSON 的形式,导致其不能作为查询条件被检索。
使用 orphanRemoval 来删除值对象
你可能有两个疑问:1 在实际项目中,不是不允许对数据进行物理删除吗? 2 删除对象还不简单,JPA 自己不是有 delete 方法吗?
关于第一点,需要区分场景,一般实体不允许做物理删除,而是用标记位做逻辑删除,也有部分不需要追溯历史的实体可以做物理删除,而值对象一般而言是可以做物理删除的,因为它只是属性而已。
第二点就有意思了,delete 不就可以直接删除对象吗,为什么需要介绍 orphanRemoval 呢?
以活动和礼包这个一对多的关系来举例。
这是一个再简单不过的一对多关系了,唯一可能觉得陌生的便是这个属性了 orphanRemoval = true 。
如果想要删除某个活动下的某个礼包,在没有 orphanRemoval 之前,你只能这么做:
GiftPackVoRepository.delete(GiftPackVo);
但其实这违反了 DDD 中的聚合根模式,GiftPackVo 只是一个值对象,其不具备实体的生命周期,删除一个礼包其实是一个不准确的做法,应当是删除某一个活动下的某一个礼包,对礼包的维护,应当由活动来负责。也就是说:应该借由 Activity 删除 GiftPackVo。使用 orphanRemoval 便可以完成这一操作,它表达这样的含义:内存中的某个 Activity 对象属于持久化态,对 List的移除操作,将被直接认为是删除操作。
于是删除某个“name = 狗年新春大礼包”的礼包便可以这样完成:
整个代码中只出现了 activityRepository 这一个仓储接口。
使用 @Version 来实现乐观锁
乐观锁一直是保证并发问题的一个有效途径,spring data jpa 对 @Version 进行了实现,我们给需要做乐观锁控制的对象加上一个 @Version 注解即可。
我们在日常操作 Activity 对象时完全不需要理会 version 这个字段,当做它不存在即可,spring 借助这个字段来做乐观锁控制。每次创建对象时,version 默认值为 0,每次修改时,会检查对象获取时和保存时的 version 是否相差 1,转化为 sql 便是这样的语句:update activity set xx = xx,yy = yy,version= 10 where id = 1 and version = 9;然后通过返回影响行数来判断是否更新成功。
测试乐观锁
当 test 方法被并发调用时,可能会存在并发问题。控制台打印出了更新信息
表面上看出现的是 StaleStateException,但实际捕获时,如果你想 catch 该异常,根本没有效果,通过 debug 信息,可以发现,真正的异常其实是 ObjectOptimisticLockingFailureException(以 Mysql 为例,实际可能和数据库方言有关,其他数据库未测试)。
在 Controller 层尝试捕获该异常,控制输出如下:
成功捕获到了并发冲突,这一切都是 @Version 帮我们完成的,非常方便,不需要我们通过编码去实现乐观锁。
总结
本文简单聊了几个个人感触比较深的 JPA 小技巧,JPA 真的很强大,也很复杂,可能还有不少“隐藏”的特性等待我们挖掘。它不仅仅是一个技术框架,本文的所有内容即使不被使用,也无伤大雅,但在领域驱动设计等软件设计思想的指导下,它完全可以实践的更好。
领取专属 10元无门槛券
私享最新 技术干货