如何将数据加载器与Mercurius GraphQL一起使用
#node #graphql #fastify #mercurius

使用Mercurius GraphQL使用数据加载程序解决N+1问题

Manuel Spigolon

如果您将FastifyMercurius一起用作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上分享并关注我!