【第1315期】GraphQL 基于 SPA 架构的工程实践
本文为 2018 年 6 月 9 日,宋小菜与 CODING 共同举办的第一届 GraphQLParty ,下午第五场国内某大型电商前端开发专家邓若奇的演讲稿,现场反响效果极好,对于想要尝试 GraphQL 和在公司初步实践的团队有很大的借鉴意义。第一场的分享见这里:【第1306期】GraphQL 聚合层解放前后端
正文从这开始~
大家好,我是阿里的邓若奇。我和 Scott 是好友,非常幸运今天站在这里和大家面对面的交流。我是一名前端,听说今天来的前端特别多,非常高兴,压力也很大。
我今天分享的主题是基于 SPA 架构的 GraphQL 工程实践。主要从一名前端的视角来看 GraphQL 在整个 web 链路中包括前端和后端协同效率的问题。
先介绍一下我自己,我叫邓若奇,做前端之前做过几年的后端开发,现在在阿里 CBU 体验技术部,主要 B2B 前端工程体系基础建设,同时是集团 nodejs 中间件客户端维护者(之一)及 devops 接口人。
今天分享大概分为五个部分:
1、谈谈我对 GraphQL 哲学的一些理解。
2、基于前后端分离一些架构设计与技术选型。
3、详细介绍基于 GraphQL 构建 BFF 这一层,我的一些分层设计和思考。
4、介绍一下前后端协作一些效率方面的问题。
5、讲一下引入 GraphQL 之后需要解决的问题。
前面其实很多讲师已经讲到了 GraphQL 一些东西,GraphQL 其实就是通过一套 schema 做领域模型的定义,官方称之为 SDL,同时引入一套类型系统。通过这套类型系统来对模型进行约束,就像 PPT 展示的这三个类型一样。
在实际使用过程中客户端通过把想要获取的字段通过 schema 文本发送给服务端,服务端经过处理之后以 json 格式返回。这是最常见的一种使用方式。
通过以上的描述大概知道 GraphQL 有以下几个特点:
第一,它提供一套统一的模型定义。第二,相比 REST 提供了灵活的按需查询的能力。第三点其实也是容易被大家忽略的一点,就是它通过这套类型系统提供了模型和模型之间关系的描述。这就引入了一个不争的事实,
架构设计与技术选型,前后端分离,说起前后端分离是一个老生常谈的问题,自从我开始做前端一直到现在,我认为前后端分离大致分为四个阶段:
第一阶段前端异步去请求数据接口,然后刷新局部的 UI;第二阶段前端接管 view 层,这个时候基于 spa 框架开始涌现,并且一直流行到今天;第三和第四阶段随着 nodejs 技术的兴起,我们开始思考与后端的协同效率问题,通过引入 BFF 这一层实现,可以让前端进行快速的业务迭代,同时后端下沉为服务或者微服务,能够变得更加稳定和高效。
这个架构图相信很多人已经看到过,就不多说了。
这是技术选型,显然它不是唯一的,因为前面很多讲师有他们自己的选型。前端选择 react 和 relay,relay 其实是一种基于 react 和 GraphQL 的一种数据整合方案,前面有讲师有提到relay的一些痛点,其实在我看来并不是完全的痛点,relay 最大痛点是文档太少了(笑)。BFF 这一层引入 eggjs,eggjs 是阿里开源的一个面向企业级开发的一个外部框架,可以理解成它就是一个 koa 或者是一个 express,亦或者是一个 mvc 的框架就行了。
如何设计BFF。
先来看一下基于传统的 mvc 模式的 web service 怎样受理一个 rest 请求的,首先请求进入到 middleware,我们叫中间件或者是拦截器,在中间件处理一些通用的逻辑,比如说用户登陆判断和 api 鉴权,然后请求到 router,router 通过 url 把请求分发到不同的 controller,在 controller 这一层调用 model 进行业务处理,然后 model 再调用 service 层进行取数,数据返回了之后在 controller 层完成数据拼装并且返回。
在引入 GraphQL 之后发生了一些变化。首先 router 不需要了,因为 GraphQL 并不是基于 endpoint 的,controller 这一层也不需要了,因为 GraphQL 天生的 resolver 会帮我们搞定数据拼装的功能,另外还引入两个模块:第一个是 connector,第二个是 schema loader,之所以有 connector这个模块,主要是因为基于两点考虑:第一点是出于性能考虑,针对 GraphQL 的一些特点需要进行特殊的缓存设计,另外在做前后端协作的时候,需要有一些约定或者规范比如说分页,跟客户端进行约定分页逻辑的时候,需要有这么一些规范,需要在 connector 层实现。
另外,schema loader 就非常好理解,在应用启动的时候需要加载 schema,所以我们需要有一个模块来加载它。
下面将我在构建 BFF 层当中体会比较深的点跟大家交流一下。第一,如何构建 schema?
这其实是一个开发模式的问题,我在刚开始写 GraphQL 代码的时候,代码是这样的,其实这段代码也是在 graphql-js 的官方 repo 上抄来的,但是我的代码跟它是一样的,但是现在看来我不会这样写。
因为它存在两个问题:首先,schema 是一个与语言无关的,只是模型的一个描述,其次,做开发的时候本着设计先行的原则,先确定模型是长什么样的,然后才开始动手写代码。
所以比较赞同:SDL First Philosophy。这个不是我提的,这个是老外提的。
首先先确定模型的描述,以及它们之间的关系,等确定模型之间的关系之后,我们再来书写 resolver 具体应该如何处理。
最后在应用加载的时候 schema loader 把两者绑定。这些都不是问题。
第二,鉴权与授权。
鉴权称之为 authentication,授权称之为 authorization,说实话我一开始总是搞错,并且这两个概念其实在中国人看来其实可以互换或者说是相同的意思,
我认为鉴权主要是针对一些通用的逻辑,比如刚才说的用户登陆的一些逻辑,是一些比较粗粒度的。
这是刚才的那张图,把用户登陆判断和 API 鉴权可以放在 middleware 里面实现。
授权是什么呢?授权其实是具有一些定制的逻辑,它的粒度可能比较细,针对 GraphQL 来说可能是细化到某一个字段。
来看一个例子,这个 query 要查询小明的工资,小明的工资只能小明自己看,如果服务端给它放出来就出现水平权限的问题。
所以我们在 resolver 里面需要加入授权逻辑,来保证一定是小明自己(才能看),这里抽象了一个 User.isSelf 接口,这里的设计,我们把授权的逻辑把它封装在 model,让它在不同的 resolver 里面都可以复用。User.isSelf 不光在 salary,可能在 login password 这种地方都可以用到。
第三,缓存设计,
这是数据库里面两条用户记录,用户 1 和用户 2 他们互为 friend。
然后程序里面有这么两段代码:第一段代码先去查询用户 1,查询回来之后再查询它的 friend,也就是用户 2,第二段代码正好反过来。那么,
它的请求时序图是这样的,可以看到一共发了四个请求,并且我们最终查到的数据只有两条,可以看到这是非常浪费的,所以考虑优化一下,
先引入缓存,在引入缓存之后第二轮的查询在第一轮缓存结果中找到,非常幸运的是第二轮不需要发新的请求了。
再进一步深挖一下,如果第一轮请求能够合并成一个请求,这样就太棒了。
所以总结一下,我们为了达成这个目的可能需要缓存,这是毫无疑问的。然后我们需要一个请求队列,请求队列什么意思?我们在同一个队列当中,node 的 event loop 是分为一个周期一个周期的,同一个周期里把所有请求全部放在一个队列里,在下一个周期合并成一个请求发出。最后,需要批量处理的能力。一个请求带着批量的 key 过来的时候应该怎么表现,
索性的是 Facebook 提供这样的解决方案,data loader,
data loader 接收,这个就是面对批量 Key 请求过来的时候应该如何处理,并且每个 data loader 的实例下面都有一个 cache。
所以,刚才的需求引入 data loader 之后实现起来就变成这样。
这段代码最终的效果把三个请求合并成一个请求,在我们的后端我们其实是执行一条指令把它搞定的。
但是,在实际使用的时候感觉结合关系型数据库使用起来还是有一点复杂。
首先,一张关系型数据库表大家会设置一些 key,除了有 primary key 之外,还会设置 unique key,我们经常会根据 pk 进行查询,或者根据 uk 进行查询,像这段代码里面会根据 id 去查询用户,也可能会根据 mobile 去查询用户,我们的代码根据 data loader 的哲学不得不初始化两个实例。
这种方式带来的最大问题因为是不同的 data loader 实例,缓存是不同的缓存,即使记录是完全一样的,但是你没法利用到,导致缓存的利用率并不高。
为了解决这个问题,我书写 rdb-dataloader 这个模块,
这个模块其实它的作用很简单,就是我无论查询PK还是查询 UK,在同一个实例里面搞定,让它复用缓存,看红框中的代码,先通过“name”来查询一条记录,然后把这条记录通过它的ID来进行第二次查询,第二次查询显然是不会发出去,它会用缓存。
所以要实现这样的功能其实这里面有一个问题,你缓存记录的时候是缓存每条记录的全部字段,让这些字段覆盖你的 UK 和 PK,你可能会担心数据量的问题,但是它其实真的不是问题,数据量控制应该由你的分页逻辑来关心的。
这里抛出一个思考 data loader 这种形式是请求级别的缓存,当一个请求进来的时候初始化一个 data loader 的实例,当请求结束之后它就销毁,这个和我们平时用的基于 redis 中心化缓存有什么不同,可不可以切成 redis 的方式,留给大家思考。
前后端如何协作,
作名一个前端其实在用了 GraphQL 之后必须要思考,对于浏览器性能怎么样,这是进一步挖掘 relay 的原因,下面给大家简单分享一下 relay 的部分特性。
这是一个最普通的 react component,一个最普通的诉求就是组件需要异步取数,然后把数据进行渲染,所以在 componentDidMount 里面去把异步取数逻辑加上。
所以现实中随着组件数越来越深,页面加载时间也就越来越长,因为子组件必须要等到父组件加载完它的数据之后才开始渲染,这个问题我想优化一下,应该怎么优化,
最简单的方式把所有组件所需要的数据全部放在首个请求里面,这个项目交付了之后很不错,后面产品经理找我要搞一个需求,
结果我搞了一个 bug,因为我已经搞不清楚这个 query 里的哪些字段是对应哪些组件的。
我们看一下 relay 的方式,relay 这里有一个 create Fragment Container这个方法,通过这个方法传入一个react组件,然后传入 GraphQL schema 来返回一个 relay container,其实这是一个高阶组件,通过这种方式,我们实现了依赖注入,也没有打破数据封装性。
这个 fragment 在首个最初始的 query 里面把它内联进去,这样就知道这个玩意是哪个组件发出来的。
relay 有一个非常 smart,非常智能的特点,应用的数据是一个图状的,
是怎样去存取应用的一个数据的?这是一个伪代码,但是表示 relay 底层的一种协作方式,从上面的例子可以看到存储三个对象,第一个对象是博客,有内容,也有作者,但是这个作者是一个 user 类型,博客不会直接存储 user 的全部数据,而是通过引用的方式引用到第二个对象,同理,在给博客里面下面添加评论,评论的作者和它属于哪个博客,同样是用引用的方式,这个有什么好处,
当我比如说作者改头像,比如说 github 你们经常改头像吗,反正我是不经常,但是我发现只要改了头像任何地方都会修改,提的 issue,代码的提交者全部都会改掉。但是我可以确定 github 不是用的 relay(笑),只是说表达这个意思,只要你改了这个对象,在界面任何引用它的地方全部都更新,这就是视图一致性。
返回来看一下在 cache 里面 123 是什么东西,是缓存 key,它的缓存 key 是一个全局唯一的,因为把所有的实体全部都塞到缓存里面去了,我不能用数据库的 ID,否则用 ID 为 1 的 user 和 ID 为 1 的博客它们之间怎么搞,
所以需要实现 relay 的规范,Global Identifier,我们要保证每个对象它的 ID 是唯一的。
这是一种简单的方式,可以定义任何一种算法或者方式来确定你的 global ID。这里我只是把简单的类型加上它数据库的 ID 进行 base64 了,
所以在 relay store 里面我看到都是这些东西。
刚才说了是 global ID 是需要后端来进行配合,我们需要在后端定义两个方法,fromGlobalId 就是在 relay 发请求的时候会把 ID 带过来,然后我在后端我只识别数据库 ID,所以要把它解包,把它解成数据库的 ID,在吐出去的时候,需要给每个数据库 ID 给装包有一个 toGlobalId,这两个方法,如果使用刚才我的方法,graphql-relay 这个包已经提供。
当客户端把文本发送到服务端,服务端经过处理的时候,我们往往发现这种文本是非常大的,特别是对于网络环境不好的一些无线终端它的体验是非常差的。
并且它们是一种静态的文本,能不能把它优化一下,比如说传一个 ID 过去,给每一个 query 赋予一个 ID,服务端根据 ID 反查成为 query,然后再把它处理出来。
所幸 relay 也提供这样的方式。它给出的答案是在构建时期做,构建 relay 脚本的时候,本质上它是一个模块,会给这个模块做一个 hash,标识当前的 schema 给它加一个指纹,
这个 hash 在后端可以去 load 到内存里,在前端也可以把它通过打包打进去,这个时候前后端就对应起来了。
所以在真实的场景中是通过 hash,而不是通过 schema text。
需要解决的问题,
前面的讲师都已经有提到过 DOS Attack。
说白了就是这种嵌套攻击,请注意这并不是死循环,这只是一个攻击者故意通过你的 query 无限写的非常复杂的嵌套,让你的服务器消耗殆尽。
最简单的你设置 query 文本大小来做预防肯定不行,而设置白名单那么我们使用 GraphQL 的意义又在何处,所以我们还是对 query 的深度进行控制,
这里给出一个例子,后面大家其实可以看到。
rate limiting 限流,
因为 GraphQL 它不是基于 rest 的,所以肯定不能对于 /graphql 这个路由你去限制它每分钟只能调用多少次,你真正要限制是读写和操作。
这是一个例子,这个例子表示每分钟最多只能添加 20 个评论,通过 directive来做,
但是限流实现成本比较大,如果你是专门实现限流这个功能,它需要依赖第三方的一些服务,比如说你在计算限流次数的时候,还有计算时间窗口的时候,因为你的应用是一个集群,你不可能做在应用里面。所以我在代码里面还没有实现。
最后,为你推荐