在现在的公司使用GraphQL有一段时间了。

现公司从创立之后的很长一段时间内是纯PHP的技术栈,前端、后端都在PHP代码中糅合在一起。新功能越加越多,页面越来越复杂之后,那些混在在PHP代码中的HTML代码越来越不可维护,于是终于有公司里的程序员看不下去,开始了技术革命,将PHP代码抽象成一个个微服务提供API,前端则采用Node+React,解放了前端工程师的生产力,使得新界面的开发越来越顺利,前端程序员也越发不用关心后端的实现了。

故事说到这里听起来皆大欢喜,然而时间长了,新的问题出现了——我们的微服务需要的调整越来越多。PM永远想要尝试新的点子,我们的新需求仍然是更加“花里胡哨”的页面,原先一个页面调用的微服务,在新的需求下需要新的数据,于是和原来比,要做的工作反而多了:在纯PHP的框架下,PHP后端代码和HTML前端代码都在同一个文件中,新需求也可能需要改一个(套)文件;然而在新的架构下,我们即需要调整微服务(PHP文件),又需要去调整前端代码(JS文件),还需要更改两者之间的协议(Apache Thrift),并且还需要严格的遵守Release的顺序和向前兼容的问题。

GraphQL就是在这样的背景下被引入到我们的技术栈之中,关于GraphQL的介绍网上有很多博文,在这里就不展开描述,个人觉得对于我们的产品开发中最有利的两点:

1. 降低了后端API的调整频度。所谓的新“需求”,有很多时候其实就是将数据转移,比如将本来在A页面展示的数据挪到B页面,或者将A和B页面合并成一个页面,抑或是A页面拆成B和C两个页面。在GraphQL引入之前,这样的展示层面的增删改都必将导致后端API的变化,但在GraphQL引入之后,前端程序员只需在Node端调整查询语句,就可以自己定制出自己需要的API。

2. 增加了前端的灵活性和可调试性。前端可以根据需求,理论上可以将整个数据库的数据在一个页面上实现任意的组合,并且由于有graphiql等强大的工具,可以边实现新的页面,边调整自己的查询语言,在出现问题时也可以通过直接执行查询语句来看是否后端返回的数据有问题。

比如有一款社交网络的应用,我们后端有一个getUserByUserId的API,可以查询一个用户的信息(ID,用户名,朋友们的ID),如果我们要做一个页面来显示一个用户的三度好友树,如果不使用GraphQL的解决方案,需要创建一个新的API,在API中先通过getUserByUserId去查询一个用户的信息和所有好友ID,再通过getUserByUserId去获得每个朋友的信息和好友ID,如此循环最后返回。

而如果使用GraphQL的解决方案,我们只需要定义用户和API的Schema:

type User {
userId: Int
userName: String
friends: [User]
}
extend type Query {
getUserByUserId(userId: Int): User //根据用户Id查询单个用户
getUsersByUserIds(userIds: [Int]): [User] //根据多个用户Id查询多个用户
}

而对应的Resolver逻辑为

export default {
Query: {
getUserByUserId: async (root, args, context) => await context.service.getUserByUserId(args.userId).then(response => response.user),
getUsersByUserIds: async (root, args, context) => await context.service.getUsersByUserIds(args.userIds).then(response => response.users),
} User: {
userId: (root) => root.userId,
userName: (root) => root.userName,
friends: (root, args, context) => await context.service.getUsersByUserIds(root.friendIds).then(response => response.users),
}
}

而getUserByUserId的返回格式为以下的格式,getUserByUserIds的话则是以下格式的列表形式

context.service.getUserByUserId(10001)

{
"userId": 10001,
"userName": "Sample User Name",
"friendIds": [ 10002, 10003, 10004 ]
}

这里我们提供了两个API,一个是单数形式getUserByUserId,一个是复数形式getUsersByUsersId,实际实现中单数形式的API可以坍缩成复数形式API只有一个参数的调用,所以可以继续简化其实现。为什么不只创建单数形式的API呢?这在之后的实战问题中会描述。

这样,我们如果要实现上面所描述的三度好友页面,只需要定义两个API——getUserByUserId和getUsersByUserIds和下面的一条GraphQL查询语句

query getUserByUserId ($userId: Int) {
user: getUserByUserId(userId: $userId) {
userId
userName
friends { //朋友
userId
userName
friends { //朋友的朋友
userId
userName
}
}
}
}

这个查询会返回给我们这样的结果

{
"userId": ,
"userName": "Sample User Name",
"friends": [
{
"userId": ,
"userName": "Sample User Name 2",
"friends": [
{
"userId": ,
"userName": "Sample User Name 3"
},
{
"userId": ,
"userName": "Sample User Name 4"
},
....
]
},
{
"userId": ,
"userName": "Sample User Name 5",
"friends": [
{
"userId": ,
"userName": "Sample User Name 6"
},
{
"userId": ,
"userName": "Sample User Name 7"
},
....
]
},
....
]
}

这样我们只用了一个API(单复数共用一个实现的话)就组合出了这样的一个复合API,如果将来想要实现四度好友,五度好友,则可以在以上面的查询基础上继续嵌套,仍旧不需要增加后台的API代码。

这个示例只是最简单的示例,理论上如果你的服务的所有实体数据之间都有联结关系,那么只需要你实现每个实体数据根据ID的自查询API和实体之间的联结查询API,那么用GraphQL就可以将所有的实体连接成一张图(Graph),你可以通过GraphQL查询语句来构建这张图中的任何子图。

-------------------------- 我是和谐的分割线 --------------------------

正如每颗硬币都有正反面,在实际使用GraphQL的时候我们也遇到了很多问题,特别是性能上的问题。拿以上这个三度好友的GraphQL查询来举例,它有哪些问题呢?

1. 过度查询(Overfetching)

在一个页面中我们可能只会用一个实体的某几个属性,那么我们在后端的查询最好只需要选取需要的字段。而我们在实现GraphQL和后端服务的桥接时,不论GraphQL的查询语句请求了几个字段,后端服务永远会查询实体的所有字段并返回,而GraphQL的引擎则会根据查询语句只提取需要的字段作为返回结果。但是在这个过程中,不必要的字段占用了数据库的传输以及前后端网络传输的带宽。

比如上面的例子,如果我们的页面只要求获得用户ID并不要求返回用户名,那么我们的query可以改成以下的模式

query getUserByUserId ($userId: Int) {
user: getUserByUserId(userId: $userId) {
userId
friends {
userId
friends {
userId
}
}
}
}

表面上来看我们确实没有去查询userName,但实际上由于我们的API会返回所有的userId, userName, friendIds,所以这个查询和前面那个例子的查询开销上是一样的。

解决方案:针对整个问题,我们在GraphQL的Resolver层面做了一些改造,在查询被执行的时候从GraphQL引擎获得当前的查询语句请求的字段,并将字段作为隐藏参数传递给后端服务,后端服务根据传进来的字段进行数据库查询的优化。解决方案的伪代码如下。

export default {

    User: {
userId: (root) => root.userId,
userName: (root) => root.userName,
friends: (root, args, context, info) => await context.service.getUsersByUserIds(root.friendIds, info.fields).then(response => response.users), // 传入GraphQL查询中的field
}
}

而服务器端的API返回值也随着调用传参也变化

context.service.getUserByUserId(10001,["userId"])

{
"userId": 10001
}

2. 重复查询(Repeated Query)

一个较为复杂的页面中可能一个实体在页面的不同位置都有展现,比如上面那个查询,用户的一度好友们的二度好友们,很有可能互相之间也是好友,那么我们的两层嵌套查询中,有部分的查询实际上是可以避免的。

解决方案:这一点暂时没有很完美的解决方案,我们目前可以做到的是在上层Query中已经查询到的数据,如果下层Query也要查询,那么通过缓存的方式,使的下层的Query不去访问API,但是如果本身是不同的Query,暂时没有办法做跨请求的缓存。缓存实现的伪代码如下。

export default {

    User: {
userId: (root) => root.userId,
userName: (root) => root.userName,
friends: (root, args, context, info) => {
let cachedUsers = root.friendIds.map(id => context.cache.users[id]).filter(x => !!x); //找出所有缓存的用户
let idsToFetch = root.friendIds.filter(id => !context.cache.users[id]); //取得未缓存的用户ID
return context.service.getUsersByUserIds(idsToFetch, info.fields).then(response => { //查询未缓存的用户信息
for(let user in response.users) {
context.cache.users[user.id] = user;//将结果存储到缓存中
}
return response.users.concat(cachedUsers)://合并缓存结果和返回结果
});
}
}
}

3. N+1查询 (N+1 Query)

N+1查询是GraphQL使用中最可能也是最经常遇到的性能问题,当出现查询嵌套并且在内部嵌套的数据是列表类型时最容易出现这样的性能问题。还是以上面的查询为例,如果系统中每个用户平均有10个好友,那么以上的三度好友查询一共进行了多少次后端API的调用?答案是1 + 1 + 10 = 12次, 为什么是12次呢?

1. 第一次调用getUserByUserId, 获得了目标用户的ID和用户名信息以及平均10个朋友的ID

2. 第二次调用getUsersByUserIds,获得了10个目标的用户民信息已经他们10*10个朋友的ID

3. 对于2中获得的10批朋友ID,我们需要分别调用10次getUsersByUserIds,去获得者100个朋友的用户名信息

第二次调用获得了N批朋友ID,每批朋友信息的查询带来了N次的额外查询,所以我们将这种Pattern称为N+1查询。这里我们可以看出为什么我们一开始在定义API的时候一定要定义复数形式的API,这样一开始我们就考虑到了会有批(Batch)查询的的需求,否则的话如果只有单查询的接口,我们则需要1 + 10 + 10 * 10 = 111次API查询。但是12次查询也是非常大的消耗,并且收到前段和后端通信的并发限制,这最后的10次通信可能需要分批进行,那么最终会导致服务器端的返回速度收到了极大的限制。

解决方案:N+1 Query的问题没有一个非常好的解决方案,我们目前的做法是在GraphQL的Resolver逻辑中插入了自己的逻辑,当我们遇到这种多层嵌套查询的时候,在第N层去尝试等待其他的resolver,拿上面的例子,我们的第二次调用后,获得了10批朋友的ID,那么在第三层的结构进行resolve逻辑的时候,我们会收集所有需要调用getUsersByUserIds的参数,将其合并成一次调用,这次调用返回的Promise在全局共享,同时在运行时将每个resolver的逻辑替换成合并后调用的结果中找出自己需要的结果并返回。为了更好的帮助大家理解,可以参照下图。

GraphQL为前后端的API提供了一种便利的解决方案,但同时收到自身设计的限制,会有各种各样的问题需要针对具体的应用场景去优化,在使用之前不妨先问问自己:我到底需不需要GraphQL。

GraphQL实战经验和性能问题的解决方案的更多相关文章

  1. (转)国内外三个不同领域巨头分享的Redis实战经验及使用场景

    随着应用对高性能需求的增加,NoSQL逐渐在各大名企的系统架构中生根发芽.这里我们将为大家分享社交巨头新浪微博.传媒巨头Viacom及图片分享领域佼佼者Pinterest带来的Redis实践,首先我们 ...

  2. HDFS配置参数及优化之实战经验(Linux hdfs)

    HDFS优化之实战经验 Linux系统优化 一.禁止文件系统记录时间 Linux文件系统会记录文件创建.修改和访问操作的时间信息,这在读写操作频繁的应用中将带来不小的性能损失.在挂载文件系统时设置no ...

  3. Redis实战经验及使用场景

    随着应用对高性能需求的增加,NoSQL逐渐在各大名企的系统架构中生根发芽.这里我们将为大家分享社交巨头新浪微博.传媒巨头Viacom及图片分享领域佼佼者Pinterest带来的Redis实践,首先我们 ...

  4. 【Oracle 集群】ORACLE DATABASE 11G RAC 知识图文详细教程之RAC 特殊问题和实战经验(五)

    RAC 特殊问题和实战经验(五) 概述:写下本文档的初衷和动力,来源于上篇的<oracle基本操作手册>.oracle基本操作手册是作者研一假期对oracle基础知识学习的汇总.然后形成体 ...

  5. MySQL数据库的优化-运维架构师必会高薪技能,笔者近六年来一线城市工作实战经验

    原文地址:http://liangweilinux.blog.51cto.com/8340258/1728131 首先在此感谢下我的老师年一线实战经验,我当然不能和我的老师平起平坐,得到老师三分之一的 ...

  6. MySQL索引实战经验总结

    MySQL索引对数据检索的性能至关重要,盲目的增加索引不仅不能带来性能的提升,反而会消耗更多的额外资源,本篇总结了一些MySQL索引实战经验. 索引是用于快速查找记录的一种数据结构.索引就像是数据库中 ...

  7. 4月27号开学! 第6期《jmeter实战接口自动化+性能》课程,零基础也能学

    2019年 第6期<jmeter实战接口自动化+性能>课程,4月27号开学! 主讲老师:飞天小子 上课方式:QQ群视频在线教学 本期上课时间:4月27号-6月9号,每周六.周日晚上20:0 ...

  8. Visual Studio 2015开发Qt项目实战经验分享(附项目示例源码)

    Visual Studio 2015开发Qt项目实战经验分享(附项目示例源码)    转 https://blog.csdn.net/lhl1124281072/article/details/800 ...

  9. RabbitMQ实战经验分享

    前言 最近在忙一个高考项目,看着系统顺利完成了这次高考,终于可以松口气了.看到那些即将参加高考的学生,也想起当年高三的自己. 下面分享下RabbitMQ实战经验,希望对大家有所帮助: 一.生产消息 关 ...

随机推荐

  1. netty支持的协议

    流经网络的数据总是具有相同的类型:字节.这些字节是如何流动的主要取决于我们所说的 网络传输--一个帮助我们抽象底层数据传输机制的概念.用户并不关心这些细节:他们只想确保他们的字节被可靠地发送和接收. ...

  2. rufus-scheduler定时任务示例代码

    require 'rubygems' require 'rufus/scheduler' scheduler = Rufus::Scheduler.start_new scheduler.in '20 ...

  3. 修改sharepoint列表样式

    1.将sharepoint 的样式修改为阴影 2.用designer打开列表的AllItems.aspx文件,将下面的样式拷贝到里面     <style type="text/css ...

  4. 【java并发编程艺术学习】(一)初衷、感想与笔记目录

    不忘初心,方得始终. 学习java编程这么长时间,自认为在项目功能需求开发中没啥问题,但是之前的几次面试和跟一些勤奋的或者小牛.大牛级别的人的接触中,才发现自己的无知与浅薄. 学习总得有个方向吧,现阶 ...

  5. VMware虚拟机重置密码

    1.给vmware虚拟机添加启动延时          1.1 编辑VMware的配置文件.vmx,开机就自动进入BIOS       加入一行:bios.forceSetupOnce = " ...

  6. Jmeter测试接口简单使用教程

    1.         打开 解决  apache-jmeter-2.13  然后进解压后的然后点击bin 文件里面的jmeter.bat  打开jmeter 2.         添加测试组件 1:添 ...

  7. fedora18下安装chrome

    ——杂言:这个fedora18是之前装着玩的,原本用的firefox来调试网站页面的,但是因为fedora上没有安装flash,以及一些其他plugin,所以还是没忍住装了chrome,一劳永逸,也好 ...

  8. Linux操作系统的内存使用方法详细解析

    我是一名程序员,那么我在这里以一个程序员的角度来讲解Linux内存的使用. 一提到内存管理,我们头脑中闪出的两个概念,就是虚拟内存,与物理内存.这两个概念主要来自于linux内核的支持. Linux在 ...

  9. 微信小程序自学第五课:条件渲染、列表渲染

    一.条件渲染 1. wx:if 在框架中,我们用 wx:if="{{condition}}" 来判断是否需要渲染该代码块: <view wx:if="{{condi ...

  10. 网页设计与开发:HTML、CSS、JavaScript实例教程 (郑娅峰) pdf扫描版

    网页设计与开发:HTML.CSS.JavaScript实例教程从实用角度出发,详细讲解了HTML.CSS和JavaScript的基本语法和设计技巧,通过一个实用的班级网站的规划.设计.实现到发布过程, ...