在Node.js中创建一个GraphQL服务器教程

317 阅读7分钟

你肯定听说过GraphQL,这是Facebook的基于图形的查询语言。自2015年发布以来,越来越多的数据提供商已经提供了GraphQL端点。这个端点通常与传统的基于REST的API一起提供。

我更喜欢前端的GraphQL端点。我喜欢能够查询到我想要的特定数据,避免过度或不足的问题。我喜欢GraphQL的自我记录性质,因为它的基于类型的模式准确地描述了预期和返回的内容。我曾多次与REST APIs打交道,却发现文档已经过时或错误。

不过在后端,我继续提供REST端点。传统的HTTP动词和路由都很熟悉,而且我可以很快就把一些功能搞出来。

在这篇文章中,我想回答的问题是,让GraphQL API启动和运行需要什么?

背景介绍

为了帮助这篇文章提供一些背景,我创建了一个虚构的冲浪店。今年夏天,我经常出去划皮划艇,而这正是这家特殊商店所销售的东西。伴随着这篇文章的代码可以在这里找到。

我的冲浪店使用MongoDB数据库,并有一个Fastify服务器可以使用。你可以在这里找到这个商店的启动代码,同时还有一个播种脚本,如果你想跟着做的话。你需要安装NodeMongoDB,这已经超出了本文的范围,但是点击名字可以进入安装页面。

为了使这成为一个现实的场景,我想让我目前正在使用REST API的客户不受影响,因为我添加了一个GraphQL端点。

让我们开始吧

GraphQL模式

有两个库我们需要添加到我们的项目中,以启动和运行GraphQL。第一个是graphql ,第二个是mercurius ,这并不令人惊讶。Mercurius是GraphQL的Fastify适配器。让我们来安装它们:

yarn add graphql mercurius

GraphQL是基于模式的,这意味着我们的API将始终被记录下来,并且类型安全。这对我们的消费者来说是一个很大的好处,在我们思考数据之间的关系时,也有助于我们。

我们的商店有两种类型,CraftOwner 。导航到Mongoose模型,你可以看到每个模型上有哪些字段可用。让我们看一下Owner 模型。

Mongoose模型看起来像这样:

const ownerSchema = new mongoose.Schema({
  firstName: String,
  lastName: String,
  email: String,
});

我们要创建一个模式目录,这是一个index.js文件,然后创建我们的GraphQL模式。这个模式中的OwnerType ,看起来将与Mongoose的模式非常相似:

const OwnerType = `type OwnerType {
  id: ID!
  firstName: String
  lastName: String
  email: String
}`;

模板字符串被用来定义我们的类型,以关键字type 和我们的类型名称开始。与JavaScript对象不同,我们类型定义的每一行后面都没有逗号。相反,每一行都有字段名和它的类型,用一个冒号分开。我在定义中使用了IDString 类型。你会注意到,ID后面有一个感叹号,! ,这标志着它是一个强制性的、不可置换的字段。所有其他字段都是可选的。

我现在要把这个类型添加到我的模式的Query 类型中:

const schema = `
type Query {
  Owners: [OwnerType]
  Owner(id: ID!): OwnerType
}

${OwnerType}
`;

你会看到,Owners 的类型是返回一个数组的OwnerType ,由方括号表示。

Owner 要求查询的消费者传递一个id字段。这是由括号中的值表示的, (id: ID!)显示了字段的名称和它必须确认的类型。

最后,我们将从这个文件中导出这个模式,并将其导入我们的主index.js 文件:

module.exports = { schema };

并且

const { schema } = require("./schema");

在导入模式的同时,我们可以导入mercurius插件,并在Fastify注册。

const mercurius = require("mercurius");

fastify.register(mercurius, {
  schema,
  graphiql: true,
});

在选项插件中,我们将传递模式和另外一个属性--我们将设置graphiql ,等于true。

GraphiQL

GraphiQL是一个基于浏览器的界面,旨在探索和使用你的GraphQL端点。现在,它被设置为true,我们可以运行我们的服务器并导航到http://localhost:3000/graphiql ,找到这个页面。

Screenshot of GraphiQL

通过这个工具,我们可以做以下工作:

  1. 编写和验证我们的查询。
  2. 添加查询变量和请求标题以帮助测试。
  3. 从我们的API获取结果。
  4. 探索我们的模式所产生的文档。

探索模式现在显示了一个根类型:query: Query 。我们正是在这个类型上添加了我们的OwnerOwners 。点击它可以看到以下内容:

Screenshot of types in GraphiQL

并点击它们中的任何一个显示出相应的类型:

Screenshot of OwnerType

我将继续设置其余的类型定义。你可以查看源代码,看看我是如何添加Craft 类型的,并为Owner 类型添加一个crafts 字段。

一旦我完成了这些,我的查询类型现在看起来像这样:

Screenshot of Owners and Crafts

字段关系都已经设置好了,但我们还不能从它们那里获得任何数据。要做到这一点,我们需要探索两个概念:查询和解析器。

GraphQL查询

就其核心而言,GraphQL是一种查询语言;它甚至在名字中就有。但是,到目前为止,我们还没有执行任何查询。GraphiQL工具有自动完成功能,所以我们现在可以开始构建我们的查询。下面的查询应该会返回所有Crafts 的名称。

query {
  Crafts {
    name
  }
}

但是,当我们执行时,我们得到了一个null 的响应。

{
  "data": {
    "Crafts": null
  }
}

这是因为我们还没有设置任何解析器。解析器是一个函数,GraphQL运行这个函数来寻找它所需要的数据来解决一个查询。

对于这个项目,我将在schema/index.js 文件中定义解析器,与模式一起。我已经为我的REST API路由所使用的两种数据类型准备了控制器。我将使用这些控制器,经过一些调整,为我的GraphQL端点服务。

首先,我将导入这些控制器:

const craftController = require("../controllers/craftController");
const ownerController = require("../controllers/ownerController");

然后,我将创建一个resolvers对象:

const resolvers = {}

这个对象应该为我们想要提供解析器的每个根类型有一个键。对于我们的使用,我们有一个单一的根类型,即Query 。这个键的值应该是一个为获得所需数据而执行的函数。这就是我们的 Crafts 字段的样子。

const resolvers = {
  Query: {
    async Crafts() {
      return await craftController.getCrafts();
    },
  },
};

然后我们导出resolvers函数,将其导入我们的主index.js ,并将其与模式一起传递给我们的插件选项对象。

// in /src/schema/index.js
module.exports = { schema, resolvers };

// in /src/index.js
const { schema, resolvers } = require("./schema");

fastify.register(mercurius, {
  schema,
  resolvers,
  graphiql: true,
});


现在,当我们运行前面的查询时,我们应该得到我们数据库中所有工艺品的名称。

GraphiQL showing craft names

棒极了然而,如果我们想查询一个特定的工艺,该怎么办?这需要多做一点工作。首先,让我们在GraphiQL编辑器中构建查询。

Screenshot showing labeled GraphiQL editor

这个查询设置看起来非常相似,但有一些区别:

  1. 我需要传入一个查询变量。在关键字query 之后,我们指出要传递的变量的名称和类型。该变量应以美元符号开头($)。
  2. 在这里,我使用变量$id 作为我的Craft字段的查询值。
  3. 查询变量的值是以JSON格式传递的。
  4. 最后,我得到了我的回应。

此刻,我没有任何数据返回。让我们来解决这个问题!

回到我的解析器中,我将为Craft添加一个函数。第一个位置参数是父级,在这个操作中我不需要它,所以我将在那里使用下划线。第二个参数是传入查询的参数,我想从中分解出id。

const resolvers = {
  Query: {
    async Crafts() {
      return await craftController.getCrafts();
    },
    async Craft(_, { id }) {
      return await craftController.getCraftById({id})
    },
  },
};

目前,我的getCraftById 函数期望得到请求对象。我需要在src/controllers/craftController.js 中更新这个函数。

这个原始函数

// Get craft by id
exports.getCraftById = async (request, reply) => {
  try {
    const craft = await Craft.findById(request.params.id);
    return craft;
  } catch (error) {
    throw boom.boomify(error);
  }
};

成为

exports.getCraftById = async (request, reply) => {
  try {
    const id = request.params === undefined ? request.id : request.params.id;
    const craft = await Craft.findById(id);
    return craft;
  } catch (error) {
    throw boom.boomify(error);
  }
};

真棒!"。现在,当我们执行我们的查询时,将返回一个结果。

Screenshot of craft with id in GraphiQL

我们需要帮助GraphQL来填充链接到其他类型的字段。如果我们的消费者查询工艺品的当前所有者,它将返回为null 。我们可以添加一些逻辑,根据存储在数据库中的owner_id ,获得所有者。然后,在传回给我们的用户之前,这可以被附加到我们的工艺品对象上。

async Craft(_, { id }) {
  const craft = await craftController.getCraftById({ id });
  if (craft && craft.owner_id) {
    const owner = await ownerController.getOwnerById({
      id: craft.owner_id,
    });
    craft.owner = owner;
  }
  return craft;
},

我们的ownerController.getOwnerById ,将需要以与相应的工艺函数相同的方式进行更新。但是,一旦处理完毕,我们就可以自由地查询所有者。

你可以检查成品代码目录,找到所有其他字段的解析器和更新的控制器函数。

GraphQL突变

我现在可以自信地提供对GraphQL端点的查询;所有的读取操作都是对我们已经做的一些调整。那么其他操作呢?具体来说,Create,Update, 和Delete 呢?

在GraphQL中,这些操作的每一个都被称为突变。我们正在以某种方式改变数据。设置突变的后端与设置查询几乎完全一样。我们需要在模式中定义突变,然后提供当突变被调用时将被执行的解析器函数。

因此,在/schema/index.js 中,我将扩展Mutation 类型并添加一个addCraft 突变。

type Mutation {
  addCraft(  
    name: String
    type: String
    brand: String
    price: String
    age: Int
  ): CraftType
}

和以前的字段定义一样,括号里的值显示了哪些字段可以被传入函数。这是每一个与它们的类型一起传递的。然后,我们接着说突变将返回什么。在这种情况下,我们的CraftType的形状是一个对象。

在GraphiQL中检查这个,我们可以看到mutation 现在是一个根类型,当我们点击时,我们的addCraft突变存在于模式中。

addCraft in GraphiQL

在GraphiQL中构造一个突变看起来与构造一个查询相同。我们需要像以前那样传入查询变量,它看起来就像这样。

Mutation without any data

但是,当我们执行时,我们会得到一个null 响应。希望这并不令人惊讶,因为我们还没有为这个突变创建一个解析器。让我们现在就做吧!

我们将为我们的解析器对象添加一个Mutation 键和一个用于addCraft 变异的函数。

Mutation: {
  async addCraft(_, fields) {
    const { _id: id } = await craftController.addCraft({ ...fields });
    const craft = { id, ...fields };
    return craft;
  },
},

我们当前的addCraft 函数只返回 Mongoose 响应,也就是_id 字段。我们将提取它并返回输入的字段,使我们符合我们先前声明的CraftType。

更新和销毁函数的配置和设置是相同的。在每种情况下,我们都在扩展模式中的突变类型,并添加一个相应的解析器。

你可以查看成品代码目录,找到其他一些突变的解析器。

结论

我想知道建立一个GraphQL服务器是否是一个巨大的不必要的麻烦。我在结束时悄悄地相信,我将在我的下一个后台项目中使用GraphQL。

与通过我们的REST API直接接触Mongo相比,最初有一些设置和模板。这有可能是一个症结所在。然而,我认为有一些令人信服的观点使这是值得的。

你不再需要为你的应用程序的某些特殊用途提供一个端点。消费者只需要调用他们在特定情况下需要的字段。这就省去了杂乱无章的路由文件和多次调用你的API,而一次就可以了。

在更新模式和解析器的过程中,你使这些数据对你的消费者立即可用。虽然你可以将字段标记为废弃的,但你可以将遗留的字段留在原地,对用户来说没有什么成本。此外,这是一个自我记录的API。你的文档网站再也不会与你的API的当前状态脱节了。

你被说服了吗?你会转向GraphQL吗?还是你将永远留在REST API团队中?