博客

🕸 GraphQL 如何以及在哪里改善 WordPress——对 REST API 的补充

Leonardo Losoviz
作者:Leonardo Losoviz ·

更新 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 涉及(至少)两个层面:

  1. 它是什么以及如何工作的概念
  2. 提供实际实现的服务器

浏览 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.metaValueUser.metaValue 等获取元值。例如,用户元数据中包含明显私密的条目 wp_capabilities,而 description 是公开的。last_name 则根据应用程序的不同,可能是公开的也可能是私有的。

为了确保访问这些数据的安全,插件将允许在设置页面通过允许/拒绝列表指定哪些条目可以被 Query,支持完整条目名称和正则表达式:

![为"option"字段定义允许/拒绝条目](/images/schema-configuration-settings.webp "为"option"字段定义允许/拒绝条目")

这样,允许的 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.metaValueUser.metaValueComment.metaValuePostTag.metaValuePostCategory.metaValue(以及它们的 metaValues 字段)获取元值时。这是因为 WordPress 函数(get_post_metaget_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 服务。


订阅我们的新闻通讯

及时了解 Gato GraphQL 的所有更新。