环球资讯:携程基于 GraphQL 的前端 BFF 服务开发实践
2022-06-27 10:01:12来源:携程技术
作者:
(资料图片)
工业聚,携程高级前端开发专家,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 Serverdata-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节点下的字段抛错会出现在顶层的errors数组里,而是所有节点的错误都会被收集起来。这种功能看似方便,实则会带来巨大的麻烦。
我们很难通过 errors 数组来查找错误的节点,尽管有 path 字段标记错误节点的位置,但由于以下原因,它带来的帮助有限:
总是需要过滤 errors 去找到自己关心的错误节点查询语句是易变的,错误节点的位置可能会发生变化任意节点都可能产生错误,要处理的潜在情形太多这个陷阱是导致 GraphQL 项目失败的重大诱因。
错误处理在 GraphQL 项目中,比 RESTful API 更重要。后者常常只需要处理一次,而 GraphQL 查询语句可以查询多个资源。每个资源的错误处理彼此独立,并非一个错误就意味着全盘的错误;每个资源所在的节点未必都是根节点,可以是任意层级的节点。
因此,GraphQL 项目里的错误处理发生的次数跟位置都变得多样。如果无法有效地管理异常,将会带来无尽的麻烦,甚至是生产事件。长此以往,项目宣告失败也在意料之内了。
第二个陷进是,用Object表达错误类型。
如上所示,AddTodoResult类型是一个Object:
data字段是一个Object,它包含了查询结果code字段是一个Int,它表示错误码message字段是一个String,它表示错误信息这种模式,即便在 RESTful API 中也很常见。但是,在 GraphQL 这种错误节点可能在任意层级的场景中,该模式会显著增加节点的层级。每当一个节点需要错误处理,它就多了一层{ code, data, message },增加了整体数据复杂性。
此外,code和message字段的类型都带!,表示非空。而data字段的类型不带!,即可能为空。这就带来一个问题,code为1表达存在错误时,data也可能不为空。从类型上,并不能保证,code为1时,data一定为空。
也就是说,用Object表达错误类型是含混的。code和data的关系全靠服务端的逻辑来决定。服务端需要保证code和data的出现关系,一定满足code为1时,data为空,以及code为0时,data不为空。
其实,在 GraphQL 中处理错误类型,有更好的方式——union type。
如上所示,AddTodoResult类型是一个union,包含AddTodoError和AddTodoSuccess两个类型,表示或的关系。
要么是AddTodoError,要么是AddTodoSuccess,但不能是两者都是。
这正是错误处理的精确表达:要么出错,要么成功。
查询数据时,我们用... on Type {}的语法,同时查询两个类型下的字段。由于它们是或的关系,是互斥的,因此查询结果总是只有一组。
失败节点的查询结果如上所示,命中了AddTodoError节点,伴随有message字段。
成功节点的查询结果如上所示,命中了AddTodoSuccess节点,伴随有newTodo字段。
当使用graphql-to-typescript后,我们可以看到,AddTodoResult类型定义如下:
export type AddTodoResult = | { __typename: "AddTodoError"; message: string; } | { __typename: "AddTodoSuccess"; newTodo: Todo; };declare const result: AddTodoResult;if (result.__typename === "AddTodoError") { console.log(result.message);} else if (result.__typename === "AddTodoSuccess") { console.log(result.newTodo);}
我们可以很容易通过共同字段__typename区分两种类型,不必猜测code和data字段之间的可能搭配。
union type不局限于组合两个类型,还可以组合更多类型,表达超过 2 种的互斥场景。
如上所示,我们把getUser节点的可能结果,都用union类型组织起来,表达更精细的查询结果,可以区分更多错误种类。
此外,union type也不局限于做错误处理,而是任意互斥的类型场景。比如获取用户权限,我们可以把Admin | Owner | Normal | Guest等多种角色,作为互斥的类型,放到UserRole类型中。而非用{ isAdmin, isOwner, isNormal, isGuest, ... }这类含混形式,难以处理它们同时为false或同时为true等无效场景。
3.3用!表达非空类型在开发GraphQL服务时,有个非常容易疏忽的地方,就是忘记给非空类型标记!,导致客户端的查询结果在类型上处处可能为空。
客户端判空成本高,对查询结果的结构也更难预测。
这个问题在TypeScript项目中影响重大,当graphql-to-typescript后,客户端会得到一份来自graphql生成的类型。由于服务端没有标记!,令所有节点都是optional的。TypeScript将会强制开发者处理空值,前端代码因而变得异常复杂和冗赘。
如果前端工程师不愿意消费GraphQL服务,久而久之,GraphQL项目的用户流失殆尽,项目也随之宣告失败了。
这是反常的现象,GraphQL的核心优势就是用户友好的查询接口,可以更灵活地查询出所需的数据。因为服务端的疏忽而丢失了这份优势,非常可惜。
善用!标记,不仅有利于前端消费数据,同时也有利于服务端开发。
在GraphQL中,空值处理有个特性是,当一个非空字段却没有值时,GraphQL会自动冒泡到最近一个可空的节点,令其为空。
Since Non-Null type fields cannot be null, field errors are propagated to be handled by the parent field. If the parent field may be null then it resolves to null, otherwise if it is a Non-Null type, the field error is further propagated to its parent field.
由于非空类型的字段不能为空,字段错误被传播到父字段中处理。如果父字段可能是null,那么它就会解析为null,否则,如果它是一个非null类型,字段错误会进一步传播到它的父字段。
如上,在GraphQL Specification的6.4.4Handling Field Errors中,明确了如何置空的问题。
假设我们有如下GraphQL接口设计:
其中,只有根节点Query.parent是可空的,其他节点都是非空的。
我们可以为Grandchild类型编写如下GraphQL Resolver:
我们概率性地分配 null 给ctx.result(它表示该类型的结果)。尽管Grandchild是非空节点,但resolver里也能够给它置空。通过置空,告诉GraphQL去冒泡到父节点。否则我们就需要在Grandchild的层级去控制parent节点的值。
这是很难做到,且不那么合理的。因为Grandchild可以被挂到任意对象节点作为字段,不一定是当前parent。所有Grandchild都可以共用一个resolver实现。这种情况下,Grandchild不假设自己的父节点,只处理自己负责的数据部分,更加内聚和简单。
我们用如下查询语句查询GraphQL服务:
当Grandchild的value结果为1时,查询结果如下:
我们得到了符合GraphQL类型的结果,所有数据都有值。
当Grandchild的value结果为null时,查询结果如下:
通过空值冒泡,Grandchild的空值,被冒泡到parent节点,令parent的结果也为空。这也是符合我们编写的GraphQL Schema的类型约束的。如果只有Grandchild的value为null,反而不符合类型,因为该节点是带!的非空类型。
3.4 最佳实践小结在GraphQL中,还有很多实践和优化技巧可以展开,大部分可以在官方文档或社区技术文章里可以找到的。我们列举的是在实践中容易出错和误解的部分,分别是:
数据网络错误处理空值处理深入理解上述三个方面,就能掌握住GraphQL的核心价值,提高GraphQL成功落地的概率。
在对 GraphQL (以下简称GQL) 有一定了解的基础上,接下来分享一些我们具体的应用场景,以及项目工程化的实践。
四、GraphQL 落地一个新的 BFF 层规划出来之后,前端团队第一个关注问题就是“我有多少代码需要重写?”,这是一个很现实的问题。新服务的接入应尽量减少对原有业务的冲击,这包括前端尽可能少的改代码以及尽可能减少测试的回归范围。由于主要工作和测试都是围绕服务返回的报文,因此首先应该让 response 契约尽可能稳定。对老功能进行改造时,接口契约可以按照以下步骤柔性进行:
保持原有服务 response 契约不变对原有契约提供剪裁能力在有必要的前提下设计新的字段,并且该字段也应能被剪裁。假设之前有个前端直接调用的接口,得到 ProductData 这个JSON结构的数据。
const Query = gql` type ProductInfo { "产品全部信息" ProductData: JSON } extend type Query { productInfo(params: ProductArgs!): ProductInfo }`
如上所示,一般情况我们可能会在一开始设计这样的 GQL 对象。即对服务端下发的字段不做额外的设计,而直接标注它的数据类型是JSON。这样的好处是可以很快的对原客户端调用的API进行替换。
这里 ProductData 是一个“大”对象,属性非常多,未来如果希望利用 GQL 的特性对它进行动态裁剪则需要将结构进行重新设计,类似如下代码:
const Query = gql` type ProductStruct { "产品id" ProductId: Int "产品名称" ProductName: String ...... } type ProductInfo { "产品全部信息" ProductData: ProductStruct } extend type Query { productInfo(params: ProductArgs!): ProductInfo }`
但这样做就会引入一个严重的问题:这个数据结构的修改是无法向前兼容的,老版本的 query 语句查询 ProductInfo 的时候会直接报错。为了解决这个问题,我们参考 SQL 的「Select *」扩展了一个结构通配符「json」。
4.1 JSON:查询通配符const Query = gql` type ProductStruct { "原始数据" json: JSON "未来扩展" ProductId: Int ...... } type ProductInfo { "产品全部信息" ProductData: ProductStruct } extend type Query { productInfo(params: ProductArgs!): ProductInfo }`
如上,对一个节点提供一个 json 的查询字段,它将返回原节点全部内容,同时框架里对最终的 response 进行处理,如果碰到了 json 字段则对其解构,同时删除 json 属性。
利用这个特性,初始接入时只需要修改 BFF 请求的 request 报文,而 response 和原服务是一致的,因此无需特别回归。而未来即使需要做契约的剪切或者增加自定义字段,也只需要将 query 内容从 {json} 改成 {ProductId, ProductName, etc....} 即可。
五、GraphQL 应用场景作为 BFF 服务,在解决单一接口快速接入之后,通常会回到聚合多个服务端接口这个最初的目的,下面是常见几种的串、并调用等应用场景。
5.1服务端并行如上图顶部的产品详情和下面的B线产品,分别是两个独立的产品。如果需要一次性获取,我们一般要设计一个批量接口。但利用 GQL 合并多个查询请求的特性,我们可以用更好的方式一次获取。
首先 GQL 内只需要实现单一产品的查询即可,非常简洁:
ProductInfo.resolve("Query", { productInfo: async (ctx) => { ctx.result = await productSvc.fetch(ctx.args.productId) }})const ProductInfoHandle: ProductInfo = { BasicInfo: async ctx => { let {BasicInfo} = ctx.parent ctx.result = { json: BasicInfo, ...BasicInfo } }, .....}ProductInfo.resolve("ProductInfo", ProductInfoHandle);
客户端在查询的时候,只需要重复添加查询语句,并且传入另外一个产品参数。GQL 内会分别执行上述 resolve,如果是调用 API,则调用是并行的。
query getProductData( $mainParams: ProductArgs! $routeParams: ProductArgs!) { mainProductInfo(params: $mainParams) { BasicInfo{json} ..... } routeProductInfo(params: $routeParams) { BasicInfo{json} ..... }}//主产品查询请求[Node] [Inject Soa Mock]: 12345/productSvc 开始:11ms 耗时: 237ms 结束: 248ms//子产品查询请求[Node] [Inject Soa Mock]: 12345/productSvc 开始: 12ms 耗时: 202ms 结束: 214ms
事实上这种方式不局限在同一接口,任何客户端希望并行的接口,都可以通过这样的方式实现。即在 GQL 内单独实现查询,然后由客户端发起一次“总查询”实现服务端聚合,这样的方式避免了 BFF 层因为前端需求变更不停跟随修改的困境。这种“拼积木”的方式可以用很小的成本实现服务的快速聚合,而且配合上面提到的“json”写法,未来也具备灵活的扩展性。
5.2服务端串行在应用中经常还会有事务型(增删改)的操作夹在这些“查”之中。比如:
mutation TicketInfo( $ticketParams: TicketArgs! $shoppingParams: ShoppingArgs!) { //查询门票 并 添加到购物车 ticketInfo(params: $ticketParams) { ticketData {json} } //根据“更新后”的购物车内的商品 获取价格明细 shoppingInfo(params: $shoppingParams) { priceDetail {json} }}
如上所示,获取价格明细的接口调用必须串行在「添加购物车」之后,这样才不会造成商品遗漏。而此例中的「mutation」操作符可以使各查询之间串行执行,如下:
//查询门票[Node] [Inject Soa Mock]: 12345/getTicketSvc 开始: 16ms 耗时: 111ms 结束: 127ms//添加到购物车[Node] [Inject Soa Mock]: 12345/updateShoppingSvc 128ms 耗时: 200ms 结束: 328ms//根据「更新后」的购物车内的商品 获取价格明细[Node] [Inject Soa Mock]: 12345/getShoppingSvc 开始: 330ms 耗时: 110ms 结束: 440ms
同时,在 GQL 代码里也应按照前端查询的操作符来决定是否执行“事务性”操作。
async function recommendExtraResource(ctx){ //查询门票 const extraResource = await getTicketSvc.fetch() const { operation } = ctx.info.operation; if (operation === "mutation"){ //添加到购物车内 await updateShoppingSvc.fetch(extraResource) } ctx.result = extraResource}ExtraResource.resolve("Query", { recommendExtraResource });ExtraResource.resolve("Mutation", { recommendExtraResource });
这样的设计使查询就变得非常灵活。如前端仅需要查询可用门票和价格明细并不需要默认添加到购物车内,仅需要将 mutation 换成 query 即可,服务端无需为此做任何调整。而且因为没有执行更新,且操作符变成了 query,两个获取数据的接口调用又会变成并行,提高了响应速度。
//查询门票[Node] [Inject Soa Mock]: 12345/getTicketSvc 开始: 16ms 耗时: 111ms 结束: 127ms//根据「当时」的购物车内的商品 获取价格明细[Node] [Inject Soa Mock]: 12345/getShoppingSvc 开始: 18ms 耗时: 104ms 结束: 112ms5.3 父子查询中的重复请求
我们经常会碰到一个接口的入参,依赖另外一个接口的 response。这种将串行调用从客户端移到服务端的做法可以有效的降低端到端的次数,是 BFF 层常见的优化手段。但是如果我们有多个节点一起查询时,可能会出现同一个接口被调用多次的问题。对应这种情况,我们可以使用 GQL 的 data-loader。
ProductInfo.resolve("Query", { productInfo: async (ctx) => { let productLoader = new DataLoader(async RequestType => { // RequestType 为数组,通过子节点的 load 方法,去重后得到。 let response = await productSvc.fetch({ RequestType }) return Array(RequestType.length).fill(response) }) ctx.result = { productLoader } }})ExtendInfo.resolve("Product",{ extendInfo: async (ctx) => { const BasicInfo = await ctx.parent.productLoader.load("BasicInfo") ctx.result = await extendSvc.fetch(BasicInfo) }})
如上,在父节点的 resolve 里构造 loader,通过 ctx.result 传递给子节点。子节点调用 load(arg) 方法将参数添加到 loader 里,父节点的 loader 根据“积累”的参数,发起真正的请求,并将结果分别下发对应地子节点。在这个过程中可以实现相同的请求合并只发一次。
六、工程化实践6.1异常处理在 GQL 关联查询中父节点失败导致子节点异常的情况很常见。而这个父子关系是由前端 query 报文决定的,因此需要我们在服务端处理异常的时候,清晰地通过日志等方式准确描述原因,上图可以看出 imEnterInfo 节点异常是由于依赖的 BasicInfo 节点为空,而根因是依赖的 API 返回错误。这样的异常处理设计对排查 GQL 的问题非常有帮助。
6.2虚拟路径由于 GQL 唯一入口的特性,服务捕获到的访问路径都是 /basename/graphql,导致定位错误很困难。因此我们扩展了虚拟路径,前端查询的时候使用类似「/basename/graphql/productInfo」。这样无论是日志、还是 metric 等平台等都可以区分于其他查询。
并且这个虚拟路径对 GQL 自身不会造成影响,前端甚至可以利用这个虚拟路径来测试 query 的节点和 BFF 响应时长的关系。如:H5 平台修改了首屏 query 的内容之后将请求路径改成 “/basename/graphql/productInfo_h5”,这样就可以通过性能监控95线等方式,对比看出这个“h5”版本对比其他版本性能是否有所下降。
在很多优化首屏的实践中,利用 GQL 动态查询,灵活剪切契约等是非常有效的手段。并且在过程中,服务端并不需要跟随前端调整代码。降低工作量的同时,也保证了其他平台的稳定性。
6.3监控运维GQL 的特性也确实造成了现有的运维工具很难分析出哪个节点可以安全废弃(删除代码)。因此需要我们在 resolve 里面对节点进行了埋点。
6.4单元测试我们利用 jest 搭建了一个测试框架来对 GQL BFF 进行单元测试。与一般单测不同的是,我们选择在当前运行环境内单独起一个服务进程,并且引入“@apollo/client”来模拟客户端对服务进行查询,并校验结果。
其他诸如 CI/CD、接口数据 mock、甚至服务的心跳检测等更多的属于 node.js 的解决方案,就不在这里赘述了。
七、总结鉴于篇幅原因,只能分享部分我们应用 GraphQL 开发 BFF 服务的思考与实践。由前端团队开发维护一套完整的服务层,在设计和运维方面还是有不小的挑战,但是能赋予前端团队更大的灵活自主性,对于研发迭代效率的提升也是显著的。