作者简介
工业聚,携程高级前端开发专家,react-lite, react-imvc, farrow 等开源项目作者。
兰迪咚,携程高级前端开发专家,对开发框架及前端性能优化有浓厚兴趣。
一、前言
过去两三年,携程度假前端团队一直在实践基于 GraphQL/Node.js 的 BFF (Backend for Frontend) 方案,在度假BU多端产品线中广泛落地。最终该方案不仅有效支撑前端团队面向多端开发 BFF 服务的需要,而且逐步承担更多功能,特别在性能优化等方面带来显著优势。
我们观察到有些前端团队曾尝试过基于 GraphQL 开发 BFF 服务,最终宣告失败,退回到传统 RESTful BFF 模式,会认为是 GraphQL 技术自身的问题。
这种情况通常是由于 GraphQL 的落地适配难度导致的,GraphQL 的复杂度容易引起误用。因此,我们期望通过本文分享我们所理解的最佳实践,以及一些常见的反模式,希望能够给大家带来一些启发。
二、GraphQL 技术栈
以下是我们 GraphQL-BFF 项目中所采用的核心技术栈:
• graphql
基于 JavaScript 的 GraphQL 实现
• koa v2
Node.js Web Framework 框架
• apollo-server-koa
适配 koa v2 的 Apollo Server
• data-loader
优化 GraphQL Resolver 内发出的请求
• graphql-scalars
提供业务中常用的 GraphQL Scalar 类型
• faker
提供基于类型的 Mock 数据
结合 GraphQL Schema 可自动生成 Mock 数据
• @graphql-codegen/typescript
基于 GraphQL Schema 生成 TypeScript 文件
• graphql-depth-limit
限制 GraphQL Query 的查询深度
• jest
单元测试框架
其他非核心或者公司特有的基础模块不再赘述。
三、GraphQL 最佳实践
携程度假 GraphQL 的主要应用场景是 IO 密集的 BFF 服务,开发面向多端所用的 BFF 服务。
所有面向外部用户的 GraphQL 服务,我们会限制只能调用其他后端 API,以避免出现密集计算或者架构复杂的情况。只有面向内部用户的服务,才允许 GraphQL 服务直接访问数据库或者缓存。
对 RESTful API 服务来说,每次接口调用的开销基本上是稳定的。而 GraphQL 服务提供了强大的查询能力,每次查询的开销,取决于 GraphQL Query 语句查询的复杂度。
因此,在 GraphQL 服务中,如果包含很多 CPU 密集的任务,其服务能力很容易受到 GraphQL Query 可变的查询复杂度的影响,而变得难以预测。
将 GraphQL 服务约束在 IO 密集的场景中,既可以发挥出 Node.js 本身的 IO 友好的优势,又能显著提高 GraphQL 服务的稳定性。
3.1 面向数据网络(Data Graph),而非面向数据接口
我们注意到有相当多 GraphQL 服务,其实是披着 GraphQL 的皮,实质还是 RESTful API 服务。并未发挥出 GraphQL 的优势,但却承担着 GraphQL 的成本。
如上所示,原本 RESTful API 的接口,只是挂载到 GraphQL 的 Query 或 Mutation 的根节点下,未作其它改动。这种实践模式,只能有限发挥 GraphQL 合并请求、裁剪数据集的作用。它仍然是面向数据接口,而非面向数据网络的。
如此无限堆砌数据接口,最终仍然是一个发散的模型,每增加一个数据消费场景需求,就追加一个接口字段。并且,当某些接口字段的参数,依赖其它接口的返回值,常常得重新发起一次 GraphQL 请求。
而面向数据网络,呈现的是收敛的模型。
如上所示,我们将用户收藏的产品列表,放到了 User 的 favorites 字段中;将关联的推荐产品列表,放到了 Product 的 recommends 字段中;构成一种层级关联,而非并列在 Query 根节点下作为独立接口字段。
相比一维的接口列表,我们构建了高维度的数据关联网络。子字段总是可以访问到它所在得上下文里的数据,因此很多参数是可以省略的。我们在一次 GraphQL 查询中,通过这些关联字段,获取到所需的数据,而不必再次发起请求。
当逐渐打通多个数据节点之间的关联关系,GraphQL 服务所能提供的查询能力可以不断增加,最后会收敛在一个完备状态。所有可能的查询路径都已被支持,新的数据消费场景,也无须开发新的接口字段,可以通过数据关联网络查询出来。
3.2 用 union 类型做错误处理
在 GraphQL 里做错误处理,有相当多的陷阱。
第一个陷阱是,通过 throw error
将错误抛到最顶层。
假设我们实现了以下 GraphQL 接口:
当查询 addTodo
节点时,其 resolver
函数抛出的错误,将会出现在顶层的 errors
数组里,而 data.addTodo
则为 null
。
不仅仅在 Query/Mutation
节点下