在过去的将近半年的时间里,作者一直在使用 GraphQL 这门相对新兴的技术开发 Web 服务,与更早出现的 SOAP 和 REST 相比,GraphQL 其实提供的是一套相对完善的查询语言,而不是类似 REST 的设计规范,所以需要语言的生态提供相应的框架支持,但是由于从它开源至今也只有两三年的时间,所以在使用的过程中,尤其是在微服务架构中实践时确实还会遇到很多问题。
这篇文章中,首先会简单介绍 GraphQL 是什么,它能够解决的问题;在这之后,我们会重点分析 GraphQL 在微服务架构中的使用以及在实践过程中遇到的棘手问题,在最后作者将给出心中合理的 GraphQL 微服务架构的设计,希望能为同样在微服务架构中使用 GraphQL 的工程师提供一定的帮助,至于给出的建议是否能够满足读者在特定业务场景下的需求就需要读者自行判断了。
GraphQL
简单对象访问协议(SOAP)从今天来看已经是一门非常古老的 Web 服务技术了,虽然很多服务仍然在使用遵循 SOAP 的接口,但是到今天 REST 风格的面向资源的 API 接口已经非常深入人心,也非常的成熟;但是这篇文章要介绍的主角其实是另一门更加复杂、完备的查询语言 GraphQL。
作为 Facebook 在 2015 年推出的查询语言,GraphQL 能够对 API 中的数据提供一套易于理解的完整描述,使得客户端能够更加准确的获得它需要的数据,目前包括 Facebook、Twitter、GitHub 在内的很多公司都已经在生产环境使用 GraphQL 提供 API;其实无论我们是否决定生产环境中使用 GraphQL,它确实是一门值得学习的技术。
类型系统
GraphQL 的强大表达能力主要还是来自于它完备的类型系统,与 REST 不同,它将整个 Web 服务中的全部资源看成一个有连接的图,而不是一个个资源孤岛,在访问任何资源时都可以通过资源之间的连接访问其它的资源。
如上图所示,当我们访问
User
资源时,就可以通过 GraphQL 中的连接访问当前
User
的
Repo
和
Issue
等资源,我们不再需要通过多个 REST 的接口分别获取这些资源,只需要通过如下所示的查询就能一次性拿到全部的结果:
{
user {
id
email
username
repos(first: 10) {
id
url
name
issues(first: 20) {
id
author
title
}
}
}
}
GraphQL 这种方式能够将原有 RESTful 风格时的多次请求聚合成一次请求,不仅能够减少多次请求带来的延迟,还能够降低服务器压力,加快前端的渲染速度。它的类型系统也非常丰富,除了标量、枚举、列表和对象等类型之外,还支持接口和联合类型等高级特性。
为了能够更好的表示非空和空字段,GraphQL 也引入了
Non-Null
等标识代表非空的类型,例如
String!
表示非空的字符串。
schema {
query: Query
mutation: Mutation
}
Schema 中绝大多数的类型都是普通的对象类型,但是每一个 Schema 中都有两个特殊类型:
query
和
mutation
,它们是 GraphQL 中所有查询的入口,在使用时所有查询接口都是
query
的子字段,所有改变服务器资源的请求都应该属于
mutation
类型。
集中式 vs 分散式
GraphQL 以图的形式将整个 Web 服务中的资源展示出来,其实我们可以理解为它将整个 Web 服务以 “SQL” 的方式展示给前端和客户端,服务端的资源最终都被聚合到一张完整的图上,这样客户端可以按照其需求自行调用,类似添加字段的需求其实就不再需要后端多次修改了。
与 RESTful 不同,每一个的 GraphQL 服务其实对外只提供了一个用于调用内部接口的端点,所有的请求都访问这个暴露出来的唯一端点。
GraphQL 实际上将多个 HTTP 请求聚合成了一个请求,它只是将多个 RESTful 请求的资源变成了一个从根资源
Post
访问其他资源的
Comment
和
Author
的图,多个请求变成了一个请求的不同字段,从原有的分散式请求变成了集中式的请求,这种方式非常适合单体服务直接对外提供 GraphQL 服务,能够在数据源和展示层建立一个非常清晰的分离,同时也能够通过一些强大的工具,例如
GraphiQL
直接提供可视化的文档;但是在业务复杂性指数提升的今天,微服务架构成为了解决某些问题时必不可少的解决方案,所以如何在微服务架构中使用 GraphQL 提高前后端之间的沟通效率并降低开发成本成为了一个值得考虑的问题。
Relay 标准
如果说 RESTful 其实是客户端与服务端在 HTTP 协议通信时定义的固定标准,那么 Relay 其实也是我们在使用 GraphQL 可以遵循的一套规范。
这种标准的出现能够让不同的工程师开发出较为相似的通信接口,在一些场景下,例如标识对象和分页这种常见的需求,引入设计良好的标准能够降低开发人员之间的沟通成本。
Relay 标准其实为三个与 API 有关的最常见的问题制定了一些规范:
-
提供能够重新获取对象的机制;
-
提供对如何对连接进行分页的描述;
-
标准化
mutation
请求,使它们变得更加可预测;
通过将上述的三个问题规范化,能够极大地增加前后端对于接口制定和对接时的工作效率。
对象标识符
Node
是 Relay 标准中定义的一个接口,所有遵循
Node
接口的类型都应该包含一个
id
字段:
interface Node {
id: ID!
}
type Faction : Node {
id: ID!
name: String
ships: ShipConnection
}
type Ship : Node {
id: ID!
name: String
}
Faction
和
Ship
两个类型都拥有唯一标识符
id
字段,我们可以通过该标识符重新从服务端取回对应的对象,
Node
接口和字段在默认情况下会假定整个服务中的所有资源的
id
都是不同的,但是很多时候我们都会将类型和
id
绑定到一起,组合后才能一个类型特定的
ID
;为了保证
id
的不透明性,返回的
id
往往都是 Base64 编码的字符串,GraphQL 服务器接收到对应
id
时进行解码就可以得到相关的信息。
连接与分页
在一个常见的数据库中,一对多关系是非常常见的,一个
User
可以同时拥有多个
Post
以及多个
Comment
,这些资源的数量在理论上不是有穷的,没有办法在同一个请求全部返回,所以要对这部分资源进行分页。
query {
viewer {
name
email
posts(first: 1) {
edge {
cursor
node {
title
}
}
}
}
}
Relay 通过抽象出的『连接模型』为一对多的关系提供了分片和分页的支持,在 Relay 看来,当我们获取某一个
User
对应的多个
Post
时,其实是得到了一个
PostConnection
,也就是一个连接:
{
"viewer": {
"name": "Draveness",
"email": "[email protected]",
"posts": {
"edges": [
"cursor": "YXJyYXljb25uZWN0aW9uOjI=",
"node": {
"title": "Post title",
}
]
}
}
}
在一个
PostConnection
中会存在多个
PostEdge
对象,其中的
cursor
就是我们用来做分页的字段,所有的
cursor
其实都是 Base64 编码的字符串,这能够提醒调用方
cursor
是一个不透明的指针,拿到当前
cursor
后就可以将它作为
after
参数传到下一个查询中:
query {
viewer {
name
email
posts(first: 1, after: "YXJyYXljb25uZWN0aW9uOjI=") {
edge {
cursor
node {
title
}
}
}
}
}
当我们想要知道当前页是否是最后一页时,其实只需要使用每一个连接中的
PageInfo
对象,其中包含了很多与分页相关的信息,一个连接对象中一般都有以下的结构和字段,例如:
Edge
、
PageInfo
以及游标和节点等。
PostConnection
├── PostEdge
│ ├── cursor
│ └── Post
└── PageInfo
├── hasNextPage
├── hasPreviousPage
├── startCursor
└── endCursor
Relay 使用了非常多的功能在连接周围构建抽象,让我们能够更加方便地管理客户端中的游标,整个连接相关的规范其实特别复杂,可以阅读
Relay Cursor Connections Specification
了解更多与连接和游标有关的设计。
可变请求
每一个 Web 服务都可以看做一个大型的复杂状态机,这个状态机对外提供两种不同的接口,一种接口是查询接口,它能够查询状态机的当前状态,而另一种接口是可以改变服务器状态的可变操作,例如
POST
、
DELETE
等请求。
按照约定,所有的可变请求都应该以动词开头并且它们的输入都以 Input 结尾,与之相对应的,所有的输出都以 Payload 结尾:
input IntroduceShipInput {
factionId: ID!
shipName: String!
clientMutationId: String!
}
type IntroduceShipPayload {
faction: Faction
ship: Ship
clientMutationId: String!
}
除此之外,可变请求还可以通过传入
clientMutationId
保证请求的幂等性。
小结
Facebook 的 Relay 标准其实是一个在 GraphQL 上对于常见领域问题的约定,通过这种约定我们能够减少工程师的沟通成本和项目的维护成本并在多人协作时保证服务对外提供接口的统一。
N + 1 问题
在传统的后端服务中,N + 1 查询的问题就非常明显,由于数据库中一对多的关系非常常见,再加上目前大多服务都使用 ORM 取代了数据层,所以在很多时候相关问题都不会暴露出来,只有真正出现性能问题或者慢查询时才会发现。
SELECT * FROM users LIMIT 3;
SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 2;
SELECT * FROM posts WHERE user_id = 3;
SELECT * FROM users LIMIT 3;
SELECT * FROM posts WHERE user_id IN (1, 2, 3);
GraphQL 作为一种更灵活的 API 服务提供方式,相比于传统的 Web 服务更容易出现上述问题,类似的问题在出现时也可能更加严重,所以我们更需要避免 N + 1 问题的发生。
数据库层面的 N + 1 查询我们可以通过减少 SQL 查询的次数来解决,一般我们会将多个
=
查询转换成
IN
查询;但是 GraphQL 中的 N + 1 问题就有些复杂了,尤其是当资源需要通过 RPC 请求从其他微服务中获取时,更不能通过简单的改变 SQL 查询来解决。
在处理 N + 1 问题之前,我们要真正了解如何解决这一类问题的核心逻辑,也就是将多次查询变成一次查询,将多次操作变成一次操作,这样能够减少由于多次请求增加的额外开销 —— 网络延迟、请求解析等;GraphQL 使用了 DataLoader 从业务层面解决了 N + 1 问题,其核心逻辑就是整个多个请求,通过批量请求的方式解决问题。
微服务架构
微服务架构在当下已经成为了遇到业务异常复杂、团队人数增加以及高并发等需求或者问题时会使用的常见解决方案,当微服务架构遇到 GraphQL 时就会出现很多理论上的碰撞,会出现非常多的使用方法和解决方案。
在这一节中,我们将介绍在微服务架构中使用 GraphQL 会遇到哪些常见的问题,对于这些问题有哪些解决方案需要权衡,同时也会分析 GraphQL 的设计理念在融入微服务架构中应该注意什么。
当我们在微服务架构中融入 GraphQL 的标准时,会遇到三个核心问题,这些问题其实主要是从单体服务迁移到微服务架构这种分布式系统时引入的一系列技术难点,这些技术难点以及选择之间的折衷是在微服务中实践 GraphQL 的关键。
Schema 设计
GraphQL 独特的 Schema 设计其实为整个服务的架构带来了非常多的变数,如何设计以及暴露对外的接口决定了我们内部应该如何实现用户的认证与鉴权以及路由层的设计。
从总体来看,微服务架构暴露的 GraphQL 接口应该只有两种;一种接口是分散式的,每一个微服务对外暴露不同的端点,分别对外界提供服务。
在这种情况下,流量的路由是根据用户请求的不同服务进行分发的,也就是我们会有以下的一些 GraphQL API 服务:
http://draveness.me/posts/api/graphql
http://draveness.me/comments/api/graphql
http://draveness.me/subscriptions/api/graphql
我们可以看到当前博客服务总共由内容、评论以及订阅三个不同的服务来提供,在这时其实并没有充分利用 GraphQL 服务的好处,当客户端或前端同时需要多个服务的资源时,需要分别请求不同服务上的资源,并不能通过一次 HTTP 请求满足全部的需求。
另一种方式其实提供了一种集中式的接口,所有的微服务对外
共同
暴露一个端点,在这时流量的路由就不是根据请求的 URL 了,而是根据请求中不同的字段进行路由。
这种路由的方式并不能够通过传统的 nginx 来做,因为在 nginx 看来整个请求其实只有一个 URL 以及一些参数,我们只有解析请求参数中的查询才能知道客户端到底访问了哪些资源。
http://draveness.me/api/graphql
请求的解析其实是对一颗树的解析,这部分解析其实是包含业务逻辑的,在这里我们需要知道的是,这种 Schema 设计下的请求是按照
field
进行路由的,GraphQL 其实帮助我们完成了解析查询树的过程,我们只需要对相应字段实现特定的 Resolver 处理返回的逻辑就可以了。
然而在多个微服务提供 Schema 时,我们需要通过一种机制将多个服务的 Schema 整合起来,这种整合 Schema 的思路最重要的就是需要解决服务之间的重复资源和冲突字段问题,如果多个服务需要同时提供同一个类型的基础资源,例如:
User
可以从多种资源间接访问到。
{
post(id: 1) {
user {
id
email
}
id
title
content
}
作为微服务的开发者或者提供方来讲,不同的微服务之间的关系是平等的,我们需要一个更高级别或者更面向业务的服务对提供整合 Schema 的功能,确保服务之间的字段与资源类型不会发生冲突。
前缀
如何解决冲突资源从目前来看有两种不同的方式,一种是为多个服务提供的资源添加命名空间,一般来说就是前缀,在合并 Schema 时,通过添加前缀能够避免不同服务出现重复字段造成冲突的可能。
SourceGraph 在实践 GraphQL 时其实就使用了这种
增加前缀
的方式,这种方式的实现成本比较低,能够快速解决微服务中 Schema 冲突的问题,读者可以阅读
GraphQL at massive scale: GraphQL as the glue in a microservice architecture
一文了解这种做法的实现细节;这种增加前缀解决冲突的方式优点就是开发成本非常低,但是它将多个服务的资源看做孤岛,没有办法将多个不同服务中的资源关系串联起来,这对于中心化设计的 GraphQL 来说其实会造成一定体验上的丢失。
粘合
除了增加前缀这种在工程上开发成本非常低的方法之外,GraphQL 官方提供了一种名为 Schema Stitching 的方案,能够将不同服务的 GraphQL Schema 粘合起来并对外暴露统一的接口,这种方式能够将多个服务中的不同资源粘合起来,能够充分利用 GraphQL 的优势。
为了打通不同服务之间资源的壁垒、建立合理并且完善的 GraphQL API,我们其实需要付出一些额外的工作,也就是在上层完成对公共资源的处理;当对整个 Schema 进行合并时,如果遇到公共资源,就会选用特定的 Resolver 进行解析,这些解析器的逻辑是在 Schema Stitching 时指定的。
const linkTypeDefs = `
extend type User {
chirps: [Chirp]
}
`;
我们需要在服务层上的业务层对服务之间的公共资源进行定义,并为这些公共资源建立新的 Resolver,当 GraphQL 解析当公共资源时,就会调用我们在合并 Schema 时传入的 Resolver 进行解析和处理。
const mergedSchema = mergeSchemas({
schemas: [
chirpSchema,
authorSchema,
linkTypeDefs,
],
resolvers: {
User: {
chirps: {
fragment: `... on User { id }`,
resolve(user, args, context, info) {
return info.mergeInfo.delegateToSchema({
schema: chirpSchema,
operation: 'query',
fieldName: 'chirpsByAuthorId',
args: {
authorId: user.id,
},
context,
info,
});
},
},
},
},
});
在整个 Schema Stitching 的过程中,最重要的方法其实就是
mergeSchemas
,它总共接受三个参数,需要粘合的 Schema 数组、多个 Resolver 以及类型出现冲突时的回调:
mergeSchemas({
schemas: Array<string | GraphQLSchema | Array<GraphQLNamedType>>;
resolvers?: Array<IResolvers> | IResolvers;
onTypeConflict?: (
left: GraphQLNamedType,
right: GraphQLNamedType,
info?: {
left: {
schema?: GraphQLSchema;
};
right: {
schema?: GraphQLSchema;
};
},
) => GraphQLNamedType;
})
Schema Stitching 其实是解决多服务共同对外暴露 Schema 时比较好的方法,这种粘合 Schema 的方法其实是 GraphQL 官方推荐的做法,同时它们也为使用者提供了 JavaScript 的工具,但是它需要我们在合并 Schema 的地方手动对不同 Schema 之间的公共资源以及冲突类型进行处理,还要定义一些用于解析公共类型的 Resolver;除此之外,目前 GraphQL 的 Schema Stitching 功能对于除 JavaScript 之外的语言并没有官方的支持,作为一个承载了
服务发现以及流量路由
等功能的重要组件,稳定是非常重要的,所以应该
慎重考虑
是否应该自研用于
Schema Stitching 组件。
组合
除了上述的两种方式能够解决对外暴露单一 GraphQL 的问题之外,我们也可以使用非常传统的 RPC 方式组合多个微服务的功能,对外提供统一的 GraphQL 接口:
当我们使用 RPC 的方式解决微服务架构下 GraphQL Schema 的问题时,内部的所有服务组件其实与其他微服务架构中的服务没有太多区别,它们都会对外提供 RPC 接口,只是我们通过另一种方式 GraphQL 整合了多个微服务中的资源。
使用 RPC 解决微服务中的问题其实是一个比较通用同时也是比较稳定的解决方案,GraphQL 作为一种中心化的接口提供方式,通过 RPC 调用其他服务的接口并进行合并和整合其实也是一个比较合理的事情;在这种架构下,我们其实可以在提供 GraphQL 接口的情况下,也让各个微服务直接或者通过其他业务组件对外暴露 RESTful 接口,提供更多的接入方式。
虽然 RPC 的使用能为我们的服务提供更多的灵活性,同时也能够将 GraphQL 相关的功能拆分到单独的服务中,但是这样给我们带来了一些额外的工作量,它需要工程师手动拼接各个服务的接口并对外提供 GraphQL 服务,在遇到业务需求变更时也可能会导致多个服务的修改和更新。
小结
从使用前缀、粘合到使用 RPC 组合各个微服务提供的接口,对外暴露的 Schema 其实是一个由点到面逐渐聚合的过程,同时实现的复杂度也会逐步上升。