3. 使用 Caffeine 缓存
10m

概述

使用中使用 PreparsedDocumentProvider 进行缓存时,我们的 DGS 服务器(构建在 Spring Boot 之上)将准备好处理很多细节。但是,我们需要提供一些东西。第一个是缓存实现。

在本课中,我们将:

  • 介绍缓存库 Caffeine
  • 创建新的缓存实例并配置其大小和过期时间
  • 实现 PreparsedDocumentProvider 的所需方法
  • 测试我们的缓存并观察缓存命中和缓存未命中

Caffeine

要创建新的缓存,我们将使用 Caffeine,这是一个通常用于 Java 应用程序的高性能缓存库。Caffeine 是一个内存中缓存。这意味着每个服务器实例都有自己的缓存,并且单独填充;这与分布式缓存(例如 Redis,其中每个服务器共享同一个实例)形成对比。

我们的 PreparsedDocumentProvider 实现将使用此缓存来存储已解析并已验证的 运算,我们的服务器接收到的。

打开你的项目的 build.gradle 文件。在依赖项下,让我们引入 Caffeine 库。

build.gradle
dependencies {
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
// ... other dependencies
}

请务必重新加载 Gradle 依赖项,以便将新软件包纳入我们的项目。

实现缓存

使用 Caffeine 实现缓存的方法有很多。为了遵循我们代码库的响应式风格,我们将使用一个名为 AsyncCache 的接口。通过使用异步缓存,我们可以在数据就绪时将其存储并加载,而不是通过多个阻塞请求在代码中创建瓶颈。

注意:请查看 Caffeine 官方 Wiki,了解有关创建新缓存的其他方法的详细信息。

我们回到 CachingPreparsedDocumentProvider 类文件并导入一些依赖项。

CachingPreparsedDocumentProvider
import com.github.benmanes.caffeine.cache.AsyncCache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.CompletableFuture;
import graphql.ExecutionInput;
import java.time.Duration;
import java.util.function.Function;

在我们类内部,我们将创建一个 private final 属性,称为 cache。我们的缓存将是 AsyncCache 类型,它接受两个 ;我们将回到此步骤,因此现在可将其留空。

private final AsyncCache<> cache

接下来,要创建缓存,我们将调用 Caffeine.newBuilder。在此基础上,我们可以将配置链接到我们的缓存,包括允许它容纳的条目数,以及何时应从缓存中逐出记录。

CachingPreparsedDocumentProvider
private final AsyncCache<> cache = Caffeine.newBuilder()
.maximumSize() // How many entries can the cache contain?
.expireAfterAccess() // When should the record be removed?
.buildAsync();

让我们为缓存赋予 250 条记录的宽裕容量,以及 2 分钟的过期时间以方便测试。

Caffeine.newBuilder()
.maximumSize(250)
.expireAfterAccess(Duration.ofMinutes(2))
.buildAsync();

现在我们回到缓存的类型定义。 AsyncCache 接受两个类型 :一个用于密钥类型,另一个用于它存储的对象类型。

缓存类型签名
AsyncCache<KeyType, ValueType>

在我们示例中,我们的缓存将包含类型为 PreparsedDocumentEntry 的对象。 PreparsedDocumentEntry,之前已从与我们的 PreparsedDocumentProvider 接口相同的 graphql 包中导入,表示已解析并验证的 结果。对于我们的密钥数据类型,我们将提供 String

CachingPreparsedDocumentProvider
private final AsyncCache<String, PreparsedDocumentEntry> cache = Caffeine
.newBuilder()
.maximumSize(250)
.expireAfterAccess(Duration.ofMinutes(2))
.buildAsync();

现在让我们启动此缓存!

构建我们的类

请回想一下 PreparsedDocumentProvider 接口要求在我们的类上定义一个特定方法: getDocumentAsync。此方法是使用每个传入的 调用的,我们的 收到。此方法的职责是返回缓存操作(如果在缓存中找到)或者解析、验证,然后将其缓存(如果在缓存中不存在)。

getDocumentAsync 方法

CachingPreparsedDocumentProvider 中腾出一些空间,让我们添加 getDocumentAsync 方法的结构。

CachingPreparsedDocumentProvider
@Override
public void getDocumentAsync() {}

注意:我们需要在此应用 @Override来覆盖 getDocumentAsync 类型在该类的超类,即 PreparsedDocumentProvider 接口中的行为。

还记得 getDocumentAsync 接收的参数吗?第一个是我们的执行输入,一个包含当前 详情的对象,由我们的 处理。我们指定一个名为 executionInput,类型为 ExecutionInput 的参数。

public void getDocumentAsync(ExecutionInput executionInput) {}

第二个参数是我们如果发现请求的 不存在于缓存中就会调用的函数。它负责解析、验证和缓存步骤。我们将其称为 parseAndValidateFunction,它是一个 Function 类型,具有两个类型 ExecutionInputPreparsedDocumentEntry。我们现在更新函数签名——稍后我们会详细说明这些参数是如何发挥作用的。

public void getDocumentAsync(
ExecutionInput executionInput,
Function<ExecutionInput, PreparsedDocumentEntry> parseAndValidateFunction
) {}

在填入 our 方法逻辑之前,我们先更新 our 返回类型。此函数是异步函数,因此我们将返回 CompletableFuture类型。 our CompletableFuture将接受 PreparsedDocumentEntry类型变量。这个变量负责存储和检索预解析且经过验证的 的实际解析和验证的 ,因此 our 的缓存对此变量负责。

public CompletableFuture<PreparsedDocumentEntry> getDocumentAsync(
ExecutionInput executionInput,
Function<ExecutionInput, PreparsedDocumentEntry> parseAndValidateFunction
) {}

很好!在完成 our 方法签名后,让我们回顾一下它的任务。

  1. 检查缓存是否包含预解析、预验证的 。如果是,则返回!
  2. 如果不是,则改为调用 parseAndValidateFunction

因此, getDocumentAsync需要调用 our 缓存的 get方法。它将传入服务器尝试解析的 ,从而向缓存提供预解析、预验证 的引用以便返回。但由于缓存可能不包含有问题的 ,因此我们需要向它传递第二个 :发生“缓存未命中”时应当运行的函数。

cache.get(
operationString, // The operation string we can use to look up the right parsed and validated doc
fallbackFunction // The function that will run if the operation is not found (a "cache miss")
)

访问操作

我们可以通过方法的 字符串(尚未分析或验证)访问实际 executionInput。一个实用的方法 getQuery,可让我们准确获取所需信息。

CachingPreparsedDocumentProvider
public CompletableFuture<PreparsedDocumentEntry> getDocumentAsync(
ExecutionInput executionInput,
Function<ExecutionInput, PreparsedDocumentEntry> parseAndValidateFunction
) {
cache.get(executionInput.getQuery());
}

备用函数

现在,我们将指定当缓存中 找到 时要运行的函数。

在此,我们要获取 executionInput 并将其传递以进行分析和验证。为此,我们将使用 parseAndValidateFunction 进程将该函数传递给我们的 方法。

但是我们不会直接调用 parseAndValidateFunction;相反,我们将传递一个函数,该函数使用 apply 来用我们的 executionInput 调用 parseAndValidate 函数。为了遵守我们的 cache.get 调用针对其第二个 预期的签名,我们需要给我们的函数一些任意输入——出于简单起见,我们称之为 s

最后,我们一定要确保返回结果。

return cache.get(
executionInput.getQuery(),
s -> parseAndValidateFunction.apply(executionInput)
);

注意: parseAndValidateFunction 遵守 Function 接口,它为我们提供了四种方法以供选择。此处我们使用 apply 来专门将该函数“应用”于我们的执行输入。进一步了解 Java 文档中的 Function 接口

放大来看,以下是我们整个方法的样貌。

CachingPreparsedDocumentProvider
@Override
public CompletableFuture<PreparsedDocumentEntry> getDocumentAsync(
ExecutionInput executionInput,
Function<ExecutionInput, PreparsedDocumentEntry> parseAndValidateFunction
) {
return cache.get(
executionInput.getQuery(),
s -> parseAndValidateFunction.apply(executionInput)
);
}

在执行特定 的时候, 进程将调用我们的 CachingPreparsedDocumentProvidergetDocumentAsync 方法。

它将传入当前执行输入,以及作为我们的 parseAndValidateFunction 值的特殊 Java 提供的函数。

通常,它会自动使用此函数根据执行输入的内容进行解析、验证和执行——但我们刚刚添加了连接,以确保它 首先 检查我们的缓存以获取已解析且经过验证的

检查缓存未命中

我们还可以修改getDocumentAsync方法来打印是否有缓存未命中,以及如果存在该未命中时的情况。

让我们为我们的类添加下面的方法, callIfCacheMiss,它将负责调用 parseAndValidateFunction.apply(executionInput),它将记录在缓存中找不到的

CachingPreparsedDocumentProvider
public PreparsedDocumentEntry callIfCacheMiss(
ExecutionInput executionInput,
Function<ExecutionInput, PreparsedDocumentEntry> parseAndValidateFunction)
{
System.out.println("Pre-parsed operation wasn't found in cache: " + executionInput.getQuery());
return parseAndValidateFunction.apply(executionInput);
}

接下来,我们将更新 getDocumentAsync以将此责任委托给我们的新函数 callIfCacheMiss

@Override
public CompletableFuture<PreparsedDocumentEntry> getDocumentAsync(
ExecutionInput executionInput,
Function<ExecutionInput, PreparsedDocumentEntry> parseAndValidateFunction
) {
return cache.get(
executionInput.getQuery(),
s -> callIfCacheMiss(executionInput, parseAndValidateFunction)
);
}

让我们重新启动服务器,然后回到 沙盒

任务!

我们将设置一个新的 来要求提供特定列表的详细信息,以及一些关于其便利设施的信息。

query GetListingAndAmenities($listingId: ID!) {
listing(id: $listingId) {
title
description
numOfBeds
amenities {
name
category
}
}
}

变量面板中,提供以下内容:

{
"listingId": "listing-1"
}

当我们运行 时,我们应该在 响应面板中看到数据,以及请求以毫秒为单位花费的时间。(记下这个数字!)回到我们的服务器终端,我们还应该能看到一些附加输出。

Pre-parsed operation wasn't found in cache: query GetListingAndAmenities($listingId: ID!) {
listing(id: $listingId) {
title
description
numOfBeds
amenities {
name
category
}
}
}

有道理。这是我们第一次运行 ,所以很自然,我们在缓存中找不到它预先解析和预先验证的自己。让我们尝试再次运行它,交换不同的列表 ID:

{
"listingId": "listing-2"
}

这次,我们的服务器没有输出!我们的 已成功缓存,即使使用不同的列表 ID,我们也可以重复使用它。这是一个“缓存命中”,因为我们找到了我们正在寻找的东西。

此外,我们应该看到我们的时间 花费大幅减少。我们节省了几毫秒的时间——否则我们将浪费在 重新解析重新验证熟悉的 上。

https://studio.apollographql.com/sandbox/explorer

The results of running the operation in Explorer, with the milliseconds highlighted

让我们微调一下我们的 。移除 for 设施,再次运行 。我们将在服务器的终端中看到一些新的输出;我们运行了一个 新的 ,所以它在缓存中创建了一个新条目!

Pre-parsed operation wasn't found in cache: query GetListingAndAmenities($listingId: ID!) {
listing(id: $listingId) {
title
description
numOfBeds
}
}

每次我们运行一个 新的 ,我们应该看到我们的终端输出标记我们的“缓存未命中”。

如果我们等待两分钟并运行一个 已经被添加到我们的缓存中,我们应该看到相同的“缓存未命中”消息——两分钟后,该条目已被逐出!

我们的 正在被缓存,我们已经看到了我们的 最佳实践:我们可以缓存一个单一操作,但将其应用于多个不同的值,而无需重新解析和重新验证。

不要犹豫,删除 callIfCacheMiss 方法,并恢复对 getDocumentAsync 方法的更改。请查看下面的可折叠部分以获取指导。

实践

Caffeine 缓存库
Caffeine 是一 
 
 我们可以用它来创建 
 
. 在构建新的缓存时,我们使用两个
 
来提供我们的类型注解:一个指示缓存的关键数据类型,另一个指示它存储的对象的类型。当作为我们 PreparsedDocumentProvider 实现的一部分进行配置时,我们可以使用缓存来存储 
 
 我们已经 
 
 一次之前。

将此框中的项目拖动到上面的空白处

  • 已解析并已验证

  • GraphQL 操作

  • Java 类

  • 逐出

  • 类型变量

  • 内存缓存

  • 方法

  • 分布式缓存

  • GraphQL 变量

  • 缓存库

要点

  • Caffeine 是一款高性能缓存库,我们可以用它在 Spring Boot 中实例化新的缓存
  • AsyncCache是一个缓存实现,可避免阻塞请求,并保持我们应用程序的响应性
  • 我们可以针对容量和过期时间配置缓存中某些限制
  • 在实现 PreparsedDocumentProvider 接口时,我们需要提供一个 getDocumentAsync 函数来遵循特定签名
  • 当我们缓存中找不到一个特定的 时,我们需要调用 getDocumentAsync 的第二个 ,即一个解析和验证函数,才能完成这两个步骤并继续执行

接下来

我们刚刚完成了缓存之旅的上半程。现在,由于我们不必对服务器已处理的 重新解析和重新验证,因此我们可以继续下一个主题:缓存我们的 响应

上一个

分享你对本课程的问题和评论

本课程目前处于

测试版
.您的反馈有助于我们提高!如果您遇到了困难或困惑,请告诉我们,我们将为您提供帮助。所有评论都是公开的,并且必须遵守 Apollo行为守则。请注意,已解决或已处理的评论可能会被删除。

您需要一个 GitHub 帐户才能在下面发帖。还没有吗? 转而在我们的 Odyssey 论坛发帖。