【第十期】基于Apollo、Koa搭建GraphQL

本文预期读者对 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 或 false
  • ID: 一个唯一的标识,经常用于重新获取一个对象或作为缓存的 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
  • argsSDL 查询中传入的参数
  • 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 结构

如下图:

其中,DOCSSCHEMA 中的内容是通过 GraphQL 的一个叫做 内省introspection)的功能提供的。

内省 功能允许我们通过客户端查询参数询问 GraphQL Schema 支持哪些查询,而 playground 在启动的时候,就会预先发送 内省 请求,获取 Schema 信息,并组织好 DOCSSCHEMA 的内容结构。

关于 内省 更详细的内容请参考: graphql.cn/learn/intro…

对于 playground 和 内省,我们希望只在开发和测试生产环境才开启它们,生产环境中我们希望它们是关闭的。

我们可以在创建 Apollo Server 实例的时候,通过对应的开关(playgroundintrospection),来屏蔽生产环境:

...
const isProd = process.env.NODE_ENV === 'production'

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: !isProd,
  playground: !isProd
})
...
复制代码

接下来,我们来考虑一个比较常见的问题:

客户端和服务端将 api 文档共享之后,往往服务端的功能需要一些时间来研发,在功能研发完毕之前,客户端实际上是无法从 api 请求到真实数据的,这个时候,为了方便客户端的研发工作,我们会让 api 返回一些假数据。

接下来,我们看看在 GraphQL 服务端,怎么做这件事。

Mock

使用基于 Apollo ServerGraphQL 服务端来实现 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 的工作

我们来设计一个 规矩,帮助我们组织好 SchemaResolver:

  • 新建文件夹 src,用于存放绝大部分工程代码
  • src 中新建文件夹 components ,用于存放数据实体
  • 每个数据实体是一个文件夹,包含两个文件:schema.jsresolver.js,他们分别存储关于当前数据实体的 SchemaResolver 的描述
  • src/components 中新建文件夹 book,并在其中新建 schema.jsresolver.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 的 SchemaResolver
  • 负责创建 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 的值中分别在 QueryMutationSubscription 三个类型入口中定义了一个名为 _,类型为 Boolean 的字段。

这个字段实际上是一个占位符,因为官方尚未支持多个扩展(extend)类型合并的方法,因此这里我们可以先设置一个占位符,以支持合并扩展(extend)类型。

Step 2

我们来定义数据实体:bookSchemaResolver 的内容:

// 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.jsdate.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 实例时,我们可以指定:

  1. 自定义标量的名称,也就是 name
  2. 自定义标量的简介,也就是 description
  3. 当自定义标量值从客户端传递到服务端时的处理函数,也就是 parseValue
  4. 当自定义标量值从服务端返回到客户端时的处理函数,也就是 serialize
  5. 对于自定义标量在 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 }
...

复制代码

最后,我们验证一下,重启服务,并请求 bookcreated 字段,我们发现服务端已经支持 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.jsauth.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 中撰写针对每个捕获到的字段上需要执行的认证逻辑

认证逻辑非常简单,在字段原来的解析器基础之上,包装一层认证逻辑即可:

  1. 我们尝试从 field 中获取它的解析器,并将它临时存储在局部变量中,方便接下来使用它。如果获取不到,则赋值默认的解析器 defaultFieldResolver
  2. 我们覆盖 field 上的解析器属性为我们自定义的函数,在函数内我们通过 args[2] 访问到了解析器函数的第三个形参,并从中获取到挂载在 ctx 上的用户信息
  3. 如果用户信息不存在,则抛出 AuthenticationError 错误
  4. 返回字段原来的解析器执行结果
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 }
复制代码

我们定义了两个数据类型: CatFood

并定义了一个查询: 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