[译 Published 9 Oct 2015] https://jacobwgillespie.com/from-rest-to-graphql-b4e95e94c26b
GraphQL 实现 Playlists 和 Tracks
让我们看看如何使用 GraphQL 解决运动员列表(playlist)接口的性能问题。这一次,我们希望只返回需要的数据,并且优化数据库查询次数,比如避免N+1次查询。
我们的 GraphQL 查询语句类似如下:
query FetchPlaylist {
playlist(id: "e66637db-13f9-4056-abef-f731f8b1a3c7") {
id
name
tracks {
id
title
viewerHasLiked
}
}
}
这样就精确的返回了需要的数据,正如 GraphQL 查询语句中定义的那样:
{
"playlist": {
"id": "e66637db-13f9-4056-abef-f731f8b1a3c7",
"name": "Excuse me while I kiss these frets",
"tracks": [
{
"id": "a1f9f37a-2a15-407d-82f8-e742ab5e3b81",
"title": "Walk This Way",
"viewerHasLiked": true
},
{
"id": "4cc1fc43-61e8-49a7-be42-9d7ad35c1284",
"title": "Like A Stone",
"viewerHasLiked": false
}
]
}
}
为简单起见,playlist ID 内嵌在了查询语句中,实践中我们更倾向于通过参数传递一个 ID。详情可以查看 GraphQL 文档。
假设在进入 GraphQL 之前我们已经进行了验证,并且在调用 GraphQL 时验证状态已经发送给了根值对象,这样解析器才可以执行。更多关于根值的内容可以查看 graphql-js 和 express-graphql 的文档,下面我们演示下它的具体使用。
首先,我们定义一个根查询对象作为查询的入口。根据上述查询语句,这个根对象应该有一个字段,称为 playlist
。
import {GraphQLObjectType, GraphQLNonNull, GraphQLString} from 'graphql'
import playlistType from './playlistType'
export default new GraphQLObjectType({
name: 'Query',
description: 'The root query object',
fields: () => ({
playlist: {
type: playlistType,
args: {
id: {
type: new GraphQLNonNull(GraphQLString),
},
},
resolve: (
_,
{id},
{
rootValue: {
ctx: {backend},
},
},
) => backend.getModel('Playlist').load(id),
},
}),
})
注意,这里我们使用的是 ES6 语法。
我们定义了一个 playlist 类型的字段(这个字段是 GraphQL 类型的,我们可以在另一个文件定义,在当前文件 import ),设置了一个非空字符串参数id
。最重要的是,我们定义了一个对象的解析函数。
解析函数的第一个参数是当前对象(由于目前就在根层,我们忽略这个参数)。第二个参数是GraphQL 调用时传递的参数,我们提取出来作为id
字段。第三个参数提供了上下文,这样就可以获取后台实例,这个实例可以通过根值
一直传递下去,现在我们用它和参数id
查询 playlist。
就是这么简单!我们从数据库中加载了 playlist,返回了一个 JS 对象。让我们进入下一步。
现在,我们定义 playlist 的 schema type(图式结构,可以理解成 graphql server 支持的字段的图形化结构,译者注):
import {GraphQLString, GraphQLArray, GraphQLObjectType} from 'graphql'
import trackType from './trackType'
export default new GraphQLObjectType({
name: 'Playlist',
description: 'A Playlist',
fields: () => ({
id: {
type: GraphQLString,
resolve: it => it.uuid,
},
name: {type: GraphQLString},
tracks: {
type: new GraphQLArray(trackType),
resolve: it => it.tracks(),
},
}),
})
好,我们为 Playlist 定义了一个新的 GraphQLObjectType 类。由于根查询解析器返回了一个 playlist 的数据模型实例,这一层的解析函数的第一个参数(命名为it
)就是这个实例。对于id
字段,在解析函数中我们调用it.uuid
就可以将命名为id
的 key 与一个uuid
类型的值对应起来。注意一下,你设计的 schema 没必要完全镜像你的数据库结构。
对于name
字段,我们没有提供解析函数,因为标量x
的默认值就是model.x
。
对于tracks
,我们调用it.tracks()
就可以加载数据库中的 tracks 信息。
注意:每个字段都有一个解析函数,但不意味着每一个字段都需要一次单独的数据库查询。对于root.playlist
,你可以选择尽量多原则取值,也可以选择尽量少原则取值,每一个子字段的解析器可以返回父级已经获取的值,也可以在必要时开启更多的查询。
最后,我们为 track 定义一个 GraphQLObjectType 类:
import {GraphQLString, GraphQLBoolean, GraphQLObjectType} from 'graphql'
// a comment
export default new GraphQLObjectType({
name: 'Track',
description: 'A Track',
fields: () => ({
id: {
type: GraphQLString,
resolve: it => it.uuid,
},
title: {type: GraphQLString},
viewerHasLiked: {
type: GraphQLBoolean,
resolve: (
it,
_,
{
rootValue: {
ctx: {auth},
},
},
) => (auth.isAuthenticated ? it.userHasLiked(auth.user) : null),
},
}),
})
和之前的操作类似,我们为id
和title
字段定义了简单的解析函数。同时增加了viewerHasLiked
字段和身份验证检查。如果用户未被验证,返回null
,否则调用track.userHasLiked()
。说明一下,auth
对象来自 GraphQL 外层,比如 Express 的中间件。
只要 Playlist.load()
加载了 playlist,playlist.tracks()
从数据库加载了当前 playlist 的 tracks 信息,然后track.userHasLiked()
查询了数据库中一个 user 和 一个 track 的关联关系,我们的 GrapgQL 查询语句就可以正确的解析。实际上,如果我们指定了剩余字段,就相当于复制了 REST API 的功能,为简单起见暂且省略了。
至此,我们解决了 REST API 两个问题中的一个:客户端可以按需请求数据,从而以多种方式优化手机应用的性能。但是,仍然存在 N+1 次查询问题 - 如果我们请求 playlist 的全部 50个 tracks 的 viewerHasLiked
信息,还是需要 50 次查询。下面我们将要使用 DataLoader 解决这个问题,这个一个相当小巧灵活的 npm 模块,由 Facebook 开发。
DataLoader FTW(数据加载器)
DataLoader 是这样的一个工具:它提取了一次执行过程中(事件循环标记)的所有的loads
调用,然后基于这一系列的调用分批加载数据。同时,它基于 key 缓存了结果,所以后续的load()
调用只要参数相同,将会命中缓存直接返回。
所以,如果我们在一次事件循环中调用了多次myDataLoader.load(id)
,循环结束后,DataLoader 将会得到一包含所有 IDs 的数组,进而分批加载请求数据。强烈建议阅读 README 以更好地理解 DataLoader 的工作过程。
在我们的例子中,为了分批解决 user 和 track 的关系,我们设计了一个 track.userHasLiked()
方法实现了对 DataLoader 实例的调用。示例如下:
import DataLoader from 'dataloader'
import BaseModel from './BaseModel'
const likeLoader = new DataLoader(requests => {
// requests is now a an array of [track, user] pairs.
// Batch-load the results for those requests, reorder them to match
// the order of requests and return.
})
export default class Track extends BaseModel {
userHasLiked(user) {
return likeLoader.load([this, user])
}
}
将这段代码放在合适的位置,50 次对 likeLoader.load()
的调用将变成一次对分批加载函数的调用。这也就是说,我们的 GraphQL 查询现在只需要 3 次数据库查询而不是 52 次。
根据 DataLoader README 的提示,我们更近一步,从数据库查询级别开始组织了多个 DataLoader 的实例。
例如,如果我们想要通过 username 获取 users,可以这样做:
-
batchQueryLoader
- DataLoader 接收查询条件但不设置缓存,数据库执行查询(分批或者并行以进行加速优化)后返回结果。 -
userByIDLoader
- DataLoader 接收 IDs,通过batchQueryLoader
查询数据库,返回 user 对象。 -
userByUsernameLoader
- DataLoader 接收 usernames,通过batchQueryLoader
查询数据库得到 user IDs,再调用userByIDLoader
返回 user 对象。
其他 DataLoaders 调用 batchQueryLoader,以类似的调用组合,保证了数据库活动是分批进行的,从而降低延迟。同时由于userByUsernameLoader
获取到 IDs 后再调用 userByIDLoader
,userByIDLoader
就成为了一个共享层,总体上减少了查询次数。在我们的设计中,我们甚至基于管道为 Redis 增加了一个 DataLoader,再整合其他 loaders 作为一个缓存层,进一步降低了查询时间。
如上边提到的,DataLoaders 基于load()
参数缓存结果。基于此,我们为每一个请求都初始化了 DataLoaders,所以在一次单独的请求周期内,数据被缓存起来,请求结束后,缓存被抛弃。
通过这样的架构,原先获取完整的 playlist 需要 170 次查询,渲染完毕需要 15s 左右,到现在只需 3 次数据库查询,耗时仅为 250ms,如果从 Redis 缓存读取数据,仅需 17ms。就这样解决了所有的性能问题。
待解决问题
接下来,还有几个问题需要我们去解决
变更(写)
截止目前,我们的 GraphQL server 为整个 API 层提供了读能力,写能力还未实现。graphql-js 提供了一个简单的 DSL 来处理 GraphQL 变更,很快我们就会将写能力集成到 GraphQL 系统。这似乎是一个简单的任务,如果能发现一些见解或者最佳实践的实现,也会是非常有意义的。
客户端缓存
我们的客户端还没有解决 GraphQL 响应的缓存问题。理想情况下,从 GraphQL 终端请求数据的系统通过 schema 自省能够理解 schema 结构,因此也能够按需缓存子资源,所以一个 model 某一处更新时,所有的地方都会更新。未来还考虑实现像 TTLs、强制更新等功能。
如果没理解错的话,Relay 可以实现这些功能。然鹅 Relay 依然比较新,目前还不支持 React Native,也不能跑在原生代码环境中。
实时或推送更新
在我们的平台有一些内容是“实时”的,如果我们将这些内容集成到 GraphQL 后台将会是非常了不起的工作。也许就可以实现在线订阅的功能。
查询性能保护
如果我们对外提供了指定用户的 followers 信息,恶意的客户端可能会提交 user.followers.followers... 这样的请求,直到服务端崩溃。对此我们还没有完善的解决方案,尤其是当我们决定暴露 GraphQL 终端作为公共 API 时。有三个思路去考虑下:
- 执行一个 schema AST 检测,去验证查询是否太“复杂”,超过一定阈值后拒绝查询。
- 增加一些查询“超时”判断,如查询耗时过长杀掉请求,对某些数据库查询的请求进行限速等。
- 可以关注下 Facebook 对“查询缓存”的实现,它将查询存储在缓存中,在生产环境中,尤其是有白名单查询的时候,客户端不用传递整个查询,而是通过指向它们的 ID获取数据。
讨论
总之,GraphQL 是极好的并且真实的解决了我们开发 Playlist 时遇到的问题。并没有做广告的嫌疑,我们分享下我们的发现,希望能帮助到别人。前言技术和功能是有趣的,但有时也是难以理解和应用的。
彩蛋来了check out this video。这个视频是金融时报使用 GraphQL 的现实实践,对我了解 GraphQL 有极大的帮助。