与GraphQL兼容的ORM Prisma
这篇文章是GraphQL Advent Calendar 2020的第十篇文章。
上一篇文章是由@mtsmfm开发的Swift用graphql-codegen插件—graphql-codegen-swift-operations。
首先
我认为Prisma是一种用于实现GraphQL的客户端库,ORM(在Prisma1中还包括GraphQL服务器)而被广泛认知。但是,从Prisma的版本2开始(下文中将简称为Prisma),它将专注于ORM部分,并朝着与GraphQL无直接关系的方向发展。
然而,Prisma源自GraphQL,在很多方面具有高度兼容性。在本文中,我想介绍与GraphQL密切相关的功能。
首先,“Prisma”是什么?与之前相比有什么不同之处?如果对此感兴趣,你可以参考之前发布的资料。
在本文所述時,Prisma的版本如下:
– 2.13.0
数据加载器
在经常被提及的GraphQL问题之一是在数据库访问中容易发生N+1问题。
这是由于GraphQL查询是一个树状结构,需要递归执行与每个元素相对应的解析器,然后返回结果。
假设有如下所述的具体Schema定义。
type Query {
users: [User]!
}
type User {
email: String!
id: Int!
name: String
Posts: [Post!]!
Profile: Profile
}
type Post {
body: String!
id: Int!
published: Boolean!
title: String!
}
type Profile {
bio: String!
id: Int!
User: User!
}
用户的Resolver实现如下。
export const User = objectType({
name: 'User',
definition(t) {
t.nonNull.int('id'),
t.string('email'),
t.string('name'),
t.nonNull.list.field('posts', {
type: 'Post',
resolve(root, _args, ctx) {
return ctx.prisma.user.findUnique({ where: { id: root.id}}).Posts()
}
})
t.field('profile', {
type: 'Profile',
resolve: (root, _, ctx) => {
return ctx.prisma.user.findUnique({ where: { id: root.id }}).Profile()
}
})
}
})
対这个实现的直观理解是,在执行以下的GraphQL时,似乎会执行获取用户数的Post和Profile的处理。
{
users {
id
Posts {
id
title
published
}
Profile {
bio
}
}
}
然而,当我查看Prisma实际执行的SQL日志时,发现发出的SQL共有以下5个。
1. SELECT "public"."User"."id", "public"."User"."email", "public"."User"."name" FROM "public"."User" WHERE 1=1 OFFSET $1
2. SELECT "public"."User"."id" FROM "public"."User" WHERE "public"."User"."id" IN ($1,$2,$3) OFFSET $4
3. SELECT "public"."Post"."id", "public"."Post"."title", "public"."Post"."body", "public"."Post"."published", "public"."Post"."userId" FROM "public"."Post" WHERE "public"."Post"."userId" IN ($1,$2,$3) OFFSET $4
4. SELECT "public"."User"."id" FROM "public"."User" WHERE "public"."User"."id" IN ($1,$2,$3) OFFSET $4
5. SELECT "public"."Profile"."id", "public"."Profile"."bio", "public"."Profile"."userId" FROM "public"."Profile" WHERE "public"."Profile"."userId" IN ($1,$2,$3) OFFSET $4
因为Prisma已经支持dataloader的优化。
在使用Rails等框架实现REST API时,我认为使用Eager Loading(预加载)是常见的用于避免N+1问题的方法。
然而,在GraphQL中,正如前面提到的,由于Resolver会递归调用其他Resolver,因此在预加载时很难进行优化。
因此,在GraphQL中,延迟加载(Lazy Loading)是避免N+1问题的常用方法。
dataloader是一个实现延迟加载的模块,最初是Facebook在GraphQL发布时作为参考实现公开的库。
https://github.com/graphql/dataloader
从那里开始,广泛使用的提供惰性加载的模块通常被称为dataloader。
在Prisma Client的实现中,不是在指令Query被触发的瞬间立即执行Query,而是将其保存在内存中的缓存中,直到node的nextTick回调被触发。
当nextTick被触发时,请求会一次性发送到Prisma Engine(用Rust实现的查询引擎)。
一次性发送的查询会在Prisma Engine上被优化为SQL,并将其执行结果返回给Node(Prisma Client)。
实际上查看一下SQL,我们可以发现上述的5个查询包含以下步骤。
-
- 获取用户列表
-
- 检查用户ID是否存在(?)* 对于这个查询的必要性一开始并不明显。我想在有时间的时候再去实现它。
-
- 获取与用户相关的帖子
-
- 与第2点相同
- 获取与用户相关的个人资料
在每个查询中,由于测试时准备了3条用户数据,因此WHERE “public”.”Post”.”userId” IN ($1,$2,$3)的条件会出现。
根据所见,我们可以看出,查询次数不是根据事务数量增加,而是根据Resolver的数量增加。
您可以从以下链接中查看实际的实施情况。
-
- Prisma Client: https://github.com/prisma/prisma/blob/2.13.0/src/packages/client/src/runtime/Dataloader.ts#L22
- Prisma Engine: https://github.com/prisma/prisma-engines/blob/2.14.0-dev.13/query-engine/core/src/query_document/mod.rs#L56
可以利用 Eager Loading 进行优化,在实现时也可以使用 include field。
const result = await prisma.user.findUnique({
where: { id: 1 },
include: { posts: true },
})
这个查询中,我们获取了用户的列表,包括帖子的列表。
嵌套写入/读取
这个与之前提到的include类似,Prisma的许多API都可以对嵌套对象进行批量操作。
const user = await prisma.user.create({
data: {
email: 'alice@prisma.io',
profile: {
create: { bio: 'Hello World' },
},
},
})
在这个例子中,我们在创建用户的同时也创建了个人资料。
这个API与GraphQL的一个优点非常相配,就是能够通过整合查询来减少请求次数。
读取为以下内容
const users = await prisma.user.findMany({
include: {
posts: {
include: {
categories: {
include: {
posts: true,
},
},
},
},
},
})
基于光标的分页
在GraphQL中,通常使用被称为游标分页(Cursor-based)的方式。
并不是说它比Offset更优秀,而是适用的地方不同。具体来说,它适用于类似无限滚动的实现方式,可以从上次获取的结果中获取最新的差异。
相反,它不适用于像Google搜索结果那样根据页数进行获取。
我认为这些方面需要根据具体情况灵活使用。
這是從GraphQL公開初期開始,Relay就被採用為最佳實踐並實施,並逐漸普及開來。可以參考https://relay.dev/graphql/connections.htm。
在基于游标的分页中,通常需要使用first和after作为参数。
first用于获取数量,after用于指定一个称为游标的字符串,该字符串在上一次结果中为每个元素分配了一个唯一的标识符,并且结果将返回after之后的内容。
如果指定 friends(first:2 after:”5″),结果将从”5″后开始并返回2项。
在这种情况下,游标并不仅限于ID,根据条件可能还包含偏移/限制的信息。
由于格式取决于要求而容易变化,GraphQL官方建议在包含必要信息后通过base64编码转换为字符串。
https://graphql.org/learn/pagination/#pagination-and-edges
由于根据上述要求,需要考虑实现游标功能,所以不能直接使用,但Prisma作为官方API提供了简单的实现方式。
const secondQuery = prisma.post.findMany({
take: 4,
cursor: {
id: myCursor,
},
where: {
title: {
contains: 'Prisma' /* Optional filter */,
},
},
orderBy: {
id: 'asc',
},
})
要运行这个,有以下限制:
– 结果列表必须经过Cursor排序,而且Cursor必须是唯一的且连续的。
– 无法指定页数进行获取。
我认为它可能不能适应各种用例,因为它是一个简单的实施,但对于简单的用例来说,它在ORM中的实施给人一种安心感。
Nexus 内克苏斯
有一个名为Nexus的用于实现GraphQL解析器的框架。
目前与Prisma没有直接的关系,但它最初是Prisma组织的子项目,至今仍有来自Prisma开发者的贡献,并且有Prisma集成的文档、库等支持,使用Prisma可以更加高效。
Nexus的目标是方便地实现Type-safe的GraphQL服务器,并提供功能以提高开发环境的自动生成类型等方面。
我认为实际上的模式定义和Resolver的实现遵循了graphql-js,所以如果你有接触过它的人,可能会更容易上手。
使用nexus的Prisma插件(nexus-plugin-prisma),在一些简单的情况下,可以避免在Resolver中编写实现,直接将model的值暴露出来。
export const User = objectType({
name: 'User',
definition(t) {
t.model.id(), // ID fieldを公開
//...
t.model.Profile(), // 以下と同様
// t.field('profile', {
// type: 'Profile',
// resolve: (root, _, ctx) => {
// return ctx.prisma.user.findUnique({ where: { id: root.id }}).Profile()
// }
// })
}
})
尽管我自己尚未完全使用过,但个人而言,我觉得Nexus体验不错。
如果您正在考虑使用Prisma,也可以考虑一下Nexus,可能也会不错的。
总之
Prisma是一个专注于ORM部分的工具,与GraphQL没有直接关系。但在其中,我将介绍一些我认为适合实现GraphQL的部分。如果您感兴趣,请不要犹豫,尽快去试试看吧。