数据加载器(Dataloader)
由于 GraphQL 的灵活性,当我们加载某个对象的关联对象时,我们通常需要执行多个查询。 这就造成了著名的 N+1 查询问题。为了解决这个问题,我们可以使用 DataLoader。
DataLoader
能够将多个请求合并为一个请求,从而减少数据库的查询次数,同时还能缓存查询结果,避免重复查询。
N+1 查询问题
考虑一个场景,我们需要查询所有用户以及他们各自发表的帖子。我们的数据表结构如下:
import { drizzleSilk } from "@gqloom/drizzle"
import { relations } from "drizzle-orm"
import * as t from "drizzle-orm/pg-core"
export const roleEnum = t.pgEnum("role", ["user", "admin"])
export const users = drizzleSilk(
t.pgTable("users", {
id: t.serial().primaryKey(),
createdAt: t.timestamp().defaultNow(),
email: t.text().unique().notNull(),
name: t.text(),
role: roleEnum().default("user"),
})
)
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}))
export const posts = drizzleSilk(
t.pgTable("posts", {
id: t.serial().primaryKey(),
createdAt: t.timestamp().defaultNow(),
updatedAt: t
.timestamp()
.defaultNow()
.$onUpdateFn(() => new Date()),
published: t.boolean().default(false),
title: t.varchar({ length: 255 }).notNull(),
authorId: t.integer().notNull(),
})
)
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
}))
一个直观的解析器实现可能是这样的:
import { field, query, resolver } from "@gqloom/core"
import { eq } from "drizzle-orm"
import { db } from "src/db"
import { posts, users } from "src/schema"
export const userResolver = resolver.of(users, {
users: query(users.$list()).resolve(() => db.select().from(users)),
posts: field(posts.$list())
.derivedFrom("id")
.resolve((user) =>
db.select().from(posts).where(eq(posts.authorId, user.id))
),
})
当我们执行以下查询时:
query usersWithPosts {
users {
id
name
posts {
id
title
}
}
}
后台的执行流程会是:
- 执行一次查询获取所有用户列表(
SELECT * FROM users
)。 - 对于每一个 返回的用户,再去执行一次查询来获取该用户的帖子(
SELECT * FROM posts WHERE authorId = ?
)。
如果第一个查询返回了 N 个用户,那么为了获取他们的帖子,我们总共需要执行 1 (获取用户) + N (获取每个用户的帖子) 次查询。这就是所谓的 “N+1 查询问题”。当 N 很大时,这会对数据库造成巨大的压力,导致性能瓶颈。
GQLoom 提供了强大的工具来优雅地解决这个问题。
field().load()
方法
最简单的用法是使用 field().load()
方法。它将解析函数从处理单个父对象转变为处理一批父对象,从而允许我们进行批量数据获取。
load
方法接受一个异步函数作为参数,该函数的第一个参数是父对象的数组 parents
,后续参数则是该字段的输入参数 args
。 这个异步函数需要返回一个与 parents
数组等长的数组,其中每个元素对应一个父对象的结果。
INFO
必须保证返回的数组与 parents
数组的顺序和长度完全一致。DataLoader
依赖此来正确地将结果映射回每个父对象。
让我们来看一个例子。要解决上面提到的 N+1 问题,我们可以这样修改解析器:
import { field, resolver } from "@gqloom/core"
import { inArray } from "drizzle-orm"
import { db } from "src/db"
import { posts, users } from "src/schema"
export const userResolver = resolver.of(users, {
posts: field(posts.$list())
.derivedFrom("id")
.load(async (userList) => {
// 1. Fetch all posts for the users at once
const postList = await db
.select()
.from(posts)
.where(
inArray(
posts.authorId,
userList.map((u) => u.id)
)
)
// 2. Group posts by authorId
const grouped = Map.groupBy(postList, (p) => p.authorId)
// 3. Map the posts back to each user in order
return userList.map((u) => grouped.get(u.id) ?? [])
}),
})
在上面的代码中,load
函数接收一个 userList
数组。我们提取所有用户的 id
,并使用 inArray
操作一次性从数据库中查询出所有相关的帖子。然后,我们将帖子按 authorId
分组,并最终映射回与 userList
顺序一致的结果数组。
这样,无论我们请求多少个用户,对 posts
表的查询都只会执行一次。
LoomDataLoader
field().load()
是 GQLoom 提供的便捷 API,它在内部为我们创建和管理 DataLoader
实例。 然而,在某些场景下,我们可能需要更精细的控制,或者在不同的解析器之间共享同一个数据加载器实例。 这时,我们可以使用 LoomDataLoader
。
GQLoom
提供了 LoomDataLoader
抽象类和 EasyDataLoader
便捷类,用于创建自定义的数据加载器。
数据加载器子类 (LoomDataLoader)
我们可以通过继承 LoomDataLoader
并实现 batchLoad
方法来创建自定义的数据加载器。
import { LoomDataLoader, field, query, resolver } from "@gqloom/core"
import { createMemoization } from "@gqloom/core/context"
import { inArray } from "drizzle-orm"
import { db } from "src/db"
import { posts, users } from "src/schema"
import * as v from "valibot"
// 1. Create a custom DataLoader
export class UserLoader extends LoomDataLoader<
number,
typeof users.$inferSelect
> {
protected async batchLoad(
keys: number[]
): Promise<(typeof users.$inferSelect | Error)[]> {
const userList = await db
.select()
.from(users)
.where(inArray(users.id, keys))
const userMap = new Map(userList.map((u) => [u.id, u]))
return keys.map(
(key) => userMap.get(key) ?? new Error(`User ${key} not found`)
)
}
}
// 2. Use createMemoization to create a shared loader instance within the request
export const useUserLoader = createMemoization(() => new UserLoader())
// 3. Use it in the resolver
export const postResolver = resolver.of(posts, {
author: field(users)
.derivedFrom("authorId")
.resolve((post) => {
const loader = useUserLoader()
return loader.load(post.authorId)
}),
})
export const userResolver = resolver.of(users, {
user: query(users)
.input({ id: v.number() })
.resolve(({ id }) => {
const loader = useUserLoader()
return loader.load(id)
}),
})
为了确保每个请求都有一个独立的数据加载器实例,避免不同请求之间的数据缓存污染,我们通常会结合上下文中的 createMemoization
函数来使用。这将在每个请求的生命周期内创建一个单例的加载器。
import { LoomDataLoader, field, query, resolver } from "@gqloom/core"
import { createMemoization } from "@gqloom/core/context"
import { inArray } from "drizzle-orm"
import { db } from "src/db"
import { posts, users } from "src/schema"
import * as v from "valibot"
// 1. Create a custom DataLoader
export class UserLoader extends LoomDataLoader<
number,
typeof users.$inferSelect
> {
protected async batchLoad(
keys: number[]
): Promise<(typeof users.$inferSelect | Error)[]> {
const userList = await db
.select()
.from(users)
.where(inArray(users.id, keys))
const userMap = new Map(userList.map((u) => [u.id, u]))
return keys.map(
(key) => userMap.get(key) ?? new Error(`User ${key} not found`)
)
}
}
// 2. Use createMemoization to create a shared loader instance within the request
export const useUserLoader = createMemoization(() => new UserLoader())
// 3. Use it in the resolver
export const postResolver = resolver.of(posts, {
author: field(users)
.derivedFrom("authorId")
.resolve((post) => {
const loader = useUserLoader()
return loader.load(post.authorId)
}),
})
export const userResolver = resolver.of(users, {
user: query(users)
.input({ id: v.number() })
.resolve(({ id }) => {
const loader = useUserLoader()
return loader.load(id)
}),
})
在这个例子中,useUserLoader()
在同一个 GraphQL 请求中多次被调用时,会返回同一个 UserLoader
实例。因此,对 loader.load(id)
的多次调用将被自动批量处理,并且 batchLoad
函数只会被执行一次。
便捷数据加载器 (EasyDataLoader)
如果你不是一个面向对象编程的爱好者,可以使用 EasyDataLoader
。它接受一个 batchLoad
函数作为构造函数参数。
上面的 useUserLoader
可以用 EasyDataLoader
简化为:
import { EasyDataLoader, field, resolver } from "@gqloom/core"
import { createMemoization } from "@gqloom/core/context"
import { inArray } from "drizzle-orm"
import { db } from "src/db"
import { posts, users } from "src/schema"
const useUserLoader = createMemoization(() => {
return new EasyDataLoader<number, typeof users.$inferSelect>(async (keys) => {
const userList = await db
.select()
.from(users)
.where(inArray(users.id, keys))
const userMap = new Map(userList.map((u) => [u.id, u]))
return keys.map(
(key) => userMap.get(key) ?? new Error(`User ${key} not found`)
)
})
})
// The usage in the resolver remains the same
export const postResolver = resolver.of(posts, {
author: field(users)
.derivedFrom("authorId")
.resolve((post) => {
const loader = useUserLoader()
return loader.load(post.authorId)
}),
})