复杂计算
@Transient注解
Jimmer实体可以用@org.babyfish.jimmer.sql.Transient定义一种和数据库表结构无关的属性。
- Java
- Kotlin
package com.example.model;
import org.babyfish.jimmer.sql.*;
public interface BookStore {
...省略其他属性...
@Transient
Object customData();
}
package com.example.model
import org.babyfish.jimmer.sql.*
interface BookStore {
...省略其他属性...
@Transient
val customData: Any?
}
这里,@Transient注解本身没有任何参数被指定,则当前数据只是一个用户自定义数据,和ORM的任何行为都没关系。
只有当@Transient注解的参数被指定时,当前属性才是复杂计算属性。
那么,@Transient注解的参数是什么?
Jimmer为复杂计算属性提供了接口:
- Java:
org.babyfish.jimmer.sql.TransientResolver<ID, V> - Kotlin:
org.babyfish.jimmer.sql.kt.KTransientResolver<ID, V>
该接口让用户自定义数据计算过程。
用户开发一个类实现此接口,并让让此类受到Spring托管。
该类如何实现会在后文详细讲解,但这里为了便于表达,先假设用户实现此接口的类的是CustomerDataResolver,@Transient注解的参数应该如此书写
-
如果项目是单工程的,实体类能引用到这个类,那么
@Transient(CustomerDataResolver.class)或@Transient(CustomerDataResolver::class) -
如果项目是多工程的,实体类不能引用到这个类,那么
@Transient(ref = "customerDataResolver")。其中,字符串"customerDataResolver"表示Spring上下文中该类对象的名称。
标量计算:BookStore.avgPrice
本小节中,我们会为BookStore添加一个计算属性BookStore.avgPrice,其类型为java.math.BigDecimal。
定义avgPrice的Resolver
每一个复杂计算属性,都对应一个TransientResolver实现类。
在定义计算属性BookStore.avgPrice之前,我们先定义BookStoreAvgPriceResolver
- Java
- Kotlin
package com.example.business.resolver;
import org.babyfish.jimmer.sql.*;
import org.babyfish.jimmer.sql.TransientResolver;
import org.springframework.stereotype.Component;
@Component
public class BookStoreAvgPriceResolver implements TransientResolver<Long, BigDecimal> {
@Override
public Map<Long, BigDecimal> resolve(Collection<Long> ids) {
稍后实现
}
@Override
public BigDecimal getDefaultValue() {
return BigDecimal.ZERO;
}
}
package com.example.business.resolver
import org.babyfish.jimmer.sql.*
import org.babyfish.jimmer.sql.kt.KTransientResolver
import org.springframework.stereotype.Component
@Component
class BookStoreAvgPriceResolver : KTransientResolver<Long, BigDecimal> {
override fun resolve(ids: Collection<Long>): Map<Long, BigDecimal> {
稍后实现
}
override fun getDefaultValue(): BigDecimal =
BigDecimal.ZERO
}
-
基接口
TransientResolver/KTransientResolver有两 个范型参数-
第1个范型参数:计算属性所属实体的id属性的类型
本例中,即将被定义的
BookStore.avgPrice所属于实体为BookStore,其id类型为long,所以这里范型参数为Long -
第2个范型参数:计算属性所属返回数据的类型
本例中,即将被定义的
BookStore.avgPrice的类型为BigDecimal,所以这里范型参数为BigDecimal
-
-
resolve是基接口的一个必须实现的方法,用户靠此方法完成计算。信息resolve方法的参数类型为Collection<Long>,而非Long;其返回类型为Map<Long, BigDecimal>。这非常重要,这表示
BookStore.avgPrice并非针对BookStore.id一个一个计算,而是针对多个BookStore.id做一次性批量化计算。这样设计的目的是为了防止因计算属性导致
N + 1查询问题。这个设计和GraphQL领域的MappedBatchLoader几乎一样,这是所有类似领域标准的编程模型。
-
getDefaultValue是基接口的一个可选实现的方法。对于
resolve方法而言,如果返回的Map的长度小于ids参数传入的集合长度,表示部分数据没有计算结果,其中每一个数据对应的计算值将会被视为null。但是,如果计算属性(本例子中的
BookStore.avgPrice)非null,就会导致问题,用户可以通过覆盖getDefaultValue()返回非null的默认值解决此问题。警告如果计算属性不允许为空,对其
TransientResolver实现类而言- 要么保证
resolve方法返回的Map的keySet包含所有参数 - 要么覆盖
getDefaultValue并返回非null的默认值
- 要么保证
实现avgPrice的Resolver
- Java
- Kotlin
package com.example.business.resolver;
import org.babyfish.jimmer.sql.*;
import org.babyfish.jimmer.sql.ast.tuple.Tuple2;
import org.springframework.stereotype.Component;
@Component
public class BookStoreAvgPriceResolver implements TransientResolver<Long, BigDecimal> {
private final JSqlClient sqlClient;
// 构造注入
public BookStoreAvgPriceResolver(JSqlClient sqlClient) {
this.sqlClient = bookStoreRepository;
}
@Override
public Map<Long, BigDecimal> resolve(Collection<Long> ids) {
return Tuple2.toMap(
sqlClient
.createQuery(table)
.where(table.storeId().in(ids)) ❶
.groupBy(table.storeId()) ❷
.select(
table.storeId(),
table.price().avg() ❸
)
.execute()
);
}
...省略其他方法...
}
package com.example.business.resolver
import org.babyfish.jimmer.sql.kt.*
import org.springframework.stereotype.Component
@Component
class BookStoreAvgPriceResolver(
// 构造注入
private val sqlClient: KSqlClient
) : KTransientResolver<Long, BigDecimal> {
override fun resolve(ids: Collection<Long>): Map<Long, BigDecimal> =
sqlClient
.createQuery(Book::class) {
where(table.store.id valueIn ids) ❶
groupBy(table.store.id) ❷
select(
table.store.id,
avg(table.price).asNonNull() ❸
)
}
.execute()
.associateBy({it._1}) {
it._2
}
...省略其他函数...
}
-
❶ 过滤
BOOK表的外键STORE_ID,限定查询范围,仅对当前需要计算的书店计算其下书籍的平均价格,而非数据库中所有书店 -
❷ 按照
BOOK表的外键STORE_ID分组 -
❸ 把每组内部的书籍的价格求平均
avg: 对分组内的Book.price求平均备注kotlin代码中,有一个
asNonNull()。按照SQL标准,如果聚合函数
avg不和分组配套使用,在没有原始数据的前提下,其返回值允许为null。所以,kotlin中avg被定义成了返回可空类型。然而,当聚合函数
avg和分组集合使用时,聚合函数不可能返回null,所以调用asNonNull得到非null的表达式。警告为保证系统架构的清晰性与查询性能,当前在 Resolver 中禁止直接使用对象抓取器(Fetcher)进行查询操作。
主要原因如下:
❶ 上下文依赖问题:Fetcher 在处理计算属性时依赖 Resolver,如果 Resolver 反过来使用 Fetcher,容易导致复杂的上下文循环依赖,增加维护成本与出错风险。
❷ 性能风险:在 Resolver 中通过 Fetcher 进行对象抓取,容易引发低效的查询模式,严重影响整体性能。
基于以上考量,暂不计划在短期内支持在 Resolver 中使用 Fetcher。
定义avgPrice
现在,BookStoreAvgPriceResolver类已经完善,我们可以为BookStore实体添加计算属性avgPrice了。
- Java
- Kotlin
package com.example.model;
import com.example.business.resolver.BookStoreAvgPriceResolver; ❶
import org.babyfish.jimmer.sql.*;
public interface BookStore {
...省略其他属性...
@Transient(BookStoreAvgPriceResolver.class) ❷
BigDecimal avgPrice();
}
package com.example.model
import com.example.business.resolver.BookStoreAvgPriceResolver ❶
import org.babyfish.jimmer.sql.*
interface BookStore {
...省略其他属性...
@Transient(BookStoreAvgPriceResolver::class) ❷
val avgPrice: BigDecimal
}
-
如果项目是单工程,这里可以引用
BookStoreAvgPriceResolver类。 -
定义计算属性
BookStore.avgPrice,并为其注解@Transient指定❶处引入的类,告诉Jimmer计算属性的计算规则。警告如果项目是多工程,代码结构进行了分割,❶处的import语句无效,这时❷处必须写
@Transient(ref = "bookStoreAvgPriceResolver")。即,使用spring上下文中该对象的名称。
抓取avgPrice
- Java
- Kotlin
List<BookStore> stores = bookStoreRepository.findAll(
Fetchers.BOOK_STORE_FETCHER
.name()
.avgPrice()
);
System.out.println(stores);
val stores = bookStoreRepository.findAll(
newFetcher(BookStore::class).by {
name()
avgPrice()
}
)
println(stores)
打印结果:
[
{
"id":2,
"name":"MANNING",
"avgPrice":80.333333333333
},
{
"id":1,
"name":"O'REILLY",
"avgPrice":57.944444444444
}
]
执行的SQL
/* 第一步:查询聚合根对象:即BookStore */
select tb_1_.ID, tb_1_.NAME from BOOK_STORE as tb_1_
/* 第二步:为id为1和2的BookStore对象计算`avgPrice`属性 */
select
tb_1_.STORE_ID,
avg(tb_1_.PRICE)
from BOOK tb_1_
where
tb_1_.STORE_ID in (
? /* 2 */, ? /* 1 */
)
group by
tb_1_.STORE_ID
关联计算:BookStore.newestBooks
明确需求
上小节中我们展示了计算属性BookStore.avgPrice,很明显,该计算属性是非关联属性。
本小节中,我们会为BookStore添加一个计算属性BookStore.newestBooks,其类型为java.util.List<Book>,很明显这是一个关联属性。
为了说明这个例子为什么要添加一个计算属性BookStore.newestBooks,先看一下原始的关联属性BookStore.books的特点。
- Java
- Kotlin
Book store = bookStoreRepository.findNullable(
1L,
Fetchers.BOOK_STORE_FETCHER
.name()
.books(
Fetchers.BOOK_FETCHER
.name()
.edition()
)
);
System.out.println(store);
val store = bookStoreRepository.findNullable(
1L,
newFetcher(BookStore::class).by {
name()
books {
name()
edition()
}
}
)
println(store)
查询结果如下
{
"id":1,
"name":"O'REILLY",
"books":[
{
"id":6,
"name":"Effective TypeScript",
"edition":3
},
{
"id":5,
"name":"Effective TypeScript",
"edition":2
},
{
"id":4,
"name":"Effective TypeScript",
"edition":1
},
{
"id":3,
"name":"Learning GraphQL",
"edition":3
},
{
"id":2,
"name":"Learning GraphQL",
"edition":2
},
{
"id":1,
"name":"Learning GraphQL",
"edition":1
},
{
"id":9,
"name":"Programming TypeScript",
"edition":3
},
{
"id":8,
"name":"Programming TypeScript",
"edition":2
},
{
"id":7,
"name":"Programming TypeScript",
"edition":1
}
]
}
我们看到,在原始的BookStore.books关联中,书店的书籍中有很多同名的。
例如,一共有三本书籍名称为"Effective TypeScript",只是发行版本edition不同而已:3、2、1。
现在我们期望新建一个靠计算而得的BookStore.newestBooks关联属性,它保证返回的书籍集合没有同名问题,每个名称对一的书籍中,只取最高的发行版的,即edition最大的。
定义newestBooks的Resolver
每一个复杂计算属性,都对应一个TransientResolver实现类。
在定义计算属性BookStore.newestBooks之前,我们先定义BookStoreNewestBooksResolver
- Java
- Kotlin
package com.example.business.resolver;
import org.babyfish.jimmer.sql.*;
import org.springframework.stereotype.Component;
@Component
public class BookStoreNewestBooksResolver implements TransientResolver<Long, List<Long>> { ❶
@Override
public Map<Long, List<Long>> resolve(Collection<Long> ids) { ❷
稍后实现
}
@Override
public List<Long> getDefaultValue() {
Collections.emptyList();
}
}
package com.example.business.resolver
import org.babyfish.jimmer.sql.kt.*
import org.springframework.stereotype.Component
@Component
class BookStoreNewestBooksResolver : KTransientResolver<Long, List<Long>> { ❶
override fun resolve(ids: Collection<Long>): Map<Long, List<Long>> { ❷
稍后实现
}
override fun getDefaultValue(): List<Long> =
emptyList()
}
-
❶ 基接口
TransientResolver/KTransientResolver有两个范型参数-
第1个范型参数:计算属性所属实体的id属性的类型
本例中,即将被定义的
BookStore.newestBooks所属于实体为BookStore,其id类型为long,所以这里范型参数为Long -
第2个范型参数:计算属性所属返回数据的类型
本例中,即将被定义的
BookStore.newestBooks的类型为List<Book>-
由于
List<Book>是集合类型,所以这里范型参数包含List -
Book是实体类型,Jimmer约定这里需要将实体类型替换为其id的类型,而Book.id是long类型
综上,第2个范型参数为
List<Long> -
-
-
❷
resolve是基接口的一个必须实现的方法,用户靠此方法完成计算。信息resolve方法的参数类型为Collection<Long>,而非Long;其返回类型为Map<Long, List<Long>>。这非常重要,这表示
BookStore.newestBooks并非针对BookStore.id一个一个计算,而是针对多个BookStore.id做一次性批量化计算。这样设计的目的是为了防止因计算属性导致
N + 1查询问题。这个设计和GraphQL领域的MappedBatchLoader几乎一样,这是所有类似领域标准的编程模型。
-
❸
getDefaultValue是基接口的一个可选实现的方法。对于
resolve方法而言,如果返回的Map的长度小于ids参数传入的集合长度,表示部分数据没有计算结果,其中每一个数据对应的计算值将会被视为null。但是,如果计算属性(本例子中的
BookStore.newestBooks)非null,就会导致问题,用户可以通过覆盖getDefaultValue()返回非null的默认值解决此问题。警告如果计算属性不允许为空,对其
TransientResolver实现类而言- 要么保证
resolve方法返回的Map的keySet包含所有参数 - 要么覆盖
getDefaultValue并返回非null的默认值
- 要么保证
实现newestBooks的Resolver
- Java
- Kotlin
package com.example.business.resolver;
import java.util.Collections;
import org.babyfish.jimmer.sql.*;
import org.babyfish.jimmer.sql.ast.tuple.Tuple2;
import org.springframework.stereotype.Component;
@Component
public class BookStoreNewestBooksResolver
implements TransientResolver<Long, List<Long>> {
private final JSqlClient sqlClient;
// 构造注入
public BookStoreAvgPriceResolver(JSqlClient sqlClient) {
this.sqlClient = sqlClient;
}
@Override
public Map<Long, List<Long>> resolve(Collection<Long> ids) {
return Tuple2.toMultiMap(
sqlClient
.createQuery(table)
.where(
Expression.tuple( ❶
table.name(),
table.edition()
).in(
sqlClient.createSubQuery(table) ❷
.where(table.storeId().in(ids)) ❸
.groupBy(table.name()) ❹
.select(
table.name(),
table.edition().max() ❺
)
)
)
.select(
table.storeId(),
table.id()
)
.execute()
);
}
...省略其他代码...
}
package com.example.business.resolver
import org.babyfish.jimmer.sql.kt.*
import org.springframework.stereotype.Component
@Component
class BookStoreNewestBooksResolver(
// 构造注入
private val sqlClient: KSqlClient
) : KTransientResolver<Long, List<Long>> {
override fun resolve(ids: Collection<Long>): Map<Long, List<Long>> =
sqlClient
.createQuery(Book::class) {
where(
tuple( ❶
table.name,
table.edition
) valueIn subQuery(Book::class) { ❷
where(table.store.id valueIn storeIds) ❸
groupBy(table.name) ❹
select(
table.name,
max(table.edition).asNonNull() ❺
)
}
)
select(
table.store.id,
table.id
)
}
.execute()
.groupBy({it._1}) {
it._2
}
...省略其他代码...
}
-
❶
Book.name和Book.edition两列组成一个SQL元组。 -
❷ 元组有两列,类型为String和int;子查询也有两列,类型也是String和int。二者完全匹配,可以使用in操作符。
-
❸ 限定查询范围,仅针对当前需要查询最新版本书籍的,而非数据库中所有书店。
在子查询上施加计算范围限定条件,性能优于在父查询上施加。
-
❹ 按照书籍名称分组,同名书籍必然属于同一组。
-
❺ 对每一组内部的同名书籍,查找
edition的最大值。警告为保证系统架构的清晰性与查询性能,当前在 Resolver 中禁止直接使用对象抓取器(Fetcher)进行查询操作。
主要原因如下:
❶ 上下文依赖问题:Fetcher 在处理计算属性时依赖 Resolver,如果 Resolver 反过来使用 Fetcher,容易导致复杂的上下文循环依赖,增加维护成本与出错风险。
❷ 性能风险:在 Resolver 中通过 Fetcher 进行对象抓取,容易引发低效的查询模式,严重影响整体性能。
基于以上考量,暂不计划在短期内支持在 Resolver 中使用 Fetcher。
定义newestBooks
现在,BookStoreNewestBooksResolver类已经完善,我们可以为BookStore实体添加计算属性newestBooks了。
- Java
- Kotlin
package com.example.model;
import com.example.business.resolver.BookStoreNewestBooksResolver; ❶
import org.babyfish.jimmer.sql.*;
public interface BookStore {
...省略其他属性...
@Transient(BookStoreNewestBooksResolver.class) ❷
List<Book> newestBooks();
}
package com.example.model
import com.example.business.resolver.BookStoreNewestBooksResolver ❶
import org.babyfish.jimmer.sql.*
interface BookStore {
...省略其他属性...
@Transient(BookStoreNewestBooksResolver::class) ❷
val newestBooks: List<Book>
}
-
如果项目是单工程,这里可以引用
BookStoreNewestBooksResolver类。 -
定义计算属性
BookStore.newestBooks,并为其注解@Transient指定❶处引入的类,告诉Jimmer计算属性的计算规则。警告如果项目是多工程,代码结构进行了分割,❶处的import语句无效,这时❷处必须写
@Transient(ref = "bookStoreNewestBooksResolver")。即,使用spring上下文中该对象的名称。
抓取newestBooks
- Java
- Kotlin
List<BookStore> stores = bookStoreRepository.findAll(
Fetchers.BOOK_STORE_FETCHER
.name()
.newestBooks( ❶
❷
Fetchers.BOOK_FETCHER
allScalarFields()
.authors(
Fetchers.AUTHOR_FETCHER
.allScalarFields()
)
)
);
System.out.println(stores);
val stores = bookStoreRepository.findAll(
newFetcher(BookStore::class).by {
name()
newestBooks { ❶
❷
allScalarFields()
authors {
allScalarFields()
}
}
}
)
println(stores)
-
抓取计算属性
BookStore.newestBooks -
计算属性本身也是关联属性,所以,其关联对象的形状可以被更深的子抓取器控制
打印结果如下
[
{
"id":2,
"name":"MANNING",
"newestBooks":[
{
"id":12,
"name":"GraphQL in Action",
"edition":3, // 此edition最大,没有同名书籍
"price":80,
"authors":[
{
"id":5,
"firstName":"Samer",
"lastName":"Buna",
"gender":"MALE"
}
]
}
]
},
{
"id":1,
"name":"O'REILLY",
"newestBooks":[
{
"id":3,
"name":"Learning GraphQL",
"edition":3, // 此edition最大,没有同名书籍
"price":51,
"authors":[
{
"id":2,
"firstName":"Alex",
"lastName":"Banks",
"gender":"MALE"
},
{
"id":1,
"firstName":"Eve",
"lastName":"Procello",
"gender":"FEMALE"
}
]
},
{
"id":6,
"name":"Effective TypeScript",
"edition":3, // 此edition最大,没有同名书籍
"price":88,
"authors":[
{
"id":3,
"firstName":"Dan",
"lastName":"Vanderkam",
"gender":"MALE"
}
]
},
{
"id":9,
"name":"Programming TypeScript",
"edition":3, // 此edition最大,没有同名书籍
"price":48,
"authors":[
{
"id":4,
"firstName":"Boris",
"lastName":"Cherny",
"gender":"MALE"
}
]
}
]
}
]
生成的SQL如下
/* 第一步:查询聚合根对象:即BookStore */
select tb_1_.ID, tb_1_.NAME
from BOOK_STORE as tb_1_
/* 第二步:为id为1和2的BookStore计算`newestBooks`能够关联到的所有Book的id集合 */
select
tb_1_.STORE_ID,
tb_1_.ID
from BOOK tb_1_
where
(tb_1_.NAME, tb_1_.EDITION) in (
select
tb_3_.NAME,
max(tb_3_.EDITION)
from BOOK tb_3_
where
tb_3_.STORE_ID in (
? /* 2 */, ? /* 1 */
)
group by
tb_3_.NAME
)
/* 第三步:对关联到的所有Book的id,进一步查询非关联字段 */
select
tb_1_.ID, tb_1_.NAME, tb_1_.EDITION, tb_1_.PRICE
from BOOK as tb_1_
where tb_1_.ID in (?, ?, ?, ?)
/* 第四步:对关联到的所有Book,进一步查询能关联到的作者 */
select
tb_2_.BOOK_ID, tb_1_.ID, tb_1_.FIRST_NAME, tb_1_.LAST_NAME, tb_1_.GENDER
from AUTHOR as tb_1_
inner join BOOK_AUTHOR_MAPPING as tb_2_
on tb_1_.ID = tb_2_.AUTHOR_ID
where
tb_2_.BOOK_ID in (?, ?, ?, ?)
这个例子展示了,当计算属性本身也是关联属性时,其关联对象的形状可以被更深的子抓取器控制。
既然存在更深的子抓取器,当然既可以包含ORM原生关联属性,也可以包含其他计算关联属性。
即,在对象抓取器查询复杂数据结构的过程中,ORM原生关联属性的抓取任务,和计算关联属性的抓取任务,可以随意混合。
-
ORM原生属性抓取任务,其实是SQL操作
(至少在我们介绍缓存之前,可以如此认为)
-
前文提到,Jimmer对计算属性的计算方法不加任何限制,你甚至可以使用SQL以外的任何技术,比如OLAP领域的技术,来实现计算过程(本文档聚焦于Jimmer本身,所以例子中计算过程也用Jimmer来实现)。
即,计算属性抓取任务,不一定是SQL操作。
所以,对象抓取器提供的功能,其实是SQL操作和非SQL操作的任意混合。