本文预期读者对 NodeJS、Koa 有必定的了解javascript
过去,服务端的研发人员设计一个数据接口,一般并不会要求使用接口的客户端研发人员知道接口内部的数据结构,而是只提供一份 api 文档(使用说明书),文档内容介绍如何调用 API,返回什么数据,文档和功能都实现后,就算完成服务端工做了。前端
咱们使用这个工做方式工做,慢慢地发现了一些问题:java
慢慢地,咱们发现,在服务端和客户端之间,须要共享一份数据描述规范:node
GraphQL 就是这么一种数据描述规范。git
官网的介绍以下:github
GraphQL 是一个用于 API 的查询语言,是一个使用基于类型系统来执行查询的服务端运行时(类型系统由你的数据定义)。web
下面是一个基于 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
)json
SDL
用于表示 schema 中可用的类型,以及这些类型之间的关系后端
SDL
必须以字符串的形式存储
SDL
规定了三类入口,分别为:
Query
用于定义读
操做 (能够理解为 CURD
中的 R
)Mutation
用于定义写
操做 (能够理解为 CURD
中的 CUD
)Subscription
用于定义长连接(基于事件的、建立和维持与服务端实时链接的方式)经过上述代码,咱们声明了一个查询,名为 book
,类型为 Book
类型 Book
拥有四个字段,分别为:
BookStatus
BookStatus
是一个枚举类型,包含:
DELETED
表明书已经下架,它的值为 0
NORMAL
表明书在正常销售中, 它的值为 1
除了能够定义本身的数据类型外,GraphQL 还内置了一下几种基础类型(标量):
Int
: 有符号 32 位整型Float
: 有符号双精度浮点型String
: UTF-8 字符序列Boolean
: true 或 falseID
: 一个惟一的标识,常常用于从新获取一个对象或做为缓存的 key这里须要注意,GraphQL 要求端点字段的类型必须是标量类型。(这里的端点能够理解为叶子节点)
关于 GraphQL
的更多信息,请参考:graphql.cn/learn/
官网的介绍以下:
Apollo 是 GraphQL 的一个实现,可帮助您管理从云到 UI 的数据。它能够逐步采用,并在现有服务上进行分层,包括 REST API 和数据库。Apollo 包括两组用于客户端和服务器的开源库,以及开发人员工具,它提供了在生产中可靠地运行 GraphQL API 所需的一切。
咱们能够将 Apollo
看做是一组工具,它分为两大类,一类面向服务端,另外一类面向客户端。
其中,面向客户端的 Apollo Client
涵盖了如下工具和平台:
面向服务端的 Apollo Server
涵盖了如下平台:
咱们在本文中会使用 Apollo
中针对 NodeJS
服务端 koa
框架的 apollo-server-koa
库
关于 apollo server 和 apollo-server-koa 的更多信息请参考:
新建一个文件夹,我这里新建了 graphql-server-demo 文件夹
mkdir graphql-server-demo
复制代码
在文件夹内初始化项目:
cd graphql-server-demo && yarn init
复制代码
安装依赖:
yarn add koa graphql apollo-server-koa
复制代码
新建 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 详细信息的值启动服务
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
。
启动咱们刚才建立的 GraphQL
后端服务:
➜ graphql-server-demo git:(master) ✗ node index.js
🚀 Server ready at http://localhost:4000/graphql
复制代码
咱们在浏览器中打开地址 http://localhost:4000/graphql
这时,咱们会看到以下界面:
在左侧输入查询参数:
{
book {
name
price
}
}
复制代码
而后点击中间那个按钮来发出请求(话说这个按钮设计得很像播放按钮,以致于我第一次看到它,觉得这是一个视频...),请求成功后,咱们会看到右侧输出告终果:
playground 还为咱们提供了以下部分功能:
以下图:
其中,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
服务端,怎么作这件事。
使用基于 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
}
}
})
...
复制代码
重启服务,再次请求,咱们发现错误信息被格式化为咱们预期的格式:
目前为止,咱们搭建的 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
复制代码
接下来咱们调整代码
先来看 GraphQL
入口文件 src/graphql/index.js
的职责:
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
)类型。
咱们来定义数据实体: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
最后,咱们来调整服务应用启动文件的内容:
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
类型:
首先,咱们安装一个第三方日期工具 moment
:
yarn add moment
复制代码
接下来,在 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/抽象語法樹
最后,让咱们将自定义标量 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 中间件,在中间件中检查请求头部是否有传递用户签名,若是有,就根据此签名获取用户信息,并将用户信息挂载到 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
上就已经挂载了用户信息
经过解析器的 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
属性(用户信息)
而后,咱们设计一个自定义指令 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
错误在建立 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
字段,值为最爱吃的食物的 idfakerIO
来模拟异步IOgetFoodById
提供根据食物 id 获取食物信息的功能,每调用一次 getFoodById
函数,都将打印一条日志到终端重启服务,请求 cats
,咱们看到正常返回告终果:
咱们去看一下终端的输出,发现:
--- enter getFoodById --- { id: 1 }
--- enter getFoodById --- { id: 2 }
--- enter getFoodById --- { id: 3 }
复制代码
getFoodById
函数被分别调用了三次。
GraphQL
的设计主张为每一个字段指定解析器,这致使了:
一个批量的请求,在关联其它数据实体时,每一个端点都会形成一次 IO。
这就是 没必要要的请求
,由于上面这些请求能够合并为一次请求。
咱们怎么合并这些 没必要要的请求
呢?
咱们能够经过一个叫作 dataLoader
的工具来合并这些请求。
dataLoader
提供了两个主要的功能:
本文中,咱们只使用它的 Batching
功能
关于
dataLoader
更多的信息,请参考: github.com/graphql/dat…
首先,咱们安装 dataLoader
yarn add dataloader
复制代码
接下来,咱们在 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