本文为 2018 年 6 月 9 日,宋小菜与 Coding 共同举办的第一届 GraphQLParty ,下午第五场国内某大型电商前端开发专家邓若奇的演讲稿,现场反响效果极好,对于想要尝试 GraphQL 和在公司初步实践的团队有很大的借鉴意义。前端
你们好,我是阿里的邓若奇。我和 Scott 是好友,很是幸运今天站在这里和你们面对面的交流。我是一名前端,据说今天来的前端特别多,很是高兴,压力也很大。node
我今天分享的主题是基于 SPA 架构的 GraphQL 工程实践。主要从一名前端的视角来看 GraphQL 在整个 web 链路中包括前端和后端协同效率的问题。 react
先介绍一下我本身,我叫邓若奇,作前端以前作过几年的后端开发,如今在阿里 CBU 体验技术部,主要 B2B 前端工程体系基础建设,同时是集团 nodejs 中间件客户端维护者(之一)及 devops 接口人。 git
今天分享大概分为五个部分: github
一、谈谈我对 GraphQL 哲学的一些理解。web
二、基于先后端分离一些架构设计与技术选型。redis
三、详细介绍基于 GraphQL 构建 BFF 这一层,个人一些分层设计和思考。算法
四、介绍一下先后端协做一些效率方面的问题。数据库
五、讲一下引入 GraphQL 以后须要解决的问题。express
前面其实不少讲师已经讲到了 GraphQL 一些东西,GraphQL 其实就是经过一套 schema 作领域模型的定义,官方称之为 SDL,同时引入一套类型系统。经过这套类型系统来对模型进行约束,就像 PPT 展现的这三个类型同样。
在实际使用过程当中客户端经过把想要获取的字段经过 schema 文本发送给服务端,服务端通过处理以后以 json 格式返回。这是最多见的一种使用方式。
经过以上的描述大概知道 GraphQL 有如下几个特色:
第一,它提供一套统一的模型定义。第二,相比 REST 提供了灵活的按需查询的能力。第三点其实也是容易被你们忽略的一点,就是它经过这套类型系统提供了模型和模型之间关系的描述。这就引入了一个不争的事实,
application data graph,虽然服务器是以 json 数据返回的,但咱们的应用数据是一个图或者说是一个网。这也是 GraphQL 成为描述应用数据的一个极佳选择。我认为也是它之因此叫 GraphQL 而不是叫 TreeQL 的缘由。
架构设计与技术选型,先后端分离,提及先后端分离是一个老生常谈的问题,自从我开始作前端一直到如今,我认为先后端分离大体分为四个阶段:
第一阶段前端异步去请求数据接口,而后刷新局部的 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来作,
可是限流实现成本比较大,若是你是专门实现限流这个功能,它须要依赖第三方的一些服务,好比说你在计算限流次数的时候,还有计算时间窗口的时候,由于你的应用是一个集群,你不可能作在应用里面。因此我在代码里面尚未实现。