最近依然在关注GraphQL这个技术,感觉它非常有前景。结合现有的业界关于网关服务的实践和总结,再融合GraphQL的思想,瞬间一个先进无比的网关服务出现在了眼前。

之前翻译过一篇文章,感觉是读过的最完整的一篇深入浅出graphql的实战文章。今天打算继续消化一下文章中引用的一份文档:Execution。这篇文档解释了GraphQL是如何根据定义的schema来完成数据的组装和聚合的,应该算是整个GraphQL架构中最核心的设计之一了,值得了解。
下面废话不多说,正文走起。

Execution

在必要的数据校验环境后,GraphQL(译:后面简称GQL)服务端会根据每一个query的实际要求来剪裁恰如其分的数据结构以进行响应,通常来说是JSON格式。

GQL非常依赖其类型模型(type system),我们来看一个实际的例子来演示如何执行一个query。这个例子和文档中其他部分是一致的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Query {
human(id: ID!): Human
}

type Human {
name: String
appearsIn: [Episode]
starships: [Starship]
}

enum Episode {
NEWHOPE
EMPIRE
JEDI
}

type Starship {
name: String
}

为了了解query执行的细节,我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 请求
{
human(id: 1002) {
name
appearsIn
starships {
name
}
}
}

// 响应
{
"data": {
"human": {
"name": "Han Solo",
"appearsIn": [
"NEWHOPE",
"EMPIRE",
"JEDI"
],
"starships": [
{
"name": "Millenium Falcon"
},
{
"name": "Imperial shuttle"
}
]
}
}
}

你可以将在GQL查询中每一个字段(field)看做是前一种类型的函数或方法,它返回下一种类型。事实上,这就是GQL的工作原理。在GQL服务端,每一个类型的每一个字段背后都是靠一个被称为resolver的函数来支撑的(译:提供数据)。每当一个字段被要求返回,与其对应的resolver函数就会被执行,并产生下一个值(译:进入新一轮resolver执行)。

如果发现字段返回的是一个标量值,如字符串或数字,此时执行就算告一段落了。
然而如果该字段执行后返回的是一个对象值,那么执行器会继续试图获取该对象值包含的字段,一直到最终得到一个标量值为止。

Root fields & resolvers

每个GQL服务的最顶层类型是一个包含一切的统一API入口类型,通常我们称之为RoottypeQuerytype
在我们的例子中,Querytype提供了一个字段叫human,它接受一个参数id。这个字段对应的resolver函数通过操作数据库并构建和返回human对象。

1
2
3
4
5
6
7
Query: {
human(obj, args, context, info) {
return context.db.loadHumanByID(args.id).then(
userData => new Human(userData)
)
}
}

这个例子是用JavaScript写的,但GQL服务端可以用多种语言来实现。resolver函数接受4个参数:

  • obj: 前一个对象(译:触发该resolver的字段所在的对象),这个参数对最顶层字段没有意义(译:当然啊,不然嘞~)
  • args: 来自GQL query
  • context: 提供所有resolver依赖的资源,例如数据库连接对象,当前登录的用户信息等
  • info: 包含与当前查询相关的特定于字段的信息的值,以及模式细节,可以参考graphqlobjecttype

Asynchronous resolvers

我们来近距离看一下这个resolver函数的细节:

1
2
3
4
5
human(obj, args, context, info) {
return context.db.loadHumanByID(args.id).then(
userData => new Human(userData)
)
}

context参数中包含了数据库连接对象,用于数据查询来得到GQL query中要求的id数据。查询数据库是一个异步调用,所以返回一个promisepromise是javascript中的异步调用概念,不过其他很多语言也有对应的实现方式,通常被称之为futurestasks或者Deferred。当数据库操作返回后,我们就可以构建和返回一个新的Human对象啦。

需要注意的是尽管resolver函数返回的是promise,但GQL query并不是异步的,它会期望human携带了所有要求返回的数据。在执行过程中,GQL会一直等到Promise,FuturesTasks完结后才会继续并最大化保持并发度(译:这一点很重要)。

Trivial resolvers

现在我们已经得到了一个Human对象,接下来GQL执行器将继续处理其下的字段。

1
2
3
4
5
Human: {
name(obj, args, context, info) {
return obj.name
}
}

GQL服务依靠类型系统来决定如何继续执行下去。甚至是在human返回任何结果之前,GQL就可以根据类型系统要求提供的human类型声明得到下一步应该处理的字段有哪些。

在这个例子里name字段的处理是非常简单明了的。传入name resolver函数的obj参数就是前一步返回的那个new Human对象。例子中我们期望得到的human对象包含name字段,已经如愿以偿。

事实上,很多GQL类库都不需要你提供这种简单的resolver,它们会默认自动从obj中读取并返回对应名字的属性(译:默认解析器规则)。

Scalar coercion

name字段被处理过后,appearsInstarships字段会被同时处理。appearsIn字段也有一个trivial resolver,我们来仔细看一下:

1
2
3
4
5
Human: {
appearsIn(obj) {
return obj.appearsIn // returns [ 4, 5, 6 ]
}
}

注意,我们的类型系统声明appearsIn将返回一个枚举类型,然而这个函数却返回的是number数组!实际上,如果我们查看结果,我们将看到相应的Enum值被归还。发生了什么?

这就是一个Scalar coercion的例子。类型系统知道应该返回什么,并将解析器返回的数据转换成了API声明要求的类型。在这个例子中,在服务的其他位置应该存在一个枚举类型的定义来标识4,5,6对应的枚举值。

List resolvers

通过appearsIn,我们已经看到了当一个字段需要一个返回多条数据时的细节。它返回了一个枚举值数组,然后类型系统将每个值转换成了对应的枚举值。那starships字段解析的细节有是什么呢?

1
2
3
4
5
6
7
8
9
Human: {
starships(obj, args, context, info) {
return obj.starshipIDs.map(
id => context.db.loadStarshipByID(id).then(
shipData => new Starship(shipData)
)
)
}
}

这个字段的resolver不只是返回一个promise,它返回了一个promise数组(译:屌不屌)。human对象拥有一个starshipsid数组,但我们需要加载所有这些id关联的starship对象。

GQL会等待所有的promise并发的完成后才会继续,当所有starship对象都得到后,GQL会继续并发的尝试获取这些对象的name字段。

Producing the result

当所有字段都处理完毕后,结果值构建成一个从叶子节点到根节点全链路的键值对映射,键为字段名,值为resolver返回的结果,最终按照请求的结构返回给客户端对应的数据结构(JSON结构)。