GraphQL搭建BFF实战

微服务背景下的前后端数据交互方案

Posted by Li Yucang on June 29, 2023

GraphQL搭建BFF实战

前言

随着多终端、多平台、多业务形态、多技术选型等各方面的发展,前后端的数据交互,日益复杂。

同一份数据,可能以多种不同的形态和结构,在多种场景下被消费。

在理想情况下,这些复杂性可以全部由后端承担。前端只管从后端接口里,拿到已然整合完善的数据。

然而,不管是因为后端的领域模型,还是因为微服务架构。作为前端,我们感受到的是,后端提供的接口,越发不够前端友好。我们必须自行组合多个后端接口,才能获取到完整的数据结构。

面向领域模型的后端需求,跟面向页面呈现的前端需求,出现了不可调和的矛盾。

在这种背景下,本着谁受益谁开发的原则。我们最后选择使用 Node.js 搭建专门服务于前端页面呈现的后端,亦即 Backend-For-Frontend,简称 BFF。

我们面临了很多不同的技术选型,主要围绕在权衡 RESTful API 和 GraphQL。

正如标题所示,我们最终选用的是 GraphQL as BFF。

本文将介绍我们对 GraphQL 所作的考察、探索、权衡、技术选型与设计等多方面的内容,希望能给大家带来一些启发。

BFF简介

BFF的由来

BFF(Backends For Frontends)指的是服务于前端的后端。BFF是解决什么问题的呢?随着移动互联网的兴起,原适应于桌面Web的服务端功能希望同时提供给移动App使用,而在这个过程中存在这样的问题:

  • 移动App和桌面Web在UI部分存在差异。

  • 移动App涉及不同的端,不仅有iOS、还有Android,这些不同端的UI之间存在差异。

  • 原有后端功能和桌面Web UI之间已经存在了较大的耦合。

因为端的差异性存在,服务端的功能要针对端的差异进行适配和裁剪,而服务端的业务功能本身是相对单一的,这就产生了一个矛盾——服务端的单一业务功能和端的差异性诉求之间的矛盾。那么这个问题怎么解决呢?引入BFF,由BFF来针对多端差异做适配,这也是目前业界广泛使用的一种模式。

在实际业务的实践中,导致这种端差异性的原因有很多,有技术的原因,也有业务的原因。比如,用户的客户端是Android还是iOS,是大屏还是小屏,是什么版本。再比如,业务属于哪个行业,产品形态是什么,功能投放在什么场景,面向的用户群体是谁等等。这些因素都会带来面向端的功能逻辑的差异性。

同样的商品业务,在C端的展示功能逻辑,深刻受到商品类型、所在行业、交易形态、投放场所、面向群体等因素的影响。同时,面向消费者端的功能频繁迭代的属性,更是加剧并深化了这种矛盾,使其演化成了一种服务端单一稳定与端的差异灵活之间的矛盾,这也是商品展示(商品展示BFF)业务系统存在的必然性原因。

BFF背景下的核心矛盾

BFF这层的引入是解决服务端单一稳定与端的差异灵活诉求之间的矛盾,这个矛盾并不是不存在,而是转移了。由原来后端和前端之间的矛盾转移成了BFF和前端之间的矛盾。下图是两个不同行业的团购货架展示模块,这两个模块我们认为是两个商品的展示场景,它们是两套独立定义的产品逻辑,并且会各自迭代。

在业务发展初期,这样的场景不多。BFF层系统“烟囱式”建设,功能快速开发上线满足业务的诉求,在这样的情况下,这种矛盾表现的不明显。而随着业务发展,行业的开拓,形成了许许多多这样的商品展示功能,矛盾逐渐加剧,主要表现在以下两个方面:

  • 业务支撑效率:随着商品展示场景变得越来越多,API呈爆炸趋势,业务支撑效率和人力成线性关系,系统能力难以支撑业务场景的规模化拓展。

  • 系统复杂度高:核心功能持续迭代,内部逻辑充斥着if…else…,代码过程式编写,系统复杂度较高,难以修改和维护。

那么这些问题是怎么产生的呢?这要结合“烟囱式”系统建设的背景和商品展示场景所面临的业务,以及系统特点来进行理解。

特点一:外部依赖多、场景间取数存在差异、用户体验要求高

图例展示了两个不同行业的团购货架模块,这样一个看似不大的模块,后端在BFF层要调用20个以上的下游服务才能把数据拿全,这是其一。在上面两个不同的场景中,需要的数据源集合存在差异,而且这种差异普遍存在,这是其二,比如足疗团购货架需要的某个数据源,在丽人团购货架上不需要,丽人团购货架需要的某个数据源,足疗团购货架不需要。尽管依赖下游服务多,同时还要保证C端的用户体验,这是其三。

这几个特点给技术带来了不小的难题:

1、聚合大小难控制,聚合功能是分场景建设?还是统一建设?如果分场景建设,必然存在不同场景重复编写类似聚合逻辑的问题。如果统一建设,那么一个大而全的数据聚合中必然会存在无效的调用。

2、聚合逻辑的复杂性控制问题,在这么多的数据源的情况下,不仅要考虑业务逻辑怎么写,还要考虑异步调用的编排,在代码复杂度未能良好控制的情况下,后续聚合的变更修改将会是一个难题。

特点二:展示逻辑多、场景之间存在差异,共性个性逻辑耦合

我们可以明显地识别某一类场景的逻辑是存在共性的,比如团单相关的展示场景。直观可以看出基本上都是展示团单维度的信息,但这只是表象。实际上在模块的生成过程中存在诸多的差异,比如以下两种差异:

  • 字段拼接逻辑差异:比如以上图中两个团购货架的团购标题为例,同样是标题,在丽人团购货架中的展示规则是:[类型] + 团购标题,而在足疗团购货架的展示规则是:团购标题。

  • 排序过滤逻辑差异:比如同样是团单列表,A场景按照销量倒排序,B场景按照价格排序,不同场景的排序逻辑不同。

诸如此类的展示逻辑的差异性还有很多。类似的场景实际上在内部存在很多差异的逻辑,后端如何应对这种差异性是一个难题,下面是最常见的一种写法,通过读取具体的条件字段来做判断实现逻辑路由,如下所示:

if(category == "丽人") {
  title = "[" + category + "]" + productTitle;
} else if (category == "足疗") {
  title = productTitle;
}

这种方案在功能实现方面没有问题,也能够复用共同的逻辑。但是实际上在场景非常多的情况下,将会有非常多的差异性判断逻辑叠加在一起,功能一直会被持续迭代的情况下,可以想象,系统将会变得越来越复杂,越来越难以修改和维护。

总结:在BFF这层,不同商品展示场景存在差异。在业务发展初期,系统通过独立建设的方式支持业务快速试错,在这种情况下,业务差异性带来的问题不明显。而随着业务的不断发展,需要搭建及运营的场景越来越多,呈规模化趋势。此时,业务对技术效率提出了更高的要求。在这种场景多、场景间存在差异的背景下,如何满足场景拓展效率同时能够控制系统的复杂性,就是我们业务场景中面临的核心问题。

BFF应用模式分析

目前业界针对此类的解决方案主要有两种模式,一种是后端BFF模式,另一种是前端BFF模式。

前端BFF模式

前端BFF模式指的是BFF本身由前端团队自己负责,如下示意图所示:

这种模式的理念是,本来能一个团队交付的需求,没必要拆成两个团队,两个团队本身带来较大的沟通协作成本。本质上,也是一种将“敌我矛盾”转化为“人民内部矛盾”的思路。前端完全接手BFF的开发工作,实现数据查询的自给自足,大大减少了前后端的协作成本。但是这种模式没有提到我们关心的一些核心问题,如:复杂性如何应对、差异性如何应对、展示模型如何设计等等问题。除此之外,这种模式也存在一些前提条件及弊端,比如较为完备的前端基础设施;前端不仅仅需要关心渲染、还需要了解业务逻辑等。

总结:前端BFF模式通过前端自主查询和使用数据,从而达到降低跨团队协作的成本,提升BFF研发效率的效果。目前这种模式的实践代表是阿里巴巴。

后端BFF模式

后端BFF模式指的是BFF由后端同学负责,这种模式目前最广泛的实践是基于GraphQL搭建的后端BFF方案,具体是:后端将展示字段封装成展示服务,通过GraphQL编排之后暴露给前端使用。如下图所示:

这种模式最大的特性和优势是,当展示字段已经存在的情况下,后端不需要关心前端差异性需求,按需查询的能力由GraphQL支持。这个特性可以很好地应对不同场景存在展示字段差异性这个问题,前端直接基于GraphQL按需查询数据即可,后端不需要变更。同时,借助GraphQL的编排和聚合查询能力,后端可以将逻辑分解在不同的展示服务中,因此在一定程度上能够化解BFF这层的复杂性。

但是基于这种模式,仍然存在几个问题:展示服务颗粒度问题、数据图划分问题以及字段扩散问题,下图是基于当前模式的具体案例:

1、展示服务颗粒度设计问题

这种方案要求展示逻辑和取数逻辑封装在一个模块中,形成一个展示服务(Presentation Service),如上图所示。而实际上展示逻辑和取数逻辑是多对多的关系,还是以前文提到的例子说明:

背景:有两个展示服务,分别封装了商品标题和商品标签的查询能力。 情景:此时PM提了一个需求,希望商品在某个场景的标题以“[类型]+商品标题”的形式展示,此时商品标题的拼接依赖类型数据,而此时类型数据商品标签展示服务中已经调用了。 问题:商品标题展示服务自己调用类型数据还是将两个展示服务合并到一起?

以上描述的问题的是展示服务颗粒度把控的问题,我们可以怀疑上述的示例是不是因为展示服务的颗粒度过小?那么反过来看一看,如果将两个服务合并到一起,那么势必又会存在冗余。这是展示服务设计的难点,核心原因在于,展示逻辑和取数逻辑本身是多对多的关系,结果却被设计放在了一起。

2、数据图划分问题

通过GraphQL将多个展示服务的数据聚合到一张图(GraphQL Schema)中,形成一个数据视图,需要数据的时候只要数据在图中,就可以基于Query按需查询。那么问题来了,这个图应该怎么组织?是一张图还是多张图?图过大的话,势必带来复杂的数据关系维护问题,图过小则将会降低方案本身的价值。

3、展示服务内部复杂性 + 模型扩散问题

上文提到过一个商品标题的展示存在不同拼接逻辑的情况,在商品展示场景,这种逻辑特别普遍。比如同样是价格,A行业展示优惠后价格,B行业展示优惠前价格;同样是标签位置,C行业展示服务时长,而D行业展示商品特性等。那么问题来了,展示模型如何设计?以标题字段为例,是在展示模型上放个title字段就可以,还是分别放个title和titleWithCategory?如果是前者那么服务内部必然会存在if…else…这种逻辑,用于区分title的拼接方式,这同样会导致展示服务内部的复杂性。如果是多个字段,那么可以想象,展示服务的模型字段也将会不断扩散。

总结:后端BFF模式能够在一定程度上化解后端逻辑的复杂性,同时提供一个展示字段的复用机制。但是仍然存在未决问题,如展示服务的颗粒度设计问题,数据图的划分问题,以及展示服务内部的复杂性和字段扩散问题。目前这种模式实践的代表有Facebook、爱彼迎、eBay、爱奇艺、携程、去哪儿等等。

RESTful 和 GraphQL

RESTful

目前在我们绝大多数的场景中,后端提供的接口是基于 RESTful 风格的接口,而 RESTful 架构风格的服务是围绕资源展开的。随着业务复杂度的提高,前端页面信息和交互体验的愈加复杂,RESTfull风格的接口,在实际研发场景下,通常会遇到以下几处痛点:

1.性能瓶颈

后端往往是采取微服务架构,拆分成多个服务,一个页面往往需要发送多个请求,才能获取足够的数据。而对于同一个接口,如果参数有多种组合,也需要调用多次才可。 请求的数量愈多,以下的耗时愈多:

  • 队列等待时间(浏览器对同一个主机域名的并发连接数有限制)

  • 链接建立和销毁耗时

  • 后端重复的权限或参数校验

  • 客户端与服务器之间来回响应时间

2.数据冗余

后端提供的接口,通常会在多个场景(或页面)中使用,后端同学无法事先预知前端需要哪些数据,因此基本都是返回大而全的信息,宁多勿缺。随着业务的发展,会加入更多的数据,而且为了保持兼容性,基本只增不减。

3.文档缺失 文档跟接口分离,无法直观通过接口或者单个文档获取所有需要的接口信息。同时,文档很难与实现完全保持一致,基本依靠人工来保证信息的准确性和及时性,不可靠。

GraphQL

与 RESTful 最明显的不同,是每一个的 GraphQL 服务其实对外只提供了一个用于调用内部接口的端点,所有的请求都访问这个暴露出来的唯一端点。

GraphQL 实际上将多个 HTTP 请求聚合成了一个请求,它只是将多个 RESTful 请求的资源变成了一个从根资源 Post 访问其他资源的 Comment 和 Author 的图,多个请求变成了一个请求的不同字段,从原有的分散式请求变成了集中式的请求,这种方式非常适合单体服务直接对外提供 GraphQL 服务,能够在数据源和展示层建立一个非常清晰的分离,同时也能够通过一些强大的工具,例如 GraphiQL 直接提供可视化的文档。

优点

1.数据的关联性和结构化更好

RESTful 所操作的资源相对是离散的;而 GraphQL 的数据更有整体性。

举个例子,如果要获取 A 的朋友的朋友,用 RESTful 该怎么做呢?

假设我们有这样一个接口:

GET /user/:userId/friends/

而 A 有 20 个好朋友,那么我们总共需要发送 20 + 1 = 21 次 REST 请求。 为了减少请求数量,为了这种特殊场景,通常会设计出以下这种接口:

GET /user/:userId/friendsAndHisFriends/

而这种情况如果是在 GraphQL 中,会怎么做呢? 首先我们需要给 User 定义 Schema (GraphQL 有一套完整的类型系统):

type User {
  id: ID!
  name: String!
  friends: [User]
}

假设我们在 Graph root 上只挂了一个 Node,叫 user:

type Query {
  user(id: ID!): User
}

那么我们从客户端发送的 query 就可以写成这样:

query ($userId: ID) {
  user(id: $userId) {
    name
    friends {
      name
      friends {
        name
      }
    }
  }
}

通过这一个请求就可以完成查询朋友的朋友这种头疼的需求。

2.精准地数据获取

  • 声明式 (Declarative) 获取数据,非常直观和精准

  • 可以自主控制需要获取的数据,数据刚刚好,不多也不少

  • 数据模型之间可以建立连接关系,大幅減少来回请求的数量

相比之下 RESTful API 就需要来回多次才能够获取足够的数据,并且大多数情况下会携带额外的数据,缺乏弹性 (multiple round-trips) 。

3.代码即文档

能够有效减少前后端的沟通成本,建立文档的时间成本基本为0。

4.提高前端控制权

以前为了适应不同的平台或者不同版本的需要,需要新开API,或者API中做平台/版本判断来控制返回值

而 GraphQL API 则只需要一套API,由前端开发来决定需要获取那些数据,并且可以预期对返回的数据的格式和内容

5.高度自由的实现方式

不依赖于某种语言,GraphQL 让你的整个应用共享一套 API,而不用被限制于特定存储引擎。GraphQL 引擎已经有多种语言实现,通过 GraphQL API 能够更好利用你的现有数据和代码。你只需要为类型系统的字段编写函数,GraphQL 就能通过优化并发的方式来调用它们。

6.强类型 (Strongly Typed)

一切面向前端的接口都有强类型的 Schema 做保证,且完整类型定义因 introspection 完全对前端可见,一旦前端发送的 query 与 Schema 不符,能快速感知到产生了错误。

支持五种基础类型 (Scalar Types),以及自定义类型

不足

1.学习成本

  • 如果要应用到整个公司或者基础架构上,仍需要时间来推广和谨慎的技术方案讨论

  • 很容易就会陷入 RESTful API 设计思维,埋下技术债

  • 很多处理方式,如执行效率、错误处理、权限控制等,需要额外的学习

2.缺少官方实现

目前 FB 官方就只有一个 Node.js 的 reference implementation,其他语言都是社区爱好者自己搞的。另外,GraphQL 在前端如何与视图层、状态管理方案结合,目前也只有 React/Relay 这个一个官方方案。

3.过于自由、规范少

  • 没有一个成熟的Best Practice时,容易出现Anti Pattern

  • 没有一定的设计规范,容易设计出过于复杂的Scheme

GraphQL 模式出现的必然性

面向前端页面的数据聚合层,其接口很容易在迭代过程中,变得愈加复杂,最终发展成一个超级接口。

它有很多调用方,各种不同的调用场景,甚至多个不同版本的接口并存,同时提供数据服务。

所有这些复杂性,都会反映到接口参数上。

接口调用的场景越多,它对接口参数结构的表达能力,要求越高。如果只有一个 boolean 类型的参数,只能满足 true | false 两种场景罢了。

以产品详情接口为例,一种很自然的请求参数结构如下:

里面包含 ChannelCode 渠道信息,IsOp 身份信息,MarketingInfo 营销相关的信息,PlatformId 平台信息,QueryNode 查询的节点信息,以及 Version 版本信息。最核心的参数 ProductId,被大量场景相关的参数所围绕。

审视一下 QueryNode 参数,很容易可以发现,它正是 GraphQL 的雏形。只不过它用的是更复杂的 JSON 来描述查询字段,而 GraphQL 用更简洁的查询语句,完成同样的目的。

并且,QueryNode 参数,只支持一个层级的字段筛选;而 GraphQL 则支持多层级的筛选。

GraphQL 可以看作是 QueryNode 这种形式的参数设计的专业化。相比用 JSON 来描述查询结果,GraphQL 设计了一个更完整的 DSL,把字段、结构、参数等,都整合到一起。

从 SearchParams, FormData 到 JSON,再到 GraphQL 查询语句,我们看到不断有新的数据通讯方式出现,满足不同的场景和复杂度的要求。

站在这个层面上看,GraphQL 模式的出现,有一定的必然性。

GraphQL 语言设计中的必然性

作为一个查询相关的 DSL,GraphQL 的语言设计,也不是随意的。

我们可以做一个思想实验。

假设你是一名架构师,你接到一项任务,设计一门前端友好的查询语言。要求:

1、查询语法跟查询结果相近

2、能精确查询想要的字段

3、能合并多个请求到一个查询语句

4、无接口版本管理问题

5、代码即文档

我们知道查询结果是 JSON 数据格式。而 JSON 是一个 key-value pair 风格的数据表示,因此可以从结果倒推出查询语句。

上图是一个查询结果。很显然,它的查询语句不可能包含 value 部分。我们删去 value 后,它变成下面这样。

查询语句跟查询结果拥有相同的 key 及其层次结构关系。这是我们想要的。

我们可以再进一步,将冗余的双引号,逗号等部分删掉。

我们得到了一个精简的写法,它已经是一段合法的 GraphQL 查询语句了。

其中的设计思路和过程是如此简单直接,很难想象还有别的方案比目前这个更满足要求。

当然,只有字段和层级,并不足够。符合这种结构的数据太多了,不可能把整个数据库都查询出来。我们还需要设计参数传递的方式,以便能缩小数据范围。

上图是一个自然而然的做法。用括号表示函数调用,里面可以添加参数,可谓经典的设计。

它跟 ES2015 里的 (Method Definitions Shorthand) 也高度相似。如下所示:

前面演示的 GraphQL 参数写法,参数值用的是字面量 userId: 123。这不是一个特别安全的做法,开发者会在代码里,用拼接字符串的方式将字面量值注入到查询语句,也就给了恶意攻击者注入代码的机会。

我们需要设计一个参数变量语法,明确参数位置和数量。

我们可以选用 $xxx 这种常见的标记方法,它被很多语言采用来表示变量。沿用这种风格,可以大大减少开发者的学习成本。

前后端通讯的另一个痛点是,命名。前端经常吐槽后端的字段名过于冗长,或者不知所云,或者拼写错误,或者不符合前端表述习惯。最常见的情况是,后端字段名以大写字母开头,而前端习惯 Class 或者 Component 是大写字母开头,实例和数据,则以小写字母开头。

我们期望有机会进行字段名调整。

别名映射(Alias)语法,正是为了这个目的而出现的。

上面这种别名映射的语法,在其它语言里也很常见。如果不这样写,顶多就是变成:uid as Uid 或者 uid = Uid 这类做法,差别不大。我认为选用冒号更佳,它跟 ES2015 的解构语法很接近。

至此,我们拥有了 key 层级结构,参数传递,变量写法,别名映射等语法,可以编写足够复杂的查询语句了。不过,还有几个小欠缺。

比如对字段的条件表达。假设有两次查询,它们唯一的差别就是,一个有 A 字段,另一个没有 A 字段,其它字段及其结构都是相同的。为了这么小的差别 ,前端难道要编写两个查询语句?

这显然不现实,我们需要设计一个语法描述和解决这个问题。

它就是——指令(Directive)。

指令,可以对字段做一些额外描述,比如

  • @include,是否包含该字段;

  • @skip,是否不包含该字段;

  • @deprecate,是否废弃该字段;

除了上述默认指令外,我们还可以支持自定义指令等功能。

指令的语法设计,在其它语言里也可以找到借鉴目标。Java,Phthon 以及 ESNext 都用了 @ 符号表示注解、装饰器等特性。

有了指令,我们可以把两个高度相似的查询语句,合并到一起,然后通过条件参数来切换。这是一个不错的做法。不过,指令是跟着单个字段走的,它不能解决多字段的问题。

比如,字段 A 和字段 B,拥有相同的总体结构,仅仅只有 1 个字段名的差异。前端并不想编写一样的 key 值重复多次。

这意味着,我们需要设计一个片段语法(Fragment)。

如上所示,用 fragment 声明一个片段,然后用三个点表示将片段在某个对象字段里展开。我们可以只编写一次公共结构,然后轻易地在多个对象字段里复用。

这种设计也是一个经典做法,跟 JavaScript 里的 Spread Properties 很相近。

至此,我们得到了一个相对完整的,对前端友好的查询语言设计。它几乎就是 GraphQL 当前的形态。

如你所见,GraphQL 的查询语言设计,借鉴了主流开发语言里的众多成熟设计。使得任何拥有丰富的编程经验的开发者,很容易上手 GraphQL。

按照同样的要求,重新来一遍,大概率得到跟当前形态高度接近的设计。这是我理解的 GraphQL 语言设计里包含的必然性。

GraphQL 的组成与链路

查询语法,是 GraphQL 面向前端,或者说面向数据消费端的部分。

除此之外,GraphQL 还提供了面向后端,或者说面向数据提供方的部分。它就是基于 GraphQL 的 Type System 构建的 Schema。

一个 GraphQL 服务和查询的链路,大致如下:

首先,服务端编写数据类型,构建一个数据结构之间的关联网络。其中 Query 对象是数据消费的入口。所有查询,都是对 Query 对象下的字段的查询。可以把 Query 下的字段,理解为一个个 RESTful API。比如上图中的,Query.post 和 Query.author,相当于 /post 和 /author 接口。

GraphQL Schema 描述了数据的类型与结构,但它只是形状(Shape),它不包含真正的数据。我们需要编写 Resolver 函数,在里面去获取真正的数据。

Resolver 的简单形式如下

每个 Query 对象下的字段,都有一个取值函数,它能获取到前端传递过来的 query 查询语句里包含的参数,然后以任意方式获取数据。Resolver 函数可以是异步的。

有了 Resolver 和 Schema,我们既定义了数据的形状,也定义了数据的获取方式。可以构建一个完整的 GraphQL 服务。

但它们只是类型定义和函数定义,如果没有调用函数,就不会产生真正的数据交互。

前端传递的 query 查询语句,正是触发 Resolver 调用的源头。

如上所示,我们发起了查询,传递了参数。GraphQL 会解析我们的查询语句,然后跟 Schema 进行数据形状的验证,确保我们查询的结构是存在的,参数是足够的,类型是一致的。任何环节出现问题,都将返回错误信息。

数据形状验证通过后,GraphQL 将会根据 query 语句包含的字段结构,一一触发对应的 Resolver 函数,获取查询结果。也就是说,如果前端没有查询某个字段,就不会触发该字段对应的 Resolver 函数,也就不会产生对数据的获取行为。

此外,如果 Resolver 返回的数据,大于 Schema 里描绘的结构;那么多出来的部分将被忽略,不会传递给前端。这是一个合理的设计。我们可以通过控制 Schema,来控制前端的数据访问权限,防止意外的将用户账号和密码泄露出去。

正是如此,GraphQL 服务能实现按需获取数据,精确传递数据。

澄清关于 GraphQL 的几个迷思

有相当多的开发者,对 GraphQL 有各种各样的误解。在这里挑选几个重要的例子,加以澄清,帮助大家更全面的认识 GraphQL。

GraphQL 不一定要操作数据库

有一些开发者认为 GraphQL 需要操作数据库,因此实现起来,几乎等于要推翻当前后端的所有架构。这是一个重大误解。

GraphQL 不仅可以不操作数据库,它甚至可以不从其它地方获取数据,而直接写死数据在 Resolver 函数里。查看 graphql.js 的官方文档,我们轻易可以找到案例:

上图定义了一个 schema,只有一个类型为 String 的 hello 字段,它的 resolver 函数里,无视所有参数,直接 return 一个 hello world 字符串。

可以看到,GraphQL 只是关于 schema 和 resolver 的一一对应和调用,它并未对数据的获取方式和来源等做任何假设。

GraphQL 跟 RESTful API 不是对立的

在网络上,有相当多的 GraphQL 文章,将它跟 RESTful API 对立起来,仿佛要么全盘 GraphQL,要么全盘 RESTful API。这也是一个重大误解。

GraphQL 和 RESTful API 不仅不对立,还是互相协作的关系。

在前面关于 Resolver 函数的图片中,我们看到,可以在 GraphQL Schema 的 Resolver 函数里,调用 RESTful API 去获取数据。

当然,也可以调用 RPC 或者 ORM 等方式,从别的数据接口或者数据库里获取数据。

因此,实现一个 GraphQL 服务,并不需要挑战当前整个后端体系。它具有高度灵活的适配能力,可以低侵入性的嵌入当前系统中。

GraphQL 不一定是一个后端服务

尽管绝大多数 GraphQL,都以 server 的形式存在。 但是,GraphQL 作为一门语言,它并没有限制在后端场景。

上图还是前面展示过的 graphql.js 的官方文档,最下面一行,就是一个普通的函数调用,它发起了一次 graphql 查询,其 response 结果如下:

这段代码,不只能在 node.js 里运行,在浏览器里也可以运行(可访问:https://codesandbox.io/s/hidden-water-zfq2t 查看运行结果)

因此,我们完全可以将 GraphQL 用在纯前端,去实现 State Management 状态管理。Relay 等框架,即包含了用在前端的 graphql。

GraphQL 不一定需要 Schema

这是一个有趣的事实,GraphQL 语言设计里的两个组成部分:

1)数据提供方编写 GraphQL Schema;

2)数据消费方编写 GraphQL Query;

这种组合,是官方提供的最佳实践。但它并不是一个实践意义上的最低配置。

GraphQL Type System 是一个静态的类型系统。我们可以称之为静态类型 GraphQL。此外,社区还有一种动态类型的 GraphQL 实践。

它跟静态类型的 GraphQL 差别在于,没有了基于 Schema 的数据形状验证阶段,而是直接无脑地根据 query 查询语句里的字段,去触发 Resolver 函数。

它也不管 Resolver 函数返回的数据类型对不对,获取到什么,就是什么。一个字段,不必先定义好,才能被前端消费,它可以动态的计算出来。

在某些场景下,动态类型的 GraphQL 有一定的便利性。不过,它同时丧失了 GraphQL 的部分精髓,这块后面将会详细描述。

值得一提的是,不管是静态类型的 GraphQL 还是动态类型的 GraphQL,都是既可以运行在服务端,也可以运行在前端。

GraphQL 的几种使用模式

到目前为止,我们见识到了 GraphQL 的高自由度和灵活性。在搭建 GraphQL Server 时,也可以根据实际需求和场景,采用不同的模式。

RESTful-Like 模式

这个模式就是简单粗暴的把 RESTful API 服务,替换成 GraphQL 实现。之前有多少 RESTful 服务,重构后就有多少 GraphQL 服务。它是一个简单的一对一关系。

默认情况下,面向两个 GraphQL 服务发起的查询是两次请求,而不是一次。举个例子:

前端需要产品数据时,从之前调用产品相关的 RESTful API,变成查询产品相关的 GraphQL。不过,需要订单相关的数据时,可能要查询另一个 GraphQL 服务。

有一些公司拿 GraphQL 小试牛刀时,采取了这个做法;将 GraphQL 用在特定服务里。

不过,这种模式难以发挥 GraphQL 合并请求和关联请求的能力。只是起到了按需查询,精确查询字段的作用,价值有限。

因此,他们在实践后,发现收效甚微;认为 GraphQL 不过如此,还不如 RESTful API 架构简单和成熟。

其实这是一种选型上的失误。

GraphQL as an API Gateway 模式

在这个模式里,GraphQL 接管了前端的一整块数据查询需求。

前端不再直接调用具体的 RESTful 等接口,而是通过 GraphQL 去间接获取产品、订单、搜索等数据。

在 GraphQL 这个中间层里,我们将各个微服务,按照它们的数据关联,整合成一个基于 GraphQL Schema 的数据关系网络。前端可以通过 GraphQL 查询语句,同时发起对多个微服务的数据的获取、筛选、裁剪等行为。

值得一提的是,作为 API Gateway 的 GraphQL 服务,可以在其 Resolver 内,向前面提到的 RESTful-like 的 GraphQL 发起查询请求。

如此,既避免了前端需要一对多的问题,也解决了 API Gateway GraphQL 需要请求 RESTful 全量数据接口的内部冗余问题。让服务到服务之间的数据调用,也可以做到更精确。

GraphQL 服务是一个对数据消费方友好的模式。而数据消费方,既可以是前端,也可以是其它服务。

当数据消费方是其它服务时,通过 GraphQL 查询语句,彼此之间可以更精确获取数据,避免冗余的数据传输和接口调用。

当数据消费方是前端时,由于前端需要跟多个数据提供方打交道,如果每个数据提供方都是单独的 GraphQL,那并不能得到本质上的改善。此时若有一个 Gateway 角色的 GraphQL,可以真正减少前端调用的复杂度。

两类 GraphQL API Gateway 服务

同样是 API Gateway 角色的 GraphQL 服务,在实现方式上也有不同的分类。

1)包含大量真实的数据操作和处理的 GraphQL

2)转发数据请求,聚合数据结果的 GraphQL

第一类,是传统意义上的后端服务;第二类,则是我们今天的重点,GraphQL as BFF。

这两类 GraphQL 服务的要求是不同的,前者可能包含大量 CPU 密集的计算,而后者总体而言主要是 Network I/O 相关的行为。

许多公司并不提倡使用 Node.js 构建第一种服务,不管是构建 RESTful 还是 GraphQL。我们也一样。

因此,后面我们讨论的 GraphQL,如果没有特别声明,都可以理解为上面所说的第二种类型。

GraphQL as a Backend Framework

在澄清关于 GraphQL 的迷思时,我们指出,GraphQL 可以不作为 Server。

这意味着,一个包含 GraphQL 实现的 Server,不一定通过 GraphQL 查询语句进行前后端数据交互,它可以继续沿用 RESTful API 风格。

也就是说,我们可以把 GraphQL 当作一个服务端开发框架,然后在 RESTful 的各个接口里,发起 graphql 查询。

不管是前端还是其它后端服务,都不必知道 GraphQL 的存在。前端的调用方式,还是 RESTful API,在 RESTful 服务内部,它自己向自己发起了 GraphQL 查询。

那么,这个模式有什么好处跟价值?

设想一下,你用 RESTful API 风格实现 BFF。由于 PC 端和移动端的场景不同,它们对同一份数据的消费方式差异很大。

在 PC 端,它可以一次请求全量数据。

在移动端,因为它屏幕小,它要分多次去请求数据。首屏一次,非首屏一次,滚动按需加载 N 次,多个 2 级页面里 M 次。

我们要么实现一个超级接口,根据请求参数适配不同场景(即实现一个半吊子的 GraphQL);要么实现多个功能相似,但又不同的 RESTful 接口。

其中的差异太大了,所以很多公司索性就把 BFF 分成,PC-BFF 和 Mobile-BFF 两个 BFF 服务。

我们可以把 PC-BFF 和 Mobile-BFF 整合成一个 GraphQL-BFF 服务。即便前后端不通过 GraphQL 查询语句进行交互,我们也可以在各个接口里,编写相对简单的查询语句,代替更高成本的接口实现。

也即是说,使用 GraphQL 搭建 BFF,如果出现前后端分工、沟通等方面的矛盾。我们可以将 GraphQL 服务降级为 RESTful 服务,无非就是把需要前端编写的查询语句,写死在后端接口里面罢了。

如果实现的是 RESTful 服务,要转换成 GraphQL 服务,就没有那么简单了。

有了这种优雅降级的能力,我们可以更加放心大胆的推动 GraphQL-BFF 方案。

GraphQL 精髓

理解 GraphQL 的精髓所在,可以帮助我们更正确地实践 GraphQL。

首先来想一下,GraphQL 为什么要叫 GraphQL,其中的 Graph 体现在什么地方?

GraphQL 的查询语句,看起来是 JSON 写法的一种简化。而 JSON 是一个 Tree 树形数据结构。为什么不叫 TreeQL,而是 GraphQL 呢?

Tree VS Graph

一个重要的前置知识是,什么是 Tree,什么是 Graph,它们有什么关系?

下图是一个 Tree 的结构示意图。

Tree 有且只有一个 Root 节点,并且对于每个非 Root 节点,有且只有一个父节点;它们组成了一个层次结构。其中任意两个节点,有且只有一条连接路径;没有循环,也没有递归引用。

下图是一个 Graph 的结构示意图。

而 Graph 里的节点之间,可能存在不只一种连接路径,可能存在循环,可能存在递归引用,可能没有 Root 节点。它们组成了一个网络结构。

我们可以把 Graph 这种网络结构,通过裁剪连接路径,把它压缩成任意节点只有唯一连接路径的简化形态。如此网络结构退化成层次结构,它变成了 Tree。

也就是说,Graph 是比 Tree 更复杂的数据结构,后者是它的简化形式。拥有 Graph,我们可以按照不同的裁剪方式,衍生出不同的 Tree。而 Tree 里包含的信息,如果不增加其它额外数据,不足以构建足够复杂的 Graph 结构。

GraphQL 里的 Graph 结构

在 GraphQL 里,承担构建网络结构的,并非 GraphQL 查询语句,而是基于 GraphQL Type System 构建的 Schema。

上图是一个 GraphQL Schema,定义了 A, B, C, D 和 E 五种数据类型,它们分别挂载到 入口类型 Query 里的 a, b, c, d 和 e 字段里。

A, B, C, D, E 里面,包含着递归的结构。A 里面包含 B 和 C,B 里面包含 C 和 D,D 里面包含 E,E 里面又包含 A,又回到了 A。

这是一个复杂的关系网络。要构建递归关联,并不需要这么复杂。直接 A 里包含 B,和 B 里包含 A 也行,此处是一个演示。

有了这个基于数据类型的 Graph 关系网络,我们可以实现从 Graph 中派生出 JSON Tree 的能力。

上图是一个 GraphQL 的查询语句,它是一个包含很多 key 的层次结构,亦即一个 Tree。

它从根节点里取 a 字段,然后向下分层,找到了 e。而 e 节点里也包含一个跟根节点同类型的 a 字段,因此它可以继续向下分层,重来一遍,又到了 e 节点,此时它只取了 data 字段,查询中止。

我编写了一个简单的 Resolver 函数,用来演示查询结果。

它很简单。Query 里返回跟字段名一样的字母,任何子节点的数据,都是拼接父节点的字母串。如此我们可以从查询结果看出数据流动的层次。

查询结果如下:

第一个 e 节点的 data 字段里,拿到了父节点里的 data 数据,其父节点的 data 数据又是通过它的父节点里获取的,因此有一个数据链条。

而第二个 e 节点同理,它有两段链条。

只要不编写后续字段,我们可以停留在任意节点的 data 字段里。

也就是说,我们用作为 Tree 的 Query 语句,去裁剪了作为 Graph 的 Schema 数据关联网络,得到了我们想要的 JSON 结构。

通过这个角度,我们可以理解为什么 GraphQL 不允许 Query 语句停留在 Object 类型,一定要明确的写出对象内部的字段,直到所有 Leaf Node 都是 Scalar 类型。

这不仅仅是一个所谓的最佳实践,这也是 Graph 本身的特征。对象节点里,可能通过循环或者递归关系,拓展出无限大的数据结构。Query 语句必须写清楚,才能帮助 GraphQL 去裁剪掉不必要的数据关联路径。

Graph 网络结构的实际价值

前面的 A, B, C, D, E 案例,并不能直观的让大家感受到,Graph 网络结构的实际价值。它看起来像一个连线游戏。

放到 Facebook 的社交网络场景下,其必要性和价值就凸显了。

假设我们要一次性获取用户的好友的好友的好友的好友的好友,基于 RESTful API 我们有什么特别好的方法吗?很难说。

而 Graph 这种递归关联的结构,实现这种查询轻而易举。

我们定义了一个 User 类型,挂到 Query 入口上的 user 字段里。User 类型的 friends 字段又是一个 User 类型的列表。这样就构建了一个递归关联。

getFriends 查询语句,可以不断地从任意用户开始,关联其 friends,得到 friends 数组结果。任意一个 friend 也是 User,它也有自己的 friends。查询语句在最外层的 friends 停了下来,它只查询了 id 和 name 字段。

看到这里,另一个经典的关于 GraphQL 的误解出现了:只有像 Facebook,Twitter 这类社交关系网络,才适合 GraphQL,而我们的场景下,GraphQL 并不适用。

其实不然,社交关系网络里使用 GraphQL 特别有效,不意味着其它场景下,GraphQL 不能带来收益。

设想一个电商平台的场景,它有用户、产品和订单这组铁三角,其它库存、价格,优惠券,收藏等先不提。在最简单的场景下,GraphQL 依然可以发挥作用。

我们构建了 User,Product 和 Order 三个类型,它们彼此之间有字段上的递归关联关系,是一个 Graph 结构。在 Query 入口类型上,分别有 user, product 和 order 三个字段。

据此,我们可以实现从 user, product 和 order 任意维度出发,通过它们的关联关系,实现丰富而灵活的查询。

比如,查看用户的所有订单及其跟订单相关的产品,Query 语句如下:

我们查询了 id 为 123 的用户,他的名字和订单列表,对于每个订单,我们获取该订单的创建时间,购买价格和关联产品,对于订单关联的产品,我们获取了产品 id,产品标题,产品描述和产品价格。

当我们的后端人员组织架构是按照领域模型来划分时,用户,产品和订单,通常是 3 个团队,他们各自提供领域相关的接口。通过 GraphQL 我们可以很容易将它们整合到一起。

再比如,查看一个产品下的所有订单及其关联用户,Query 语句如下:

我们查询了 id 为 123 的产品,它的产品标题,产品描述和价格,以及关联的订单。对于每个关联订单,我们查询了订单的创建时间,购买价格以及下订单的用户,对于下订单的用户,我们查询了他的用户 id 和名称。

如你所见,只要构建出了 Graph 结构的数据网络,它不像 Tree 那样有唯一的 Root 节点。从任意入口出发,它都可以通过关联路径,不断的衍生出数据,得到 JSON 结果。

我们不必疲于编写面向产品详情页的接口,面向订单详情页的接口,面向用户信息的接口。我们编写了一个数据关系网络,就足以适配不同的场景。

此处演示的,只是用户,产品和订单这三个资源的关系网络,已经可以看出 GraphQL 的适用性。在实际场景中,我们能搭建出更复杂的数据网络,它具备更强大的数据表达能力,可以给我们的业务带来更多收益。

GraphQL-BFF 实践

在掌握上述关于 GraphQL 的纲领知识后,我们来看一下在实践中 ,GraphQL-BFF 的一种实际做法。

首先是技术选型,我们主要采用了如下技术栈。

开发语言选用了 TypeScript,跑在 Node.js v10.x 版本上,服务端框架是 Koa v2.x 版本,使用 apollo-server-koa 模块去运行 GraphQL 服务。

Apollo-GraphQL 是 Node.js 社区里,比较知名和成熟的 GraphQL 框架。做了很多的细节工作,也有一些相对前沿的探索,比如 Apollo Federation 架构等。

不过,有两点值得一提:

1、Apollo-GraphQL 属于 GraphQL 社区的一部分,而非 Facebook 官方的 GraphQL 开发团队。Apollo-GraphQL 在官方 GraphQL 的基础上进行了带有他们自身理念特点的封装和设计。像 Apollo Federation 这类目前看来比较激进的方案,即使是 GraphQL 官方的开发人员,对此也持保留态度。

2、Apollo-GraphQL 的重心是前文所说的第一类 API Gateway 角色的 GraphQL 服务,本文探讨的是第二类。因此,Apollo-GraphQL 里有很多功能对我们来说没必要,有一些功能的使用方式,跟我们的场景也不契合。

我们主要使用的是 Apollo-GraphQL 的 graphql-tools 和 apollo-server-koa 两个模块,并在此基础上,进行了符合我们场景的设计和改编。

GraphQL-BFF 架构设计

GraphQL-BFF 的核心思路是,将多个 services 整合成一个中心化 data graph。

每个 service 的数据结构契约,都放入了一个大而全的 GraphQL Schema 里;如果不做任何模块化和解耦,开发体验将会非常糟糕。每个团队成员,都去修改同一份 Schema 文件。

这明显是不合理的。GraphQL-BFF 的开发模式,应该跟 service 的领域模型,有一一对应的关系。然后通过某种形式,多个 services 自然整合到一起。

因此,我们设计了 GraphQL-Service 的概念。

GraphQL-Service

GraphQL-Service 是一个由 Schema + Resolver 两部分组成的 JS 模块,它对应基于领域模型的后端的某个 Servcie。每个 GraphQL-Service 应该可以按照模块化的方式编写,跟其它 GraphQL-Service 组合起来后,构建出更大的 GraphQL-Server。

GraphQL-Service 通过 GraphQL 的 Type Extensions 构建数据关联关系。

如上所示,我们的 UserService 里面,只涉及到了 User 相关的类型处理。它定义了自己的基本字段,id 和 name。通过 extend type 定义了它在 Order 和 Product 数据里的关联字段,以及定义在 Query 里的入口字段。

从 User Schema 里我们可以看到,User 有两类查询路径。

1、通过根节点 Query 以传递参数的方式,获取到 User 信息。

2、通过 Product 或 Order 节点,以数据关联的方式,获取到 User 信息。

上图是 OrderService 的 Schema,它也只涉及了 Order 相关的类型逻辑。同样是通过 extend type 定义了在 User 和 Product 里的关联字段,以及定义了在根节点 Query 里的入口字段。

Order 数据跟 User 一样,有两种消费路径。一种通过 Query 节点,另一种是通过数据关联节点。

前面我们演示 User, Order 和 Product 铁三角关系时,是在同一个 Schema 里编写它们的关联。我们把多个 GraphQL-Service 的 Schema 整合到一起后,可以生成同样的结果:

上图不是我们手动编写的,而是 merge 多个 GraphQL-Service 的 Schema 后生成的结果。可以看出来,跟之前手写的版本,总体上是一样的。

有了解耦的 Schema 并不足够,它只定义了数据类型及其关联。我们需要 Resolver 去定义数据的具体获取方式,Resolver 也需要解耦。

GraphQL-Resolver

不管是在官方的 GraphQL 文档里,还是 Apollo-GraphQL 的文档里,Resolver 都是以普通函数的形态出现。

这在简单场景下,没有什么问题。

然而,在更复杂的真实项目中,我们实际上需要 express、koa 等服务端框架,用中间件的模式编写我们的服务端处理逻辑,由框架将它们整合为一个requestListener 函数,注册到 http.createServer(requestListener) 里。

在 GraphQL Server 里,虽然 endpoint 只有 /graphql 一个,但不代表它只需要一组 Koa 中间件。

正如一开始我们指出的,每个超级接口里都包含一半功能的 GraphQL 实现。GraphQL 是往超级接口的方向更进一步,不能简单地以普通接口的眼光去看待它。

在 Query 下的每个字段,都可能对应 1 到多个内部服务的 API 的调用和处理。只用普通函数构成的 resolverMap,不足以充分表达其中的逻辑复杂度。

不管是用 endpoint 来表示资源,还是用 GraphQL Field 字段来表示资源,它们只是外在形式略有不同,不会改变业务逻辑的复杂度。

因此,采用比普通函数具有更好的表达能力的中间件,组合出一个个 Resolver,再整合到一个 ResolverMap 里。可以更好的解决之前解决不了,或者很难的问题。

所谓的架构能力,体现在理解我们面对的问题的复杂度及其本质特征,并能选择和设计出合适的程序表达模型。

后面我们将演示,正确的架构,如何轻易地克服之前难以解决的问题。

用 koa-compose 组织我们的 Resolver

或许很多同学并不清楚,express 或 koa 里的中间件模式,可以脱离作为服务端框架的它们而单独使用。正如 GraphQL 可以单独不作为 server,在任意支持 JavaScript 运行的地方使用一样。

我们将使用 koa-compose 这个 npm 模块,去构造我们的 Resolver。

前文里提到的 gql 函数,接受一个 Schema 返回一个 GraphQL-Service,每个 GraphQL-Service 都有一个 resolve 方法:

resolve 方法,接受两个参数。第一个是 typeName,对应 GraphQL-Schema 里的 Object Type 的类型名称;第二个是 fieldHandlers,每个 handler 支持中间件模式,最终它们将被 koa-compose 整合成一个 Resolver。

以 UserService 为例,其 Resolver 写起来如下:

作为普通函数的 Resolver 接收的所有参数,都被整合到了 ctx 里面。ctx.result 则是该字段的最终输出,类似于 koa server 里的 ctx.body。我们刻意采用了 ctx.result 这个不同于 ctx.body 的属性,明确区分我们处理的是一个接口还是一个字段。

在简单场景下,中间件模式的 Resolver 跟普通函数的 Resolver,仅仅是参数的数量和返回值的方式不同。并不会增加大量的代码复杂度。

当我们多个字段要复用相同的逻辑时,编写成中间件,然后将 handler 变成数组形式即可。(在代码里我们用 json 模拟了数据库表,所以是同步代码,实际项目里,它可以是异步的调用接口或者查询数据库)。

上面的 logger,只是一个简单案例。除此之外,我们可以编写 requireLogin 中间件,决定一个字段是否只对登陆用户可用。我们可以编写不同的工具型中间件,注入 ctx.fetch, ctx.post, ctx.xxx 等方法,以供后续中间件使用。

每个 GraphQL Field 字段,都拥有独立的一组中间件和 ctx 对象,跟其他字段互相不影响。我们同时,可以把所有字段共享的中间件,放到 koa server 里的中间件里。

如上图所示,绿框是 endpoint,可以编写 koa server 层面的 middleware。而蓝框是 GraphQL Field 字段,可以编写 Resolver 层面的 middleware。endpoint 层面的 middleware 对 ctx 的修改,会影响到后面所有字段。

也就是说,我们可以像上面那样。挂接口层面的 logger,可以知道整个 graphql 查询的耗时。编写一个中间件,在 next 之前,挂载一些方法,供后续中间件使用;在 next 之后,拿到 graphql 的查询结果,进行额外的处理。

解决 mock 难题

GraphQL 是天生 mock 友好的模式,因为其 Schema 里已经指明了所有数据的类型及其关联;很容易可以通过 faker data 之类的手段,自动根据类型生成假数据。

然而,在实践中,实现 GraphQL Mocking 还是有不少的挑战。

如上图所示,在 Apollo GraphQL 里,mock 看似很简单,只需要在创建服务时,设置 mock 为 true,或者提供一个 mock resolver 即可。但是,一个全局的,跟着服务创建走的 mock,太过粗暴。

mock 的价值在于提供更好的数据灵活性以加速开发效率。它既可以在没有数据时,提供假数据;也可以在真数据的接口有问题时,不用重启服务,也能降级为假数据。它既可以是整个 GraphQL 查询级别的 mock,也可以是字段级别的 mock。

作为超级接口的 GraphQL 服务,全局的,在启动阶段就固化的 mocking,意义不大。

Apollo GraphQL 的 mocking 实践问题,正是它采用普通函数来描述 Resolver 所带来的;它很难简单的通过拓展某个 resolver 而支持 mocking。它不得不在创建服务时,额外新增一个 mock resolver map 去承担 mocking 职能。

而我们的 composed resolver 处理动态 mocking 却异常简单。

它不仅可以在运行时动态确定,它不仅可以细化到字段级别,它甚至可以跟着某次查询走 mock 逻辑(通过添加 @mock 指令)。

上图是默认情况下,基于 faker 这个 npm 包,根据数据类型生成的 mock data。

在我们的设计里,默认的 mocking,其内部实现方式很简单。我们先是编写了上图,根据 GraphQL Type 调用 faker 模块对应的方法,生成假数据。

然后在 createResolver 这个将中间件整合成 resolver 的函数里,先判断中间件里是否存在自定义的 mock handler 函数,如果没有,就追加前面编写的 mocker 处理函数。

我们还提供了 mock 中间件,让开发者能指定 mock 数据来源,比如指定 mock json 文件。

mock 中间件,接收字符串参数时,它会搜寻本地的 mock 目录下是否有同名文件,作为当前字段的返回值。它也接收函数作为参数,在该函数里,我们可以手动编写更复杂的 mock 数据逻辑。

有趣的地方是,mock/user.json 文件里,只包含上图红框的数据,其关联出来的 collections 字段,是真实的。这是合理的做法,mock 应该跟着 resolver 走。关联字段拥有自己的 resolver,可能调用自己的接口;不应该因为父节点是 mock 的,子节点也进入 mock 模式。

如此,我们可以在父节点 resolver 对应的后端接口挂掉后,mock 它,让没挂掉的子节点 resolver 正常运行。如果我们希望子节点 resolver 也进入 mock。很简单,添加一个 @mock 指令即可。

如上所示,user 字段和 collections 字段的 resolver 都进入了 mock 模式。

自定义 mock resolver 函数的方式如上图所示,mock 中间件保证了,只有在该字段进入 mock 模式时,才执行 mock resolver function。并且,mock resolver function 内部依然有机会通过调用 next 函数,触发后面的真实数据获取逻辑。

以上所有这些灵活性,都来自于我们选用了表达能力和可组合性更好的中间件模式,代替普通该函数,承担 resolver 的职能。

总结

至此,我们得到了一个简单而灵活的实践模式。我们用 Schema 去构建 Data Graph 数据关联图,我们用 Middleware 去构建 Resolver Map,它们都具备很强的表达能力。

在开发 GraphQL-BFF 时,我们的 GraphQL-Service 跟后端基于领域模型的 Service,具有总体上的一一对应关系。不会产生后端数据层解耦后,在 GraphQL 层重新耦合的尴尬现象。

总的而言,根据我们对 GraphQL 的考察和实践,我们认为它可以比 RESTful API 更好的解决我们面对的问题。