GraphQL:前后端数据交互方案

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

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

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

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

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

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

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

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

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

一、GraphQL 模式出现的必然性

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

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

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

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

以产品详情接口为例,一种很自然的请求参数结构如下:
GraphQL:前后端数据交互方案_第1张图片

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

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

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

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

仿照格林斯潘第十定律:

任何C或Fortran程序复杂到一定程度之后,都会包含一个临时开发的、不合规范的、充满程序错误的、运行速度很慢的、只有一半功能的Common Lisp实现。

或许可以说:

任何接口设计复杂到一定程度后,都会包含一个临时开发的、不合规范的、只有一半功能的 GraphQL 实现。

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

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

二、GraphQL 语言设计中的必然性

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

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

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

  1. 查询语法跟查询结果相近
  2. 能精确查询想要的字段
  3. 能合并多个请求到一个查询语句
  4. 无接口版本管理问题
  5. 代码即文档

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

GraphQL:前后端数据交互方案_第2张图片

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

GraphQL:前后端数据交互方案_第3张图片

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

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

GraphQL:前后端数据交互方案_第4张图片
我们得到了一个精简的写法,它已经是一段合法的 GraphQL 查询语句了。

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

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

GraphQL:前后端数据交互方案_第5张图片

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

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

GraphQL:前后端数据交互方案_第6张图片

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

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

GraphQL:前后端数据交互方案_第7张图片

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

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

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

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

GraphQL:前后端数据交互方案_第8张图片

上面这种别名映射的语法,在其它语言里也很常见。如果不这样写,顶多就是变成:

uid as Uid 或者 uid = Uid 这类做法,差别不大。我认为选用冒号更佳,它跟 ES2015 的解构语法很接近。

GraphQL:前后端数据交互方案_第9张图片

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

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

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

它就是——指令(Directive)。

GraphQL:前后端数据交互方案_第10张图片

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

@include,是否包含该字段;

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

@deprecate,是否废弃该字段;

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

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

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

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

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

GraphQL:前后端数据交互方案_第11张图片

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

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

GraphQL:前后端数据交互方案_第12张图片

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

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

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

三、GraphQL 的组成与链路

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

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

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

GraphQL:前后端数据交互方案_第13张图片

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

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

Resolver 的简单形式如下

GraphQL:前后端数据交互方案_第14张图片

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

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

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

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

GraphQL:前后端数据交互方案_第15张图片

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

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

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

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

GraphQL:前后端数据交互方案_第16张图片

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

GraphQL:前后端数据交互方案_第17张图片

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

GraphQL:前后端数据交互方案_第18张图片

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

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

GraphQL:前后端数据交互方案_第19张图片

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

GraphQL:前后端数据交互方案_第20张图片

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

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

GraphQL:前后端数据交互方案_第21张图片

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

GraphQL:前后端数据交互方案_第22张图片

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

GraphQL:前后端数据交互方案_第23张图片

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

总结

至此,我们得到了一个简单而灵活的实践模式。在开发 GraphQL-BFF 时,我们的 GraphQL-Service 跟后端基于领域模型的 Service,具有总体上的一一对应关系。不会产生后端数据层解耦后,在 GraphQL 层重新耦合的尴尬现象。

关于 GraphQL 还有很多话题可以讨论,比如 batching , caching 等。这部分内容在网络上很多 GraphQL 的文档和教程里都可以找到,这里我们不再赘述。

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

我们对 GraphQL 的期望,不仅仅停留在 BFF 层。我们希望通过积累在 BFF 层使用 GraphQL 的成功经验,帮助我们摸索出在 Micro Frontend 架构上使用 GraphQL 模式的合理设计。

GraphQL 让我们看到,基于领域模型的微前端架构,可能是更好的方向。一个简单的支付按钮,也综合了多个领域模型,由多个开发者有组织的协同开发。并不因为它表面上看起来是一个 Button 组件,就由某个团队单独维护。

当然,探索 GraphQL 的其它方向的前提是,GraphQL-BFF 架构得到成功的验证。就现阶段的实践成果来看,我们对此充满了信心。

尽管我们的代码暂无开源计划,不过相信这篇文章,足够完整和清楚地介绍了我们的 GraphQL-BFF 方案。希望它能给大家带来一点帮助。​​​​

你可能感兴趣的:(GraphQL:前后端数据交互方案)