跳到主要内容

复杂计算

@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(storeIds))
.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的表达式。

定义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,
/* highlight-next-line */
avg(tb_1_.PRICE)
from BOOK tb_1_
where
/* highlight-next-line */
tb_1_.STORE_ID in (
? /* 2 */, ? /* 1 */
)
group by
/* highlight-next-line */
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的最大值。

定义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集合 */
/* highlight-start */
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
)
/* highlight-end */

/* 第三步:对关联到的所有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操作的任意混合。