首页 > 解决方案 > 为什么 GraphQL 查询返回 null?

问题描述

我有一个graphql//端点apollo-servergraphql-yoga此端点公开从数据库(或 REST 端点或其他服务)返回的数据。

我知道我的数据源正在返回正确的数据——如果我在解析器中记录对数据源的调用结果,我可以看到正在返回的数据。但是,我的 GraphQL 字段总是解析为 null。

如果我将该字段设为非空,我会errors在响应中的数组内看到以下错误:

不能为不可为空的字段返回 null

为什么 GraphQL 不返回数据?

标签: graphqlgraphql-jsapollo-server

解决方案


您的一个或多个字段解析为 null 的常见原因有两个:1)在解析器中以错误的形状返回数据;和 2) 没有正确使用 Promises。

注意:如果您看到以下错误:

不能为不可为空的字段返回 null

根本问题是您的字段返回空值。您仍然可以按照下面概述的步骤尝试解决此错误。

以下示例将引用此简单模式:

type Query {
  post(id: ID): Post
  posts: [Post]
}

type Post {
  id: ID
  title: String
  body: String
}

以错误的形状返回数据

data我们的模式和请求的查询一起定义了端点返回的响应中对象的“形状” 。所谓形状,是指对象具有哪些属性,以及这些属性的“值”是标量值、其他对象还是对象数组或标量。

与模式定义总响应的形状相同,单个字段的类型定义该字段值的形状。我们在解析器中返回的数据的形状也必须与这个预期的形状相匹配。如果没有,我们的响应中经常会出现意外的空值。

不过,在我们深入研究具体示例之前,了解 GraphQL 如何解析字段非常重要。

了解默认解析器行为

虽然您当然可以为架构中的每个字段编写解析器,但通常没有必要,因为 GraphQL.js 在您不提供解析器时使用默认解析器。

在高层次上,默认解析器所做的很简单:它查看字段解析到的值,如果该值是 JavaScript 对象,它会在该对象上查找与正在解析的字段同名的属性。如果它找到该属性,则解析为该属性的值。否则,它解析为 null。

假设在我们的post字段解析器中,我们返回 value { title: 'My First Post', bod: 'Hello World!' }。如果我们不为Post类型上的任何字段编写解析器,我们仍然可以请求post

query {
  post {
    id
    title
    body
  }
}

我们的回应将是

{
  "data": {
    "post" {
      "id": null,
      "title": "My First Post",
      "body": null,
    }
  }
}

即使我们没有为它提供解析器,该title字段也已解析,因为默认解析器完成了繁重的工作——它看到title在父字段(在本例中post)解析到的 Object 上有一个名为的属性,因此它刚刚解析到该财产的价值。该id字段解析为 null,因为我们在post解析器中返回的对象没有id属性。由于拼写错误,该body字段也解析为 null - 我们有一个名为bod而不是body!

专业提示:如果bod不是拼写错误而是API 或数据库实际返回的内容,我们总是可以为该body字段编写一个解析器以匹配我们的模式。例如:(parent) => parent.bod

要记住的一件重要事情是,在 JavaScript 中,几乎所有东西都是 Object。因此,如果该post字段解析为字符串或数字,则该Post类型上每个字段的默认解析器仍将尝试在父对象上找到适当命名的属性,不可避免地会失败并返回 null。如果字段具有对象类型,但您在其解析器中返回对象以外的内容(如字符串或数组),您将不会看到有关类型不匹配的任何错误,但该字段的子字段将不可避免地解析为 null。

常见场景 #1:包装响应

如果我们正在为post查询编写解析器,我们可能会从其他端点获取我们的代码,如下所示:

function post (root, args) {
  // axios
  return axios.get(`http://SOME_URL/posts/${args.id}`)
    .then(res => res.data);

  // fetch
  return fetch(`http://SOME_URL/posts/${args.id}`)
    .then(res => res.json());

  // request-promise-native
  return request({
    uri: `http://SOME_URL/posts/${args.id}`,
    json: true
  });
}

post字段具有类型Post,因此我们的解析器应该返回一个具有 和 等属性的id对象 。如果这是我们的 API 返回的内容,我们就准备好了。但是,通常响应实际上是一个包含附加元数据的对象。所以我们实际上从端点返回的对象可能看起来像这样:titlebody

{
  "status": 200,
  "result": {
    "id": 1,
    "title": "My First Post",
    "body": "Hello world!"
  },
}

在这种情况下,我们不能按原样返回响应并期望默认解析器正常工作,因为我们返回的对象没有我们需要的id,titlebody属性。我们的解析器不需要执行以下操作:

function post (root, args) {
  // axios
  return axios.get(`http://SOME_URL/posts/${args.id}`)
    .then(res => res.data.result);

  // fetch
  return fetch(`http://SOME_URL/posts/${args.id}`)
    .then(res => res.json())
    .then(data => data.result);

  // request-promise-native
  return request({
    uri: `http://SOME_URL/posts/${args.id}`,
    json: true
  })
    .then(res => res.result);
}

注意:上面的例子从另一个端点获取数据;但是,当直接使用数据库驱动程序(而不是使用 ORM)时,这种包装响应也很常见!例如,如果您使用的是node-postgres,您将获得一个Result包含 、 和 等rows属性 fieldsrowCount对象command。您需要从该响应中提取适当的数据,然后再将其返回到您的解析器中。

常见场景#2:数组而不是对象

如果我们从数据库中获取一个帖子,我们的解析器可能看起来像这样:

function post(root, args, context) {
  return context.Post.find({ where: { id: args.id } })
}

Post我们通过上下文注入的模型在哪里。如果我们正在使用sequelize,我们可能会调用findAll. mongoose并且typeormfind。这些方法的共同点是,虽然它们允许我们指定WHERE条件,但它们返回的 Promises仍然解析为数组而不是单个对象。虽然您的数据库中可能只有一个具有特定 ID 的帖子,但当您调用其中一种方法时,它仍然包装在一个数组中。因为 Array 仍然是 Object,GraphQL 不会将该post字段解析为 null。但它会将所有子字段解析为 null,因为它无法在数组上找到适当命名的属性。

您可以通过抓取数组中的第一项并在解析器中返回它来轻松解决这种情况:

function post(root, args, context) {
  return context.Post.find({ where: { id: args.id } })
    .then(posts => posts[0])
}

如果您从另一个 API 获取数据,这通常是唯一的选择。另一方面,如果您使用的是 ORM,则通常可以使用另一种方法(例如findOne),该方法将显式地从数据库中仅返回一行(如果不存在,则返回 null)。

function post(root, args, context) {
  return context.Post.findOne({ where: { id: args.id } })
}

关于INSERTUPDATE调用的特别说明:我们经常期望插入或更新行或模型实例的方法返回插入或更新的行。通常他们会这样做,但有些方法不会。例如,sequelize'upsert方法解析为布尔值,或被插入的记录和布尔值的元组(如果returning选项设置为 true)。mongoose'sfindOneAndUpdate解析为具有value包含已修改行的属性的对象。在将结果返回到解析器之前,请查阅您的 ORM 文档并适当地解析结果。

常见场景#3:对象而不是数组

在我们的模式中,posts字段的类型是 a Listof Posts,这意味着它的解析器需要返回一个对象数组(或解析为一个的 Promise)。我们可能会获取这样的帖子:

function posts (root, args) {
  return fetch('http://SOME_URL/posts')
    .then(res => res.json())
}

然而,来自我们 API 的实际响应可能是一个包装帖子数组的对象:

{
  "count": 10,
  "next": "http://SOME_URL/posts/?page=2",
  "previous": null,
  "results": [
    {
      "id": 1,
      "title": "My First Post",
      "body" "Hello World!"
    },
    ...
  ]
}

我们无法在解析器中返回此对象,因为 GraphQL 需要一个数组。如果我们这样做,该字段将解析为 null 并且我们将在响应中看到一个错误,例如:

应为 Iterable,但没有为字段 Query.posts 找到一个。

与上述两种情况不同,在这种情况下,GraphQL 能够显式检查我们在解析器中返回的值的类型,并且如果它不是像数组那样的Iterable ,则会抛出。

就像我们在第一个场景中讨论的那样,为了修复这个错误,我们必须将响应转换为适当的形状,例如:

function posts (root, args) {
  return fetch('http://SOME_URL/posts')
    .then(res => res.json())
    .then(data => data.results)
}

没有正确使用 Promise

GraphQL.js 在底层使用了 Promise API。因此,解析器可以返回一些值(如{ id: 1, title: 'Hello!' }),或者它可以返回将解析为该值的 Promise。对于具有List类型的字段,您还可以返回一个 Promises 数组。如果 Promise 拒绝,该字段将返回 null 并且相应的错误将添加到errors响应中的数组中。如果字段具有 Object 类型,则 Promise 解析为的值将作为父值传递给任何子字段的解析器。

Promise是一个“对象表示异步操作的最终完成(或失败)及其结果值。” 接下来的几个场景概述了在解析器中处理 Promise 时遇到的一些常见陷阱。但是,如果您不熟悉 Promises 和较新的 async/await 语法,强烈建议您花一些时间阅读基础知识。

注意:接下来的几个例子是指一个getPost函数。这个函数的实现细节并不重要——它只是一个返回 Promise 的函数,它将解析为一个 post 对象。

常见场景 #4:不返回值

该字段的工作解析器post可能如下所示:

function post(root, args) {
  return getPost(args.id)
}

getPosts返回一个 Promise,我们将返回该 Promise。无论 Promise 解析为什么,都将成为我们的字段解析为的值。看起来不错!

但是如果我们这样做会发生什么:

function post(root, args) {
  getPost(args.id)
}

我们仍在创建一个将解析为帖子的 Promise。但是,我们没有返回 Promise,因此 GraphQL 不知道它并且不会等待它解决。在没有显式语句的 JavaScript 函数return中隐式返回undefined。所以我们的函数创建了一个 Promise 然后立即返回undefined,导致 GraphQL 为该字段返回 null。

如果getPost拒绝返回的 Promise,我们也不会在响应中看到任何错误——因为我们没有返回 Promise,底层代码不关心它是解析还是拒绝。事实上,如果 Promise 被拒绝,你会 UnhandledPromiseRejectionWarning在你的服务器控制台中看到一个。

解决这个问题很简单——只需添加return.

常见场景 #5:未正确链接 Promise

您决定记录对 的调用结果getPost,因此您将解析器更改为如下所示:

function post(root, args) {
  return getPost(args.id)
    .then(post => {
      console.log(post)
    })
}

当您运行查询时,您会在控制台中看到记录的结果,但 GraphQL 将该字段解析为 null。为什么?

当我们调用thenPromise 时,我们实际上是在获取 Promise 解析为的值并返回一个新的 Promise。你可以把它想象成Array.map除了 Promises 之外的东西。then可以返回一个值,或者另一个 Promise。无论哪种情况,其中返回的内容then都会“链接”到原始 Promise 上。多个 Promise 可以像这样通过使用多个thens 链接在一起。链中的每一个 Promise 都是按顺序解析的,最终的值就是被有效解析为原始 Promise 的值。

在上面的示例中,我们在 内部没有返回任何内容then,因此 Promise 解析为undefined,而 GraphQL 将其转换为 null。为了解决这个问题,我们必须返回帖子:

function post(root, args) {
  return getPost(args.id)
    .then(post => {
      console.log(post)
      return post // <----
    })
}

如果您需要在解析器中解析多个 Promise,则必须通过使用then并返回正确的值来正确链接它们。例如,如果我们需要在调用之前调用另外两个异步函数(getFoogetBargetPost,我们可以这样做:

function post(root, args) {
  return getFoo()
    .then(foo => {
      // Do something with foo
      return getBar() // return next Promise in the chain
    })
    .then(bar => {
      // Do something with bar
      return getPost(args.id) // return next Promise in the chain
    })

专业提示:如果您正在为正确链接 Promises 而苦苦挣扎,您可能会发现 async/await 语法更简洁、更易于使用。

常见场景#6

在 Promises 之前,处理异步代码的标准方法是使用回调,或在异步工作完成后调用的函数。例如,我们可能会像这样调用mongoose'sfindOne方法:

function post(root, args) {
  return Post.findOne({ where: { id: args.id } }, function (err, post) {
    return post
  })

这里的问题是双重的。第一,在回调中返回的值不会用于任何事情(即它不会以任何方式传递给底层代码)。二、当我们使用回调时,Post.findOne不返回 Promise;它只是返回未定义。在这个例子中,我们的回调将被调用,如果我们记录 的值,post我们将看到从数据库返回的任何内容。然而,因为我们没有使用 Promise,GraphQL 不会等待这个回调完成——它接受返回值(未定义)并使用它。

最流行的库,包括mongoose开箱即用的 Promises 支持。那些不经常拥有添加此功能的免费“包装器”库。使用 GraphQL 解析器时,应避免使用利用回调的方法,而应使用返回 Promises 的方法。

专业提示:同时支持回调和 Promise 的库经常以这样的方式重载它们的函数,如果没有提供回调,函数将返回一个 Promise。查看库的文档以获取详细信息。

如果您绝对必须使用回调,您也可以将回调包装在 Promise 中:

function post(root, args) {
  return new Promise((resolve, reject) => {
    Post.findOne({ where: { id: args.id } }, function (err, post) {
      if (err) {
        reject(err)
      } else {
        resolve(post)
      }
    })
  })

推荐阅读