在看些文章时不经意看到智联团的的GraphQL使用的技术沉淀。拿出来给大家分享。
此文是作者考虑 GraphQL 在 Node.js 架构中的落地方案后所得。从最初考虑可以(以内置中间件)加入基础服务并提供完整的构建、发布、监控支持,到最终选择不改动基础服务以提供独立包适配,不限制实现技术选型,交由业务团队自由选择的轻量方式落地。中间经历了解除误解,对收益疑惑,对最初定位疑惑,最终完成利弊权衡的过程。
文章会从解除误解,技术选型,利弊权衡的角度,结合智联招聘的开发现状进行交流分享。
文章会以 JavaScript 生态和 JavaScript 客户端调用与服务端开发体验为例。
对入门知识不做详细阐述,可自行查阅学习指南中文(https://graphql.cn/learn/)/英文(https://graphql.org/learn/),规范中文(https://spec.graphql.cn/)/英文(https://github.com/graphql/graphql-spec/tree/master/spec),中文文档有些滞后,但不影响了解 GraphQL。
GraphQL 是一种 API 规范。不是拿来即用的库或框架。不同对 GraphQL 的实现在客户端的用法几乎没有区别,但在服务端的开发方式则天差地别。
一套运行中的 GraphQL 分为三层:
仅仅有客户端是无法工作的。
GraphQL 的实现能让客户端获取以结构化的方式,从服务端结构化定义的数据中只获取想要的部分的能力。
符合 GraphQL 规范的实现我称之为 GraphQL 引擎。
这里的服务端不仅指网络服务,用 GraphQL 作为中间层数据引擎提供本地数据的获取也是可行的,GraphQL 规范并没有对数据源和获取方式加以限制。
我们把客户端调用时发送的数据称为 Query Document
(查询文档),是段结构化的字符串,形如:
# 客户端发送
query {
contractedAuthor: {
name
articles {
time
title
}
}
updateTime
}
# 或
mutation {
# xxxxxx
}
需要注意的是 Query Document
名称中的 Query 和操作模型中的 query 是没有关系的,像上述示例所示,Query Document
也可以包含 mutation 操作。所以为了避免误解,后文将把 Query Document
(查询文档)称为 Document 或文档。一个 Document 中可包含单个或多个操作,每个操作都可以查询补丁数量的跟字段。
其中 query 下的 updateTime、contractedAuthor 这种操作下的第一层字段又称之为 root field
(根字段)。其他具体规范请自行查阅文档。
服务端使用名为 GraphQL Schema Language
(或 Schema Definition Language
、SDL
)的语言定义 Schema 来描述服务端数据。
# 服务端 schema
type Query {
contractedAuthor: Author
unContractedAuthor: Author
updateTime: String
}
type Mutation{
# xxx
}
type Subscription {
# xxx
}
type Author {
name: String
articles: [Article]
}
type Article {
time: String
title: String
content: String
}
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
可以看到,由于 GraphQL 是语言无关的,所以 SDL 带有自己简单的类型系统。具体与 JavaScript、Go 其他语言的类型如何结合,要看各语言的实现。
从上面的 Schema 中我们可以得到如下的一个数据结构,这就是服务可提供的完整的数据的 Graph
(图):
{
query: {
contractedAuthor: {
name: String
articles: [{
time: String
title: String
content: String
}]
}
unContractedAuthor: {
name: String
articles: [{
time: String
title: String
content: String
}]
}
updateTime: String
}
mutation: {
# xxx
}
subscription: {
# xxx
}
}
在 Schema 定义中存在三种特殊的类型 Query、Mutation、Subscription,也称之为 root types
(根类型),与 Document 中的操作模型一一对应的。
结合 Document 和 Schema,可以直观的感受到 Document 和 Schema 结构的一致,且 Document 是 Schema 结构的一部分,那么数据就会按照 Document 的这部分返回,会得到如下的数据:
{
errors: [],
data: {
contractedAuthor: {
name: 'zpfe',
articles: [
{
time: '2020-04-10',
title: '深入理解GraphQL'
},
{
time: '2020-04-11',
title: 'GraphQL深入理解'
}
]
},
updateTime: '2020-04-11'
}
}
预期数据会返回在 data 中,当有错误时,会出现 errors 字段并按照规范规定的格式展示错误。
现在 Document 和 Schema 结构对应上了,那么数据如何来呢?
Selection Sets
选择集:
query {
contractedAuthor: {
name
articles {
time
title
}
honour {
time
name
}
}
updateTime
}
如上的查询中存在以下选择集:
# 顶层
{
contractedAuthor
updateTime
}
# 二层
{
name
articles
honour
}
# articles:三层 1
{
time
title
}
# honour:三层 2
{
time
name
}
Field
字段:类型中的每个属性都是一个字段。省略一些如校验、合并的细节,数据获取的过程如下:
root field
(根字段)。Selection Sets
(选择集)。query 操作下,引擎一般会以广度优先、同层选择集并行执行获取选择集数据,规范没有明确规定。mutation 下,因为涉及到数据修改,规范规定要按照由上到下按顺序、深度优先的方式获取选择集数据。执行字段:
确定了选择集的执行顺序后开始真正的字段值的获取,非常简化的讲,Schema 中的类型应该对其每个字段提供一个叫做 Resolver 的解析函数用于获取字段的值。那么可执行的 Schema 就形如:
type Query {
contractedAuthor () => Author
}
type Author {
name () => String
articles () => [Article]
}
type Article {
time () => String
title () => String
content () => String
}
其中每个类型方法都是一个 Resolver。
contractedAuthor()
后得到值类型为 Author,会继续执行 name ()
和 articles()
以获取 name 和 articles 的值,直到得到类型为标量(String、Int等)的值。至此由 Schema 和 Resolver 组合而成的可执行 Schema
就诞生了,Schema 跑了起来,GraphQl 引擎也就跑了起来。
GrahpQL 服务端开发的核心就是定义 Schema (结构)和实现相应的 Resolver(行为)。
当然,在使用 GraphQL 的过程中,还可以:
Variables
(变量)复用同一段 Document 来动态传参。Fragments
(片段)降低 Document 的复杂度。Field Alias
(字段别名)进行简单的返回结果字段重命名。这些都没有什么问题。
但是在 Directives
(指令)的支持和使用上,规范和实现是有冲突的。
而在研究 GraphQL 时发生的的误解在于:
Query Document
整个操作还是,Document 中的 query 操作,亦或是服务端侧定义在 Schema 中的 Query 类型。GraphQL 的典型实现主要有以下几种:
graphql-js 可以说是其他实现的基础。
可执行 Schema 的创建方式是这几种实现最大的不同,下面将就这部分进行展示。
npm install --save graphql
创建可执行 Schema
import {
graphql,
GraphQLList,
GraphQLSchema,
GraphQLObjectType,
GraphQLString,
} from 'graphql'
const article = new GraphQLObjectType({
fields: {
time: {
type: GraphQLString,
description: '写作时间',
resolve (parentValue) {
return parent.date
}
},
title: {
type: GraphQLString,
description: '文章标题',
}
}
})
const author = new GraphQLObjectType({
fields: {
name: {
type: GraphQLString,
description: '作者姓名',
},
articles: {
type: GraphQLList(article),
description: '文章列表',
resolve(parentValue, args, ctx, info) {
// return ajax.get('xxxx', { query: args })
},
}
},
})
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'RootQuery',
fields: {
contractedAuthor: {
type: author,
description: '签约作者',
resolve(parentValue, args, ctx, info) {
// return ajax.get('xxxx', { query: args })
},
},
},
}),
})
能明确的看到,graphql-js 实现通过 GraphQLSchema 创建出的 schema 中,field 和 resolver 和他们一一对应的关系,同时此 schema 就是可执行 Schema。
import { parse, execute, graphql } from 'graphql'
import { schema } from '上面的schema'
// 实际请求中,document 由 request.body 获取
const document = `
query {
contractedAuthor {
name
articles {
title
}
}
}`
// 或使用导入的 graphql 方法执行
const response = await execute({
schema,
document: parse(document),
// 其他变量参数等
})
传入可执行 schema 和解析后的 Document 即可得到预期数据。
Apollo 提供了完整的 GraphQL Node.js 服务框架,但是为了更直观的感受可执行 Schema 的创建过程,使用 Apollo 提供的 graphql-tools
进行可执行 Schema 创建。
npm install graphql-tools graphql
上面是 Apollo 给出的依赖安装命令,可以看到 graphql-tools 需要 graphql-js(graphql)作为依赖 。
创建可执行 Schema
import { makeExecutableSchema } from 'graphql-tools'
const typeDefs = `
type Article {
time: String
title: String
}
type Author {
name: String
articles: [Article]
}
type Query {
contractedAuthor: Author
}
schema {
query: Query
}
`
const resolvers = {
Query: {
contractedAuthor (parentValue, args, ctx, info) {
// return ajax.get('xxxx', { query: args })
}
},
Author: {
articles (parentValue, args, ctx, info) {
// return ajax.get('xxxx', { query: args })
}
},
Article: {
time (article) {
return article.date
}
}
}
const executableSchema = makeExecutableSchema({
typeDefs,
resolvers,
})
resolvers 部分以类型为维度,以对象方法的形式提供了 Resolver。在生成可执行 Schema 时,会将 Schema 和 Resolver 通过类型映射起来,有一定的理解成本。
这部分涉及 TypeScript,只做不完整的简要展示,详情自行查阅文档。
npm i graphql @types/graphql type-graphql reflect-metadata
可以看到 type-graphql 同样需要 graphql-js(graphql)作为依赖 。
创建可执行 Schema
import 'reflect-metadata'
import { buildSchemaSync } from 'type-graphql'
@ObjectType({ description: "Object representing cooking recipe" })
class Recipe {
@Field()
title: string
}
@Resolver(of => Recipe)
class RecipeResolver {
@Query(returns => Recipe, { nullable: true })
async recipe(@Arg("title") title: string): Promise {
// return await this.items.find(recipe => recipe.title === title);
}
@Query(returns => [Recipe], { description: "Get all the recipes from around the world " })
async recipes(): Promise {
// return await this.items;
}
@FieldResolver()
title(): string {
return '标题'
}
}
const schema = buildSchemaSync({
resolvers: [RecipeResolver]
})
type-graphql 的核心是类,使用装饰器注解的方式复用类生成 Schema 结构,并由 reflect-metadata
将注解信息提取出来。如由 @ObjectType()
和 @Field
将类 Recipe 映射为含有 title 字段的 schema Recipe 类型。由 @Query 注解将 recipe
、recipes
方法映射为 schema query 下的根字段。由 @Resolver(of => Recipe)
和 @FieldResolver()
将 title()
方法映射为类型 Recipe
的 title 字段的 Resolver。
同:在介绍 Apollo 和 type-graphql 时,跳过了执行部分的展示,是因为这两种实现生成的可执行 Schema 和 graphql-js 的是通用的,查看这两者最终生成的可执行 Schema 可以发现其类型定义都是使用的由 graphql-js 提供的 GraphQLObjectType
等, 可以选择使用 graphql-js 提供的执行函数(graphql、execute 函数),或 apollo-server 提供的服务执行。
异:
功能:
对 GraphQL 的直观印象就是按需、无冗余,这是显而易见的好处,那么在实际应用中真的这么直观美好么?
xxx/graphql/im
)无法像 RESTful 接口那样(如:xxx/graphql/im/message
、xxx/graphql/im/user
)从 URL 直接分辨出业务类型,会给故障排查带来一些不便。上面提到的点几乎都是出于调用方的视角,可以看到,作为 GraphQL 服务的调用方是比较舒服的。
由于智联招聘前端架构Ada中包含基于 Node.js 的 BFF(Backends For Frontends 面向前端的后端)层,前端开发者有能力针对具体功能点开发一对一的接口,有且已经进行了数据聚合、处理、缓存工作,也在 BFF 层进行过数据模型定义的尝试,同时已经有团队在现有 BFF 中接入了 GraphQL 能力并稳定运行了一段时间。所以也会从 GraphQL 的开发者和两者间的角度谈谈成本和收益。
root field
(根字段)为粒度。这也是需要额外考虑的。综合来看,可用的 GraphQL 服务(不考虑拿 GraphQL 做本地数据管理的情况)的重心在服务提供方。作为 GraphQL 的调用方是很爽的,且几乎没有弊端。那么要不要上马 GraphQL 就要重点衡量服务端的成本收益了。就我的体会而言,有以下几种情况: