踩坑:6年后为何不用GraphQL了?


GraphQL 是一项令人难以置信的技术,自从我在 2018 年首次开始将其投入生产以来,它就吸引了很多人的注意力。
在一大堆无类型的 JSON REST API 上构建了许多 React SPA 之后,我发现 GraphQL 是一股清新的空气。

然而,随着时间的推移,我有机会部署到更需要关注安全性性能可维护性等非功能性要求的环境中,我的观点发生了变化。在本文中,我想向您介绍为什么今天我不向大多数人推荐 GraphQL,以及我认为更好的替代方案。

安全性
从 GraphQL 诞生之初,很明显将查询语言暴露给不受信任的客户端会增加应用程序的攻击面。然而,需要考虑的攻击种类比我想象的还要多,缓解这些攻击是一项相当大的负担。以下是我多年来遇到的最糟糕的情况……


1、授权
这是 GraphQL 最广为人知的风险。
如果您向所有客户端公开一个完全自文档化的查询 API,您最好确保每个字段都针对当前用户进行了适当的授权,以适应正在获取该字段的上下文。最初授权对象似乎足够了,但这很快就会变得不够。

query {
  user(id: 321) {
    handle # 我可以查看用户的公开信息
    email # 我不应该因为可以查看用户名,就能看到他们的个人信息。
  }
  user(id: 123) {
    blockedUsers {
      # 有时我甚至不应该看到他们的公开信息,
      # 因为上下文很重要!
      handle
    }
  }
}

人们不禁要问,GraphQL 对“访问控制失效”升至OWASP 前 10 名中的第 1 名负有多大责任。

这里的一个缓解措施是通过与GraphQL 库的授权框架集成,使 API 默认安全。每个返回的对象和/或解析的字段,都会调用您的授权系统来确认当前用户是否具有访问权限。

将此与 REST 世界进行比较,一般来说,您会授权每个端点,这是一项小得多的任务。

2、速率限制
我刚刚针对一个非常受欢迎的网站的 GraphQL API 浏览器测试了此攻击:

query {
  __schema{
    types{
      __typename
      interfaces {
        possibleTypes {
          interfaces {
            possibleTypes {
              name
            }
          }
        }
      }
    }
  }
}


并在 10 秒后获得了 500 响应。我刚刚耗费了某人 10 秒的 CPU 时间来运行这个(删除空格)128 字节查询,而且它甚至不需要我登录。

这种攻击的常见缓解方法:

  • 估算解析数据结构schema中每个字段的复杂度,放弃超过某个最大复杂度值的查询
  • 捕捉运行查询的实际复杂度,并将其从按一定间隔重置的积分桶中提取出来

要正确计算复杂度是一件很复杂的事情。如果在执行前不知道返回列表字段的长度,计算就会变得特别棘手。您可以对这些字段的复杂性进行假设,但如果假设错误,最终可能会限制有效查询的速率或不限制无效查询的速率。

更糟糕的是,构成结构schema的图形通常包含循环。比方说,您运行一个博客,其中的每篇文章都有多个标签,您可以从中看到相关的文章。

type Article {
  title: String
  tags: [Tag]
}
type Tag {
  name: String
  relatedTags: [Tag]
}

在估算 Tag.relatedTags 的复杂度时,您可能会假设一篇文章永远不会有超过 5 个标签,因此将该字段的复杂度设为 5(或 5 * 其子字段的复杂度)。这里的问题是 Article.relatedTags 可以是它自己的子标签,因此您的估计不准确性会以指数形式增加。计算公式为 N^5 * 1:

query {
  tag(name: "security") {
    relatedTags {
      relatedTags {
        relatedTags {
          relatedTags {
            relatedTags { name }
          }
        }
      }
    }
  }
}

您预计复杂度为 5^5 = 3,125。如果攻击者能找到一篇有 10 个标签的文章,他们就能触发一个 "真实 "复杂度为 10^5 = 100_000 的查询,比预计的复杂度高 20 倍。

部分缓解措施是防止深度嵌套查询。不过,上面的示例表明,这并不是真正的防御措施,因为这并不是一个异常深的查询。GraphQL Ruby 的默认最大深度是 13,而这只是 7。

与 REST 端点的速率限制相比,后者的响应时间通常相当。在这种情况下,你所需要的只是一个桶式速率限制器,防止用户在所有端点上的请求超过每分钟 200 次。如果确实有速度较慢的端点(如 CSV 报告或 PDF 生成器),可以为其定义更严格的速率限制。使用某些 HTTP 中间件,这一点非常简单:

Rack::Attack.throttle('API v1', limit: 200, period: 60) do |req|
  if req.path =~ '/api/v1/'
    req.env['rack.session']['session_id']
  end
end

3、查询解析
在执行查询之前,首先要对其进行解析。我们曾经收到过一份笔试报告,证明有可能伪造出一个无效的查询字符串,导致服务器宕机。例如

query {
  __typename @a @b @c @d @e ... # imagine 1k+ more of these
}

这是一个语法上有效的查询,但对我们的结构schema来说是无效的。符合规范的服务器会对其进行解析,并开始生成包含数千个错误的错误响应,我们发现这些错误所消耗的内存是查询字符串本身的 2,000 倍。由于这种内存放大效应,仅仅限制有效负载的大小是不够的,因为你会遇到比最小的危险恶意查询还要大的有效查询。

如果你的服务器提供了一个概念,即在放弃解析之前最多会出现多少次错误,那么这种情况就可以得到缓解。如果没有,您就必须自行解决。目前还没有与这种严重程度相当的 REST 攻击。

性能
说到 GraphQL 的性能,人们经常会说它与 HTTP 缓存不兼容。就我个人而言,这并不是一个问题。对于 SaaS 应用程序来说,数据通常是高度用户特定的,提供陈旧的数据是不可接受的,所以我没有发现自己错过了响应缓存(或缓存失效导致的错误......)。

我发现自己在处理的主要性能问题是...

1、数据获取和 N+1 问题
我认为这个问题如今已被广泛理解。简而言之:如果字段解析器命中一个外部数据源(如 DB 或 HTTP API),并且它嵌套在一个包含 N 个项的列表中,那么它将执行这些调用 N 次。

这并不是 GraphQL 独有的问题,实际上,严格的 GraphQL 解析算法已经让大多数库共享了一种通用的解决方案:Dataloader 模式。

但 GraphQL 的独特之处在于,由于它是一种查询语言,当客户端修改查询时,如果后端没有任何变化,这就会成为一个问题。因此,我发现最终不得不在各处防御性地引入 Dataloader 抽象,以防将来客户端最终在列表上下文中获取字段。这需要编写和维护大量的模板。

与此同时,在 REST 中,我们通常可以将嵌套的 N+1 查询上传到控制器,我认为这种模式更容易理解:

class BlogsController < ApplicationController
  def index
    @latest_blogs = Blog.limit(25).includes(:author, :tags)
    render json: BlogSerializer.render(@latest_blogs)
  end

  def show
    # No prefetching necessary here since N=1
    @blog = Blog.find(params[:id])
    render json: BlogSerializer.render(@blog)
  end
end

2、授权和 N+1 问题
还有更多的 N+1!

如果你按照之前的建议与库包的授权框架集成,那么你现在就有了一个全新的 N+1 问题需要处理。让我们继续前面的 X API 示例:

class UserType < GraphQL::BaseObject
  field :handle, String
  field :birthday, authorize_with: :view_pii
end

class UserPolicy < ApplicationPolicy
  def view_pii?
    # 哦,不,我点击了数据库来获取用户的好友信息
    user.friends_with?(record)
  end
end
query {
  me {
    friends { # returns N Users
      handle
      birthday # runs UserPolicyview_pii? N times
    }
  }
}

这实际上比我们之前的例子更难处理,因为授权代码并不总是在 GraphQL 上下文中运行。例如,它可能在后台作业或 HTML 端点中运行。这意味着我们不能天真地使用 Dataloader,因为 Dataloader 需要在 GraphQL 中运行(无论如何,在 Ruby 实现中)。

根据我的经验,这实际上是性能问题的最大根源。我们经常会发现,我们的查询花费在授权数据上的时间比其他任何事情都多。同样,这个问题在 REST 世界中根本不存在。

我曾使用请求级全局等讨厌的方法来缓解这一问题,以便在策略调用中记忆缓存数据,但感觉并不好。

3、耦合
根据我的经验,在成熟的 GraphQL 代码库中,您的业务逻辑会被强制引入传输层。这是通过一系列机制实现的,其中一些我们已经讨论过:

  • 解决数据授权问题,在整个 GraphQL 类型中加入授权规则
  • 解决突变/参数授权问题,从而在整个 GraphQL 参数中加入授权规则
  • 解决解析器数据获取 N+1 的问题,从而将这一逻辑转移到 GraphQL 特定的数据加载器中
  • 利用(可爱的)中继连接模式,将数据获取逻辑转移到 GraphQL 特定的自定义连接对象中

所有这一切的最终结果是,要对应用程序进行有意义的测试,就必须在集成层进行广泛的测试,即运行 GraphQL 查询。我发现这样做会带来痛苦的体验。遇到的任何错误都会被框架捕获,从而导致阅读 JSON GraphQL 错误响应中的堆栈跟踪这一有趣的任务。

由于授权和 Dataloaders 的许多工作都是在框架内完成的,因此调试通常要困难得多,因为您想要的断点并不在应用程序代码中。

当然,同样,由于这是一种查询语言,您需要编写更多的测试来确认我们提到的所有参数和字段级别的行为是否正常工作。

复杂性
总的来说,我们所讨论的各种安全和性能问题的缓解措施都会大大增加代码库的复杂性。并不是说 REST 就没有这些问题(虽然它的问题肯定要少一些),只是 REST 解决方案对于后端开发人员来说,实施和理解起来通常要简单得多。

总结主要原因
以上就是我不喜欢 GraphQL 的主要原因。我还有一些其他的憎恶,但为了让这篇文章继续下去,我将在这里总结一下。

  • GraphQL 不鼓励破坏性更改,也不提供处理这些更改的工具。这就为那些控制着所有客户端的人增加了不必要的复杂性,他们不得不寻找变通办法。
  • 对 HTTP 响应代码的依赖在工具中随处可见,因此处理 200 可能意味着从一切正常到一切宕机的所有情况,这可能会相当恼人。
  • 在 HTTP 2+ 时代,在一次查询中获取所有数据往往不利于缩短响应时间,事实上,如果服务器没有并行化,与向不同服务器发送并行处理的请求相比,响应时间会更长。


替代方案
好了,废话少说。我有什么建议?如果符合下述条件:

  • 控制所有客户
  • 拥有 ≤ 3 个客户端
  • 有一个用静态类型语言编写的客户端
  • 在服务器和客户端上使用的语言>1 种2

您最好使用符合 OpenAPI 3.0+ 标准的 JSON REST API。

根据我的经验,如果您的前端开发人员喜欢 GraphQL 的主要原因是其自文档化的类型安全特性,那么我认为这将非常适合您。

自从 GraphQL 出现以来,这方面的工具已经有了很大改进;有很多生成类型化客户端代码的选项,甚至包括特定框架的数据获取库。

到目前为止,我的经验非常接近于 "我使用 GraphQL 的最佳部分,但没有 Facebook 所需的复杂性"。

与 GraphQL 一样,有几种实现方法...

1、首先,实现工具从类型化/类型提示服务器中生成 OpenAPI 规范。Python 中的  FastAPI和 TypeScript 中的  tsoa 就是这种方法的很好例子,这是我最有经验的方法,而且我认为它运行良好。

2、规范先行相当于 GraphQL 中的 "结构schema 先行"。规范先行工具会根据手写的规范生成代码。我不能说我曾经看着一个 OpenAPI YAML 文件,然后想 "我真想自己写这个",但最近发布的 TypeSpec 完全改变了一切。

有了 TypeSpec,就可以实现相当优雅的结构优先工作流程:

  1. 编写简洁易读的 TypeSpec 结构
  2. 从中生成 OpenAPI YAML 规范
  3. 为您选择的前端语言(如 TypeScript)生成静态类型的 API 客户端
  4. 为您的后端语言和服务器框架生成静态类型的服务器处理程序(例如,TypeScript + Express、Python + FastAPI、Go + Echo)
  5. 为处理程序编写可编译的实现,并确保其类型安全

这种方法不太成熟,但我认为大有可为。