跳到主要内容

自定义过滤器

缓存友好

基本概念

全局过滤器一文中,我们介绍了自定义全局过滤器需要实现Filter/KFilter接口。

然而,使用该接口定义的普通过滤器并不是缓存友好的。

Book实体为例,如果为其设置缓存不友好的全局过滤器,将会导致以下所有对过滤器敏感的属性

  • Book作为目标类型的关联属性。比如,BookStore.booksAuthor.books

  • 依赖于上述关联属性的计算属性。比如,BookStore.avgPriceBookStore.newestBooks(文档没提及newestBook,可参见例子)

都无法支持缓存。

Jimmer采用CacheableFilter/KCacheableFilter接口如下接口定义缓存友好的过滤器

CacheableFilter.java
package org.babyfish.jimmer.sql.filter;

import org.babyfish.jimmer.sql.ast.table.Props;
import org.babyfish.jimmer.sql.event.EntityEvent;

import java.util.SortedMap;

public interface CacheableFilter<P extends Props> extends Filter<P> {

SortedMap<String, Object> getParameters();

boolean isAffectedBy(EntityEvent<?> e);
}

该接口从Filter/KFilter接口继承,在其基础上,添加了两个新方法:

  • getParameters: 该过滤器的为多视图缓存贡献的SubKey片段。

  • isAffectedBy: 接受一个被过滤实体被修改的事件,判断当前过滤器所依赖的过滤字段是否被修改。

信息

一个实体类型允许被多个全局过滤器处理,如果出现了多个全局过滤器

  • 任何一个全局过滤器对缓存不友好,都会导致对此过滤器敏感的其他属性都无法支持缓存

    因此,这些全局过滤器,要么都是缓存不友好的Filter/KFilter,要么都是缓存友好的CacheableFilter/KCacheableFilter;二者混用没有意义。

    如果不小心导致这种无意义的混用,Jimmer会告诉为什么缓存未生效

  • 当所有全局过滤器都缓存友好时,所有CacheableFilter/KCacheableFilter对象的getParameters()方法返回的数据合并在一起,作为多视图缓存的SubKey

    例如,实体同时被两个全局过滤器处理。一个是逻辑删除所隐含的过滤器,记为a;另外一个是用户自定义过滤器,记为b

    假如

    • agetParameters()返回{"logicalDeleted":false}

    • bgetParameters()返回{"tenant":"a"}

    那么最终多视图缓存中的SubKey

    {"logicalDeleted":false,"tenant":"a"}

定义缓存友好过滤器

在[查询篇/自定义过滤器]一文中,我们为实体定义了一个超类型TenantAware,让我们再次回顾其代码,如下

TenantAware.java
@MappedSuperclass
public interface TenantAware {

String tenant();
}

任何需要支持多租户的实体类型都可以继承TenantAware,例如Book

Book.java
@Entity
public interface Book extends TenantAware {

...省略代码...
}

假设Spring上下文中有一个类型为TenantProvider的对象,其Java方法get()和kotlin属性tenant用于从当前操作者身份信息中提取所属租户。定义如下过滤器

@Component
public class TenantFilter implements CacheableFilter<TenantAwareProps> {

private final TenantProvider tenantProvider;

public TenantFilter(TenantProvider tenantProvider) {
this.tenantProvider = tenantProvider;
}

@Override
public void filter(FilterArgs<TenantAwareProps> args) {
String tenant = tenantProvider.get();
if (tenant != null) {
args.where(args.getTable().tenant().eq(tenant));
}
}

@Override
public SortedMap<String, Object> getParameters() {
String tenant = tenantProvider.get();
if (tenant == null) {
return null;
}
SortedMap<String, Object> map = new TreeMap<>();
map.put("tenant", tenant);
return map;
}

@Override
public boolean isAffectedBy(EntityEvent<?> e) {
return e.isChanged(TenantAwareProps.TENANT)
}
}

启用多视图缓存

普通的写法

@Bean
public CacheFactory cacheFactory(
RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper
) {
return new CacheFactory() {

@Override
public Cache<?, ?> createObjectCache(@NotNull ImmutableType type) {
...省略代码...
}

@Override
public Cache<?, ?> createAssociatedIdCache(@NotNull ImmutableProp prop) {
...省略代码...
}

@Override
public Cache<?, ?> createAssociatedIdCache(@NotNull ImmutableProp prop) {
return createPropCache(
prop == BookStoreProps.BOOKS.unwrap() ||
prop == AuthorProps.BOOKS.unwrap()
prop,
Duration.ofMinutes(5),
Duration.ofHours(5),
);
}

@Override
public Cache<?, ?> createResolverCache(ImmutableProp prop) {
return createPropCache(
prop == BookStoreProps.AVG_PRICE.unwrap() ||
prop == BookStoreProps.NEWEST_BOOKS.unwrap()
prop,
Duration.ofHours(1),
Duration.ofHours(24)
);
}

private <K, V> Cache<K, V> createPropCache(
boolean isMultiviewCache,
ImmutableProp prop,
Duration caffeineDuration,
Duration redisDuration
) {
if (isMultiView) {
return new ChainCacheBuilder<K, V>()
.add(
CaffeineHashBinder
.forProp(prop)
.maximumSize(512)
.duration(caffeineDuration)
.build()
)
.add(
RedisHashBinder
.forProp(prop)
.redis(connectionFactory)
.objectMapper(objectMapper)
.duration(redisDuration)
.build()
)
.build();
}

return new ChainCacheBuilder<>()
.add(
CaffeineValueBinder
.forProp(prop)
.maximumSize(128)
.duration(caffeineDuration)
.build()
)
.add(
RedisValueBinder
.forProp(prop)
.redis(connectionFactory)
.objectMapper(objectMapper)
.duration(redisDuration)
.build()
)
.build();
}
};
}

上面的代码中RedisHashBinder是非常重要的实现类,利用redis支持多视图缓存,其背后存储结构对应Redis Hashes,即,嵌套的Hash结构。

缓存技术风格是否多视图抽象接口内置实现

支持自Loading的缓存

(通常是一级缓存技术:比如Guava, Caffeine)

单视图

LoadingBinder<K, V>

CaffeineValueBinder<K, V>

多视图

LoadingBinder.Parameterized<K, V>

不支持自Loading的缓存

(通常是二级缓存技术:比如Redis)

单视图

SimpleBinder<K, V>

RedisValueBinder<K, V>

多视图

SimpleBinder.Parameterized<K, V>

CaffeineHashBinder<K, V>

RedisHashBinder<K, V>

更好的写法

在上面的代码中,createAssociatedIdListCache方法内部对参数prop进行判断,以决定究竟应该构建多视图缓存还是单视图缓存。然而

提示

就关联属性而言,是否需要构建多视图缓存的唯一判断依据是目标实体是否被施加了过滤器,Jimmer为此提供了更好的支持。

开发人员只需要把超接口CacheFactory/KCacheFactory替换为超类AbstractCacheFactory/AbstractKCacheFactory, 即可继承一个叫做getFilterState/filterState的成员,它可以帮助我们判断是否应该构建多视图缓存。

@Bean
public CacheFactory cacheFactory(
RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper
) {
return new AbstractCacheFactory() {

@Override
public Cache<?, ?> createObjectCache(@NotNull ImmutableType type) {
...省略代码...
}

@Override
public Cache<?, ?> createAssociatedIdCache(@NotNull ImmutableProp prop) {
return createPropCache(
getFilterState().isAffectedBy(prop.getTargetType()),
prop,
Duration.ofMinutes(1),
Duration.ofHours(1)
);
}

@Override
public Cache<?, ?> createAssociatedIdCache(@NotNull ImmutableProp prop) {
return createPropCache(
getFilterState().isAffectedBy(prop.getTargetType()),
prop,
Duration.ofMinutes(1),
Duration.ofHours(1)
);
}

@Override
public Cache<?, ?> createResolverCache(ImmutableProp prop) {
return createPropCache(
prop == BookStoreProps.AVG_PRICE.unwrap() ||
prop == BookStoreProps.NEWEST_BOOKS.unwrap()
prop,
Duration.ofMinutes(1),
Duration.ofHours(1)
);
}

private <K, V> Cache<K, V> createPropCache(
boolean isMultiviewCache,
ImmutableProp prop,
Duration caffeineDuration,
Duration redisDuration
) {
...省略代码...
}
};
}
警告

很遗憾,这种方法仅能用于简化关联缓存构建,即,简化createAssociatedIdCachecreateAssociatedIdListCache方法。

对于计算属性而言,由于框架对用户自定义计算属性的内部逻辑一无所知,无法简化,用户需根据自己业务特点自行决定是否需要构建多视图缓存。

计算属性的SubKey

我们在TenantFilter中定义了getParameters方法,所有受影响的关联属性都会自动为其关联缓存指定SubKey

然而,不幸的是,由于计算属性引入了框架无法理解的用户自定义计算规则,开发人员必须手动为计算属性的Resolver实现指定SubKey

BookStoreAvgPriceResolver.java
@Component
public class BookStoreAvgPriceResolver implements TransientResolver<Long, BigDecimal> {

private final JSqlClient sqlClient;

@Override
public Ref<SortedMap<String, Object>> getParameterMapRef() {
return sqlClient
.getFilters()
.getTargetParameterMapRef(BookStoreProps.BOOKS);
}

...省略其他代码...
}

很明显,计算属性BookStore.avgPrice其实是由关联属性BookStore.books决定的,随后者的变化而变化。

因此,在当前调用上下文中,BookStore.books为多视图缓存系统指定什么SubKeyBookStore.avgPrice就指定什么SubKey

备注

BookStore.avgPrice也受Book.price的影响,随后者的变化而变化。

然而,Book.price是对象的非关联属性,而非关联属性,因此一定和多视图缓存系统无关,这里实现getParameterMapRef方法时无需考虑。

使用

现在,我们已经让关联属性BookStore.books和计算属性BookStore.avgPrice都支持了多视角缓存,让我们使用对象抓取器来查询它们

BookStoreTable table = Tables.BOOK_STORE_TABLE;
List<BookStore> stores = sqlClient
.createQuery(table)
.select(
table.fetch(
Fetchers.BOOK_STORE_FETCHER
.allScalarFields()
.books(
Fetchers.BOOK_FETCHER
.allScalarFields()
)
.avgPrice()
)
)
.execute();
System.out.println(stores);

以一种身份执行

假设当前租户名称为a,执行过程如下

  • 第一步:查询聚合根

    首先查询聚合根对象,执行如下SQL

    select
    tb_1_.ID,
    tb_1_.NAME,
    tb_1_.WEBSITE
    from BOOK_STORE tb_1_

    这里实现了代码中的查询,得到了一些BookStore对象。这种被用户直接查询而得的对象叫做聚合根对象

  • 第二步:通过关联缓存查询❶处的BookStore.books

    上面的代码会得到一系列聚合根对象,如果数据库采用官方例子的数据,会得到两个聚合根对象,这两条BOOK_STORE的主键ID为1和2。

    Jimmer先从Redis查找数据,被查找的键为BookStore.books-1BookStore.books-2

    假设无法在Redis中找到这些键所对应的数据

    127.0.0.1:6379> keys BookStore.books-*
    (empty array)

    所以,执行如下SQL,完成关联属性BookStore.books

    SQL: select,
    tb_1_.STORE_ID,
    tb_1_.ID
    from BOOK tb_1_
    where
    tb_1_.STORE_ID in (
    ? /* 1 */, ? /* 2 */
    )
    and
    // highlight-next-line
    tb_1_.TENANT = ? /* a */
    order by
    tb_1_.NAME asc,
    tb_1_.EDITION desc
    信息

    其中tb_1_.TENANT = 'a'这个过滤条件来自用户过滤器TenantFilter

    Jimmer会把从查询结构放入Redis,因此,我们可以从redis中查看这些数据

    127.0.0.1:6379> keys BookStore.books-*
    1) "BookStore.books-2"
    2) "BookStore.books-1"

    127.0.0.1:6379> hgetall BookStore.books-1
    1) "{\"tenant\":\"a\"}"
    2) "[5,3,1,9,7]"

    127.0.0.1:6379> hgetall BookStore.books-2
    1) "{\"tenant\":\"a\"}"
    2) "[11]"
    信息

    多视角缓存采用Redis的Hash结构,所以,这里需要使用redis的hgetall指令,普通get指令会报错

    Redis的Hash是两层KV结构的嵌套

    • 外层结构的Redis Key,BookStore.books-1BookStore.books-2,和单视图缓存的RedisKey无异。

    • 内层结构的Hash Key,在Jimmer中也称SubKey,由全局过滤器的提供。

      这里,{"tenant":"a"}TenantProvider提供,表示当前缓存项的值并非所有关联对象的id集合,而仅仅包含租户a可以看到的关联对象的id集合。

    提示

    毫无疑问,在Redis中的数据因超时而被清除之前,用相同的租户身份再次执行上述Java/Kotlin代码,将直接从Redis中返回关联数据,相关SQL不会被生成。

  • 第三步:把id集合转化为关联对象

    在上一步中,我们得到了关联属性BookStore.books所对应的关联对象id集合,它们表示租户a可以看到的关联对象id集合。

    现在,就可以利用Book类型的对象缓存,把Book id的集合转化为Book的集合。

    这个步骤非常简单,无需讨论。

  • 第四步:通过计算缓存查询❷处的BookStore.avgPrice

    上面的代码会得到一系列聚合根对象,如果数据库采用官方例子的数据,会得到两个聚合根对象,这两条BOOK_STORE的主键ID为1和2。

    Jimmer先从Redis查找数据,被查找的键为BookStore.avgPrice-1BookStore.avgPrice-2

    假设无法在Redis中找到这些键所对应的数据

    127.0.0.1:6379> keys BookStore.avgPrice-*
    (empty array)

    所以,执行如下SQL,完成计算属性的计算

    select
    tb_1_.ID,
    avg(tb_2_.PRICE)
    from BOOK_STORE tb_1_
    left join BOOK tb_2_
    on tb_1_.ID = tb_2_.STORE_ID
    where
    tb_1_.ID in (
    ? /* 1 */, ? /* 2 */
    )
    and
    // highlight-next-line
    tb_1_.TENANT = ? /* a */
    group by
    tb_1_.ID
    信息

    其中tb_1_.TENANT = 'a'这个过滤条件来自用户过滤器TenantFilter

    Jimmer会把从查询结构放入Redis,因此,我们可以从redis中查看这些数据

    127.0.0.1:6379> keys BookStore.avgPrice-*
    1) "BookStore.avgPrice-2"
    2) "BookStore.avgPrice-1"

    127.0.0.1:6379> hgetall BookStore.avgPrice-1
    1) "{\"tenant\":\"a\"}"
    2) "53.1"

    127.0.0.1:6379> hgetall BookStore.avgPrice-2
    1) "{\"tenant\":\"a\"}"
    2) "81"
    信息

    多视角缓存采用Redis的Hash结构,所以,需要使用redis的hgetall指令,而非get

    Redis的Hash是两层KV结构的嵌套

    • 外层结构的Redis Key,BookStore.avgPrice-1BookStore.avgPrice-2,和单视图缓存的RedisKey无异。

    • 内层结构的Hash Key,在Jimmer中也称SubKey,由全局过滤器的提供。

      这里,{"tenant": "a"}TenantProvider提供,表示当前缓存项的值并非所有关联对象的平均价格,而是租户a可以看到的关联对象的平均价格。

    提示

    毫无疑问,在Redis中的数据因超时而被清除之前,用相同的租户身份再次执行上述Java/Kotlin代码,将直接从Redis中返回关联数据,相关SQL不会被生成。

最终,Jimmer把4个步骤的结果拼接在一起,作为最终返回给用户的数据

[
{
"id":2,
"name":"MANNING",
"website":null,
"books":[
{
"id":11,
"name":"GraphQL in Action",
"edition":2,
"price":81
}
],
"avgPrice":81
},
{
"id":1,
"name":"O'REILLY",
"website":null,
"books":[
{
"id":5,
"name":"Effective TypeScript",
"edition":2,
"price":69
},
{
"id":3,
...略...
},
{
"id":1,
...略...
},
{
"id":9,
...略...
},
{
"id":7,
...略...
}
],
"avgPrice":53.1
}
]

多种身份反复执行

之前讨论过了租户为a时的查询执行过程。同理,我们那可以利用不同租户身份执行多次,让如下三种视角都在Redis中留下缓存数据。

  • tenant = null
  • tenant = "a"
  • tenant = "b"
信息

对于官方附带例子而言,TenantProvider基于HTTP请求头实现,并附有

的支持,用三种不同的用户身份执行三次很容易。

其中,tenant = null对应swagger UI的unauthorized/logout状态。

打开redis-cli,我们可以验证Redis中的数据

127.0.0.1:6379> keys BookStore.books-*
1) "BookStore.books-2"
2) "BookStore.books-1"

127.0.0.1:6379> hgetall BookStore.books-1
1) "{\"tenant\":\"b\"}"
2) "[6,4,2,8]"
3) "{\"tenant\":\"a\"}"
4) "[5,3,1,9,7]"
5) "{}"
6) "[6,5,4,3,2,1,9,8,7]"

127.0.0.1:6379> hgetall BookStore.books-2
1) "{\"tenant\":\"b\"}"
2) "[12,10]"
3) "{\"tenant\":\"a\"}"
4) "[11]"
5) "{}"
6) "[12,11,10]"

127.0.0.1:6379> keys BookStore.avgPrice-*
1) "BookStore.avgPrice-2"
2) "BookStore.avgPrice-1"

127.0.0.1:6379> hgetall BookStore.avgPrice-1
1) "{\"tenant\":\"b\"}"
2) "65.25"
3) "{\"tenant\":\"a\"}"
4) "53.1"
5) "{}"
6) "58.500000"

127.0.0.1:6379> hgetall BookStore.avgPrice-2
1) "{\"tenant\":\"b\"}"
2) "80"
3) "{\"tenant\":\"a\"}"
4) "81"
5) "{}"
6) "80.333333"
提示

读者可以仔细看看这些redis-cli命令,很容易发现,Subey{"tenant":"a"}的数据和SubKey{"tenant":"b"}的数据合并起来刚好就是SubKey{}的数据。

不同租户查询到的数据分别为

[
{
"id":2,
"name":"MANNING",
"website":null,
"books":[
{
"id":12,
"name":"GraphQL in Action",
"edition":3,
"price":80,
},
{
"id":11,
...略...
},
{
"id":10,
...略...
}
],
"avgPrice":80.333333
},
{
"id":1,
"name":"O'REILLY",
"website":null,
"books":[
{
"id":6,
"name":"Effective TypeScript",
"edition":3,
"price":88
},
{
"id":5,
...略...
},
{
"id":4,
...略...
},
{
"id":3,
"name":"Learning GraphQL",
"edition":3,
"price":51
},
{
"id":2,
...略...
},
{
"id":1,
...略...
},
{
"id":9,
"name":"Programming TypeScript",
"edition":3,
"price":48
},
{
"id":8,
...略...
},
{
"id":7,
...略...
}
],
"avgPrice":58.5
}
]

一致性

现在,让我们来修改id为6的Book对象的tenant属性, 从"b"改为"a"。

由于Book-6隶属于BookStore-1,所以可以预见的是,属性BookStore.books-1BookStore.avgPrice-1所对应的多视角缓存必然失效。

  • 假如启用了BinLog触发器,用任何手段修改数据库都可以导致Jimmer缓存一致性的介入。比如直接在SQL IDE中执行如下SQL

    update BOOK
    set TENANT = 'a'
    where ID = 6;
  • 假如只启用了Transaction触发器,则必须用Jimmer的API修改数据库

    sqlClient.save(
    Immutables.createBook(draft -> {
    draft.setId(6L);
    draft.setTenant("a");
    })
    );

无论通过上述何种方式修改数据,你都会在看到如下日志输出

Delete data from redis: [Book-6] ❶
Delete data from redis: [Author.books-3] ❷
Delete data from redis: [BookStore.books-1] ❸
Delete data from redis: [BookStore.avgPrice-1] ❹
  • ❶ 更新被修改实体的对象缓存

  • ❷ 所有目标类型为Book的关联属性必然被影响,当然包括Author.books

    按数据库现有数据,受影响的Author对象的id为3

  • ❸ 所有目标类型为Book的关联属性必然被影响,当然包括BookStore.books

    按数据库现有数据,受影响的BookStore对象的id为1

  • ❹ id为1的BookStore对象的计算缓存BookStore.avgPrice也被影响了,这是最奇妙的特征。

    虽然框架并不知道用户在计算属性中使用何种计算方法,但是在计算缓存一文中,我们在BookStoreAvgPriceResolver类中讨论过如下代码

    @EventListener
    public void onAssociationChange(AssociationEvent e) {
    if (sqlClient.getCaches().isAffectedBy(e) &&
    e.isChanged(BookStoreProps.BOOKS)
    ) {
    ...省略...
    }
    }

    如果忘记了这段代码的具体逻辑,可以回顾计算缓存一文,这里关注高量的那行代码即可。这里,当前计算属性对BookStore.books关联的变化感兴趣。

    提示

    修改表之间关联字段并非触发关联属性变更事件的唯一方法,修改关联对象中能影响全局过滤器的被过滤字段,比如这里的TENANT,也可以触发关联属性变更事件。

    这是Jimmer触发器机制中一个非常重要的特性!

    很明显,❸处已经感知到了BookStore.books关联属性发生了变更,因此,会进一步导致此处计算属性也会受影响。