GraphQL 后端架构的经验分享

Backend Nov 17, 2021

修订记录

  • 2021.11.17 - 初稿完成

目前互联网上关于 GraphQL 的公开资料其实挺少的,多数是一些 demo 级别的小脚本,还有各类文档的搬运,有些示例代码甚至都运行不了。

近期有幸参与的一个项目使用的技术栈为 GraphQL,做项目之余自己也花了点时间尝试设计 GraphQL 后端的架构,写一只五脏俱全的小麻雀。在设计的过程中我翻阅了大量的资料,在这里非常感谢这些作者给我带来的启发。在下文里我会结合自己实践中的体验,分享一下 GraphQL 的简史以及在生产环境中遇到的优势和挑战。

GraphQL 是什么

GraphQL 是一门查询语言,是一个使用基于类型系统来执行查询的服务端运行时 (runtime)。GraphQL 确切的说只是一套标准,没有和任何特定数据库或者存储引擎绑定,换句话说就是 GraphQL 的数据源可以是任何东西,包括不限于数据库,RESTful API,本地文件,代码变量等等。

GraphQL 很像一个网关,很像通常大家描述的 BFF (Backend For Frontend) 系统,主要作用有几点

  1. 帮助前端聚合数据 - 前端一个请求可以触发多条查询,并且聚合起来按照一定格式返回给前端
  2. 帮助前端屏蔽掉后端的细节 - 只要网关作出对应修改,后端改变的时候对业务是无感的
  3. 缓存 - 降低后端的负载

或者下一个不严谨的定义,GraphQL 是一套还算不错的 BFF 规范。

Apollo 和 GraphQL 是什么关系

GraphQL 本身是由 Facebook 提出并且制定的标准,但是 FB 官方只提供了 Javascript 的实现,其他语言的 GraphQL 实现都是社区基于这套标准来实现的。

Apollo 就属于是社区的中流砥柱,Apollo 贡献了前后端技术栈的各种实现,并且基于 GraphQL 标准实现了一套完整的工具链比如缓存控制,错误处理,Playground 等等。

所以简单说,GraphQL 是标准,Apollo 是实现。

GraphQL 的设计理念

GraphQL 的设计理念不得不说非常的先进,GraphQL 简单说只有两个模块,一个是 schema,一个是 resolver。schema 定义了数据长什么样,分级权限控制,可选类型标记等等属性,resolver 定义了数据的获取方式,再复杂的数据模型也可以根据 resolver 递归抽丝剥茧生成出来,举个简单的例子:

// news.graphql

type Country {
  code: String!
  name: String!
}

type News {
  newsId: String!
  sourceId: String
  sourceName: String!
  author: String
  title: String!
  description: String
  url: String!
  imageUrl: String
  content: String
  country: Country
  category: String
  publishedAt: DateTime!
}

type Query {
  getNews: [News]
}

上面就定义了一个 schema,我们定义了 Country 和 News 两种类型,还定义了 getNews 这个查询方法。

// news.js
const { NewsModel } = require('../../service/db')

const getNewsHeadLine = async () => {
  // 参数筛选可省略,GraphQL 会根据 schema 一一映射过去
  return NewsModel.find({}, 'sourceId sourceName author title description url imageUrl content country category publishedAt', { sort: '-publishedAt', skip: 0, limit: 10 }).populate('country')
}

module.exports = {
  Query: {
    async getNews() {
      const result = await getNewsHeadLine()
      return result
    },
  },
}

然后我们定义了一个 resolver,其中 module.exports = resolvers 方法可以从任何地方拿数据,比如操作数据库,或者从 RESTful API 拿。

query {
  getNews {
    title
    sourceName
    publishedAt
  }
}

接着就是查询,查询的时候可以按需填写自己需要的参数,甚至是嵌套的对象都可以,当然了前提是数据源要有同样结构的嵌套对象。

通常情况下,互联网上的公开文章到这里就结束了,实际上作为一个合格的生产环境后端服务器,还有很多的地方要做,比如安全,比如限流,缓存等等。下面我会结合项目经验和自己的探索,讨论这些话题然后给出一些相对严谨的结论。

GraphQL 安全

作为服务器端,最重要的就是安全,防止被脱裤,在这一点上 RESTful API 可以做鉴权并且加签名防止重放,可以根据不同路径做限流,基本可以满足需求。

鉴权

GraphQL 在鉴权和加签名方面没问题,都可以通过中间件支持,但是调用频率限制配置起来比较难,因为所有 GraphQL 的请求路径都是同一个。

关于 API 加签可以多说两句,我个人通常会遵循这个原则:“什么人 + 什么时间 + 什么地点 + 做了什么”,对应到摘要实现就是

crypto(authorization + timestamp + path + body, SIGN_KEY)

分级权限

RESTful 正常情况下只能做到路由级别的权限控制了,比如 /api/v1/users 只允许管理员调用,接口里面的字段想要做到分级控制比较难。

而 GraphQL 内置的 Directives 模块就很方便的做权限控制,包括字段级别,对象级别,接口级别,在字段权限控制方面的颗粒度非常细。

防止注入

GraphQL 简化一下就好像是前端直接传递 SQL 语句,也会有早期 MySQL 存在的注入风险,所以在正式使用的时候会把要传递的参数写在 variables 里面。

{
  "query": "query loginOperator($username:Email!,$password:String!){loginOperator(auth:{email:$username,password:$password}){message statusCode result{token refreshToken expiresAt}}}",

  "variables": {
    "username": "name",
    "password": "pwd"
  }
}

GraphQL 优势

GraphQL 的优势是很明显的,比如内容聚合大大减少了 API 请求的次数,比如数据字段可选大大减少无用流量的传输,还有 schema 文件中的强类型,类似于 Swift 中的 Optional 类型,极大的降低程序 crash 的风险。除此之外还有很多好处,其他文章也都介绍了七七八八,所以后面我会重点介绍下 GraphQL 在生产环境中遇到的问题,以及开发过程中遇到的问题。

GraphQL 挑战

apollo-server(-express) 和 express-graphql

选 Apollo 的实现还是选其他社区版本的实现,相信很多上手 GraphQL 的同学都纠结过,又因为这是开始干活前的第一步,框架选型失误会给后面带来较高的迁移成本,我也不例外,花了不少时间对比这些框架。

Apollo 实现的 GraphQL 是目前最流行的框架,如果你实在不知道怎么选,选 Apollo 就对了。Apollo 框架相对复杂一些,因为他们实现了一套完整的工具链比如缓存控制,错误处理,内建 HTTP 插件,Playground 等等。

express-graphql 和类似的一些框架只是包括了基本的 GraphQL 实现,适合快速的做一些 demo。

鉴权

在 RESTful 的时候,可以针对路由放置鉴权用的中间件,一般来讲有如下几种方案:

  1. 针对不同路由加载中间件
  2. 中间件加载时机放在需要登陆的路由上方
  3. 在 jwt 里面 exclude 掉部分路由

然而在 GraphQL 中,任何一条都行不通,因为 GraphQL 根本不存在路由的概念,每一个请求都是 POST 到 the-url:port/graphql 路径下。

所以关于统一鉴权,如果想分路由设置权限的话,就得在中间件中字符串匹配 req.body 中的 query 名字,个人觉得这种做法很不靠谱。也可以考虑把请求解析成树结构,如果没什么好方案的话只能这么干了。

如果可以接受在 Query 层面做鉴权的话,GraphQL 倒是非常的舒服,我们可以在 context 字段拿到鉴权需要的数据。

限流

限流应该是生产服务器上最基本的功能了,基于路由的 API 做限流极其的便利,我们甚至可以针对不同路由做不同的限流策略。但是 GraphQL 每个请求的路由都一样,而且有些查询背后是十几二十个数据库查询,一刀切形式的限流起不到作用。而针对请求去限流就需要去解析 HTTP 的 body,性能会大打折扣。

在 RESTful 下,我们可以给某些路由加上一样的前缀 /api/v1/some-prefix/business,限流配置正则表达式就可以精准覆盖,但是 GraphQL 下基本上实现不了了,因为每个 Query 方法名都是独立的。

缓存

在 RESTful 规范下,做缓存不要太容易,URI (Uniform Resource Identifier) 也是实至名归,一个 URL 对应一个资源,缓存的配置可以精细到 API 级别,并且大部分情况下都不需要额外的配置,自动生效。

但是 GraphQL 就是另外一番景象了,所有数据都是 POST 到同一个路由下,而且请求参数的排列组合多种多样,很不利于做缓存。当然了目前社区也有一些探索,比如客户端给请求参数做个哈希,发送 POST 请求之前先尝试 GET 一下这个哈希,如果不存在就正式发起查询,方案总的来说还是不够成熟,短时间难以普及。

深度爆炸

试想这么一个场景,Book 类型可以有作者 Author 属性,Author 可以有 Book 属性,这就存在一个循环引用的情况,在 GraphQL 中,由于各个类型就是像图一样互相嵌套,客户端很容易就能构造出复杂度特别高的请求。尽管服务器端可以设置最大对象数量,或者限制查询深度,但是由于客户端的真实情况不确定,请求的复杂度很难评估。

换句话说就是客户端发请求之前,这个 API 的复杂度是不确定的,这个时候对服务器的性能和限流配置都是一个考验。即使优化数据库结构可以避免部分查询深度爆炸的问题,然而 GraphQL 与生俱来的 schema + resolver 就注定了深度爆炸一定是存在的。

业界实践

京东在尝试 GraphQL 的时候,遇到了 API 服务器个数的问题,因为客户端集成的时候只允许有一个 schema.json 文件。此外他们还遇到了深度爆炸的问题,客户端团队总能一次又一次突破预估的深度值,这个问题最终也是无解的。

美团使用 GraphQL 担任了 BFF 的功能(类似于阿里集团的 MTOP 网关),同时再嵌套一层 HTTP 接口暴露给客户端用,既利用了 GraphQL 的优势,又避免了客户端调用 GraphQL 产生的诸多问题。

个人建议

我在跟朋友聊天的时候,了解到他们的做法是所有接口都采用 GraphQL,于是我在设计后端结构的时候先入为主的尽可能使用 GraphQL,于是发现了上述的诸多问题。

所以我最后的结论是,如果你没有场景需要聚合数据,就不要用 GraphQL。如果你有此类场景,可以考虑 GraphQL 担任 BFF 的功能而不是一个完整的后端服务器,如果你同时有多个 BFF 服务器,封装一层 HTTP 可以解决客户端集成的问题。

Tags

Jie Li

🚘 On-road / 📉 US Stock / 💻 Full Stack Engineer / ®️ ENTJ