本文预期读者对 NodeJS、Koa 有一定的了解
GraphQL 解决了什么问题
过去,服务端的研发人员设计一个数据接口,通常并不会要求使用接口的客户端研发人员知道接口内部的数据结构,而是只提供一份 api 文档(使用说明书),文档内容介绍如何调用 API,返回什么数据,文档和功能都实现后,就算完成服务端工作了。
我们使用这个工作方式工作,慢慢地发现了一些问题:
- 专门写 API 文档成了一种负担
- API 文档和 API 服务经常部署在不同的域名,我们需要记住文档在哪
- 我们总是发现 API 的实际行为和文档不一致
- API 内部数据的枚举值,总是泄露到客户端
- API 参数校验工作在客户端和服务器重复进行
- 我们很难看到整个应用数据的结构图
- 我们不得不维护多个 API 版本
慢慢地,我们发现,在服务端和客户端之间,需要共享一份数据描述规范:
- 这份数据描述就是功能的一部分(注意,它不是注释),它参与实现 API 功能。
- 这份数据描述本身就是文档,我们不在需要专门写文档,更不需要专门去部署文档服务。
- 当我们修改了数据描述细节,API 功能就会发生变化,我们无需担心文档和行为不一致的问题。
- 数据描述本身支持枚举类型,限制枚举值泄露的问题。
- 数据描述本身有类型系统,我们无需在客户端和服务器重复做参数校验工作。
- 数据描述本身就是整个应用数据的结构图。
- 数据描述能屏蔽版本维护的问题。
GraphQL 就是这么一种数据描述规范。
什么是 GraphQL
官网的介绍如下:
GraphQL 是一个用于 API 的查询语言,是一个使用基于类型系统来执行查询的服务端运行时(类型系统由你的数据定义)。
下面是一个基于 GraphQL 的数据描述:
type Query {
book: Book
}
enum BookStatus {
DELETED
NORMAL
}
type Book {
id: ID
name: String
price: Float
status: BookStatus
}
复制代码
为了不和特定的平台绑定,且更容易理解服务端的功能,GraphQL 实现了一个易读的 schema 语法: Schema Definition Language
(SDL
)
SDL
用于表示 schema 中可用的类型,以及这些类型之间的关系
SDL
必须以字符串的形式存储
SDL
规定了三类入口,分别为:
Query
用于定义读
操作 (可以理解为CURD
中的R
)Mutation
用于定义写
操作 (可以理解为CURD
中的CUD
)Subscription
用于定义长链接(基于事件的、创建和维持与服务端实时连接的方式)
通过上述代码,我们声明了一个查询,名为 book
,类型为 Book
类型 Book
拥有四个字段,分别为:
- id, 代表每本书的唯一id, 类型为 ID
- name, 代表每本书的名字, 类型为字符串
- price, 代表每本书的价格, 类型为浮点数
- status, 代表每本书的状态, 类型为
BookStatus
BookStatus
是一个枚举类型,包含:
DELETED
代表书已经下架,它的值为0
NORMAL
代表书在正常销售中, 它的值为1
除了可以定义自己的数据类型外,GraphQL 还内置了一下几种基础类型(标量):
Int
: 有符号 32 位整型Float
: 有符号双精度浮点型String
: UTF-8 字符序列Boolean
: true 或 falseID
: 一个唯一的标识,经常用于重新获取一个对象或作为缓存的 key
这里需要注意,GraphQL 要求端点字段的类型必须是标量类型。(这里的端点可以理解为叶子节点)
关于 GraphQL
的更多信息,请参考:graphql.cn/learn/
什么是 Apollo
官网的介绍如下:
Apollo 是 GraphQL 的一个实现,可帮助您管理从云到 UI 的数据。它可以逐步采用,并在现有服务上进行分层,包括 REST API 和数据库。Apollo 包括两组用于客户端和服务器的开源库,以及开发人员工具,它提供了在生产中可靠地运行 GraphQL API 所需的一切。
我们可以将 Apollo
看作是一组工具,它分为两大类,一类面向服务端,另一类面向客户端。
其中,面向客户端的 Apollo Client
涵盖了以下工具和平台:
- React + React Native
- Angular
- Vue
- Meteor
- Ember
- IOS (Swift)
- Android (Java)
- ...
面向服务端的 Apollo Server
涵盖了以下平台:
- Java
- Scala
- Ruby
- Elixir
- NodeJS
- ...
我们在本文中会使用 Apollo
中针对 NodeJS
服务端 koa
框架的 apollo-server-koa
库
关于 apollo server 和 apollo-server-koa 的更多信息请参考:
搭建 GraphQL 后端 api 服务
快速搭建
step1:
新建一个文件夹,我这里新建了 graphql-server-demo 文件夹
mkdir graphql-server-demo
复制代码
在文件夹内初始化项目:
cd graphql-server-demo && yarn init
复制代码
安装依赖:
yarn add koa graphql apollo-server-koa
复制代码
step2:
新建 index.js 文件,并在其中撰写如下代码:
'use strict'
const path = require('path')
const Koa = require('koa')
const app = new Koa()
const { ApolloServer, gql } = require('apollo-server-koa')
/**
* 在 typeDefs 里定义 GraphQL Schema
*
* 例如:我们定义了一个查询,名为 book,类型是 Book
*/
const typeDefs = gql`
type Query {
book: Book
hello: String
}
enum BookStatus {
DELETED
NORMAL
}
type Book {
id: ID
name: String
price: Float
status: BookStatus
}
`;
const BookStatus = {
DELETED: 0,
NORMAL: 1
}
/**
* 在这里定义对应的解析器
*
* 例如:
* 针对查询 hello, 定义同名的解析器函数,返回字符串 "hello world!"
* 针对查询 book,定义同名的解析器函数,返回预先定义好的对象(实际场景可能返回来自数据库或其他接口的数据)
*/
const resolvers = {
// Apollo Server 允许我们将实际的枚举映射挂载到 resolvers 中(这些映射关系通常维护在服务端的配置文件或数据库中)
// 任何对于此枚举的数据交换,都会自动将枚举值替换为枚举名,避免了枚举值泄露到客户端的问题
BookStatus,
Query: {
hello: () => 'hello world!',
book: (parent, args, context, info) => ({
name:'地球往事',
price: 66.3,
status: BookStatus.NORMAL
})
}
};
// 通过 schema、解析器、 Apollo Server 的构造函数,创建一个 server 实例
const server = new ApolloServer({ typeDefs, resolvers })
// 将 server 实例以中间件的形式挂载到 app 上
server.applyMiddleware({ app })
// 启动 web 服务
app.listen({ port: 4000 }, () =>
console.log(`🚀 Server ready at http://localhost:4000/graphql`)
)
复制代码
通过观察上述代码,我们发现: SDL
中定义的查询 book
,有一个同名的解析器 book
作为其数据源的实现。
事实上,GraphQL
要求每个字段都需要有对应的 resolver
,对于端点字段,也就是那些标量类型的字段,大部分 GraphQL
实现库允许省略这些字段的解析器定义,这种情况下,会自动从上层对象(parent)中读取与此字段同名的属性。
因为上述代码中,hello
是一个根字段,它没有上层对象,所以我们需要主动为它实现解析器,指定数据源。
解析器是一个函数,这个函数的形参名单如下:
parent
上一级对象,如当前为根字段,则此参数值为undefined
args
在SDL
查询中传入的参数context
此参数会被提供给所有解析器,并且持有重要的上下文信息比如当前登入的用户或者数据库访问对象info
一个保存与当前查询相关的字段特定信息以及 schema 详细信息的值
step3:
启动服务
node index.js
复制代码
此时,我们在终端看到如下信息:
➜ graphql-server-demo git:(master) ✗ node index.js
🚀 Server ready at http://localhost:4000/graphql
复制代码
代表服务已经启动了
打开另一个终端界面,请求我们刚刚启动的 web 服务:
curl 'http://localhost:4000/graphql' -H 'Content-Type: application/json' --data-binary '{"query":"{hello}"}'
复制代码
或者
curl 'http://localhost:4000/graphql' -H 'Content-Type: application/json' --data-binary '{"query":"{book{name price status}}"}'
复制代码
看到如下信息:
{"data":{"hello":"Hello world!"}}
复制代码
或者
{"data":{"book":{"name":"地球往事","price":66.3,"status":"NORMAL"}}}
复制代码
代表我们已经成功创建了 GraphQL
API 服务~
在终端使用命令来调试 GraphQL
API,这显然不是我们大部分人想要的。
我们需要一个带有记忆功能的图形界面客户端,来帮助我们记住上一次每个查询的参数。
除了通过这个客户端自定义查询参数外,还能自定义头部字段、查看 Schema
文档、查看整个应用的数据结构...
接下来,我们来看 Apollo
为我们提供的 palyground
。
Playground
启动我们刚才创建的 GraphQL
后端服务:
➜ graphql-server-demo git:(master) ✗ node index.js
🚀 Server ready at http://localhost:4000/graphql
复制代码
我们在浏览器中打开地址 http://localhost:4000/graphql
这时,我们会看到如下界面:
在左侧输入查询参数:
{
book {
name
price
}
}
复制代码
然后点击中间那个按钮来发出请求(话说这个按钮设计得很像播放按钮,以至于我第一次看到它,以为这是一个视频...),请求成功后,我们会看到右侧输出了结果:
playground 还为我们提供了如下部分功能:
- 创建多个查询,并记住它们
- 自定义请求头部字段
- 查看整个 api 文档
- 查看完整的服务端 Schema 结构
如下图:
其中,DOCS
和 SCHEMA
中的内容是通过 GraphQL
的一个叫做 内省
(introspection
)的功能提供的。
内省
功能允许我们通过客户端查询参数询问 GraphQL Schema
支持哪些查询,而 playground 在启动的时候,就会预先发送 内省
请求,获取 Schema
信息,并组织好 DOCS
和 SCHEMA
的内容结构。
关于
内省
更详细的内容请参考: graphql.cn/learn/intro…
对于 playground 和 内省
,我们希望只在开发和测试生产环境才开启它们,生产环境中我们希望它们是关闭的。
我们可以在创建 Apollo Server
实例的时候,通过对应的开关(playground
和 introspection
),来屏蔽生产环境:
...
const isProd = process.env.NODE_ENV === 'production'
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: !isProd,
playground: !isProd
})
...
复制代码
接下来,我们来考虑一个比较常见的问题:
客户端和服务端将 api 文档共享之后,往往服务端的功能需要一些时间来研发,在功能研发完毕之前,客户端实际上是无法从 api 请求到真实数据的,这个时候,为了方便客户端的研发工作,我们会让 api 返回一些假数据。
接下来,我们看看在 GraphQL
服务端,怎么做这件事。
Mock
使用基于 Apollo Server
的 GraphQL
服务端来实现 api 的 mock 功能,是非常简单的。
我们只需要在构建 Apollo Server
实例时,开启 mocks 选项即可:
...
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: !isProd,
playground: !isProd,
mocks: true
})
...
复制代码
重新启动服务,并在 playground 中发出请求,会看到请求结果的数据变成了类型内的随机假数据:
得益于 GraphQL
的类型系统,虽然我们通过 mock 提供了随机数据,但这些数据的类型和 Schema
中定义的类型是一致的,这无疑减轻了我们配置 mock 的工作量,让我们可以把精力节省下来,聚焦到类型上。
实际上,我们对于类型定义得越是精准,我们的 mock 服务的质量就越高。
参数校验与错误信息
上一节我们看到了类型系统对于 mock 服务给予的一些帮助。
对于类型系统来说,它能发挥的另一个场景是:请求参数校验
通过类型系统,GraphQL
能很容易得预先判断一个查询是否符合 Schema
的规格,而不必等到后面执行的时候才发现请求参数的问题。
例如我们查询一个 book
中不存在的字段,会被 GraphQL
拦截,并返回错误:
我们看到请求返回结果中不再包含 data
字段,而只有一个 error
字段,在其中的 errors
数组字段里展示了每一个错误的具体错误细节。
实际上,当我们在 playground 中输入 none
这个错误的字段名称时,playgorund 就已经发现了这个错误的参数,并给出了提示,注意到上图左侧那个红色小块了么,将鼠标悬停在错误的字段上时,playground 会给出具体的错误提示,和服务端返回的错误内容是一致的:
这种错误在我们写查询参数的时候,就能被发现,不必发出请求,真是太棒了,不是么。
另外我们发现服务端返回的错误结果,实际上并不是那么易读,对于产品环境来说,详细的错误信息,我们只想打印到服务端的日志中,并不像返回给客户端。
因此对于响应给客户端的信息,我们可能只需要返回一个错误类型和一个简短的错误描述:
{
"error": {
"errors":[
{
"code":"GRAPHQL_VALIDATION_FAILED",
"message":"Cannot query field \"none\" on type \"Book\". Did you mean \"name\"?"
}
]
}
}
复制代码
我们可以在构建 Apollo Server
实例时,传递一个名为 formatError
的函数来格式化返回的错误信息:
...
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: !isProd,
playground: !isProd,
mocks: true,
formatError: error => {
// log detail of error here
return {
code: error.extensions.code,
message: error.message
}
}
})
...
复制代码
重启服务,再次请求,我们发现错误信息被格式化为我们预期的格式:
组织 Schema 与 Resolver
目前为止,我们搭建的 GraphQL
服务端还非常的简陋:
├── index.js
├── package.json
└── yarn.lock
复制代码
它还无法应用到实际工程中,因为它太 自由
了,我们需要为它设计一些 规矩
,来帮助我们更好得应对实际工程问题。
到这一节,相信读者已经感觉到 GraphQL
带来了哪些心智模型的改变:
- 我们原来组织
路由
的工作,部分变成了现在的组织Schema
的工作 - 我们原来组织
控制器
的工作,部分变成了现在的组织Resolver
的工作
我们来设计一个 规矩
,帮助我们组织好 Schema
和 Resolver
:
- 新建文件夹
src
,用于存放绝大部分工程代码 - 在
src
中新建文件夹components
,用于存放数据实体 - 每个数据实体是一个文件夹,包含两个文件:
schema.js
和resolver.js
,他们分别存储关于当前数据实体的Schema
和Resolver
的描述 - 在
src/components
中新建文件夹book
,并在其中新建schema.js
和resolver.js
用于存放book
相关的描述 - 在
src
创建文件夹graphql
,存放所有GraphQL
相关逻辑 - 在
graphql
中新建文件index.js
,作为GraphQL
启动文件,负责在服务端应用启动时,收集所有数据实体,生成Apollo Server
实例
按照上述步骤调整完毕后,graphql-server-demo
的整个结构如下:
├── index.js
├── package.json
├── src
│ ├── components
│ │ └── book
│ │ ├── resolver.js
│ │ └── schema.js
│ └── graphql
│ └── index.js
└── yarn.lock
复制代码
接下来我们调整代码
Step 1
先来看 GraphQL
入口文件 src/graphql/index.js
的职责:
- 负责读取合并所有 components 的
Schema
和Resolver
- 负责创建
Apollo Server
实例
入口文件 src/graphql/index.js
的最终代码如下:
const fs = require('fs')
const { resolve } = require('path')
const { ApolloServer, gql } = require('apollo-server-koa')
const defaultPath = resolve(__dirname, '../components/')
const typeDefFileName = 'schema.js'
const resolverFileName = 'resolver.js'
/**
* In this file, both schemas are merged with the help of a utility called linkSchema.
* The linkSchema defines all types shared within the schemas.
* It already defines a Subscription type for GraphQL subscriptions, which may be implemented later.
* As a workaround, there is an empty underscore field with a Boolean type in the merging utility schema, because there is no official way of completing this action yet.
* The utility schema defines the shared base types, extended with the extend statement in the other domain-specific schemas.
*
* Reference: https://www.robinwieruch.de/graphql-apollo-server-tutorial/#apollo-server-resolvers
*/
const linkSchema = gql`
type Query {
_: Boolean
}
type Mutation {
_: Boolean
}
type Subscription {
_: Boolean
}
`
function generateTypeDefsAndResolvers () {
const typeDefs = [linkSchema]
const resolvers = {}
const _generateAllComponentRecursive = (path = defaultPath) => {
const list = fs.readdirSync(path)
list.forEach(item => {
const resolverPath = path + '/' + item
const stat = fs.statSync(resolverPath)
const isDir = stat.isDirectory()
const isFile = stat.isFile()
if (isDir) {
_generateAllComponentRecursive(resolverPath)
} else if (isFile && item === typeDefFileName) {
const { schema } = require(resolverPath)
typeDefs.push(schema)
} else if (isFile && item === resolverFileName) {
const resolversPerFile = require(resolverPath)
Object.keys(resolversPerFile).forEach(k => {
if (!resolvers[k]) resolvers[k] = {}
resolvers[k] = { ...resolvers[k], ...resolversPerFile[k] }
})
}
})
}
_generateAllComponentRecursive()
return { typeDefs, resolvers }
}
const isProd = process.env.NODE_ENV === 'production'
const apolloServerOptions = {
...generateTypeDefsAndResolvers(),
formatError: error => ({
code: error.extensions.code,
message: error.message
}),
introspection: !isProd,
playground: !isProd,
mocks: false
}
module.exports = new ApolloServer({ ...apolloServerOptions })
复制代码
上述代码中,我们看到 linkSchema
的值中分别在 Query
、Mutation
、Subscription
三个类型入口中定义了一个名为 _
,类型为 Boolean
的字段。
这个字段实际上是一个占位符,因为官方尚未支持多个扩展(extend
)类型合并的方法,因此这里我们可以先设置一个占位符,以支持合并扩展(extend
)类型。
Step 2
我们来定义数据实体:book
的 Schema
和 Resolver
的内容:
// src/components/book/schema.js
const { gql } = require('apollo-server-koa')
const schema = gql`
enum BookStatus {
DELETED
NORMAL
}
type Book {
id: ID
name: String
price: Float
status: BookStatus
}
extend type Query {
book: Book
}
`
module.exports = { schema }
复制代码
这里我们不在需要 hello
这个查询,所以我们在调整 book
相关代码时,移除了 hello
通过上述代码,我们看到,通过 extend
关键字,我们可以单独定义针对 book
的查询类型
// src/components/book/resolver.js
const BookStatus = {
DELETED: 0,
NORMAL: 1
}
const resolvers = {
BookStatus,
Query: {
book: (parent, args, context, info) => ({
name: '地球往事',
price: 66.3,
status: BookStatus.NORMAL
})
}
}
module.exports = resolvers
复制代码
上述代码定义了 book
查询的数据来源,resolver
函数支持返回 Promise
Step 3
最后,我们来调整服务应用启动文件的内容:
const Koa = require('koa')
const app = new Koa()
const apolloServer = require('./src/graphql/index.js')
apolloServer.applyMiddleware({ app })
app.listen({ port: 4000 }, () =>
console.log(`🚀 Server ready at http://localhost:4000/graphql`)
)
复制代码
wow~,服务启动文件的内容看起来精简了很多。
在前面章节中我们说过:对于字段类型,我们定义得越是精准,我们的 mock 服务和参数校验服务的质量就越好。
那么,现有的这几个标量类型不满足我们的需求时,怎么办呢?
接下来,我们来看如何实现自定义标量
自定义标量实现日期字段
我们为 Book
新增一个字段,名为 created
,类型为 Date
...
type Book {
id: ID
name: String
price: Float
status: BookStatus
created: Date
}
...
复制代码
book: (parent, args, context, info) => ({
name: '地球往事',
price: 66.3,
status: BookStatus.NORMAL,
created: 1199116800000
})
复制代码
GraphQL
标准中并没有 Date
类型,我们来实现自定义的 Date
类型:
Step 1
首先,我们安装一个第三方日期工具 moment
:
yarn add moment
复制代码
Step 2
接下来,在 src/graphql
中新建文件夹 scalars
mkdir src/graphql/scalars
复制代码
我们在 scalars
这个文件夹中存放自定义标量
在 scalars
中新建文件: index.js
和 date.js
src/graphql/
├── index.js
└── scalars
├── date.js
└── index.js
复制代码
文件 scalars/index.js
负责导出自定义标量 Date
module.exports = {
...require('./date.js')
}
复制代码
文件 scalars/date.js
负责实现自定义标量 Date
const moment = require('moment')
const { Kind } = require('graphql/language')
const { GraphQLScalarType } = require('graphql')
const customScalarDate = new GraphQLScalarType({
name: 'Date',
description: 'Date custom scalar type',
parseValue: value => moment(value).valueOf(),
serialize: value => moment(value).format('YYYY-MM-DD HH:mm:ss:SSS'),
parseLiteral: ast => (ast.kind === Kind.INT)
? parseInt(ast.value, 10)
: null
})
module.exports = { Date: customScalarDate }
复制代码
通过上述代码,我们看到,实现一个自定义标量,只需要创建一个 GraphQLScalarType
的实例即可。
在创建 GraphQLScalarType
实例时,我们可以指定:
- 自定义标量的名称,也就是
name
- 自定义标量的简介,也就是
description
- 当自定义标量值从客户端传递到服务端时的处理函数,也就是
parseValue
- 当自定义标量值从服务端返回到客户端时的处理函数,也就是
serialize
- 对于自定义标量在
ast
中的字面量的处理函数,也就是parseLiteral
(这是因为在ast
中的值总是格式化为字符串)
ast
即抽象语法树,关于抽象语法树的细节请参考:zh.wikipedia.org/wiki/抽象語法樹
Step 3
最后,让我们将自定义标量 Date
挂载到 GraphQL
启动文件中:
...
const allCustomScalars = require('./scalars/index.js')
...
const linkSchema = gql`
scalar Date
type Query {
_: Boolean
}
type Mutation {
_: Boolean
}
type Subscription {
_: Boolean
}
`
...
function generateTypeDefsAndResolvers () {
const typeDefs = [linkSchema]
const resolvers = { ...allCustomScalars }
...
复制代码
最后,我们验证一下,重启服务,并请求 book
的 created
字段,我们发现服务端已经支持 Date
类型了:
自定义指令实现登录校验功能
本小节,我们来学习如何在 GraphQL
服务端实现登录校验功能
过去,我们每个具体的路由,对应一个具体的资源,我们很容易为一部分资源添加保护(要求登录用户才有访问权限),我们只需要设计一个中间件,并在每一个需要保护的路由上添加一个标记即可。
GraphQL
打破了路由与资源对应的概念,它主张在 Schema
内部标记哪些字段是受保护的,以此来提供资源保护的功能。
我们想要在 GraphQL
服务中实现登录校验的功能,就需要借助于以下几个工具:
- koa 中间件
- resolver 中的 context
- 自定义指令
Step 1
首先,我们定义一个 koa 中间件,在中间件中检查请求头部是否有传递用户签名,如果有,就根据此签名获取用户信息,并将用户信息挂载到 koa 请求上下文对象 ctx
上。
在 src
中新建文件夹 middlewares
,用来存放所有 koa 的中间件
mkdir src/middlewares
复制代码
在文件夹 src/middlewares
中新建文件 auth.js
,作为挂载用户信息的中间件:
touch src/middlewares/auth.js
复制代码
async function main (ctx, next) {
// 注意,在真实场景中,需要在这里获取请求头部的用户签名,比如:token
// 并根据用户 token 获取用户信息,然后将用户信息挂载到 ctx 上
// 这里为了简单演示,省去了上述步骤,挂载了一个模拟的用户信息
ctx.user = { name: 'your name', age: Math.random() }
return next()
}
module.exports = main
复制代码
将此中间件挂载到应用上:
...
app.use(require('./src/middlewares/auth.js'))
apolloServer.applyMiddleware({ app })
app.listen({ port: 4000 }, () =>
console.log(`🚀 Server ready at http://localhost:4000/graphql`)
)
复制代码
这里需要注意一个细节,auth
中间件的挂载,必须要在 apolloServer
挂载的前面,这是因为 koa
中的请求,是按照挂载顺序通过中间件栈的,我们预期在 apolloServer
处理请求前,在 ctx
上就已经挂载了用户信息
Step 2
通过解析器的 context 参数,传递 ctx
对象,方便后续通过该对象获取用户信息(在前面小节中,我们介绍过解析器的形参名单,其中第三个参数名为 context
)
在创建 Apollo Server
实例时,我们还可以指定一个名为 context
的选项,值可以是一个函数
当 context
的值为函数时,应用的请求上下文对象 ctx
会作为此函数第一个形参的一个属性,传递给当前 context
函数;而 context
函数的返回值,会作为 context
参数传递给每一个解析器函数
因此我们只需要这么写,就可以将请求的上下文对象 ctx
,传递给每个解析器:
...
const apolloServerOptions = {
...generateTypeDefsAndResolvers(),
formatError: error => ({
code: error.extensions.code,
message: error.message
}),
context: ({ ctx }) => ({ ctx }),
introspection: !isProd,
playground: !isProd,
mocks: false
}
...
复制代码
这样,每个解析器函数,只需要简单获取第三个形参就能拿到 ctx
了,从而可以通过 ctx
获取其上的 user
属性(用户信息)
Step 3
然后,我们设计一个自定义指令 auth
(它是 authentication
的简写)
在 src/graphql
中新建文件夹 directives
,用来存放所有自定义指令:
mkdir src/graphql/directives
复制代码
我们在 directives
这个文件夹中存放自定义指令
在 directives
中新建文件: index.js
和 auth.js
src/graphql
├── directives
│ ├── auth.js
│ └── index.js
├── index.js
└── scalars
├── date.js
└── index.js
复制代码
文件 directives/index.js
负责导出自定义指令 auth
module.exports = {
...require('./auth.js')
}
复制代码
文件 directives/auth.js
负责实现自定义指令 auth
const { SchemaDirectiveVisitor, AuthenticationError } = require('apollo-server-koa')
const { defaultFieldResolver } = require('graphql')
class AuthDirective extends SchemaDirectiveVisitor {
visitFieldDefinition (field) {
const { resolve = defaultFieldResolver } = field
field.resolve = async function (...args) {
const context = args[2]
const user = context.ctx.user
console.log('[CURRENT USER]', { user })
if (!user) throw new AuthenticationError('Authentication Failure')
return resolve.apply(this, args)
}
}
}
module.exports = {
auth: AuthDirective
}
复制代码
通过上述代码,我们看到 Apollo Server
除了提供基础的指令访问者类 SchemaDirectiveVisitor
外,还提供了认证错误类 AuthenticationError
我们声明一个自定义的 AuthDirective
类,继承 SchemaDirectiveVisitor
,并在其类方法 visitFieldDefinition
中撰写针对每个捕获到的字段上需要执行的认证逻辑
认证逻辑非常简单,在字段原来的解析器基础之上,包装一层认证逻辑即可:
- 我们尝试从
field
中获取它的解析器,并将它临时存储在局部变量中,方便接下来使用它。如果获取不到,则赋值默认的解析器defaultFieldResolver
- 我们覆盖
field
上的解析器属性为我们自定义的函数,在函数内我们通过args[2]
访问到了解析器函数的第三个形参,并从中获取到挂载在ctx
上的用户信息 - 如果用户信息不存在,则抛出
AuthenticationError
错误 - 返回字段原来的解析器执行结果
Step 4
在创建 Apollo Server
实例时通过 schemaDirectives
选项挂载自定义指令:
...
const allCustomDirectives = require('./directives/index.js')
...
const apolloServerOptions = {
...generateTypeDefsAndResolvers(),
formatError: error => ({
code: error.extensions.code,
message: error.message
}),
schemaDirectives: { ...allCustomDirectives },
context: ({ ctx }) => ({ ctx }),
introspection: !isProd,
playground: !isProd,
mocks: false
}
...
复制代码
在全局 linkSchema
中声明该指令,并在数据实体的 Schema
中为每个需要保护的字段,标记上 @auth
(代表需要登录才能访问此字段)
...
const linkSchema = gql`
scalar Date
directive @auth on FIELD_DEFINITION
type Query {
_: Boolean
}
type Mutation {
_: Boolean
}
type Subscription {
_: Boolean
}
`
...
复制代码
上述代码中,FIELD_DEFINITION
代表此命令只作用于具体某个字段
这里,我们为仅有的 book
查询字段添加上我们的自定义指令 @auth
...
const schema = gql`
enum BookStatus {
DELETED
NORMAL
}
type Book {
id: ID
name: String
price: Float
status: BookStatus
created: Date
}
extend type Query {
book: Book @auth
}
`
...
复制代码
我们为 book
查询字段添加了 @auth
约束
接下来,我们重启服务,请求 book
,我们发现终端打印出:
[CURRENT USER] { user: { name: 'your name', age: 0.30990570160950015 } }
复制代码
这代表自定义指令的代码运行了
接下来我们注释掉 auth
中间件中的模拟用户代码:
async function main (ctx, next) {
// 注意,在真实场景中,需要在这里获取请求头部的用户签名,比如:token
// 并根据用户 token 获取用户信息,然后将用户信息挂载到 ctx 上
// 这里为了简单演示,省去了上述步骤,挂载了一个模拟的用户信息
// ctx.user = { name: 'your name', age: Math.random() }
return next()
}
module.exports = main
复制代码
重启服务,再次请求 book
,我们看到:
结果中出现了 errors
,其 code 值为 UNAUTHENTICATED
,这说明我们的指令成功拦截了未登录请求
合并请求
最后,我们来看一个由 GraphQL
的设计所导致的一个问题: 不必要的请求
我们在 graphql-server-demo
中增加一个新的数据实体: cat
最终目录结构如下:
src
├── components
│ ├── book
│ │ ├── resolver.js
│ │ └── schema.js
│ └── cat
│ ├── resolver.js
│ └── schema.js
├── graphql
│ ├── directives
│ │ ├── auth.js
│ │ └── index.js
│ ├── index.js
│ └── scalars
│ ├── date.js
│ └── index.js
└── middlewares
└── auth.js
复制代码
其中 src/components/cat/schema.js
的代码如下:
const { gql } = require('apollo-server-koa')
const schema = gql`
type Food {
id: Int
name: String
}
type Cat {
color: String
love: Food
}
extend type Query {
cats: [Cat]
}
`
module.exports = { schema }
复制代码
我们定义了两个数据类型: Cat
和 Food
并定义了一个查询: cats
, 此查询返回一组猫
src/components/cat/resolver.js
的代码如下:
const foods = [
{ id: 1, name: 'milk' },
{ id: 2, name: 'apple' },
{ id: 3, name: 'fish' }
]
const cats = [
{ color: 'white', foodId: 1 },
{ color: 'red', foodId: 2 },
{ color: 'black', foodId: 3 }
]
const fakerIO = arg => new Promise((resolve, reject) => {
setTimeout(() => resolve(arg), 300)
})
const getFoodById = async id => {
console.log('--- enter getFoodById ---', { id })
return fakerIO(foods.find(food => food.id === id))
}
const resolvers = {
Query: {
cats: (parent, args, context, info) => cats
},
Cat: {
love: async cat => getFoodById(cat.foodId)
}
}
module.exports = resolvers
复制代码
根据上述代码,我们看到:
- 每只猫都有一个
foodId
字段,值为最爱吃的食物的 id - 我们通过函数
fakerIO
来模拟异步IO - 我们实现了一个函数
getFoodById
提供根据食物 id 获取食物信息的功能,每调用一次getFoodById
函数,都将打印一条日志到终端
重启服务,请求 cats
,我们看到正常返回了结果:
我们去看一下终端的输出,发现:
--- enter getFoodById --- { id: 1 }
--- enter getFoodById --- { id: 2 }
--- enter getFoodById --- { id: 3 }
复制代码
getFoodById
函数被分别调用了三次。
GraphQL
的设计主张为每个字段指定解析器,这导致了:
一个批量的请求,在关联其它数据实体时,每个端点都会造成一次 IO。
这就是 不必要的请求
,因为上面这些请求可以合并为一次请求。
我们怎么合并这些 不必要的请求
呢?
我们可以通过一个叫做 dataLoader
的工具来合并这些请求。
dataLoader
提供了两个主要的功能:
- Batching
- Caching
本文中,我们只使用它的 Batching
功能
关于
dataLoader
更多的信息,请参考: github.com/graphql/dat…
Step 1
首先,我们安装 dataLoader
yarn add dataloader
复制代码
Step 2
接下来,我们在 src/components/cat/resolver.js
中:
- 提供一个批量获取
food
的函数getFoodByIds
- 引入
dataLoader
, 包装getFoodByIds
函数,返回一个包装后的函数getFoodByIdBatching
- 在
love
的解析器函数中使用getFoodByIdBatching
来获取food
const DataLoader = require('dataloader')
...
const getFoodByIds = async ids => {
console.log('--- enter getFoodByIds ---', { ids })
return fakerIO(foods.filter(food => ids.includes(food.id)))
}
const foodLoader = new DataLoader(ids => getFoodByIds(ids))
const getFoodByIdBatching = foodId => foodLoader.load(foodId)
const resolvers = {
Query: {
cats: (parent, args, context, info) => cats
},
Cat: {
love: async cat => getFoodByIdBatching(cat.foodId)
}
}
...
复制代码
重启服务,再次请求 cats
,我们依然看到返回了正确的结果,此时,我们去看终端,发现:
--- enter getFoodByIds --- { ids: [ 1, 2, 3 ] }
复制代码
原来的三次 IO 请求已经成功合并为一个了。
最终,我们的 graphql-server-demo
目录结构如下:
├── index.js
├── package.json
├── src
│ ├── components
│ │ ├── book
│ │ │ ├── resolver.js
│ │ │ └── schema.js
│ │ └── cat
│ │ ├── resolver.js
│ │ └── schema.js
│ ├── graphql
│ │ ├── directives
│ │ │ ├── auth.js
│ │ │ └── index.js
│ │ ├── index.js
│ │ └── scalars
│ │ ├── date.js
│ │ └── index.js
│ └── middlewares
│ └── auth.js
└── yarn.lock
复制代码
结束语
读到这里,相信您对于构建 GraphQL
服务端,有了一个大致的印象。
这篇文章实际上只介绍了 GraphQL
中相当有限的一部分知识。想要全面且深入地掌握 GraphQL
,还需要读者继续探索和学习。
至此,本篇文章就结束了,希望这篇文章能在接下来的工作和生活中帮助到您。
参考
关于
Apollo Server
构造器选项的完整名单请参考:www.apollographql.com/docs/apollo…
水滴前端团队招募伙伴,欢迎投递简历到邮箱:fed@shuidihuzhu.com
近期评论