什么是 GraphQL 订阅?

GraphQL 订阅允许客户端订阅更改。客户端可以实时接收更新,而不是轮询更改。这是我们的GraphQL Federation Demo中的一个简单示例:

subscription {
    updatedPrice: federated_updatedPrice {
        upc
        name
        price
        reviews {
            id
            body
            author {
                id
                name
            }
        }
    }
}

这是基于阿波罗联盟。一旦“产品”微服务有价格更新,WunderGraph 将数据与来自“评论”微服务的评论连接起来,与来自“用户”微服务的一些用户信息进行额外连接,并将数据发送回客户端。客户端将其作为数据流获取。这样,用户界面可以实时更新。

实现 GraphQL 订阅的传统方式

实现 GraphQL 订阅最广泛采用的方式是使用 WebSockets。 WebSocket API 是一种 HTTP 1.1 标准,通常被所有现代浏览器支持。 (根据caniuse.com,94.22% 的浏览器支持 WebSockets API)

首先,客户端发送 HTTP 升级请求,要求服务器将连接升级为 WebSocket。一旦服务器升级连接,客户端和服务器都可以通过 WebSocket 传递消息来发送和接收数据。现在让我们讨论 WebSocket 的问题

WebSocket API 是 HTTP 1.1 标准

现在大多数网站都使用 HTTP/2 甚至 HTTP/3 来加速网络。 HTTP/2 允许通过单个 TCP 连接多路复用多个请求。这意味着客户端可以同时发送多个请求。 HTTP/3 进一步改进了这一点,但这不是本文的重点。

问题在于,如果您的网站同时使用 HTTP/1.1 和 HTTP/2,则客户端将不得不打开到服务器的多个 TCP 连接。客户端可以通过单个 TCP 连接轻松多路复用多达 100 个 HTTP/2 请求,而使用 WebSockets,您必须为每个 WebSocket 打开一个新的 TCP 连接。如果用户打开您网站的多个选项卡,每个选项卡都会打开一个到服务器的新 TCP 连接。使用 HTTP/2,多个选项卡可以共享同一个 TCP 连接。因此,WebSockets 的第一个问题是它使用了一个过时且未维护的协议,这会导致额外的 TCP 连接。

WebSockets 是有状态的

WebSockets 的另一个问题是客户端和服务器必须跟踪连接的状态。如果我们看一下 REST 的原理,其中之一声明请求应该是无状态的。在这种情况下,无状态意味着每个请求都应包含能够处理它所需的所有信息。让我们看一些场景如何将 GraphQL 订阅与 WebSocket 一起使用:

1.发送带有升级请求的授权标头

正如我们在上面了解到的,每个 WebSocket 连接都以 HTTP 升级请求开始。如果我们在升级请求中发送授权标头怎么办?这是可能的,但这也意味着当我们使用 WebSocket 消息“订阅”时,“订阅”不再是无状态的,因为它依赖于我们之前发送的 Authorization 标头。如果用户在此期间注销,但我们忘记关闭 WebSocket 连接怎么办?

这种方法的另一个问题是 WebSocket 浏览器 API 不允许我们在升级请求上设置标头。这只能使用自定义 WebSocket 客户端。

所以,在现实中,这种实现 GraphQL 订阅的方式并不是很实用。

2.使用“connection_init”WebSocket 消息发送 Auth Token

另一种方法是使用“connection_init”WebSocket 消息发送 Auth Token。这是 Reddit 的做法。如果您访问reddit.com,打开 Chrome DevTools,单击网络选项卡并过滤“ws”。您将看到一个 WebSocket 连接,其中客户端发送一个带有“connection_init”消息的 Bearer 令牌。这种方法也是有状态的。您可以复制此令牌并使用任何其他 WebSocket 客户端订阅 GraphQL 订阅。然后,您可以在不关闭 WebSocket 连接的情况下在网站上注销。随后的订阅消息也将依赖于由初始“connection_init”消息设置的上下文,只是为了强调它仍然是有状态的这一事实。也就是说,这种方法存在更大的问题。

如您所见,客户端发送了一个带有“connection_init”消息的 Bearer 令牌。这意味着在某个时间点,客户端可以访问所述令牌。因此,在浏览器中运行的 JavaScript 可以访问令牌。过去,我们遇到过许多问题,即广泛使用的 npm 包被恶意代码污染。授予 Web 应用程序的 JavaScript 部分访问 Bearer 令牌可能会导致安全问题。更好的解决方案是始终将此类令牌保存在安全位置,我们稍后会谈到这一点。

3.使用“订阅”WebSocket 消息发送身份验证令牌

另一种方法是发送带有“订阅”WebSocket 消息的身份验证令牌。这将使我们的 GraphQL 订阅再次无状态,因为处理请求的所有信息都包含在“订阅”消息中。但是,这种方法会产生许多其他问题。

首先,这意味着我们必须允许客户端匿名打开 WebSocket 连接而不检查它们是谁。由于我们想让我们的 GraphQL 订阅保持无状态,我们第一次发送授权令牌将是我们发送“订阅”消息时。

如果数以百万计的客户端打开 WebSocket 连接到您的 GraphQL 服务器而不发送“订阅”消息会发生什么?

升级 WebSocket 连接可能非常昂贵,而且您还必须拥有 CPU 和内存来保持连接。什么时候应该切断“恶意”WebSocket 连接?如果你有误报怎么办?这种方法的另一个问题是,您或多或少地重新发明了基于 WebSocket 的 HTTP。如果您使用“订阅”消息发送“授权元数据”,那么您实际上是在重新实现 HTTP 标头。为什么不直接使用 HTTP 呢?稍后我们将讨论更好的方法(SSE/Fetch)。

WebSockets 允许双向通信

WebSockets 的下一个问题是它们允许双向通信。客户端可以向服务器发送任意消息。如果我们重新审视 GraphQL 规范,我们会发现实现订阅不需要双向通信。客户订阅一次。

之后,只有服务器向客户端发送消息。如果您使用允许客户端向服务器发送任意消息的协议(WebSockets),您必须以某种方式限制客户端可以发送的数量。如果恶意客户端向服务器发送大量消息怎么办?服务器在解析和关闭消息时通常会花费 CPU 时间和内存。使用拒绝客户端向服务器发送任意消息的协议不是更好吗?

WebSockets 不适合 SSR(服务器端渲染)

我们面临的另一个问题是在进行 SSR(服务器端渲染)时 WebSocket 的可用性。

我们最近解决的问题之一是允许使用 GraphQL 订阅进行“通用渲染”(SSR)。我们一直在寻找一种能够在服务器和浏览器中呈现 GraphQL 订阅的好方法。你为什么想做这个?想象一下,您建立了一个网站,该网站应始终显示股票或文章的最新价格。您肯定希望网站(接近)实时,但出于 SEO 和可用性原因,您还希望在服务器上呈现内容。

这是我们的GraphQL Federation 演示的示例:

const UniversalSubscriptions = () => {
    const priceUpdate = useSubscription.PriceUpdates();
    return (
        <div>
            <h1>Price Updates</h1>
            <ul>
                {priceUpdate.map(price => (
                    <li key={price.id}>
                        {price.product} - {price.price}
                    </li>
                ))}
            </ul>
        </div>
    )
}

export default withWunderGraph(UniversalSubscriptions);

这个(NextJS)页面首先在服务器上呈现,然后在客户端上重新水合,继续订阅。我们稍后会更详细地讨论这个问题,让我们首先关注 WebSockets 的挑战。如果服务器必须呈现此页面,它必须首先启动到 GraphQL 订阅服务器的 WebSocket 连接。然后它必须等到从服务器收到第一条消息。只有这样,它才能继续渲染页面。

虽然技术上可行,但没有简单的“异步等待”API 来解决这个问题,因此没有人真正这样做,因为它太昂贵、不健壮且难以实现。

WebSockets 上的 GraphQL 订阅问题总结

  • WebSockets 使您的 GraphQL 订阅有状态

  • WebSockets 导致浏览器回退到 HTTP/1.1

  • WebSockets 通过将 Auth Tokens 暴露给客户端导致安全问题

  • WebSockets 允许双向通信

  • WebSockets 不适合 SSR(服务器端渲染)

总结上一节,基于 WebSocket 的 GraphQL 订阅会导致性能、安全性和可用性方面的一些问题。如果我们正在为现代网络构建工具,我们应该考虑更好的解决方案。

为什么我们选择 SSE (Server-Sent Events) / Fetch 来实现 GraphQL 订阅

让我们一一解决问题并讨论我们是如何解决的。

请记住,我们选择的方法只有在您使用“GraphQL Operation Compiler”时才有可能。默认情况下,GraphQL 客户端必须将所有信息发送到服务器才能启动 GraphQL 订阅。

多亏了我们的 GraphQL 操作编译器,我们处于一个独特的位置,它允许我们只将“操作名称”以及“变量”发送到服务器。这种方法使我们的 GraphQL API 更加安全,因为它将它隐藏在 JSON-RPC API 后面。您可以在此处查看示例,我们也即将开源该解决方案。那么,为什么我们选择 SSE(Server-Sent Events)/Fetch 来实现 GraphQL 订阅?

SSE(服务器发送事件)/Fetch 是无状态的

SSE 和 Fetch 都是无状态 API,非常易于使用。只需使用操作名称和变量作为查询参数发出 GET 请求。每个请求都包含启动订阅所需的所有信息。当浏览器与服务器通信时,它可以使用 SSE API,如果浏览器不支持 SSE,它可以回退到 Fetch API。

这是一个示例请求(获取):

curl http://localhost:9991/api/main/operations/PriceUpdates

响应如下所示:

{"data":{"updatedPrice":{"upc":"1","name":"Table","price":916,"reviews":[{"id":"1","body":"Love it!","author":{"id":"1","name":"Ada Lovelace"}},{"id":"4","body":"Prefer something else.","author":{"id":"2","name":"Alan Turing"}}]}}}

{"data":{"updatedPrice":{"upc":"1","name":"Table","price":423,"reviews":[{"id":"1","body":"Love it!","author":{"id":"1","name":"Ada Lovelace"}},{"id":"4","body":"Prefer something else.","author":{"id":"2","name":"Alan Turing"}}]}}}

它是一个 JSON 对象流,由两个换行符分隔。

或者,我们也可以使用 SSE API:

curl http://localhost:9991/api/main/operations/PriceUpdates?wg_sse=true

该响应看起来与 Fetch 响应非常相似,只是以“data”为前缀:

data: {"data":{"updatedPrice":{"upc":"2","name":"Couch","price":1000,"reviews":[{"id":"2","body":"Too expensive.","author":{"id":"1","name":"Ada Lovelace"}}]}}}

data: {"data":{"updatedPrice":{"upc":"1","name":"Table","price":351,"reviews":[{"id":"1","body":"Love it!","author":{"id":"1","name":"Ada Lovelace"}},{"id":"4","body":"Prefer something else.","author":{"id":"2","name":"Alan Turing"}}]}}}

SSE(服务器发送事件)/Fetch 可以利用 HTTP/2

SSE 和 Fetch 都可以利用 HTTP/2。实际上,当 HTTP/2 不可用时,您应该避免将 SSE/Fetch 用于 GraphQL 订阅,因为将其与 HTTP 1.1 一起使用会导致浏览器创建大量 TCP 连接,从而很快耗尽浏览器的最大并发 TCP 连接数可以同源开。将 SSE/Fetch 与 HTTP/2 结合使用意味着您可以获得一个现代的、易于使用的 API,而且速度也非常快。在极少数情况下您必须回退到 HTTP 1.1,您仍然可以使用 SSE/Fetch 思想。

SSE(服务器发送事件)/获取可以很容易地得到保护

我们已经实现了“令牌处理程序模式”以确保我们的 API 安全。 Token Handler Pattern 是一种在服务器上而不是在客户端上处理 Auth Tokens 的方式。

首先,您将用户重定向到身份提供者,例如钥匙斗篷。登录完成后,用户将使用身份验证代码重定向回“WunderGraph 服务器”。然后将此身份验证代码交换为令牌。在后台通道上交换令牌的身份验证代码,浏览器无法知道它。成功交换代码后,我们将创建一个安全、加密的仅 http cookie。

这意味着cookie的内容只能由服务器读取(加密)。浏览器的 JavaScript 代码无法访问或更改 cookie(仅限 http)。此 cookie 只能从第一方域访问(安全),因此只能在api.example.com或example.com上访问,但不能在foobar.com上访问。设置此 cookie 后,每个 SSE/Fetch 请求都会自动进行身份验证。如果用户退出,则 cookie 将被删除,并且无法再进行订阅。每个订阅请求始终包含启动订阅(无状态)所需的所有信息。与 Reddit 的方法相反,浏览器的 JavaScript 代码无法访问 Auth 令牌。

SSE (Server-Sent Events) / Fetch 不允许客户端发送任意数据

顾名思义,服务器发送事件 (SSE) 是一种用于将事件从服务器发送到客户端的 API。一旦启动,客户端就可以从服务器接收事件,但是这个通道不能用于回传。结合“令牌处理模式”,这意味着我们可以在读取 HTTP 标头后立即关闭请求。 Fetch API 也是如此,因为它与 SSE 非常相似。

Fetch 可以很容易地用于为 GraphQL 订阅实现 SSR(服务器端渲染)

我们通过 SSE/Fetch 实现订阅的核心部分是“HTTP Flusher”。在每个事件写入响应缓冲区后,我们必须“刷新”连接以将数据发送到客户端。为了支持服务器端渲染 (SSR),我们添加了一个非常简单的技巧。

在服务器上使用“PriceUpdates”API 时,我们将查询参数附加到 URL:

curl http://localhost:9991/api/main/operations/PriceUpdates?wg_sse=true&wg_subscribe_once=true

标志“wg_subscribe_once”告诉服务器只向客户端发送一个事件,然后关闭连接。因此,我们不是刷新连接然后等待下一个事件,而是简单地关闭它。

此外,如果未设置标志,我们仅发送以下标头:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

在“wg_subscribe_once”的情况下,我们只需省略这些标头并将内容类型设置为“application/json”。这样,node-fetch 在做服务端渲染时就可以轻松处理这个 API。

摘要

通过 SSE/Fetch 实现 GraphQL 订阅为我们提供了一个现代的、易于使用的 API,并且具有很高的可用性。它是高性能、安全的,并且允许我们为 GraphQL 订阅实现 SSR(服务器端渲染)。它非常简单,您甚至可以使用 curl 来使用它。

另一方面,WebSockets 在安全性和性能方面存在很多问题。

根据caniuse.com,93.76% 的浏览器支持 HTTP/2。94.65% 的浏览器支持 EventSource API (SSE)。93.62% 支持 Fetch。

我认为是时候从 WebSocket 迁移到 SSE/Fetch 以进行 GraphQL 订阅了。

如果你想获得一些灵感,这里有一个你可以在本地运行的演示:https://github.com/wundergraph/wundergraph-demo

我们也将很快开源我们的实现。如果您希望在准备就绪时收到通知,请使用您的电子邮件注册。您如何看待这种方法?你如何自己实现 GraphQL 订阅?加入我们的 Discord并分享您的想法!


还发布这里

Logo

React社区为您提供最前沿的新闻资讯和知识内容

更多推荐