概念、想法、策略
概念、想法、策略通过 Persisted Queries 实现缓存控制

通过 Persisted Queries 实现缓存控制

GraphQL 通常通过 POST 操作,将所有查询执行到单个端点,并通过请求正文传递参数。该单个端点的 URL 会返回不同的响应,这意味着它无法被缓存(至少不能以 URL 作为标识符来缓存)。

因此,在 GraphQL 中支持缓存的标准方式是在客户端层,通过 Apollo 客户端及类似的库来实现,这些库独立缓存返回的对象,并通过其唯一的全局 ID 进行标识。

(相比之下,在服务器端缓存时,我们通常使用 URL 作为标识符,并将响应中所有实体的数据一起缓存。)

但是,这种解决方案有几个缺点:

  • 应用程序需要在客户端运行更多 JavaScript。通过低端手机访问网站会导致性能下降
  • 应用程序变得更复杂,且有更多的活动部件,因为现在还需要考虑实现缓存层
  • 并非所有人都了解 JavaScript(例如:网站可能是用 PHP 编写的),但处理 JS 也成为了一项责任

更好的解决方案是使用 HTTP 缓存。让我们来了解实现这一方案所需的前提条件。

通过 GET 访问 GraphQL

使用 HTTP 缓存意味着我们将以 URL 作为标识符来缓存 GraphQL 响应。这有两个含义:

  1. 必须通过 GET 访问 GraphQL 的单个端点
  2. 必须将查询和变量作为 URL 参数传递

因此,如果单个端点是 /graphql,则可以对 URL /graphql?query=...&variables=... 执行 GET 操作。

这适用于从服务器检索数据(通过 query 操作)的情况。对于修改数据(通过 mutation 操作),仍然必须使用 POST。这里没有问题,因为 mutation 始终是全新执行的;我们无法缓存 mutation 的结果,因此也不会对其使用 HTTP 缓存。

这种方法是可行的(官方网站也有建议),但有一些需要注意的事项。

通过 URL 参数编写 GraphQL 查询

GraphQL 查询通常跨越多行。例如:

{
  posts {
    id
    title
  }
}

然而,我们不能直接在 URL 参数中输入这个多行字符串。

解决方案是对其进行编码。例如,GraphiQL 客户端会将上面的查询编码为:

%7B%0A%20%20posts%20%7B%0A%20%20%20%20id%0A%20%20%20%20title%0A%20%20%7D%0A%7D

好吧,这可以运行。但看起来并不直观,对吧?谁能理解这个查询的含义?

GraphQL 的优点之一就是其查询非常易于理解。稍加练习,一看到查询就能立即明白它的含义。但一旦经过编码,这种优势就荡然无存,只有机器才能解读;人类被排除在外了。

另一种解决方案是将查询中的所有换行符替换为空格,这是可行的,因为换行符对查询不增加任何语义含义。这样,上面的查询可以表示为:

?query={ posts { id title } }

这对于简单的查询很有效。但如果有一个非常长的查询,包含许多 { } 的开闭、字段参数和指令,那么就会越来越难以理解。

例如,这个查询:

{
  posts(limit:5) {
    id
    title @titleCase
    excerpt @default(
      value:"No title",
      condition:IS_EMPTY
    )
    author {
      name
    }
    tags {
      id
      name
    }
    comments(
      limit:3,
      order:"date|DESC"
    ) {
      id
      date(format:"d/m/Y")
      author {
        name
      }
      content
    }
  }
}

会变成这样的单行查询:

{ posts(limit:5) { id title @titleCase excerpt @default(value:"No title", condition:IS_EMPTY) author { name } tags { id name } comments(limit:3, order:"date|DESC") { id date(format:"d/m/Y") author { name } content } } } 

同样,执行这个查询是可以的,但我们根本不知道自己在执行什么。

如果查询还包含片段,那就彻底放弃吧,完全没有办法理解它。

Persisted Queries 来拯救

如果在 URL 中传递查询不够令人满意,我们还有什么其他选择?那就是——不在 URL 中传递查询!

这就是所谓的「persisted query」方法:将查询存储在服务器上,并使用标识符(例如数字 ID,或以查询为输入应用哈希算法生成的唯一字符串)来检索它。最后,将这个标识符作为 URL 参数传递,而不是查询本身。

例如,查询可以用 ID 2908(或像 "50ac3e81" 这样的哈希值)标识,然后对 URL /graphql?id=2908 执行 GET 操作。GraphQL 服务器将检索与该 ID 对应的查询,执行并返回结果。

Gato GraphQL 让这一切更加简单:persisted query 被实现为自定义文章类型,因此我们可以像普通文章一样创建并发布它,所选的别名(默认基于输入的标题)将成为其标识符。Persisted queries 使 HTTP 缓存的实现变得非常简单。

计算 max-age

HTTP 缓存通过在响应中发送 Cache-Control 头来工作,其中包含 max-age 值(表示响应应缓存的时间),或 no-store(表示不缓存)。

考虑到不同字段可能有不同的 max-age 值,GraphQL 服务器如何计算查询的 max-age 值?

答案是:获取查询中请求的所有字段的 max-age 值,找出其中最小的一个。那就是响应的 max-age

例如,假设我们有一个 User 类型的实体。根据分配给该实体的行为,我们可以为相应字段分配可缓存的时长:

🛠 其 ID 永远不会改变 ⇒ 给字段 id 设置 1 年的 max-age

🛠 其 URL 几乎不会更新(如果有的话) ⇒ 给字段 url 设置 1 天的 max-age

🛠 人物名称可能偶尔会更改(例如:添加状态,或写成「Milton(戴口罩)」) ⇒ 给字段 name 设置 1 小时的 max-age

🛠 用户在网站上的 karma 值随时可能变化(例如:有人为其评论点赞后) ⇒ 给字段 karma 设置 1 分钟的 max-age

🛠 如果查询的是已登录用户的数据,则无论获取哪个字段,响应都完全不能被缓存 ⇒ max-age 必须为 no-store

因此,以下 GraphQL 查询的响应将具有以下 max-age 值(在本例中我们忽略 Root.users 字段的 max-age,但实际上也会考虑在内):

Querymax-age
{
  users {
    id
  }
}
1 年
{
  users {
    id
    url
  }
}
1 天
{
  users {
    id
    url
    name
  }
}
1 小时
{
  users {
    id
    url
    name
    karma
  }
}
1 分钟
{
  me {
    id
    url
    name
    karma
  }
}
no-store(不缓存)

创建 Cache Control List

确定每个字段的 max-age 后,通过 Cache Control List 输入这些信息:

定义缓存控制策略

Gato GraphQL 随后会自动计算响应的 max-age 值,并将其作为 Cache-Control HTTP 头返回。