作者简介 工业聚,携程高级前端开发专家,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 最佳实践携程度假 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 的成本。 这种实践模式,只能有限发挥 GraphQL 合并请求、裁剪数据集的作用。它仍然是面向数据接口,而非面向数据网络的。 如此无限堆砌数据接口,最终仍然是一个发散的模型,每增加一个数据消费场景需求,就追加一个接口字段。并且,当某些接口字段的参数,依赖其它接口的返回值,常常得重新发起一次 GraphQL 请求。 而面向数据网络,呈现的是收敛的模型。 如上所示,我们将用户收藏的产品列表,放到了 User 的 favorites 字段中;将关联的推荐产品列表,放到了 Product 的 recommends 字段中;构成一种层级关联,而非并列在 Query 根节点下作为独立接口字段。 相比一维的接口列表,我们构建了高维度的数据关联网络。子字段总是可以访问到它所在得上下文里的数据,因此很多参数是可以省略的。我们在一次 GraphQL 查询中,通过这些关联字段,获取到所需的数据,而不必再次发起请求。 当逐渐打通多个数据节点之间的关联关系,GraphQL 服务所能提供的查询能力可以不断增加,最后会收敛在一个完备状态。所有可能的查询路径都已被支持,新的数据消费场景,也无须开发新的接口字段,可以通过数据关联网络查询出来。 3.2 用 union 类型做错误处理 在 GraphQL 里做错误处理,有相当多的陷阱。 第一个陷阱是,通过 假设我们实现了以下 GraphQL 接口: 当查询 不仅仅在 我们很难通过 errors 数组来查找错误的节点,尽管有 path 字段标记错误节点的位置,但由于以下原因,它带来的帮助有限:
这个陷阱是导致 GraphQL 项目失败的重大诱因。 错误处理在 GraphQL 项目中,比 RESTful API 更重要。后者常常只需要处理一次,而 GraphQL 查询语句可以查询多个资源。每个资源的错误处理彼此独立,并非一个错误就意味着全盘的错误;每个资源所在的节点未必都是根节点,可以是任意层级的节点。 因此,GraphQL 项目里的错误处理发生的次数跟位置都变得多样。如果无法有效地管理异常,将会带来无尽的麻烦,甚至是生产事件。长此以往,项目宣告失败也在意料之内了。 第二个陷进是,用 如上所示,
这种模式,即便在 RESTful API 中也很常见。但是,在 GraphQL 这种错误节点可能在任意层级的场景中,该模式会显著增加节点的层级。每当一个节点需要错误处理,它就多了一层 此外, 也就是说,用 其实,在 GraphQL 中处理错误类型,有更好的方式——union type。 如上所示, 要么是 这正是错误处理的精确表达:要么出错,要么成功。 查询数据时,我们用 失败节点的查询结果如上所示,命中了 成功节点的查询结果如上所示,命中了 当使用 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); } 我们可以很容易通过共同字段
如上所示,我们把 此外, 3.3 用 在开发 客户端判空成本高,对查询结果的结构也更难预测。 这个问题在 如果前端工程师不愿意消费 这是反常的现象, 善用 在
如上,在 假设我们有如下 其中,只有根节点 我们可以为 我们概率性地分配 null 给 这是很难做到,且不那么合理的。因为 我们用如下查询语句查询 当 我们得到了符合 当 通过空值冒泡, 3.4 最佳实践小结 在
深入理解上述三个方面,就能掌握住 在对 GraphQL (以下简称GQL) 有一定了解的基础上,接下来分享一些我们具体的应用场景,以及项目工程化的实践。 四、GraphQL 落地一个新的 BFF 层规划出来之后,前端团队第一个关注问题就是“我有多少代码需要重写?”,这是一个很现实的问题。新服务的接入应尽量减少对原有业务的冲击,这包括前端尽可能少的改代码以及尽可能减少测试的回归范围。由于主要工作和测试都是围绕服务返回的报文,因此首先应该让 response 契约尽可能稳定。对老功能进行改造时,接口契约可以按照以下步骤柔性进行:
假设之前有个前端直接调用的接口,得到 ProductData 这个JSON结构的数据。
如上所示,一般情况我们可能会在一开始设计这样的 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:查询通配符
如上,对一个节点提供一个 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,则调用是并行的。
事实上这种方式不局限在同一接口,任何客户端希望并行的接口,都可以通过这样的方式实现。即在 GQL 内单独实现查询,然后由客户端发起一次“总查询”实现服务端聚合,这样的方式避免了 BFF 层因为前端需求变更不停跟随修改的困境。这种“拼积木”的方式可以用很小的成本实现服务的快速聚合,而且配合上面提到的“json”写法,未来也具备灵活的扩展性。 5.2 服务端串行 在应用中经常还会有事务型(增删改)的操作夹在这些“查”之中。比如: mutation TicketInfo( $ticketParams: TicketArgs! $shoppingParams: ShoppingArgs! ) { //查询门票 并 添加到购物车 ticketInfo(params: $ticketParams) { ticketData {json} } //根据“更新后”的购物车内的商品 获取价格明细 shoppingInfo(params: $shoppingParams) { priceDetail {json} } } 如上所示,获取价格明细的接口调用必须串行在「添加购物车」之后,这样才不会造成商品遗漏。而此例中的「mutation」操作符可以使各查询之间串行执行,如下:
同时,在 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,两个获取数据的接口调用又会变成并行,提高了响应速度。
5.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 服务的思考与实践。由前端团队开发维护一套完整的服务层,在设计和运维方面还是有不小的挑战,但是能赋予前端团队更大的灵活自主性,对于研发迭代效率的提升也是显著的。 希望对大家有所帮助,欢迎更多关于 GraphQL 的实践和交流。 |
|