From REST to GraphQL(译)(下)

[译 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),
    },
  }),
})

和之前的操作类似,我们为idtitle字段定义了简单的解析函数。同时增加了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 后再调用 userByIDLoaderuserByIDLoader就成为了一个共享层,总体上减少了查询次数。在我们的设计中,我们甚至基于管道为 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 时。有三个思路去考虑下:

  1. 执行一个 schema AST 检测,去验证查询是否太“复杂”,超过一定阈值后拒绝查询。
  2. 增加一些查询“超时”判断,如查询耗时过长杀掉请求,对某些数据库查询的请求进行限速等。
  3. 可以关注下 Facebook 对“查询缓存”的实现,它将查询存储在缓存中,在生产环境中,尤其是有白名单查询的时候,客户端不用传递整个查询,而是通过指向它们的 ID获取数据。

讨论

总之,GraphQL 是极好的并且真实的解决了我们开发 Playlist 时遇到的问题。并没有做广告的嫌疑,我们分享下我们的发现,希望能帮助到别人。前言技术和功能是有趣的,但有时也是难以理解和应用的。

彩蛋来了check out this video。这个视频是金融时报使用 GraphQL 的现实实践,对我了解 GraphQL 有极大的帮助。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容