做后台管理系统那阵子,我被一个需求折腾得挺烦:文章列表页要标题和状态,文章详情页要正文和分类名,App 端只要标题和封面图。三个端,三套字段,后端就得维护三个接口,或者维护一个返回 50 个字段的「大而全」接口。前端拿到一坨 JSON,还得靠猜才知道哪个字段是有用的。
后来把这套东西用 GraphQL 重写了一遍,前端要什么字段自己在查询里写,后端只管把 Schema 描述清楚。这篇是我当时的完整实战笔记,从 mongodb 造数据开始,到 Express、Koa2 两种集成方式,再到 Vue 里用 Apollo 消费接口,中间的坑我都标出来了。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- GraphQL 到底解决了 RESTful 的哪几个具体痛点,什么场景下不值得上
- GraphQL 的类型系统:标量类型、Object、List、Non-Null 分别怎么用
- 整套 Demo 的架构分层,请求从 Vue 组件走到 mongodb 中间经过了什么
- Express 集成
express-graphql,写出第一个可查询的 Schema - Koa2 集成
koa-graphql,实现聚合查询、分页查询和增删改(mutation) - Vue 里用
vue-apollo+apollo-boost做简单查询、带参查询、mutation 和上拉分页 - 上线前必须确认的一份 checklist,以及 2022 年至今这套依赖的现状
# 一、GraphQL 解决了什么问题
# 1.1 先说清楚它是什么
GraphQL 是一种新的 API 的查询语言,它提供了一种更高效、强大和灵活 API 查询。它是由 Facebook 开发和开源,目前由来自世界各地的大公司和个人维护。GraphQL 对你的 API 中的数据提供了一套易于理解的完整描述,使得客户端能够准确地获得它需要的数据,而且没有任何冗余。它弥补了 RESTful API(字段冗余,扩展性差、无法聚合 api、无法定义数据类型、网络请求次数多)等不足
这里有个很多人一上来就搞混的点,GraphQL 是 API 的查询语言,不是数据库。它和 SQL 没有半点关系,也不关心你底层用的是 mongodb、MySQL 还是别人家的 HTTP 接口。我更愿意把它理解成架在业务 API 之上的一层描述和编排:你把数据长什么样、字段是什么类型、怎么取到,全部用 Schema 声明一遍,剩下的拼装交给客户端。
正因为它和存储无关,所以在哪种语言里都能落地。
- 服务器端语言:C# / .NET、Clojure、Elixir、Erlang、Go、Groovy、Java、JavaScript、PHP、Python、Scala、Ruby
- 客户端语言:js、React + React Native、Angular、Vue.js、Apollo Link、Native iOS、Native Android、Scala.js
两个官方入口先存下来,后面写 Schema 查类型的时候要反复翻:
- 中文文档:http://graphql.cn
- Github: https://github.com/facebook/graphql
# 1.2 它是被什么倒逼出来的
当提起 API 设计的时候,大家通常会想到 SOAP(一种简单的基于 XML 的协议)、RESTful 等设计方式。从 2000 年 RESTful 的理论被提出的时候,在业界引起了很大反响,因为这种设计理念更易于用户的使用,所以便很快的被大家所接受。
REST 是一种从服务器公开数据的流行方式。当 REST 的概念被提出来的时候,客户端应用程序对数据的需求相对简单,而迭代的速度也没有达到今天的水平。
那时候 REST 对于许多应用程序来说是非常合适的。可业务越来越复杂,客户对系统扩展性的要求越来越高,API 环境发生了巨大的变化,RESTful 就显得心有余而力不足。具体表现就是那几条:字段冗余、扩展性差、无法聚合 api、无法定义数据类型、网络请求次数多。
GraphQL 的出现正好补上了 RESTful API 的这几块短板。
已经在用 GraphQL 的公司不少,官方维护了一份名单(https://graphql.org/users/):

# 1.3 为什么推荐 GraphQL 而不是 RESTful API
在过去的十多年中,REST 已经成为设计 web api 的标准(虽然只是一个模糊的标准)。它提供了一些很棒的想法,比如无状态服务器和结构化的资源访问。
然而 REST api 表现得过于僵化,跟不上客户端需求变化的速度。
先看 RESTful 具体卡在哪三个地方。
扩展性差,多个终端需要返回不同的字段。 单个 RESTful 接口返回的数据会越滚越臃肿。前端对于真正用到的字段是没有直观映像的,仅仅通过 url 地址,无法预测也无法回忆返回的字段数目和字段是否有效。接口返回 50 个字段,实际只用 5 个,剩下 45 个就是纯粹的带宽浪费。
API 聚合问题。 某个前端页面的一次展现,实际需要调用多个独立的 RESTful API 才能凑齐数据,导致网络请求次数多。文章详情页要拿文章 + 分类 + 作者,就是三个请求,串行起来首屏直接慢一截。
前后端字段频繁改动,导致类型不一致。 错误的数据类型可能会导致网站出错,尤其是在业务多变的场景中,很难在保证工程质量的同时快速满足业务需求。后端偷偷把 status 从数字改成字符串,前端的 === 1 判断就悄无声息地失效了。
那 GraphQL 好在哪?它吸收了 RESTful API 的特性,同时补上了这几块。
所见即所得。 各种不同的前端框架和平台可以指定自己需要的字段,查询的返回结果就是输入的查询结构的精确映射。你写什么形状,就回什么形状,不多不少。
客户端可以自定义 API 聚合。 如果设计的数据结构是从属的,直接就能在查询语句中指定;即使数据结构是独立的,也可以在查询语句中指定上下文。只需要一次网络请求,就能拿到资源和子资源的全部数据。上面那个「文章 + 分类 + 作者」的例子,在 GraphQL 里就是一次请求。
代码即是文档。 GraphQL 会把 schema 定义和相关的注释生成可视化的文档,代码变更直接反映到最新的文档上,避免 RESTful 里手工维护 Swagger 造成代码、文档不一致的问题。这个设计是真的舒服,后面用 GraphiQL 调试的时候你会有体感。
参数类型强校验。 RESTful 方案本身没有对参数的类型做规定,往往都需要自行实现参数的校验机制以确保安全;GraphQL 提供了强类型的 schema 机制,从而天然确保了参数类型的合法性。必填字段少传一个,请求根本进不到 resolve 里。
这里也要泼一点冷水。不是说 RESTful 不行,而是 GraphQL 有它自己的代价:缓存策略比 REST 复杂(所有请求都打到一个 URL,CDN 和浏览器缓存基本用不上)、深层嵌套查询容易触发 N+1 数据库查询、还得额外防一手恶意的超深查询。如果你的项目只有一个 Web 端、接口十来个、字段也基本稳定,那上 GraphQL 大概率是给自己找活干。 我自己的判断是,多端 + 字段差异大 + 页面需要聚合多个资源,这三条至少中两条才值得上。
# 二、GraphQL 的类型系统
Schema 是整个 GraphQL 服务的骨架,而 Schema 是由类型拼出来的。这一节先把类型认全,后面写 schema/default.js 的时候才不会照抄不知所以。
# 2.1 标量类型和高级类型
可以将 GraphQL 的类型系统分为
标量类型(Scalar Types)和其他高级数据类型。标量类型即可以表示最细粒度数据结构的数据类型,可以和 JavaScript 的原始类型对应
标量类型是叶子节点,一个查询最终一定会落到某个标量上,不然 GraphQL 会直接报错说这个字段必须再选子字段。规范目前规定支持的标量类型有这几个:
- Int:有符号
32位整数,对应GraphQLInt - Float:有符号双精度浮点值,对应
GraphQLFloat - String:
UTF-8字符序列,对应GraphQLString - Boolean:
true或者false,对应GraphQLBoolean - ID(
GraphQLID):表示一个唯一标识符,通常用以重新获取对象或者作为缓存中的键。ID 类型使用和 String 一样的方式序列化,但把它定义为 ID 就是在告诉调用方「这玩意儿不用给人看」
这里有个坑要注意:mongodb 的 _id 是 ObjectId,序列化出来是 24 位十六进制字符串。用 GraphQLID 或 GraphQLString 都能跑,但语义上 GraphQLID 更准,Apollo Client 的默认缓存策略也是靠 id / _id 字段做归一化的。后面 Koa 那版 Schema 就把 _id 换成了 GraphQLID。
再往上是高级数据类型。
Object(new GraphQLObjectType) 用于描述层级或者树形数据结构。对于树形数据结构来说,叶子字段的类型都是标量数据类型。几乎所有 GraphQL 类型都是对象类型。Object 类型有一个 name 字段,以及一个很重要的 fields 字段,fields 可以描述出一个完整的数据结构。
下面这段定义了一个地址对象,重点看 formatted 字段,它在数据库里根本不存在,是靠 resolve 现算出来的:
const AddressType = new GraphQLObjectType({
name: 'Address',
fields: {
street: {
type: GraphQLString
},
number: {
type: GraphQLInt
},
formatted: {
type: GraphQLString,
resolve(obj) {
return obj.number + ' ' + obj.street
}
}
}
});
resolve(obj) 里的 obj 就是上一层传下来的数据对象。这个能力后面会反复用到:只要一个字段能写 resolve,它就可以去查另一张表、调另一个服务,聚合查询就是靠这个实现的。
剩下几个类型平时用得少一些,但要知道有:
Interface:接口,用于描述多个类型的通用字段Union:联合类型,用于描述某个字段能够支持的所有返回类型以及具体请求时真正的返回类型Enum:枚举,用于表示可枚举数据结构的类型(订单状态这种就很适合)InputObject:输入对象,mutation 参数多的时候用它打包List:列表
列表是其他类型的封装,通常用于对象字段的描述。像下面 PersonType 的 parents 和 children 字段,装的都是 PersonType 自己:
const PersonType = new GraphQLObjectType({
name: 'Person',
fields: () => ({
parents: { type: new GraphQLList(PersonType) },
children: { type: new GraphQLList(PersonType) },
})
})
注意 fields 这里写成了箭头函数而不是对象字面量。这不是风格问题,是必须的:PersonType 在定义自己的时候引用了自己,写成对象字面量的话执行到那一行 PersonType 还是 undefined。用函数包一层就变成了延迟求值,等真正需要 fields 的时候再执行,循环引用就解开了。这个我踩过,报错信息还挺不直观的。
最后是 Non-Null,强制类型的值不能为 null,并且在请求出错时一定会报错。用在那些绝对不该为空的字段上,比如数据库某一行的 id: