复杂计算
@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几乎一样,这是所有类似领域标准的编程模型。