🕸 GraphQL 如何以及在哪里改善 WordPress——对 REST API 的补充
更新 2024年1月5日: 查看 Gato GraphQL vs WP REST API 对比。
上周末我发布了博客文章 🦸🏿♂️ Gato GraphQL 现已从 PHP 8.0 转译至 7.1。
将该文章分享到 Reddit 的 /r/php 后,社区就在 WordPress 中使用 GraphQL 的价值、它与 WP REST API 的区别,以及为 WordPress 引入另一套 API 是否合理展开了热烈讨论。
我认为大多数评论切中要害,但也有一些遗漏了关键信息。GraphQL 不仅仅是一个接口,也是一种实现。这意味着来自不同提供商的不同 GraphQL 服务器,可能在设计上各有侧重。因此,我们无法对 GraphQL 所提供的内容抱有统一的预期,也难以对 GraphQL 引擎的工作方式有完整的理解。
例如,WordPress 和 Laravel 中的 GraphQL 体验会有所不同,WPGraphQL 和 Gato GraphQL 这两个服务器所提供的体验也各异。
本文是我对这一话题的看法,针对 Reddit 帖子中的若干评论进行回应。
GraphQL vs WP REST API
[真是个馊主意] 在已经使用自己 REST API 的 WordPress 上再架一套 GraphQL API。直接用 REST API 不就好了。 [来源]
REST API 和 GraphQL 服务于相同的目的:为应用程序提供所需的数据。然而,它们的实现方式不同:REST 使用提供特定数据集的预定义端点,而 GraphQL 则可以精确提供所需的数据。
这种行为差异会直接影响应用程序的性能。使用 REST 时,如果需要获取文章列表及每篇文章的作者数据,则需要发送额外的请求——可能是为所有作者数据发送 1 个额外请求,或者为每位作者各发送 1 个额外请求。与此同时,网站访客可能正在等待页面渲染。
GraphQL 改善了这一情况,因为我们可以在单次请求中直接获取所有文章和作者数据,网页渲染速度也会更快:
{
posts {
id
title
excerpt
date
url
author {
id
name
url
}
}
}因此,即使 WordPress 已经有了 REST API,也不意味着它对所有任务都是最合适的工具。当然,我们可以一直使用它,但如果同时能访问 GraphQL,那么在 GraphQL 提供优势的场景下,我们可以选择使用这个 API,效果会更好。
GraphQL 初始配置复杂 + 需要编写 resolver
GraphQL 的初始配置比 REST 要复杂得多,这一点确实有道理;你说得对,关联关系必须手动配置。 [来源]
还有……
你和网上几乎所有人都忽略了一点:要让这种 API 格式正常工作,你必须编写解析器(resolver + 类型),这带来了一系列 REST 中不存在的问题。 [来源]
这些评论并不完全准确,因为 WPGraphQL 和 Gato GraphQL 已经将 WordPress 数据模型映射到了 GraphQL schema 中(WPGraphQL 完整映射,我的插件也映射了大部分)。
因此,安装任一插件后,您可以立即开始为应用程序获取数据,无需创建任何 resolver,也无需手动设置实体间的关联。
确实,要从应用程序自有实体(如 CPT)中获取自定义数据,需要通过 resolver 进行映射,这部分工作需要自己完成。但这与 REST 没有本质区别:如果需要从 CPT 获取自定义数据,同样需要创建一个 REST 端点来获取该自定义数据。自定义端点也是一种 resolver。
因此,就对 resolver 的需求而言,REST 和 GraphQL API 基本相同。
浏览网站和文档时,确实会给人一种 GraphQL 配置起来更费力的印象,这种直觉有一定的合理性。
我认为有几个原因。首先,GraphQL 涉及(至少)两个层面:
- 它是什么以及如何工作的概念
- 提供实际实现的服务器
浏览 GraphQL 文档(如官方网站 graphql.org)时,其内容聚焦于 GraphQL 背后的概念,详细讲解 resolver 是什么以及为什么需要它。
这在从零开始构建应用程序时非常有用,例如使用 Laravel 和 Lighthouse。在那种情况下,确实需要自己编写 resolver(但同样也需要创建 REST 端点)。
然而,WordPress 本身就是应用程序,WPGraphQL 和 Gato GraphQL 是其解决方案。这两个插件已经为我们创建了 resolver,所以我们无需操心(就像 WP REST API 也提供了一套初始端点,我们同样无需操心一样)。
此外,GraphQL 更面向开发者,其文档看起来是直接对开发者说话的。开发者在服务端创建 resolver,也在客户端通过自定义 query 使用这些 resolver。由于构建 resolver 是开发者的工作,它自然而然地频繁出现。
对于 REST,(我认为)默认期望是提供所需数据的端点已经存在(由 WP REST API 提供)。只有在端点不存在时,才需要考虑设置自定义端点。因此,REST 中关于创建 resolver 的讨论较少。
总结而言,REST 和 GraphQL 都能提供所需的数据。但 REST 鼓励静态方式——端点应当已经存在,只有在不存在时才去处理;而 GraphQL 鼓励动态方式——每个 query 都是定制的,可以为其编写完美的 resolver。
因此,归根结底,REST 和 GraphQL 之间没有根本性的差异,只是对如何满足需求有不同的理解。
GraphQL 的漏洞 + 安全注意事项
编写安全的解释器非常困难,所以总有一天会从 GraphQL 中暴露出巨大的漏洞。 [来源]
还有……
WordPress 已经如此庞大,本身就是一个巨大的攻击目标;添加任何插件都会带来大量风险,而一个声称要公开 WordPress 几乎所有内容的插件——包括绕过安全模型的大量代码示例,如将菜单和菜单项设为公开——对我来说是绝对不可接受的。非主题驱动的输出应尽可能受到限制(除非我明确要求,否则不应存在),超出绝对必要的范围。我希望这永远不会进入核心。 [来源]
GraphQL 确实带来了额外的安全风险,需要加以应对。我完全同意这种担忧。
但我不认为这是阻止 GraphQL 进入 WP 核心的决定性问题。而且,我也不认为这真的很难解决。
所需要的是 GraphQL 服务器利用 WordPress 现有的安全机制,然后由开发者使用这些机制,确保某个字段只能由适当的用户访问:
- 用户是否已登录?
- 用户是否是管理员?
- 用户是否具有某种角色或权限?
- 用户是否是该文章的作者?
为满足这一要求,Gato GraphQL 提供了 Access Control Lists(访问控制列表),可以通过配置定义谁能访问每个字段和指令。
当然,有时仅靠 ACL 还不够,GraphQL 服务器需要提供额外的安全措施。下面介绍我目前正在为 Gato GraphQL 即将发布的 v0.8 所做的工作。
字段 posts(用于获取文章数据)不需要授权,任何用户(无论是否登录)都可以访问。因此,出于安全考虑,它只获取已发布的文章。
但在某些情况下,我们也需要获取草稿/待审/已删除的文章,例如:
- 构建静态网站(由管理员执行,可以访问网站的所有数据)
- 文章作者需要列出所有草稿文章以便继续编辑
为此,我设计了以下方案。获取文章将有 3 个字段:
posts:对任何人开放,只能获取已发布的文章myPosts:对任何人开放,只获取当前登录用户的文章,支持任意状态(已发布/草稿/待审/已删除)postsForAdmin:只有管理员可以访问,可获取任意状态的所有文章
而 postsForAdmin 默认是禁用的,因此不会出现在 GraphQL schema 中,除非管理员明确启用它(而且很可能只在构建静态网站时才会启用)。
另一种情况是某个字段可以同时获取公开数据和私有数据。例如,字段 option 从表 wp_options 中获取数据。一些条目是公开的(如 blogname),而另一些则不是(如 admin_email)。
类似的情况还有通过字段 Post.metaValue、User.metaValue 等获取元值。例如,用户元数据中包含明显私密的条目 wp_capabilities,而 description 是公开的。last_name 则根据应用程序的不同,可能是公开的也可能是私有的。
为了确保访问这些数据的安全,插件将允许在设置页面通过允许/拒绝列表指定哪些条目可以被 Query,支持完整条目名称和正则表达式:

这样,允许的 option 的 Query 将正常工作,而被拒绝的 option 则会返回 null:
{
# This option is allowed
siteName: optionValue(name: "blogname")
# This optionValue is not allowed
adminEmail: optionValue(name: "admin_email")
}只要 GraphQL 服务器提供适当的安全措施,并且开发者保持基本的安全意识,构建安全的 GraphQL API 并不困难。
GraphQL 拖垮数据库
GraphQL 是一种支持深度关系 Query 的丰富语法,对于像 WordPress 这样数据模型扩展性依赖于实体-属性-值模式的生态系统而言,这意味着当 GraphQL Query 深度大、复杂或递归时,会对数据库造成难以置信的压力,可能导致网站无响应。WordPress 已经以能把 MySQL/MariaDB 实例逼到极限而著称,如果 Query 没有被妥善编写、认证和限流,添加 GraphQL 可能会让情况更糟。 [来源]
拖垮数据库是 GraphQL 服务器的严重隐患。下面介绍 Gato GraphQL 如何尝试避免这种情况。
Gato GraphQL 通过架构设计从根本上避免 N+1 问题的发生。它通过让引擎负责从数据库加载实体来实现这一点,而不是由开发者负责。
在 resolver 中解析关联时,返回值是对象的 ID(或 ID 列表),而不是对象本身。例如,获取自定义文章的作者是这样实现的:
class CustomPostFieldResolver extends AbstractDBDataFieldResolver
{
private CustomPostUserTypeAPIInterface $customPostUserTypeAPI;
public function getClassesToAttachTo(): array
{
return [
CustomPostFieldInterfaceResolver::class,
];
}
public function getSchemaFieldType(string $fieldName): ?string
{
return match($fieldName) {
'author' => SchemaDefinition::TYPE_ID,
default => null,
};
}
public function resolveValue(
TypeResolverInterface $typeResolver,
object $customPost,
string $fieldName,
array $fieldArgs = []
): mixed {
switch ($fieldName) {
case 'author':
return $this->customPostUserTypeAPI->getAuthorID($customPost);
}
return null;
}
public function resolveFieldTypeResolverClass(
TypeResolverInterface $typeResolver,
string $fieldName
): ?string {
switch ($fieldName) {
case 'author':
return UserTypeResolver::class;
}
return null;
}
}通过从 resolveValue 获得 DB 实体的 ID,以及从 resolveFieldTypeResolverClass(由类 UserTypeResolver 表示)获得对象类型,GraphQL 引擎便可以加载该对象的数据。
为了加载数据,引擎使用了一种极为高效的算法:其时间复杂度为 O(n),其中 n 是 Query 中的类型数量,而不是节点数量。
该算法之所以能达到这种效率,是因为它并不遍历图,而是将数据结构转换为组件栈,这样解析起来简单得多。(GraphQL 中的"图"是一个概念,而不是实际的实现。)
即使 Query 有多个层级,每个层级都获取大量实体,该算法仍能应对自如。例如,运行以下深度为 10 层的 Query,影响也不大:
{
posts(pagination: { limit: 10 }) {
excerpt
title
url
author {
name
url
posts(pagination: { limit: 10 }) {
title
tags(pagination: { limit: 10 }) {
slug
url
posts(pagination: { limit: 10 }) {
title
comments(pagination: { limit: 10 }) {
content
date
author {
name
posts(pagination: { limit: 10 }) {
title
url
comments(pagination: { limit: 10 }) {
content
date
author {
name
username
url
}
}
}
}
}
}
}
}
}
}
}这种效率的例外情况是通过 Post.metaValue、User.metaValue、Comment.metaValue、PostTag.metaValue 和 PostCategory.metaValue(以及它们的 metaValues 字段)获取元值时。这是因为 WordPress 函数(get_post_meta、get_user_meta 等)每次只获取 1 个 ID 的数据,意味着每个实体都需要一次数据库调用来获取其元值。因此,元值的解析会随节点数量的增加而扩展,而不是随类型数量扩展(关于这一点,原评论可谓一针见血)。
为防止恶意行为者使用和滥用元字段,Gato GraphQL(v0.8)将默认禁用这些字段。管理员必须明确启用它们,并且在启用时可以将这些字段置于某个 Access Control List 之下,从而确保数据库不会面临攻击风险。
限流也是一个好主意,我计划在某个未来版本中提供支持。
此外还有对 Query 复杂度的分析和限制(例如允许多少层深度)。GraphQL 服务器以时间复杂度 O(n) 解析 Query,因此循环方面的危害并不大。然而,单个 Query 仍然可能从数据库中获取无限量的数据,这是我们可能希望避免的。
例如,下面这个简单的 Query 会在单次请求中带回大量数据(我的演示网站只有几百条记录,所以我可以演示执行该 Query):
{
posts000: posts(pagination: { limit: 100 }) {
...PostFields
}
posts100: posts(pagination: { limit: 100, offset: 100 }) {
...PostFields
}
posts200: posts(pagination: { limit: 100, offset: 200 }) {
...PostFields
}
posts300: posts(pagination: { limit: 100, offset: 300 }) {
...PostFields
}
posts400: posts(pagination: { limit: 100, offset: 400 }) {
...PostFields
}
posts500: posts(pagination: { limit: 100, offset: 500 }) {
...PostFields
}
posts600: posts(pagination: { limit: 100, offset: 600 }) {
...PostFields
}
posts700: posts(pagination: { limit: 100, offset: 700 }) {
...PostFields
}
posts800: posts(pagination: { limit: 100, offset: 800 }) {
...PostFields
}
posts900: posts(pagination: { limit: 100, offset: 900 }) {
...PostFields
}
}
fragment PostFields on Post {
id
title
content
date
}由此可见,Query 甚至不需要嵌套就能制造麻烦。因此,Query 复杂度分析是一项棘手的工作,需要精细调整才能真正有用。
我希望也能支持 Query 分析,但这并不在我的高优先级列表中,因为结合其他功能(如持久化 Query 或自定义端点,再加上 Access Control Lists),我们已经可以阻止恶意行为者,而我们自己也不会(不应该!)滥用自己的 GraphQL 服务。