云计算百科
云计算领域专业知识百科平台

使用 Prisma 与 Prisma Bindings 构建实时 GraphQL 服务器:Subscriptions 实战指南

  • 后端
  • 数据库
  • GraphQL

【免费下载链接】prisma1

💾 Database Tools incl. ORM, Migrations and Admin UI (Postgres, MySQL & MongoDB) [deprecated]

项目地址:
https://gitcode.com/gh_mirrors/pr/prisma1

点击查看 免费下载

本指南基于 Prisma 官方教程《Build a Realtime GraphQL Server with Subscriptions》(原文档位于 docs/1.10/03-Tutorials2/02-Build-GraphQL-Servers/01-Development/03-Build-a-Realtime-GraphQL-Server-with-Subscriptions.md),完整讲解如何借助 Prisma 的 Subscription API 与 Prisma bindings 的封装,在 GraphQL 应用层快速实现两类实时订阅(新文章创建/标题更新、文章删除)。读完本文,你将掌握 Prisma 自动生成的订阅 Schema(Subscription 类型、SubscriptionWhereInput、SubscriptionPayload)的完整字段语义、subscribe/resolve 两级订阅解析器的编写方法,以及基于 WebSocket 的长连接推送的底层原理。

目录

  • Overview:为什么订阅是 GraphQL 实时能力的基石
  • 1. 项目搭建
  • 2. 理解 Prisma 的订阅 API
  • 3. 添加 publications 订阅
  • 4. 添加 postDeleted 订阅
  • 5. 深入底层:仓库中的实现证据
  • 总结

Overview:为什么订阅是 GraphQL 实时能力的基石

GraphQL 订阅(Subscriptions)最便捷的特性之一,是它与查询(Query)、变更(Mutation)使用完全相同的语法。对客户端而言,学习订阅几乎零成本。

订阅与查询/变更的本质区别在于执行模型:

  • 查询和变更遵循典型的“请求-响应”循环,与普通 HTTP 请求无异——客户端发出请求,服务器计算并一次性返回结果;
  • 订阅则不同:当 GraphQL 服务器收到订阅请求后,它并不会立即返回数据,而是与发起请求的客户端建立一条长连接(long-lived connection),客户端借此表达对某个事件(例如某个用户点赞了一张图片)的持续关注。

例如,下面这条订阅表达了“当 userId 对应的用户点赞图片时,向我推送图片的 URL 与用户名”:

subscription($userId: ID!) {
likeCreated(userId: $userId) {
user {
name
}
picture {
url
}
}
}

当事件真正发生时,服务器通过连接将请求的数据推送给订阅客户端:

{
"data": {
"likeCreated": {
"user": {
"name": "Alice"
},
"picture": {
"url": "https://media.giphy.com/media/5r5J4JD9miis/giphy.gif"
}
}
}
}

用 WebSocket 实现订阅

订阅通常基于 WebSocket 实现。除了实时逻辑(通常由 pub/sub 系统承担),你还需要实现 GraphQL 订阅的官方通信协议。只有当服务器严格遵循协议定义的流程时,客户端才能正确发起订阅请求并接收事件数据。

同时处理实时逻辑、pub/sub 系统、数据库访问以及订阅协议,复杂度会迅速上升;认证与授权逻辑还会进一步增加实现难度。此时,使用合适的抽象层会大幅简化工作——Prisma 与 Prisma bindings 的组合正是这样一层抽象:可以把它理解为一层“GraphQL ORM”,实时订阅开箱即用,让你轻松为 API 添加订阅能力。

1. 项目搭建

1.1 下载并探索 starter 项目

本教程配套一个 starter 项目(subscriptions 仓库)。使用以下命令下载并安装依赖:

curl https://codeload.github.com/nikolasburk/subscriptions/tar.gz/starter | tar -xz subscriptions-starter
cd subscriptions-starter
yarn install # or npm install

该项目包含一个非常简单的 GraphQL API,Schema 如下:

# import Post from "./generated/prisma.graphql"

type Query {
feed: [Post!]!
}

type Mutation {
writePost(title: String!): Post
updateTitle(id: ID!, newTitle: String!): Post
deletePost(id: ID!): Post
}

Post 类型由 Prisma 数据模型(database/datamodel.graphql)定义:

type Post {
id: ID! @unique
title: String!
}

本项目的目标是为 API 添加两条订阅:

  • 一条订阅:当新的 Post 被创建,或已有 Post 的 title 被更新时触发;
  • 一条订阅:当已有 Post 被删除时触发。

1.2 部署 Prisma 数据库 API

在启动服务器之前,需要先确保 Prisma 数据库 API 可用,且能被你的 GraphQL 服务器(通过 Prisma bindings)访问。

在 subscriptions-starter 目录下运行:

yarn prisma deploy

CLI 会询问一系列关于“如何部署 API”的问题。本教程选择 Demo server 选项,然后直接按 Enter 接受 service name 与 stage 的推荐值(如果你装有 Docker,也可以选择本地部署)。

部署完成后,CLI 会打印 Prisma 数据库 API 的 HTTP endpoint。复制该 endpoint,粘贴到 index.js 中实例化 GraphQLServer 的位置——注意需要用真实 endpoint 替换占位符 __PRISMA_ENDPOINT__。完成后代码大致如下:

const server = new GraphQLServer({
typeDefs: './src/schema.graphql',
resolvers,
context: req => ({
…req,
db: new Prisma({
typeDefs: 'src/generated/prisma.graphql',
endpoint: 'https://eu1.prisma.sh/jane-doe/subscriptions-example/dev',
secret: 'mysecret123',
debug: true,
}),
}),
})

1.3 打开 GraphQL Playground

运行 yarn dev 启动服务器并打开 GraphQL Playground。你可以自由探索项目,并发送一些查询与变更。

Note:Playground 会展示 .graphqlconfig.yml 中定义的两个 GraphQL API:app 项目代表应用层,由 /src/schema.graphql 定义;database 项目代表数据库层,由自动生成的 Prisma GraphQL Schema(/src/generated/prisma.graphql)定义。

2. 理解 Prisma 的订阅 API

2.1 概览

在动手实现订阅之前,先花一点时间理解 Prisma 提供的订阅 API——因为接下来正是要借力(piggyback)这个 API 来实现应用层的订阅。

一般来说,Prisma 允许你对数据模型中的每种类型订阅三类事件。以 Post 为例:

  • 一个新的 Post 被创建
  • 一个已有的 Post 被更新
  • 一个已有的 Post 被删除

对应的 Subscription 类型定义如下(可在 /src/generated/prisma.graphql 中找到):

type Subscription {
post(where: PostSubscriptionWhereInput): PostSubscriptionPayload
}

如果不用 where 参数进一步约束,post 订阅会对上述三类事件全部触发。

2.2 过滤特定事件

where 参数允许客户端精确指定自己关心的事件。比如:某个客户端只想在 Post 被删除时收到通知;或者只想在 title 包含特定关键词的 Post 被创建时收到通知。这些约束都可以用 where 参数表达。

where 的类型定义如下:

input PostSubscriptionWhereInput {
# Filter for a specific mutation:
# CREATED, UPDATED, DELETED
mutation_in: [MutationType!]

# Filter for a specific field being updated
updatedFields_contains: String
updatedFields_contains_every: [String!]
updatedFields_contains_some: [String!]

# Filter for concrete values of the Post being mutated
node: PostWhereInput

# Combine several filter conditions
AND: [PostSubscriptionWhereInput!]
OR: [PostSubscriptionWhereInput!]
}

上面提到的两个例子,可以在 Prisma API 中用以下订阅表达:

# 仅在被_删除_的 Post 上触发
subscription {
post(where: {
mutation_in: [DELETED]
}) {
# … 选择集稍后讨论
}
}

# 仅在 title 包含 "GraphQL" 的 Post 被_创建_时触发
subscription {
post(where: {
mutation_in: [CREATED]
node: {
title_contains: "GraphQL"
}
}) {
# … 选择集稍后讨论
}
}

2.3 探索订阅的选择集

PostSubscriptionPayload 类型定义了 post 订阅中可以请求的字段:

type PostSubscriptionPayload {
mutation: MutationType!
node: Post
updatedFields: [String!]
previousValues: PostPreviousValues
}

2.3.1 mutation: MutationType!

MutationType 是一个包含三个值的枚举:

enum MutationType {
CREATED
UPDATED
DELETED
}

PostSubscriptionPayload 上的 mutation 字段因此携带了“发生了什么类型的变更”这一信息。

2.3.2 node: Post

该字段表示被创建、更新或删除的 Post 元素,允许你检索关于它的更多信息。

注意:对于 DELETED 变更,node 始终为 null。如果你需要了解被删除 Post 的更多细节,可以改用 previousValues 字段。

Note:GraphQL 中有时用 node 指代单个元素。一个 node 本质上对应数据库中的一条记录(record)。

2.3.3 updatedFields: [String!]

对于 UPDATED 变更,你可能关心的是“哪些字段被更新了”——这正是 updatedFields 的用途。

假设某客户端用以下订阅订阅了 Prisma API:

subscription {
post {
updatedFields
}
}

现在,假设服务器收到如下变更,更新某个 Post 的 title:

mutation {
updatePost(
where: {
id: "…"
}
data: {
title: "Prisma is the best way to build GraphQL servers"
}
) {
id
}
}

订阅客户端将收到如下载荷:

{
"data": {
"post": {
"updatedFields": ["title"]
}
}
}

这是因为该变更只更新了 Post 的 title 字段——没有别的。

2.3.4 previousValues: PostPreviousValues

PostPreviousValues 类型与 Post 本身非常相似:

type PostPreviousValues {
id: ID!
title: String!
}

它本质上是一个辅助类型,仅仅镜像 Post 的字段。

previousValues 只用于 UPDATED 和 DELETED 变更。对于 CREATED 变更,它始终为 null(原因与 DELETED 变更时 node 为 null 相同——事件发生时该记录还不存在)。

2.3.5 综合示例

再次考虑 2.3.3 节中的 updatePost 变更,但假设订阅查询包含上面讨论的所有字段:

subscription {
post {
mutation
updatedFields
node {
title
}
previousValues {
title
}
}
}

服务器执行上述变更后,推送给客户端的载荷如下:

{
"data": {
"post": {
"mutation": "UPDATED",
"updatedFields": ["title"],
"node": {
"title": "Prisma is the best way to build GraphQL servers",
},
"previousValues": {
"title": "GraphQL servers are best built with conventional ORMs",
}
}
}
}

注意,这里假设被更新的 Post 在执行变更前的 title 是 “GraphQL servers are best built with conventional ORMs”。

3. 添加 publications 订阅

掌握了 Prisma 的订阅 API 之后,就可以消费这套 API 来在应用层实现自己的订阅了。先从“新 Post 被创建或已有 Post 的 title 被更新”这条订阅开始。

3.1 扩展应用 Schema

第一步是扩展应用层的 GraphQL Schema,添加对应的订阅定义。

打开 schema.graphql,添加如下 Subscription 类型:

type Subscription {
publications: PostSubscriptionPayload
}

这里引用的 PostSubscriptionPayload 直接取自 Prisma GraphQL Schema,因此还需要在文件顶部导入它:

# import Post, PostSubscriptionPayload from "./generated/prisma.graphql"

Note:这种基于注释的导入语法由 graphql-import 包提供。截至目前,GraphQL SDL 还没有官方的跨文件类型导入方式。

3.2 实现订阅解析器

与查询、变更类似,添加新 API 功能的下一步是实现对应的解析器(resolver)。不过,订阅的解析器有些不同。

订阅解析器不再只是 Schema 定义中一个单一的解析函数,而是提供一个对象,其中至少包含一个名为 subscribe 的字段。subscribe 是一个函数,返回一个 AsyncIterator,用于为每个独立事件产出值。此外,还可以提供一个 resolve 字段——下一节会讨论它,现在先聚焦 subscribe。

更新 index.js 中的 resolvers 对象,加入 Subscription:

const resolvers = {
Query: {
// … 同前
},
Mutation: {
// … 同前
},
Subscription: {
publications: {
subscribe: (parent, args, ctx, info) => {
return ctx.db.subscription.post(
{
where: {
mutation_in: ['CREATED', 'UPDATED'],
},
},
info,
)
},
},
},
}

这里 Prisma bindings 帮你完成了繁重的工作:db.subscription.post(…) 返回的 AsyncIterator 会在 Post 类型上的每个事件发生时产出一个新值。

注意,这里特意过滤了 CREATED 和 UPDATED 变更,确保 publications 订阅只在这两类事件上触发。

3.3 测试订阅

测试订阅需要先启动服务器并打开 Playground——运行 yarn dev 即可。

在 Playground 中运行如下订阅:

subscription {
publications {
node {
id
title
}
}
}

Note:GraphQL Playground 有时会表现出一个已知 bug,即订阅直接返回 null 载荷。如果遇到这种情况,可以尝试该 issue 中提供的变通方案。

订阅运行后,响应面板会出现加载指示器,Play 按钮会变成红色 Stop 按钮,用于停止订阅。

此时可以再开一个标签页,发送变更来触发订阅:

mutation {
writePost(title: "GraphQL subscriptions are awesome") {
id
}
}

切回最初的标签页,你会看到订阅数据已经出现在响应面板中。还可以用 updateTitle 变更继续体验。

4. 添加 postDeleted 订阅

本节实现一个“每当 Post 被删除时触发”的订阅。流程与 publications 解析器大体相似,区别在于:这次将只返回被删除的 Post,而不是 PostSubscriptionPayload 类型的对象。

4.1 扩展应用 Schema

与往常一样,为 GraphQL API 添加新功能的第一步,是在 Schema 中把新操作表达为根字段(root field)。

打开 /src/schema.graphql,将 Subscription 类型调整为:

type Subscription {
publications: PostSubscriptionPayload
postDeleted: Post
}

对于 postDeleted,不再返回 PostSubscriptionPayload,而是直接返回被删除的 Post 对象。

4.2 实现订阅解析器

在 3.2 节中曾提到:实现订阅解析器的对象可以持有第二个函数 resolve(subscribe 是必须的)。本节将实际使用它。

下面是解析 postDeleted 订阅时 subscribe 与 resolve 的实现:

const resolvers = {
Query: {
// … 同前
},
Mutation: {
// … 同前
},
Subscription: {
publications: {
// … 同前
},
postDeleted: {
subscribe: (parent, args, ctx, info) => {
const selectionSet = `{ previousValues { id title } }`
return ctx.db.subscription.post(
{
where: {
mutation_in: ['DELETED'],
},
},
selectionSet,
)
},
resolve: (payload, args, context, info) => {
return payload ? payload.post.previousValues : payload
},
},
},
}

理解 subscribe 与 resolve 组合的关键在于:subscribe 返回的 AsyncIterator 产出的值,会作为 payload 参数传入 resolve!这意味着你可以用 resolve 按需转换和/或过滤 AsyncIterator 发出的事件数据。

注意,这里给 post 绑定函数传入的是一个硬编码的选择集,而不是像大多数情况那样传入 info 对象。因此,这次绑定函数的调用等价于向 Prisma API 发起如下订阅请求:

subscription {
post {
previousValues {
id
title
}
}
}

info 对象携带的是传入 GraphQL 操作(查询、变更、订阅皆然)的 AST(从而包含选择集)。但在这里,传入的选择集无法直接套用到 Prisma API 的 post 订阅上,原因如下:

  • 传入订阅的返回类型只是 Post(如你在 schema.graphql 中所定义);
  • Prisma GraphQL API 的 post 订阅返回类型是 PostSubscriptionPayload。

也就是说,传入的 info 对象与 post 订阅所需的结构不匹配,因此需要以字符串形式手动指定 post 订阅的选择集。

Note:这一点初看有些绕。如果一时难以理解,可以阅读这篇关于 info 对象及其在 GraphQL 解析器中作用的技术深潜文章。

坦白说,这种方式也并不完美:对于字段很多的类型,硬编码选择集会很快失控;另外,传入的订阅可能并不会请求类型的所有字段,此时就会发生过度获取(overfetching)。更优的方案是手动从 info 对象中提取被请求的字段,再传给 post 订阅(相关讨论见 graphql-binding 的 issue)。

无论如何,通过硬编码选择集,可以保证传入 resolve 的 payload 参数具有如下结构:

{
"post": {
"previousValues": {
"id": "…",
"title": "…",
}
}
}

正因如此,在 resolve 内部只需返回 payload.post.previousValues,得到的就是一个符合 Post 类型结构的对象。(注意:用三元运算符检查 payload 只是为了确保它不是 undefined,因为 undefined 可能破坏订阅。)

4.3 测试订阅

测试新订阅前需要重启服务器,确保改动生效:按 CTRL+C 终止服务器,再用 yarn dev 重启。

订阅运行后,发送如下变更(把 __POST_ID__ 占位符替换为数据库中实际 Post 的 id):

mutation {
deletePost(id: "__POST_ID__") {
id
}
}

切回订阅标签页,你会看到 id 与 title 已按活动订阅请求的内容被推送到响应面板。

5. 深入底层:仓库中的实现证据

应用层的这些 API 与行为,在当前仓库(Prisma 1.x)的源码中都能找到对应的实现证据。本教程展示的 Prisma bindings 客户端(db.subscription.post(…))与 Prisma 服务端的订阅子系统,构成了“应用层绑定 → WebSocket 协议 → pub/sub 事件 → 数据库事件解析 → 载荷回推”的完整链路。

5.1 客户端:订阅如何通过 WebSocket 传输

Prisma 客户端(Prisma bindings 所依赖的底层)在 cli/packages/prisma-client-lib/src/Client.ts 中实现了订阅的传输逻辑:

  • 构造 SubscriptionClient 时,将 HTTP endpoint 替换为 WebSocket 协议:endpoint.replace(/^http/, 'ws'),并配置 lazy: true、reconnect: true 以及 60 秒的 inactivityTimeout(Client.ts 第 89-100 行);
  • 当操作类型为 subscription 时,execute 通过 this._subscriptionClient.request({ query, variables }) 发起订阅,并把返回的可观察对象转换为 AsyncIterator(Client.ts 第 227-237 行)——这正是文档 3.2 节所说“db.subscription.post(…) 返回 AsyncIterator”的代码来源;
  • 每个事件的载荷还会经过 mapSubscriptionPayload / extractPayload 处理:沿对象键逐层解包,剥去 { __typename } 之类的元数据,得到纯净的订阅载荷(Client.ts 第 164-225 行)。

可见,应用层解析器拿到的 AsyncIterator 和事件值,是 Prisma bindings 在客户端完成 WebSocket 通信、协议封装与载荷解包后的结果。

5.2 服务端:订阅子系统与消息通道

仓库的 Scala 服务端包含一个完整的订阅子系统(server/servers/subscriptions),用于支撑 Prisma API 的订阅能力:

  • 协议层:SubscriptionProtocol 实现了两种 GraphQL 订阅通信协议——graphql-ws(v0.7,含 connection_init、start、data、complete 等消息类型)与 graphql-subscriptions(v0.5,含 init、subscription_start、subscription_data 等消息类型),见 protocol/SubscriptionProtocol.scala;
  • 分发层:SubscriptionsManager 按项目维护订阅管理器,SubscriptionsManagerForModel 则针对每个数据模型管理活动订阅。它按“查询文本 + 变量”对相同订阅分组,只执行一次数据库事件处理并复用结果广播给多个订阅者(resolving/SubscriptionsManagerForModel.scala 第 117-158 行);
  • 事件通道:MutationChannelUtil 为每个模型的创建/更新/删除分别构造频道名 createPost、updatePost、deletePost,并以 subscription:event:$projectId:$channel 的形式订阅 pub/sub 消息(resolving/MutationChannelUtil.scala);
  • 载荷构造:SubscriptionResolver 将数据库事件解析为 DatabaseCreateEvent / DatabaseUpdateEvent / DatabaseDeleteEvent,其中更新事件携带 changedFields(对应 updatedFields)与 previousValues,删除事件携带删除前的 node 作为 previousValues 的数据来源(resolving/DatabaseEvents.scala 与 resolving/SubscriptionResolver.scala)。注意服务端对更新/删除事件构造 previousValues、对创建事件不构造,这与文档 2.3 节“previousValues 对 CREATED 恒为 null、node 对 DELETED 恒为 null”的语义完全对应。

5.3 Schema 生成器:订阅类型如何被自动生成

Prisma 的 GraphQL Schema 生成器(cli/packages/prisma-generate-schema)负责为每个数据模型生成订阅相关的 SDL 类型,这解释了 /src/generated/prisma.graphql 中 Subscription、PostSubscriptionPayload、PostSubscriptionWhereInput 的来源:

  • subscriptionGenerator.ts 为每个模型生成形如 post(where: PostSubscriptionWhereInput): PostSubscriptionPayload 的根订阅字段(src/generator/default/subscription/subscriptionGenerator.ts);
  • modelSubscriptionPayloadGenerator.ts 生成 mutation、node、updatedFields,并在模型含标量字段时附加 previousValues(src/generator/default/subscription/modelSubscriptionPayloadGenerator.ts);
  • modelSubscriptionWhereInputGenerator.ts 生成 mutation_in、updatedFields_contains、updatedFields_contains_every、updatedFields_contains_some、node 以及 AND/OR/NOT 逻辑组合(src/generator/default/subscription/modelSubscriptionWhereInputGenerator.ts)。

生成结果的实测样例可以查看生成器自带的黑盒测试 fixture,例如 cli/packages/prisma-generate-schema/tests/blackbox/cases/defaultValue/document.graphql 中的 ASubscriptionWhereInput(第 255-277 行)与 ASubscriptionPayload(第 248-253 行),它们与文档中 PostSubscriptionWhereInput / PostSubscriptionPayload 的结构一一对应。

总结

在本教程中,你学会了如何使用 Prisma 与 Prisma bindings 为 GraphQL API 添加实时订阅能力。

与使用 Prisma 实现查询和变更类似,你是在“借力” Prisma 的 GraphQL API,把数据库访问与 pub/sub 逻辑的重活留给 Prisma 查询引擎承担。通过本文你应该掌握了:

  • Prisma 订阅 API 的三类事件(CREATED / UPDATED / DELETED)与 SubscriptionWhereInput 的过滤语义(mutation_in、updatedFields_*、node、AND/OR);
  • SubscriptionPayload 中 mutation、node、updatedFields、previousValues 四个字段各自的取值规则;
  • 应用层订阅解析器中 subscribe(返回 AsyncIterator)与 resolve(转换/过滤事件载荷)的分工与配合;
  • 订阅事件从数据库变更、pub/sub 频道到 WebSocket 推送的完整链路在仓库源码中的落地位置。

赞

分享

  • 后端
  • 数据库
  • GraphQL

【免费下载链接】prisma1

💾 Database Tools incl. ORM, Migrations and Admin UI (Postgres, MySQL & MongoDB) [deprecated]

项目地址:
https://gitcode.com/gh_mirrors/pr/prisma1

点击查看 免费下载

上一篇:
Mac运行Windows软件的终极优化方案:CXPatcher技术深度解析

下一篇:
MediaPipe背景分割终极指南:从模型选型到实战部署

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

赞(0)
未经允许不得转载:网硕互联帮助中心 » 使用 Prisma 与 Prisma Bindings 构建实时 GraphQL 服务器:Subscriptions 实战指南
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!