本文概述
前言
几年前, Facebook引入了一种称为GraphQL的构建后端API的新方法, 该方法基本上是一种用于数据查询和操作的领域特定语言。最初, 我并没有特别注意它, 但是最终, 我发现自己参与了srcmini的一个项目, 在那里我不得不实现基于GraphQL的后端API。从那时起, 我开始学习如何将我在REST中学到的知识应用于GraphQL。
这是一次非常有趣的经历, 在实施期间, 我不得不以对GraphQL更友好的方式重新考虑REST API中使用的标准方法和方法。在本文中, 我尝试总结首次实现GraphQL API时要考虑的常见问题。
所需的库
GraphQL由Facebook内部开发, 并于2015年公开发布。2018年晚些时候, GraphQL项目从Facebook移至由非营利性Linux基金会托管的新成立的GraphQL基金会, 该基金会维护和开发GraphQL查询语言规范和参考。 JavaScript的实现。
由于GraphQL仍然是一项新兴技术, 并且最初的参考实现可用于JavaScript, 因此大多数成熟的库都存在于Node.js生态系统中。另外还有两家公司Apollo和Prisma, 它们为GraphQL提供开源工具和库。本文中的示例项目将基于这两家公司提供的GraphQL JavaScript和库的参考实现:
- Graphql-js – GraphQL for JavaScript的参考实现
- Apollo服务器–用于Express, Connect, Hapi, Koa等的GraphQL服务器
- Apollo-graphql工具–使用SDL构建, 模拟和缝合GraphQL模式
- Prisma-graphql-中间件–在中间件功能中拆分GraphQL解析器
在GraphQL世界中, 你使用GraphQL模式描述你的API, 为此, 规范定义了自己的语言, 称为GraphQL模式定义语言(SDL)。 SDL使用起来非常简单直观, 同时功能强大且富有表现力。
有两种创建GraphQL模式的方法:代码优先方法和模式优先方法。
- 在代码优先方法中, 你基于graphql-js库将GraphQL模式描述为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": {
"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的交互式可视外壳(称为Playground), 在该外壳上我们可以执行GraphQL查询和变异并查看结果数据。
在GraphQL世界中, API函数分为三组, 分别称为查询, 变异和预订:
- 客户端使用查询从服务器请求其所需的数据。
- 客户端使用突变来在服务器上创建/更新/删除数据。
- 客户端使用订阅来创建和维护与服务器的实时连接。这使客户端能够从服务器获取事件并采取相应的措施。
在我们的文章中, 我们将只讨论查询和变异。订阅是一个巨大的话题-订阅应该有自己的文章, 并不是每个API实现都需要订阅。
高级标量数据类型
在使用GraphQL之后不久, 你会发现SDL仅提供原始数据类型, 而缺少每个API的重要组成部分的高级标量数据类型(如Date, Time和DateTime)。幸运的是, 我们有一个库可以帮助我们解决此问题, 它称为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实现中, 要求限制全局访问并提供某种基于规则的访问策略。为此, 我们必须在代码中引入:身份验证(确认用户身份)和授权(强制执行基于规则的访问策略)。
在GraphQL世界(如REST世界)中, 通常对于身份验证, 我们使用JSON Web令牌。为了验证传递的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查询, 这种方法称为pyramida-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的访问, 但是屏蔽非常灵活, 并且使用它, 我们可以为用户实现非常丰富的授权架构。例如, 在示例应用程序中, 我们具有两个角色:USER和USER_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服务器不同, 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个查询
使用GraphQL API时经常要忽略的另一个问题是N + 1个查询。当我们在架构中定义的类型之间存在一对多关系时, 就会发生此问题。例如, 为了演示它, 让我们使用示例项目的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中表示为创建者字段。此模式的解析器映射定义如下:
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的工作原理不是很简单, 因此让我们首先来看解决上面演示的问题的示例, 然后解释其背后的逻辑。
在我们的示例项目中, 为Creator字段定义了DataLoader, 如下所示:
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后, 我们可以像下面那样更改创建者字段的解析器:
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。在编写批处理加载函数时, 我们必须记住两件重要的事情:
- 结果数组的长度必须与请求的ID数组的长度相同。例如, 如果我们请求ID [1、2、3], 则返回的结果数组必须恰好包含三个对象:[{” id”:1, ” fullName”:” user1″}, {” id”:2 , ” fullName”:” user2″}, {” id”:3, ” fullName”:” user3″}]
- 结果数组中的每个索引必须对应于请求的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, 并为用户提供丰富的查询数据的功能。这给应用程序的客户端提供了很大的功能, 并且, 正如Ben叔叔所说的那样:”强大的功能带来了巨大的责任。”没有适当的安全性, 恶意用户可能会提交昂贵的查询, 并在我们的服务器上造成DoS(拒绝服务)攻击。
保护API的第一件事是禁用GraphQL模式的自省。默认情况下, GraphQL API服务器具有对整个架构进行内部检查的功能, 该功能通常由诸如GraphiQL和Apollo Playground之类的交互式可视化外壳使用, 但对于恶意用户基于我们的API构造复杂查询也非常有用。 。我们可以通过在创建Apollo服务器时将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的下一步是限制查询的深度。如果我们的数据类型之间存在循环关系, 则这一点尤其重要。例如, 在我们的示例中, 项目类型”作者”具有字段簿, 而类型”书”具有字段作者。显然, 这是一种循环关系, 没有什么可以阻止恶意用户编写如下查询:
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 }`);
})
在这里, 我们将查询的最大深度限制为五个。
Scriptum之后:从REST到GraphQL有趣
在本教程中, 我尝试演示了开始实施GraphQL API时遇到的常见问题。但是, 由于其大小, 它的某些部分提供了非常浅的代码示例, 并且仅刮擦了所讨论问题的表面。因此, 要查看更多完整的代码示例, 请参考我的示例GraphQL API项目的Git存储库:graphql-example。
最后, 我想说GraphQL是非常有趣的技术。它将取代REST吗?没有人知道, 也许在瞬息万变的IT世界中, 明天会出现一些更好的API开发方法, 但是GraphQL确实属于有趣的技术类别, 绝对值得学习。
评论前必须登录!
注册