使用Mercurius GraphQL使用数据加载程序解决N+1问题
如果您将Fastify与Mercurius一起用作GraphQl适配器,则可能正在寻找解决N+1问题的解决方案。本文将向您展示如何解决它并加快GraphQl应用程序。
如果您不使用fastify,则可以在阅读本文之前阅读Quick Start guidebe。
什么是n+1问题?
我必须说,我找不到tl; dr(太长;没有阅读)n+1问题的解释,以便在继续之前阅读。因此,我将尝试用一个快速的代码示例来解释它,我们将在本文稍后进行修复。
让我们看看n+1问题 in Action 。
首先,我们需要一个应用程序启动和运行。创建一个将包含简单GQL模式字符串的gql-schema.js
文件:
type Query {
developers: [Developer]
}
type Developer {
id: Int
name: String
builtProjects: [Project]
}
type Project {
id: Int
name: String
}
让我们将先前的架构连接到一个新的app.js
文件,我们将在其中实现fastify + mercurius应用程序。我们将使用内存数据库存储模拟数据。您可以在source code on GitHub中找到本文使用的SQL数据。
const Fastify = require('fastify')
const mercurius = require('mercurius')
const gqlSchema = require('./gql-schema')
run()
async function run() {
const app = Fastify({ logger: true })
// Initialize an in-memory SQLite database
await app.register(require('fastify-sqlite'), {
promiseApi: true,
})
// For the sake of the test, we are going to create a table with some data
await app.sqlite.migrate({ migrationsPath: 'migrations/' })
const resolvers = {
Query: {
// This is the resolver for the Query.developers field
developers: async function (parent, args, context) {
const sql = `SELECT * FROM Developers`
context.app.log.warn('sql: %s', sql)
return context.app.sqlite.all(sql)
},
},
// This is the resolver for the Developer Typo Object
Developer: {
builtProjects: async function (parent, args, context) {
const sql = `SELECT * FROM Projects WHERE devId = ${parent.id}`
context.app.log.warn('sql: %s', sql)
return context.app.sqlite.all(sql)
},
},
}
app.register(mercurius, {
schema: gqlSchema,
graphiql: true,
resolvers,
})
await app.listen({ port: 3001 })
}
太好了,我们准备通过运行node app.js
命令来启动应用程序。多亏了graphiql: true
选项,我们可以在http://localhost:3001/graphiql
上打开GraphiQL接口。
从GraphiQl接口中,我们可以通过点击Play
按钮来运行以下查询:
{
developers {
name
builtProjects {
name
}
}
}
到目前为止,一切都很好!您应该在GraphIQL接口右侧看到服务器的输出。但是,如果我们查看服务器的日志,我们可以看到服务器已执行4个SQL查询:
{"level":40,"hostname":"Eomm","msg":"sql: SELECT * FROM Developers"}
{"level":40,"hostname":"Eomm","msg":"sql: SELECT * FROM Projects WHERE devId = 1"}
{"level":40,"hostname":"Eomm","msg":"sql: SELECT * FROM Projects WHERE devId = 2"}
{"level":40,"hostname":"Eomm","msg":"sql: SELECT * FROM Projects WHERE devId = 3"}
您可以看到,查询没有优化,因为我们进行了查询来为每个开发人员获取项目,而不是在单个查询中获取所有项目。
现在您已经看到了n+1个问题:
- 1 :我们运行一个根查询以获取第一个数据列表
- +n :我们对上一个列表的每个项目进行查询,以获取相关数据
所以,如果我们有100个开发人员,我们将运行101个查询,而不是2个!现在我们已经看到了问题,让我们解决它。
如何解决n+1问题?
解决n+1问题的最常见方法是使用 dataLoader 。数据加载器允许您批处理查询结果并在必要时重复使用。
Mercurius为您提供了两种使用DataLoader的方法:
- Loader:这是一种内置的数据加载式解决方案,可以快速设置和使用。
- DataLoader:这是N+1问题的标准解决方案。
在本文中,我们将看到解决方案并比较它们。
Mercurius装载机在行动
loader
功能是一种内置数据加载剂的解决方案,可以快速设置和使用。它取代 Mercurius的resolvers
选项。
让我们通过优化以前的app.js
示例来看看如何使用它:
// ... previous code
const loaders = {
Developer: {
builtProjects: async function loader(queries, context) {
const devIds = queries.map(({ obj }) => obj.id)
const sql = `SELECT * FROM Projects WHERE devId IN (${devIds.join(',')})`
context.app.log.warn('sql: %s', sql)
const projects = await context.app.sqlite.all(sql)
return queries.map(({ obj }) => {
return projects.filter((p) => p.devId === obj.id)
})
},
},
}
const resolvers = {
Query: {
// ... previous code
},
Developer: {
// Delete the `builtProjects` resolver. It would be ignored in any case
},
}
// ... previous code
app.register(mercurius, {
schema: gqlSchema,
graphiql: true,
loaders, // add the loaders option
resolvers,
})
您可以看到,我们用loaders
替换了resolvers.Developer.builtProjects
函数。区别在于,loaders
接收一系列查询(来自父询问的结果),而不是单个parent
对象。 Mercurius将批量查询,并仅调用loader
函数一次。
在此新加载程序功能中,您可以运行一个查询以获取所需的所有数据,然后您必须返回一个位置匹配的结果阵列。
专利:
- 快速设置并使用。
- 不必污染上下文。
- 它由Mercurius管理。
- 在解析器和装载机之间明确分开关注。
cons:
- 不可能在其他解析器中重复使用加载程序的缓存。
数据加载器的作用
DataLoader
是N+1问题的标准解决方案。它最初是由Facebook创建的。让我们看看如何将其集成到我们的应用程序中。
首先,您应该还原app.js
文件以删除loaders
配置。其次,我们需要安装dataloader
软件包:
npm install dataloader
最后,我们必须为每个请求实例化数据加载程序,因此我们需要扩展context
对象:
const DataLoader = require('dataloader')
// ... previous code
const resolvers = {
Query: {
// ... previous code
},
Developer: {
builtProjects: async function (parent, args, context) {
return context.projectsDataLoader.load(parent.id)
},
},
}
app.register(mercurius, {
schema: gqlSchema,
graphiql: true,
resolvers,
context: () => {
// Instantiate a DataLoader for each request
const projectsDataLoader = new DataLoader(async function (keys) {
const sql = `SELECT * FROM Projects WHERE devId IN (${keys.join(',')})`
app.log.warn('sql: %s', sql)
const projects = await app.sqlite.all(sql)
return keys.map((id) => projects.filter((p) => p.devId === id))
})
// decorate the context with the dataloader
return {
projectsDataLoader,
}
},
})
在这个新示例中,我们已将新的projectsDataLoader
对象添加到Mercurius context
中。此对象是我们从dataloader
软件包导入的数据加载程序类的实例。
DataLoader
类接受一个batchLoader
函数,每批查询只会被调用一次。它支持累积查询的不同方法:
- 执行框架:这是默认行为。它积累了查询,直到下一个滴答为止。这是Mercurius Loader使用的方法。
- 时间范围:它累积查询直到指定的时间范围。
batchLoader
函数接收键数组作为单个参数,并且必须返回将输入数组匹配的结果数组。如您所见,这是Mercurius Loader使用的方法。
专利:
- 这是一种标准的DeFacto解决方案。
- 灵活性:可以重复使用其他解析器中的加载器缓存。
cons:
- 需要更多的代码来设置和配置,您需要创建自己的
context
才能访问数据库。 - 解析器必须意识到并使用
DataLoader
实例。
概括
您现在已经学会了如何通过探索两个不同的解决方案来解决N+1问题,将其与Mercurius一起使用。您可能会认为混合解析器和装载机可能是个好主意。肯定是可行的,但是您必须关闭两个缓存之一,以避免矛盾,并且管理可能会有些混乱。
如果您发现这个有用,则可以阅读other articles about Mercurius。
现在跳入source code on GitHub,开始使用Fastify中实现的GraphQL播放。
如果您喜欢这篇文章的评论,请在twitter上分享并关注我!