@levinzhang
2018-07-16T22:26:54.000000Z
字数 9354
阅读 570
在实现API时,REST是常用的方式,但是RESTful API有一些固有的不足,在本文中,作者介绍了GraphQL的基本原理和设计思想,以及如何借助其灵活性来解决RESTful API面临的问题,并且探讨了灵活性所带来的成本。
本文最初发布于Medium上freeCodeCamp的博客站点,经原作者Samer Buna授权由InfoQ中文站翻译并分享。
在处理过多年的REST API之后,当我第一次学习到GraphQL以及它试图要解决的问题时,我禁不住发了一条推文,这条推文的内容恰好就是本文的标题。
当然,那个时候,我只是抱着好奇的心态进行了尝试,但现在,我相信当时的戏言却正在变成现实。
请不要误解,我并不是说GraphQL将会“杀死”REST或类似的断定。REST可能永远都不会消亡,就像XML永远也不会灭亡一样。我只是认为GraphQL之于REST,就像JSON之于XML那样。
本文不会100%地鼓吹GraphQL,这里面会有一个很重要的章节讨论GraphQL灵活性的代价。巨大的灵活性会带来巨大的成本。
GraphQL能够非常漂亮地解决三个重要的问题:
本文将会详细阐述GraphQL是如何解决这些问题的。
在开始之前,有些人可能还不熟悉GraphQL,所以我们先给出一个简单的定义。
GraphQL是一门语言。如果我们将GraphQL传授给一个软件应用的话,这个应用能够以声明式的方式与同样使用GraphQL的后端数据服务交流任意的数据需求。
孩子能够快速学习一门新的语言,而成人学起来就会更困难一些。与之类似,在一个新应用中从头开始使用GraphQL要比将其引入到一个成熟的语言中更容易一些。
要教会一个数据服务使用GraphQL语言,我们需要实现一个运行时层,并将其暴露给想要与服务通信的客户端。我们可以将服务器端的这个层视为一个简单的GraphQL翻译器,或者是讲GraphQL语言的代理,它代表了数据服务。GraphQL不是一个存储引擎,所以它自己无法成为一个解决方案。这也是为什么我们无法具备一个只使用GraphQL语言的服务器,而是要实现一个转换运行时的原因。
这个运行时层,可以使用任何语言编写,它定义了一个基于图的通用模式(schema),该模式能够发布数据服务的能力(capabilities)。使用GraphQL语言的客户端应用能够在它的能力范围内查询该模式。这种方式将客户端和服务端进行了解耦,允许它们都能独立地演化和扩展。
GraphQL可以是查询(读操作),也可以是变更(写操作)。在这两种情况下,请求都是一个简单字符串,该字符串能够被GraphQL服务解析、执行并以特定的格式解析数据。在移动和Web应用中,流行的响应格式是JSON。
GraphQL就是关于数据通信的。我们有客户端和服务器端,它们之间都需要进行对话。客户端需要告诉服务器端它需要什么数据,而服务器端要以实际的数据满足客户端的需求。GraphQL就位于这种通信之间。
图片来源于作者Pluralsight课程的截图:使用GraphQL构建可扩展的API
你可能会问,我们为什么不能让客户端和服务器端直接通信呢?当然可以。
有多个原因促使我们在客户端和服务器端之间放置一个GraphQL层。其中有个原因,可能也是最常见的,那就是效率。客户端通常需要跟服务端要求多个资源,而服务端通常只能理解如何响应单个资源。所以,客户端需要发起多轮请求,这样才能收集到它需要的所有数据。
借助GraphQL,我们可以将这种多请求的复杂性转移到服务端,让GraphQL层来对其进行处理。客户端对GraphQL层发起一个请求并且会得到一个响应,该响应中精确包含了客户端所需的内容。
使用GraphQL还会有很多收益,比如,另外一个收益就是与多个服务进行通信的时候。如果你有多个客户端要从多个服务请求数据的话,位于中间的GraphQL层能够简化和标准化这种通信。尽管这并不是针对REST API的(因为它也能很容易地实现),但是GraphQL运行时提供了一个结构化和标准化的方式来实现这一点。
图片来源于作者Pluralsight课程的截图:使用GraphQL构建可扩展的API
客户端不会与两个不同的数据服务直接交互,我们现在可以让客户端与GraphQL层进行通信。然后,GraphQL层会与两个不同的数据服务进行通信。这样的话,GraphQL首先能够将客户端进行隔离,这样它们就没有必要使用多种语言进行通信了,同时,GraphQL还会将一个请求转换为针对不同服务的多个请求,这些不同的服务可能会使用不同的语言编写。
让我们假设有三个不同的人,他们使用不同的语言并且具备不同类型的知识。假设你有一个问题,该问题需要组合这三个人的知识才能给出答案。如果你有一个能够说这三门语言的翻译器,那么为你的问题给出答案就会变得很容易。这其实就是GraphQL运行时所做的事情。
计算机还没有足够智能来回答任意的问题(至少目前还不可以),所以它们必须要在某些地方遵循一定的算法。这也是我们需要在GraphQL运行时上定义模式的原因,客户端会使用该模式。
基本上来讲,模式就是一个能力文档,它包含了客户端可以请求GraphQL层的所有问题的列表。在如何使用模式方面有一定的灵活性,因为我们在这里所讨论的是一个节点图。模式主要体现的是GraphQL层所能回答的问题都有哪些限制。
还感到不清楚吗?那我们一针见血地回答GraphQL是什么:REST API的替代品。那么接下来,我们来回答你最可能会提出的一个问题。
REST API最大的问题在于其多端点的特质。这需要客户端进行多轮请求才能获取到想要的数据。
REST API通常是端点的集合,其中每个端点代表了一个资源。所以,当客户端需要来自多个资源的数据时,就需要针对REST API发起多轮请求,这样才能将客户端所需的数据组合完整。
在REST API中,没有客户端请求语言。客户端对服务端返回的数据没有控制权。在这方面,没有语言能够帮助它们实现这一点。更精确地说,客户端可用的语言非常有限。
例如,用来实现读取(READ)的REST API一般不外乎如下两种形式:
/ResouceName
:获取指定资源的所有记录的列表,或者/ResourceName/ResourceID
:根据ID获取单条记录。举例来说,客户端无法指定该选择记录中的哪个字段。这些信息位于REST API服务本身之中,不管客户端实际需要哪些字段,REST API服务始终都会返回所有的字段。GraphQL对该问题的描述术语是过度加载(over-fetching)不需要的信息。不管是对于客户端还是对于服务器端,这都是网络和内存资源的一种浪费。
REST API的另外一个大问题是版本化。如果你需要支持多版本的话,通常意味着要有多个端点。在使用和维护这些端点的时候,这通常会导致更多的问题,而这也可能是服务端出现代码重复的原因所在。
上文所述的REST API的问题恰好是GraphQL所要致力解决的。上面所述的这些肯定不是REST API的所有问题,我也不想过多讨论REST API是什么,不是什么。我主要讲的是基于资源的HTTP端点API。这些API最终都会变成常规REST端点和自定义专门端点的混合品,其中自定义的专门端点大多都是因为性能的原因而制作的。在这种情况下,GraphQL能够提供好得多的方案。
在GraphQL背后有着很多理念和设计决策,但是最为重要的包括:
其中,正是由于最后一项理念,我个人认为GraphQL将是一个游戏规则的改变者。
这些都是高层级的理念,接下来让我们看一些细节。
为了解决多轮网络调用的问题,GraphQL将响应服务器变成了只有一个端点。从根本上来讲,GraphQL将自定义端点的思想发挥到了极致,将整个服务器变成了一个自定义的端点,使其能够应对所有的数据请求。
与这个单端点概念相关的另一个重要理念是富客户端请求语言(rich client request language),这是使用自定义端点所需要的。如果没有客户端请求语言的话,单端点是没有什么用处的。它需要有一种语言来处理自定义的请求并为该请求响应数据。
具备客户端请求语言就意味着客户端将会是可控的。客户端能够确切地请求它们想要的内容,服务器端则能够确切地给出客户端想要的东西。这解决了过度加载的问题。
在版本化方面,GraphQL有一种非常有趣的做法,能够彻底避免版本化的问题。从根本上来讲,我们可以添加新的字段,而不必移除旧的字段,因为我们有一个图,从而可以通过添加节点来灵活地扩展这个图。所以,我们可以继续保留旧API的路径,并引入新的API,而不必将其标记为新版本。API只是不断增长而已。
这对于移动端尤为重要,因为我们无法控制它们使用哪个版本的API。一旦安装之后,移动应用可能会多年一直使用相同版本的旧API。在Web端,我们能够很容易地控制API的版本,我们只需推送并使用新的代码即可。对移动应用来说,这样做就有些困难了。
还不完全相信吗?我们通过一个具体的例子来对GraphQL和REST进行一对一的比较如何?
假设我们是开发人员,负责构建一个崭新的用户界面,展现《星球大战》电影及其角色。
我们要构建的第一个UI界面很简单:显示每个《星球大战》人物信息的视图。例如,Darth Vader以及他在哪些电影中出现过。这个视图将会展现人物的姓名、出生年份、星球名称以及他们所出现的电影的名字。
听起来非常简单,但实际上我们在处理三种不同类型的资源:人物(Person)、星球(Planet)以及电影(Film)。这些资源之间的关系很简单,任何人都可以猜测到数据的形状。每个Person对象属于一个Planet对象,同时每个Person对象有一个或多个Film对象。
这个UI的JSON数据可能会如下所示:
{
"data": {
"person": {
"name": "Darth Vader",
"birthYear": "41.9BBY",
"planet": {
"name": "Tatooine"
},
"films": [
{ "title": "A New Hope" },
{ "title": "The Empire Strikes Back" },
{ "title": "Return of the Jedi" },
{ "title": "Revenge of the Sith" }
]
}
}
}
假设某个数据服务能够为我们提供这种格式的数据,如下是使用React.js展现视图的一种可能的方式:
// The Container Component:
<PersonProfile person={data.person} ></PersonProfile>
// The PersonProfile Component:
Name: {person.name}
Birth Year: {person.birthYear}
Planet: {person.planet.name}
Films: {person.films.map(film => film.title)}
这是一个非常简单的样例,《星球大战》的体验可能会为我们带来一些帮助,UI和数据的关系是非常清晰的。UI用到了JSON数据对象中所有的“key”。
现在,我们看一下如何使用RESTful API请求该数据。
我们需要一个人的信息,假设我们知道人员的ID,暴露该信息的RESTful API预期将会是这样的:
GET - /people/{id}
这个请求将会为我们提供该人员的姓名、生日和其他信息。一个好的RESTful API还会给我们提供该人员的星球ID以及这个人员所出现的所有电影的ID数组。
该请求的JSON响应可能会像如下所示:
{
"name": "Darth Vader",
"birthYear": "41.9BBY",
"planetId": 1
"filmIds": [1, 2, 3, 6],
*** 其他我们并不需要的信息 ***
}
为了读取星球的名称,我们需要调用:
GET - /planets/1
随后,为了读取电影的名称,我们还要调用:
GET - /films/1
GET - /films/2
GET - /films/3
GET - /films/6
从服务器端得到这六个响应之后,我们就可以将它们组合起来以满足视图的数据需求。
为了满足一个简单UI的数据需求,我们发起了六轮请求,除此之外,我们在这里的方式是命令式的。我们需要给出如何获取数据以及如何处理数据使其满足视图需求的指令。
如果你想要明白我的真实含义的话,那么你可以自行尝试一下。在http://swapi.co/站点上,《星球大战》的数据目前有一个RESTful API。你可以去那里尝试构建我们的人员数据对象。所使用的key可能会略有差异,但是API端点是相同的。你需要六次API调用,除此之外,在这个过程中还会过度加载视图并不需要的信息。
当然,这仅仅是该数据的一种RESTful API实现方式而已。我们可能还会有更好的实现方式,让视图编写起来更加容易。例如,如果API服务器的实现能够嵌套资源并理解人员和电影之间的关联关系,那么我们通过该API来读取电影数据:
GET - /people/{id}/films
但是,纯粹的RESTful API可能并不会实现这些,我们需要请求后端工程师为我们创建这个自定义的端点。这就是RESTful API进行扩展的现实:我们只能添加自定义端点来有效满足不断增长的客户端需求。管理这样的自定义端点是非常困难的。
现在,我们再来看一下GraphQL的方式。GraphQL在服务端拥抱了自定义端点的理念,并将其发挥到了极致。服务器只有一个端点,至于通道则无关紧要。如果你通过HTTP来实现的话,HTTP方法也是无关紧要的。我们假设有一个通过HTTP暴露的GraphQL端点,其地址为/graphql
:
因为想要通过一轮请求就将数据获取到,所以需要有一种方式来向服务器表达完整的数据需求。我们通过一个GraphQL查询来实现这一点:
GET or POST - /graphql?query={...}
GraphQL查询只是一个字符串,但是它需要包含我们所需数据的所有片段。此时,声明式的方式就能发挥作用了。
在中文中,会这样描述我们的数据需求:我们需要一个人员的姓名、出生年份、星球的名字以及所有相关电影的名称。在GraphQL中,这会翻译为:
{
person(ID: ...) {
name,
birthYear,
planet {
name
},
films {
title
}
}
}
再次阅读一下使用中文表达的需求,然后将其与GraphQL查询进行对比。你会发现,它们非常接近。现在,对比一下这个GraphQL查询和我们开始时所见到的原始JSON数据。GraphQL查询与JSON数据的格式完全相同,唯一的差异在于“值(value)”部分。如果我们将其想象为问题和答案的关系的话,所提出的问题就是将答案语句刨除了答案值。
如果答案语句是:
距离太阳最近的行星是水星。
对该问题进行表述时,一种非常好的方式就是将相同语句的答案部分刨除掉:
距离太阳最近的行星是(什么)?
同样的关系可以用到GraphQL查询中。以JSON响应为例,我们将其中的”答案“部分(也就是JSON中的值)移除掉,最终就能得到一个GraphQL查询,它能够非常恰当地表述JSON响应所对应的问题。
现在,对比一下GraphQL查询和我们为数据所定义的声明式React UI。GraphQL查询中的所有内容都用到了UI之中,而UI中用到的所有内容也都出现在了GraphQL查询中。
这是GraphQL非常强大的思想模型。UI知道它所需要的确切数据,抽取需求相对是非常容易的。生成GraphQL是一项非常简单的任务,只需将UI所需的数据直接抽取为变量即可。
如果我们将这个模型反过来,它依然非常强大。有一个GraphQL查询之后,我们就能知道如何在UI中使用它的响应,这是因为查询与响应有着相同的”结构“。我们不需要探查响应就能知道如何使用它,我们甚至不需要任何关于该API的文档。它都是内置的。
https://github.com/graphql/swapi-graphql站点将《星球大战》的数据托管为GraphQL API。你可以在这里进行尝试,构建我们的人员数据对象。这里有些小的差异,如下给出了一个官方的查询,我们可以基于该API读取视图所需的数据(以Darth Vader为例):
{
person(personID: 4) {
name,
birthYear,
homeworld {
name
},
filmConnection {
films {
title
}
}
}
}
这个请求给出的响应结构非常类似于我们视图所使用的结构,需要记住的是,我们在一轮请求中就得到了所有的数据。
完美的解决方案只可能出现在童话之中。GraphQL带来了灵活性,同时也有一些值得关注的地方和问题。
GraphQL所带来的一个非常重要的风险就是资源耗尽攻击(即拒绝服务攻击)。GraphQL服务器可以通过过于复杂的查询来进行攻击,这种查询将会消耗尽服务器的所有资源。它也非常容易查询深层的嵌套关联关系(用户->好友->好友),或者使用字段别名多次查询相同的字段。资源耗尽攻击并不是GraphQL特有的,但是在使用GraphQL的时候,我们必须格外小心。
我们能够采取一些措施来缓解这种情况。我们可以在查询之前进行预先的成本分析,并限制人们可以消费的数据量。我们还可以实现超时功能,将消耗过长时间进行解析的请求杀掉。同时,因为GraphQL只是一个解析层,我们可以在GraphQL层之下,进行速度的限制。
如果我们试图保护的GraphQL API端点不是公开的,也就是只用于客户端(Web或移动)的内部使用,那么可以使用白名单的方式,服务器只能执行预选得到许可的查询。客户端可以使用一个唯一的查询标识符,请求服务器执行预先许可的查询。Facebook似乎采用了这种方式。
在使用GraphQL时,另外一个需要考虑的地方就是认证和授权。在GraphQL查询解析之前、之后或解析的过程中,我们需要在这方面进行处理吗?
要回答这个问题,我们可以将GraphQL视为你自己的后端数据获取逻辑之上的一个DSL(领域特定语言)。它只是一个分层,我们可以将其放到客户端和实际的数据服务(或多个服务)之间。
我们将认证和授权视为另一个分层。GraphQL并不会为实际的认证和授权逻辑提供帮助。它也不是做这个的。但是如果你想要将这些分层放到GraphQL之后的话,我们可以使用GraphQL在客户端和限制逻辑之间传输访问token。这非常类似于在RESTful API认证和授权时,我们所采取的做法。
在客户端数据缓存方面,GraphQL也面临着更多的挑战。RESTful API由于其字典(dictionary)的特性,因此更容易进行缓存。对应的地址给出数据,因此我们可以使用这个地址本身作为缓存的key。
在使用GraphQL的时候,我们可以采用类似的基本方法,使用查询的文本作为key来缓存它的响应。但是,这种方式是有一定限制的,效率不高,并且会导致数据一致性方面的问题。多个GraphQL查询的结果很容易出现重叠,这种基本的缓存机制不能解决重叠的问题。
但是,在这方面有一个很好的解决方案。图查询(Graph Query)意味着图缓存。如果我们将GraphQL查询的响应规范化为一个扁平的记录集合,为每条记录提供一个全局唯一的ID,那么我们就可以缓存这些记录,而不是整个响应。
不过,这并不是一个简单的过程。记录会引用其他的记录,我们将会管理一个循环图。填充和读取缓存需要遍历查询。我们需要编码实现一个分层来处理缓存逻辑。但是,总体而言,这种方式要比基于响应的缓存高效得多。Relay.js是采用该缓存策略的一个库,它会在内部进行自动管理。
关于GraphQL,我们最需要关注的问题可能就是所谓的N+1 SQL查询。GraphQL的查询字段被设计为独立的函数,在数据库中为这些字段解析获取数据可能会导致每个字段都需要一个新的数据库请求。
对于简单的RESTful API端点,可以通过增强的结构化SQL查询来分析、检测和解决N+1查询问题。GraphQL动态解析字段,因此并没有那么简单。幸好,Facebook正在通过DataLoader方案来解决这个问题。
顾名思义,DataLoader是一个工具,我们可以借助它从数据库中读取数据,并将其提供给GraphQL解析函数使用。我们可以使用DataLoader读取数据,避免直接使用SQL查询从数据库中进行查询,DataLoader将会作为我们的代理,减少实际发往数据库中的SQL查询。
DataLoader组合使用批处理和缓存来实现这一点。如果相同的客户端请求需要向数据库查询许多内容的话,DataLoader能够合并这些问题,并从数据库中批量加载问题的答案。DataLoader还会对答案进行缓存,后续的问题如果请求相同的资源的话,就可以使用缓存了。
感谢您的阅读。