创建您的第一个 GraphQL API

已发表: 2022-03-11

前言

几年前,Facebook 引入了一种构建后端 API 的新方法,称为 GraphQL,它基本上是一种用于数据查询和操作的特定领域语言。 起初,我并没有太在意它,但最终,我发现自己参与了 Toptal 的一个项目,在那里我必须实现基于 GraphQL 的后端 API。 就在那时,我开始学习如何将我为 REST 学到的知识应用到 GraphQL。

这是一次非常有趣的经历,在实施期间,我不得不以更加 GraphQL 友好的方式重新思考 REST API 中使用的标准方法和方法。 在本文中,我尝试总结首次实现 GraphQL API 时要考虑的常见问题。

所需的库

GraphQL 由 Facebook 内部开发并于 2015 年公开发布。2018 年晚些时候,GraphQL 项目从 Facebook 转移到新成立的 GraphQL 基金会,由非营利性 Linux 基金会托管,该基金会维护和开发 GraphQL 查询语言规范和参考JavaScript 的实现。

由于 GraphQL 仍然是一项年轻的技术,并且最初的参考实现可用于 JavaScript,因此大多数成熟的库都存在于 Node.js 生态系统中。 还有另外两家公司 Apollo 和 Prisma 为 GraphQL 提供开源工具和库。 本文中的示例项目将基于这两家公司提供的 GraphQL for JavaScript 和库的参考实现:

  • Graphql-js – GraphQL for JavaScript 的参考实现
  • Apollo-server – 用于 Express、Connect、Hapi、Koa 等的 GraphQL 服务器
  • Apollo-graphql-tools – 使用 SDL 构建、模拟和拼接 GraphQL 模式
  • Prisma-graphql-middleware – 在中间件函数中拆分你的 GraphQL 解析器

在 GraphQL 世界中,您使用 GraphQL 模式来描述您的 API,为此,规范定义了自己的语言,称为 GraphQL 模式定义语言 (SDL)。 SDL 使用起来非常简单直观,同时功能强大且富有表现力。

存在两种创建 GraphQL 模式的方法:代码优先方法和模式优先方法。

  • 在代码优先方法中,您将 GraphQL 模式描述为基于 graphql-js 库的 JavaScript 对象,并且 SDL 是从源代码自动生成的。
  • 在模式优先的方法中,您在 SDL 中描述您的 GraphQL 模式,并使用 Apollo graphql-tools 库连接您的业务逻辑。

就个人而言,我更喜欢模式优先的方法,并将在本文的示例项目中使用它。 我们将实现一个经典的书店示例并创建一个后端,该后端将提供 CRUD API 来创建作者和书籍以及用于用户管理和身份验证的 API。

创建一个基本的 GraphQL 服务器

要运行基本的 GraphQL 服务器,我们必须创建一个新项目,使用 npm 对其进行初始化,并配置 Babel。 要配置 Babel,首先使用以下命令安装所需的库:

 npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/node

安装 Babel 后,在我们项目的根目录中创建一个名为.babelrc的文件,并在那里复制以下配置:

 { "presets": [ [ "@babel/env", { "targets": { "node": "current" } } ] ] }

还要编辑package.json文件并将以下命令添加到scripts部分:

 { ... "scripts": { "serve": "babel-node index.js" }, ... }

配置 Babel 后,使用以下命令安装所需的 GraphQL 库:

 npm install --save express apollo-server-express graphql graphql-tools graphql-tag

安装所需的库后,要以最少的设置运行 GraphQL 服务器,请将以下代码片段复制到我们的index.js文件中:

 import gql from 'graphql-tag'; import express from 'express'; import { ApolloServer, makeExecutableSchema } from 'apollo-server-express'; const port = process.env.PORT || 8080; // Define APIs using GraphQL SDL const typeDefs = gql` type Query { sayHello(name: String!): String! } type Mutation { sayHello(name: String!): String! } `; // Define resolvers map for API definitions in SDL const resolvers = { Query: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } }, Mutation: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } } }; // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolvers maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); });

之后,我们可以使用命令npm run serve运行我们的服务器,如果我们在 Web 浏览器中导航到 URL http://localhost:8080/graphql ,GraphQL 的交互式可视化 shell(称为 Playground)将打开,我们可以在其中执行 GraphQL 查询和突变并查看结果数据。

在 GraphQL 世界中,API 函数分为三组,分别称为查询、突变和订阅:

  • 客户端使用查询从服务器请求它需要的数据。
  • 客户端使用突变在服务器上创建/更新/删除数据。
  • 客户端使用订阅来创建和维护与服务器的实时连接。 这使客户端能够从服务器获取事件并采取相应的行动。

在我们的文章中,我们将只讨论查询和突变。 订阅是一个巨大的话题——它们应该有自己的文章,并且不是每个 API 实现都需要的。

高级标量数据类型

玩过 GraphQL 不久,您就会发现 SDL 只提供原始数据类型,而缺少高级标量数据类型,例如日期、时间和日期时间,这些都是每个 API 的重要组成部分。 幸运的是,我们有一个库可以帮助我们解决这个问题,它叫做 graphql-iso-date。 安装后,我们需要在模式中定义新的高级标量数据类型,并将它们连接到库提供的实现:

 import { GraphQLDate, GraphQLDateTime, GraphQLTime } from 'graphql-iso-date'; // Define APIs using GraphQL SDL const typeDefs = gql` scalar Date scalar Time scalar DateTime type Query { sayHello(name: String!): String! } type Mutation { sayHello(name: String!): String! } `; // Define resolvers map for API definitions in SDL const resolvers = { Date: GraphQLDate, Time: GraphQLTime, DateTime: GraphQLDateTime, Query: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } }, Mutation: { sayHello: (obj, args, context, info) => { return `Hello ${ args.name }!`; } } };

除了日期和时间之外,还存在其他有趣的标量数据类型实现,根据您的用例,它们可能对您有用。 例如,其中之一是 graphql-type-json,它使我们能够在 GraphQL 模式中使用动态类型,并使用我们的 API 传递或返回无类型的 JSON 对象。 还有一个库 graphql-scalar,它使我们能够定义具有高级清理/验证/转换的自定义 GraphQL 标量。

如果需要,您还可以定义自定义标量数据类型并在架构中使用它,如上所示。 这并不难,但对它的讨论超出了本文的范围——如果有兴趣,您可以在 Apollo 文档中找到更高级的信息。

拆分模式

在向您的架构添加更多功能后,它将开始增长,我们将理解将整个定义集保存在一个文件中是不可能的,我们需要将其拆分为小块以组织代码并使其更具可扩展性更大的尺寸。 幸运的是,Apollo 提供的模式构建器函数makeExecutableSchema也接受模式定义和数组形式的解析器映射。 这使我们能够将模式和解析器映射拆分为更小的部分。 这正是我在示例项目中所做的; 我将 API 分为以下几个部分:

  • auth.api.graphql – 用于用户认证和注册的 API
  • author.api.graphql – 作者条目的 CRUD API
  • book.api.graphql - 书籍条目的 CRUD API
  • root.api.graphql - 模式和通用定义的根(如高级标量类型)
  • user.api.graphql - 用于用户管理的 CRUD API

在拆分模式期间,我们必须考虑一件事。 其中一个部分必须是根模式,而其他部分必须扩展根模式。 这听起来很复杂,但实际上很简单。 在根模式中,查询和突变定义如下:

 type Query { ... } type Mutation { ... }

在其他的中,它们的定义如下:

 extend type Query { ... } extend type Mutation { ... }

就这样。

认证和授权

在大多数 API 实现中,都需要限制全局访问并提供某种基于规则的访问策略。 为此,我们必须在代码中引入: Authentication - 确认用户身份 - 和Authorization ,以强制执行基于规则的访问策略。

在 GraphQL 世界中,与 REST 世界一样,我们通常使用 JSON Web Token 进行身份验证。 要验证传递的 JWT 令牌,我们需要拦截所有传入请求并检查它们的授权标头。 为此,在创建 Apollo 服务器期间,我们可以注册一个函数作为上下文钩子,该函数将与当前请求一起调用,该请求创建在所有解析器之间共享的上下文。 这可以这样做:

 // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema, context: ({ req, res }) => { const context = {}; // Verify jwt token const parts = req.headers.authorization ? req.headers.authorization.split(' ') : ['']; const token = parts.length === 2 && parts[0].toLowerCase() === 'bearer' ? parts[1] : undefined; context.authUser = token ? verify(token) : undefined; return context; } }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); });

在这里,如果用户将传递正确的 JWT 令牌,我们将对其进行验证并将用户对象存储在上下文中,在请求执行期间所有解析器都可以访问该对象。

我们验证了用户身份,但我们的 API 仍然可以全局访问,并且没有什么可以阻止我们的用户在未经授权的情况下调用它。 防止这种情况的一种方法是直接在每个解析器中检查上下文中的用户对象,但这是一种非常容易出错的方法,因为我们必须编写大量样板代码,并且在添加新解析器时我们可能忘记添加检查. 如果我们看一下 REST API 框架,一般这类问题都是使用 HTTP 请求拦截器来解决的,但是对于 GraphQL,它没有意义,因为一个 HTTP 请求可以包含多个 GraphQL 查询,如果我们仍然添加它,我们只能访问查询的原始字符串表示,并且必须手动解析它,这绝对不是一个好方法。 这个概念不能很好地从 REST 转换到 GraphQL。

所以我们需要某种方式来拦截 GraphQL 查询,这种方式称为 prisma-graphql-middleware。 这个库允许我们在调用解析器之前或之后运行任意代码。 它通过启用代码重用和清晰的关注点分离来改进我们的代码结构。

GraphQL 社区已经基于 Prisma 中间件库创建了一堆很棒的中间件,它解决了一些特定的用例,并且对于用户授权,有一个名为 graphql-shield 的库,它可以帮助我们为我们的 API 创建一个权限层。

安装 graphql-shield 后,我们可以为我们的 API 引入一个权限层,如下所示:

 import { allow } from 'graphql-shield'; const isAuthorized = rule()( (obj, args, { authUser }, info) => authUser && true ); export const permissions = { Query: { '*': isAuthorized, sayHello: allow }, Mutation: { '*': isAuthorized, sayHello: allow } }

我们可以将这一层作为中间件应用到我们的模式中,如下所示:

 // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); const schemaWithMiddleware = applyMiddleware(schema, shield(permissions, { allowExternalErrors: true })); // Build Apollo server const apolloServer = new ApolloServer({ schemaWithMiddleware }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); })

在创建屏蔽对象时,我们将allowExternalErrors设置为 true,因为默认情况下,屏蔽的行为是捕获和处理解析器内部发生的错误,这对于我的示例应用程序来说是不可接受的行为。

在上面的示例中,我们只限制了经过身份验证的用户对 API 的访问,但是屏蔽非常灵活,使用它,我们可以为我们的用户实现非常丰富的授权模式。 例如,在我们的示例应用程序中,我们有两个角色: USERUSER_MANAGER ,并且只有具有USER_MANAGER角色的用户才能调用用户管理功能。 这是这样实现的:

 export const isUserManager = rule()( (obj, args, { authUser }, info) => authUser && authUser.role === 'USER_MANAGER' ); export const permissions = { Query: { userById: isUserManager, users: isUserManager }, Mutation: { editUser: isUserManager, deleteUser: isUserManager } }

我还要提到的一件事是如何在我们的项目中组织中间件功能。 与模式定义和解析器映射一样,最好将它们按模式拆分并保存在单独的文件中,但与 Apollo 服务器不同,它接受模式定义和解析器映射的数组并为我们缝合它们,Prisma 中间件库不这样做并且只接受一个中间件映射对象,所以如果我们拆分它们,我们必须手动将它们缝合回去。 要查看我对这个问题的解决方案,请参阅示例项目中的ApiExplorer类。

验证

GraphQL SDL 提供非常有限的功能来验证用户输入; 我们只能定义哪个字段是必需的,哪个是可选的。 任何进一步的验证要求,我们必须手动实施。 我们可以直接在解析器函数中应用验证规则,但是这个功能真的不属于这里,这是用户 GraphQL 中间件的另一个很好的用例。 例如,让我们使用用户注册请求输入数据,我们必须验证用户名是否是正确的电子邮件地址、密码输入是否匹配以及密码是否足够强大。 这可以像这样实现:

 import { UserInputError } from 'apollo-server-express'; import passwordValidator from 'password-validator'; import { isEmail } from 'validator'; const passwordSchema = new passwordValidator() .is().min(8) .is().max(20) .has().letters() .has().digits() .has().symbols() .has().not().spaces(); export const validators = { Mutation: { signup: (resolve, parent, args, context) => { const { email, password, rePassword } = args.signupReq; if (!isEmail(email)) { throw new UserInputError('Invalid Email address!'); } if (password !== rePassword) { throw new UserInputError('Passwords don\'t match!'); } if (!passwordSchema.validate(password)) { throw new UserInputError('Password is not strong enough!'); } return resolve(parent, args, context); } } }

我们可以将验证器层作为中间件应用到我们的模式中,以及这样的权限层:

 // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); const schemaWithMiddleware = applyMiddleware(schema, validators, shield(permissions, { allowExternalErrors: true })); // Build Apollo server const apolloServer = new ApolloServer({ schemaWithMiddleware }); apolloServer.applyMiddleware({ app })

N + 1 个查询

另一个需要考虑的问题是 N + 1 个查询,它发生在 GraphQL API 中并且经常被忽视。 当我们在模式中定义的类型之间存在一对多关系时,就会发生此问题。 为了演示它,例如,让我们使用示例项目的 book API:

 extend type Query { books: [Book!]! ... } extend type Mutation { ... } type Book { id: ID! creator: User! createdAt: DateTime! updatedAt: DateTime! authors: [Author!]! title: String! about: String language: String genre: String isbn13: String isbn10: String publisher: String publishDate: Date hardcover: Int } type User { id: ID! createdAt: DateTime! updatedAt: DateTime! fullName: String! email: String! }

在这里,我们看到User类型与Book类型是一对多的关系,这种关系在Book中表示为 creator 字段。 此模式的解析器映射定义如下:

 export const resolvers = { Query: { books: (obj, args, context, info) => { return bookService.findAll(); }, ... }, Mutation: { ... }, Book: { creator: ({ creatorId }, args, context, info) => { return userService.findById(creatorId); }, ... } }

如果我们使用此 API 执行书籍查询,并查看 SQL 语句日志,我们将看到如下内容:

 select `books`.* from `books` select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? select `users`.* from `users` where `users`.`id` = ? ...

很容易猜到——在执行过程中,首先为书籍查询调用解析器,它返回书籍列表,然后每个书籍对象被称为创建者字段解析器,这种行为导致了 N + 1 次数据库查询。 如果我们不想爆炸我们的数据库,那么这种行为并不是很好。

为了解决 N + 1 查询问题,Facebook 开发人员创建了一个非常有趣的解决方案,称为 DataLoader,它在其 README 页面上是这样描述的:

“DataLoader 是一个通用实用程序,可用作应用程序数据获取层的一部分,通过批处理和缓存为各种远程数据源(例如数据库或 Web 服务)提供简化且一致的 API”

理解 DataLoader 的工作原理并不是很简单,所以让我们先看看解决上面演示的问题的示例,然后解释其背后的逻辑。

在我们的示例项目中,DataLoader 为 creator 字段定义如下:

 export class UserDataLoader extends DataLoader { constructor() { const batchLoader = userIds => { return userService .findByIds(userIds) .then( users => userIds.map( userId => users.filter(user => user.id === userId)[0] ) ); }; super(batchLoader); } static getInstance(context) { if (!context.userDataLoader) { context.userDataLoader = new UserDataLoader(); } return context.userDataLoader; } }

一旦我们定义了 UserDataLoader,我们可以像这样改变 creator 字段的解析器:

 export const resolvers = { Query: { ... }, Mutation: { ... }, Book: { creator: ({ creatorId }, args, context, info) => { const userDataLoader = UserDataLoader.getInstance(context); return userDataLoader.load(creatorId); }, ... } }

应用更改后,如果我们再次执行 books 查询并查看 SQL 语句日志,我们将看到如下内容:

 select `books`.* from `books` select `users`.* from `users` where `id` in (?)

在这里,我们可以看到 N + 1 个数据库查询被减少为两个查询,其中第一个查询选择图书列表,第二个查询选择图书列表中作为创建者呈现的用户列表。 现在让我们解释一下DataLoader是如何实现这个结果的。

DataLoader 的主要功能是批处理。 在单次执行阶段,DataLoader 将收集所有单个加载函数调用的所有不同 id,然后使用所有请求的 id 调用批处理函数。 要记住的重要一点是 DataLoaders 的实例不能被重用,一旦调用批处理函数,返回的值将永远缓存在实例中。 由于这种行为,我们必须在每个执行阶段创建新的 DataLoader 实例。 为此,我们创建了一个静态getInstance函数,该函数检查 DataLoader 的实例是否存在于上下文对象中,如果未找到,则创建一个。 请记住,为每个执行阶段创建一个新的上下文对象,并在所有解析器之间共享。

DataLoader 的批量加载函数接受一组不同的请求 ID,并返回一个 Promise,该 Promise 解析为相应对象的数组。 在编写批量加载函数时,我们必须记住两件重要的事情:

  1. 结果数组的长度必须与请求的 ID 数组的长度相同。 例如,如果我们请求 ID [1, 2, 3] ,返回的结果数组必须正好包含三个对象: [{ "id": 1, “fullName”: “user1” }, { “id”: 2, “fullName”: “user2” }, { “id”: 3, “fullName”: “user3” }]
  2. 结果数组中的每个索引必须对应于请求的 ID 数组中的相同索引。 例如,如果请求的 ID 数组具有以下顺序: [3, 1, 2] ,则返回的结果数组必须包含完全相同顺序的对象: [{ "id": 3, “fullName”: “user3” }, { “id”: 1, “fullName”: “user1” }, { “id”: 2, “fullName”: “user2” }]

在我们的示例中,我们使用以下代码确保结果的顺序与请求的 ID 的顺序相匹配:

 then( users => userIds.map( userId => users.filter(user => user.id === userId)[0] ) )

安全

最后但并非最不重要的一点是,我想提一下安全性。 使用 GraphQL,我们可以创建非常灵活的 API,并为用户提供丰富的数据查询功能。 这为应用程序的客户端赋予了相当大的权力,正如本叔叔所说,“权力越大,责任越大。” 如果没有适当的安全措施,恶意用户可以提交昂贵的查询并在我们的服务器上引发 DoS(拒绝服务)攻击。

为了保护我们的 API,我们可以做的第一件事是禁用 GraphQL 模式的自省。 默认情况下,GraphQL API 服务器公开了自省其整个模式的能力,这通常被 GraphiQL 和 Apollo Playground 等交互式可视化 shell 使用,但对于恶意用户基于我们的 API 构建复杂查询也非常有用. 我们可以通过在创建 Apollo Server 时将introspection参数设置为 false 来禁用此功能:

 // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema, introspection: false }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); })

为了保护我们的 API,我们可以做的下一件事是限制查询的深度。 如果我们的数据类型之间存在循环关系,这一点尤其重要。 例如,在我们的示例中,项目类型Author具有字段书籍,类型Book具有字段作者。 这显然是一个循环关系,没有什么可以阻止恶意用户编写这样的查询:

 query { authors { id, fullName books { id, title authors { id, fullName books { id, title, authors { id, fullName books { id, title authors { ... } } } } } } } }

很明显,如果嵌套足够多,这样的查询很容易炸毁我们的服务器。 为了限制查询的深度,我们可以使用一个名为 graphql-depth-limit 的库。 安装完成后,我们可以在创建 Apollo Server 时应用深度限制,如下所示:

 // Configure express const app = express(); // Build GraphQL schema based on SDL definitions and resolver maps const schema = makeExecutableSchema({ typeDefs, resolvers }); // Build Apollo server const apolloServer = new ApolloServer({ schema, introspection: false, validationRules: [ depthLimit(5) ] }); apolloServer.applyMiddleware({ app }); // Run server app.listen({ port }, () => { console.log(`Server ready at http://localhost:${ port }${ apolloServer.graphqlPath }`); })

在这里,我们将查询的最大深度限制为五个。

Post Scriptum:从 REST 迁移到 GraphQL 很有趣

在本教程中,我尝试演示在开始实施 GraphQL API 时会遇到的常见问题。 然而,它的某些部分提供了非常浅薄的代码示例,并且由于其大小而仅触及所讨论问题的表面。 因此,要查看更完整的代码示例,请参阅我的示例 GraphQL API 项目的 Git 存储库:graphql-example。

最后,我想说 GraphQL 是一个非常有趣的技术。 它会取代 REST 吗? 没有人知道,也许明天在瞬息万变的 IT 世界中,会出现一些更好的 API 开发方法,但 GraphQL 确实属于绝对值得学习的有趣技术类别。