跳到主要内容

复杂计算

@Transient注解

Jimmer实体可以用@org.babyfish.jimmer.sql.Transient定义一种和数据库表结构无关的属性。

BookStore.java
package com.example.model;

import org.babyfish.jimmer.sql.*;

public interface BookStore {

...省略其他属性...

@Transient
Object customData();
}

这里,@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

BookStoreAvgPriceResolver.java
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;
}
}
  • 基接口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

BookStoreAvgPriceResolver.java
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()
);
}

...省略其他方法...
}
  • ❶ 过滤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了。

BookStore.java
package com.example.model;

import com.example.business.resolver.BookStoreAvgPriceResolver;

import org.babyfish.jimmer.sql.*;

public interface BookStore {

...省略其他属性...

@Transient(BookStoreAvgPriceResolver.class)
BigDecimal avgPrice();
}
  1. 如果项目是单工程,这里可以引用BookStoreAvgPriceResolver类。

  2. 定义计算属性BookStore.avgPrice,并为其注解@Transient指定❶处引入的类,告诉Jimmer计算属性的计算规则。

    警告

    如果项目是多工程,代码结构进行了分割,❶处的import语句无效,这时❷处必须写@Transient(ref = "bookStoreAvgPriceResolver")

    即,使用spring上下文中该对象的名称。

抓取avgPrice

List<BookStore> stores = bookStoreRepository.findAll(
Fetchers.BOOK_STORE_FETCHER
.name()
.avgPrice()
);
System.out.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的特点。

Book store = bookStoreRepository.findNullable(
1L,
Fetchers.BOOK_STORE_FETCHER
.name()
.books(
Fetchers.BOOK_FETCHER
.name()
.edition()
)
);
System.out.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

BookStoreNewestBooksResolver.java
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();
}
}
  • ❶ 基接口TransientResolver/KTransientResolver有两个范型参数

    • 第1个范型参数:计算属性所属实体的id属性的类型

      本例中,即将被定义的BookStore.newestBooks所属于实体为BookStore,其id类型为long,所以这里范型参数为Long

    • 第2个范型参数:计算属性所属返回数据的类型

      本例中,即将被定义的BookStore.newestBooks的类型为List<Book>

      • 由于List<Book>是集合类型,所以这里范型参数包含List

      • Book是实体类型,Jimmer约定这里需要将实体类型替换为其id的类型,而Book.idlong类型

      综上,第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

BookStoreNewestBooksResolver.java
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()
);
}

...省略其他代码...
}
  • Book.nameBook.edition两列组成一个SQL元组。

  • ❷ 元组有两列,类型为String和int;子查询也有两列,类型也是String和int。二者完全匹配,可以使用in操作符。

  • ❸ 限定查询范围,仅针对当前需要查询最新版本书籍的,而非数据库中所有书店。

    在子查询上施加计算范围限定条件,性能优于在父查询上施加。

  • ❹ 按照书籍名称分组,同名书籍必然属于同一组。

  • ❺ 对每一组内部的同名书籍,查找edition的最大值。

    警告

    为保证系统架构的清晰性与查询性能,当前在 Resolver 中禁止直接使用对象抓取器(Fetcher)进行查询操作。

    主要原因如下:

    ❶ 上下文依赖问题:Fetcher 在处理计算属性时依赖 Resolver,如果 Resolver 反过来使用 Fetcher,容易导致复杂的上下文循环依赖,增加维护成本与出错风险。

    ❷ 性能风险:在 Resolver 中通过 Fetcher 进行对象抓取,容易引发低效的查询模式,严重影响整体性能。

    基于以上考量,暂不计划在短期内支持在 Resolver 中使用 Fetcher。

定义newestBooks

现在,BookStoreNewestBooksResolver类已经完善,我们可以为BookStore实体添加计算属性newestBooks了。

BookStore.java
package com.example.model;

import com.example.business.resolver.BookStoreNewestBooksResolver;

import org.babyfish.jimmer.sql.*;

public interface BookStore {

...省略其他属性...

@Transient(BookStoreNewestBooksResolver.class)
List<Book> newestBooks();
}
  1. 如果项目是单工程,这里可以引用BookStoreNewestBooksResolver类。

  2. 定义计算属性BookStore.newestBooks,并为其注解@Transient指定❶处引入的类,告诉Jimmer计算属性的计算规则。

    警告

    如果项目是多工程,代码结构进行了分割,❶处的import语句无效,这时❷处必须写@Transient(ref = "bookStoreNewestBooksResolver")

    即,使用spring上下文中该对象的名称。

抓取newestBooks

List<BookStore> stores = bookStoreRepository.findAll(
Fetchers.BOOK_STORE_FETCHER
.name()
.newestBooks(


Fetchers.BOOK_FETCHER
allScalarFields()
.authors(
Fetchers.AUTHOR_FETCHER
.allScalarFields()
)
)
);
System.out.println(stores);
  1. 抓取计算属性BookStore.newestBooks

  2. 计算属性本身也是关联属性,所以,其关联对象的形状可以被更深的子抓取器控制

打印结果如下

[
{
"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操作的任意混合。

Resolver Context(实验性)

实验性状态

此功能为 实验性功能。Kotlin 中需通过 @ExperimentalTransientResolverContext 显式 opt-in, Java 中标记为 @ApiStatus.Experimental。未来版本中 API 可能发生变化。

Jimmer 0.10.13+ 提供了 resolve 的重载方法,接收 TransientResolverContext / KTransientResolverContext。 这允许 resolver 实现检查当前的不可变属性元数据、数据库连接以及正在解析的源 ID。

为什么需要 resolver context?

没有 context 时,一个共享的泛型 resolver 无法知道当前正在解析的是哪个具体实体或属性。 在实际项目中,这会导致重复代码:

// 没有 context —— 每个具体实体都需要自己的 resolver
class ProductCategoryBreadcrumbResolver : KTransientResolver<Long, String?> { ... }
class DeviceCategoryBreadcrumbResolver : KTransientResolver<Long, String?> { ... }
// 两者都实现相同的树/面包屑算法 —— 只有实体类型不同。

有了 context,一个共享 resolver 可以为任何具体实体分发相同的算法:

@Component
class BaseTreeBreadcrumbResolver : KTransientResolver<Long, String?> {

@OptIn(ExperimentalTransientResolverContext::class)
override fun resolve(
ids: Collection<Long>,
ctx: KTransientResolverContext
): Map<Long, String?> {
val declaringType = ctx.prop.declaringType
val propName = ctx.prop.name
// 运行相同的算法,使用 declaringType 来知道
// 要查询哪个具体实体的表/列。
return ... // 为每个 id 计算面包屑
}
}

Context 属性

KTransientResolverContext / TransientResolverContext 暴露了以下属性:

  • connection – 当前 JDBC 连接(适用于 resolver 未被 Spring 管理的情况)。
  • prop – 当前正在解析的 ImmutableProp
  • resolver – 正在被调用的 resolver 实例。
  • sourceIds – 源实体 ID 的集合。

向后兼容性

只覆盖 resolve(Collection<ID>) 的已有 resolver 无需做任何改动。 新的重载方法有一个默认实现,委托给原始方法。

// 旧风格 —— 仍然可用
class MyResolver : KTransientResolver<Long, String> {
override fun resolve(ids: Collection<Long>): Map<Long, String> = ...
}

// 新风格 —— opt-in
class MyResolver : KTransientResolver<Long, String> {
@OptIn(ExperimentalTransientResolverContext::class)
override fun resolve(
ids: Collection<Long>,
ctx: KTransientResolverContext
): Map<Long, String> = ...
}

泛型映射父类继承

Jimmer 0.10.13+ 支持带 where 约束的自限界泛型映射父类。 这允许定义一个可复用的树/基实体层次结构,具体实体可以继承。

自限界泛型树基类

@MappedSuperclass
interface BaseTreeNode<T> where T : BaseTreeNode<T> {
@ManyToOne
val parent: T?

@OneToMany(mappedBy = "parent")
val children: List<T>
}

@MappedSuperclass
interface BaseNamed {
val name: String
}

@MappedSuperclass
interface BaseNamedTreeNode<T> : BaseTreeNode<T>, BaseNamed
where T : BaseNamedTreeNode<T>

@Entity
interface ProductCategory : BaseNamedTreeNode<ProductCategory> {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long
}

此层次结构可以正确编译:ProductCategory 继承了 parentchildren(具有正确的递归类型 ProductCategory)和 name,同时提供了自己的 @Id

为什么需要这个功能

没有泛型映射父类继承时,每个具体树实体必须分别重新声明其 parentchildren 关联。 有了泛型基类后,树结构只定义一次,即可在所有树形实体中复用。

结合 resolver context,单个共享 resolver 可以为所有继承同一泛型基类的具体树实体提供计算属性(例如面包屑路径)。