GraphQL及元数据驱动架构在后端BFF中的实践

GraphQL是Facebook提出的一种数据查询语言,核心特性是数据聚合和按需索取,目前被普遍应用于先后端之间,解决客户端灵活使用数据问题。本文介绍的是GraphQL的另外一种实践,咱们将GraphQL下沉至后端BFF层之下,结合元数据技术,实现数据和加工逻辑的按需查询和执行。这样不只解决了后端BFF层灵活使用数据的问题,这些字段加工逻辑还能够直接复用,大幅度提高了研发的效率。本文介绍的实践方案已经在美团部分业务场景中落地,并取得不错效果,但愿这些经验可以对你们有帮助。

1 BFF的由来

BFF一词来自Sam Newman的一篇博文《Pattern:Backends For Frontends》,指的是服务于前端的后端。BFF是解决什么问题的呢?据原文描述,随着移动互联网的兴起,原适应于桌面Web的服务端功能但愿同时提供给移动App使用,而在这个过程当中存在这样的问题:html

  • 移动App和桌面Web在UI部分存在差别。
  • 移动App涉及不一样的端,不只有iOS、还有Android,这些不一样端的UI之间存在差别。
  • 原有后端功能和桌面Web UI之间已经存在了较大的耦合。

由于端的差别性存在,服务端的功能要针对端的差别进行适配和裁剪,而服务端的业务功能自己是相对单一的,这就产生了一个矛盾——服务端的单一业务功能和端的差别性诉求之间的矛盾。那么这个问题怎么解决呢?这也是文章的副标题所描述的"Single-purpose Edge Services for UIs and external parties",引入BFF,由BFF来针对多端差别作适配,这也是目前业界普遍使用的一种模式。前端

图1 BFF示意图

在实际业务的实践中,致使这种端差别性的缘由有不少,有技术的缘由,也有业务的缘由。好比,用户的客户端是Android仍是iOS,是大屏仍是小屏,是什么版本。再好比,业务属于哪一个行业,产品形态是什么,功能投放在什么场景,面向的用户群体是谁等等。这些因素都会带来面向端的功能逻辑的差别性。java

在这个问题上,笔者所在团队负责的商品展现业务有必定的发言权,一样的商品业务,在C端的展现功能逻辑,深入受到商品类型、所在行业、交易形态、投放场所、面向群体等因素的影响。同时,面向消费者端的功能频繁迭代的属性,更是加重并深化了这种矛盾,使其演化成了一种服务端单一稳定与端的差别灵活之间的矛盾,这也是商品展现(商品展现BFF)业务系统存在的必然性缘由。本文主要在美团到店商品展现场景的背景下,介绍面临的一些问题及解决思路。git

2 BFF背景下的核心矛盾

BFF这层的引入是解决服务端单一稳定与端的差别灵活诉求之间的矛盾,这个矛盾并非不存在,而是转移了。由原来后端和前端之间的矛盾转移成了BFF和前端之间的矛盾。笔者所在团队的主要工做,就是和这种矛盾做斗争。下面以具体的业务场景为例,结合当前的业务特色,说明在BFF的生产模式下,咱们所面临的具体问题。下图是两个不一样行业的团购货架展现模块,这两个模块咱们认为是两个商品的展现场景,它们是两套独立定义的产品逻辑,而且会各自迭代。程序员

图2 展现场景

在业务发展初期,这样的场景很少。BFF层系统“烟囱式”建设,功能快速开发上线知足业务的诉求,在这样的状况下,这种矛盾表现的不明显。而随着业务发展,行业的开拓,造成了许许多多这样的商品展现功能,矛盾逐渐加重,主要表如今如下两个方面:github

  • 业务支撑效率:随着商品展现场景变得愈来愈多,API呈爆炸趋势,业务支撑效率和人力成线性关系,系统能力难以支撑业务场景的规模化拓展。
  • 系统复杂度高:核心功能持续迭代,内部逻辑充斥着if…else…,代码过程式编写,系统复杂度较高,难以修改和维护。

那么这些问题是怎么产生的呢?这要结合“烟囱式”系统建设的背景和商品展现场景所面临的业务,以及系统特色来进行理解。算法

特色一:外部依赖多、场景间取数存在差别、用户体验要求高后端

图例展现了两个不一样行业的团购货架模块,这样一个看似不大的模块,后端在BFF层要调用20个以上的下游服务才能把数据拿全,这是其一。在上面两个不一样的场景中,须要的数据源集合存在差别,并且这种差别广泛存在,这是其二,好比足疗团购货架须要的某个数据源,在丽人团购货架上不须要,丽人团购货架须要的某个数据源,足疗团购货架不须要。尽管依赖下游服务多,同时还要保证C端的用户体验,这是其三。设计模式

这几个特色给技术带来了不小的难题:1)聚合大小难控制,聚合功能是分场景建设?仍是统一建设?若是分场景建设,必然存在不一样场景重复编写相似聚合逻辑的问题。若是统一建设,那么一个大而全的数据聚合中必然会存在无效的调用。2)聚合逻辑的复杂性控制问题,在这么多的数据源的状况下,不只要考虑业务逻辑怎么写,还要考虑异步调用的编排,在代码复杂度未能良好控制的状况下,后续聚合的变动修改将会是一个难题。缓存

特色二:展现逻辑多、场景之间存在差别,共性个性逻辑耦合

咱们能够明显地识别某一类场景的逻辑是存在共性的,好比团单相关的展现场景。直观能够看出基本上都是展现团单维度的信息,但这只是表象。实际上在模块的生成过程当中存在诸多的差别,好比如下两种差别:

  • 字段拼接逻辑差别:好比以上图中两个团购货架的团购标题为例,一样是标题,在丽人团购货架中的展现规则是:[类型] + 团购标题,而在足疗团购货架的展现规则是:团购标题
  • 排序过滤逻辑差别:好比一样是团单列表,A场景按照销量倒排序,B场景按照价格排序,不一样场景的排序逻辑不一样。

诸如此类的展现逻辑的差别性还有不少。相似的场景实际上在内部存在不少差别的逻辑,后端如何应对这种差别性是一个难题,下面是最多见的一种写法,经过读取具体的条件字段来作判断实现逻辑路由,以下所示:

if(category == "丽人") {
  title = "[" + category + "]" + productTitle;
} else if (category == "足疗") {
  title = productTitle;
}

这种方案在功能实现方面没有问题,也可以复用共同的逻辑。可是实际上在场景很是多的状况下,将会有很是多的差别性判断逻辑叠加在一块儿,功能一直会被持续迭代的状况下,能够想象,系统将会变得愈来愈复杂,愈来愈难以修改和维护。

总结:在BFF这层,不一样商品展现场景存在差别。在业务发展初期,系统经过独立建设的方式支持业务快速试错,在这种状况下,业务差别性带来的问题不明显。而随着业务的不断发展,须要搭建及运营的场景愈来愈多,呈规模化趋势。此时,业务对技术效率提出了更高的要求。在这种场景多、场景间存在差别的背景下,如何知足场景拓展效率同时可以控制系统的复杂性,就是咱们业务场景中面临的核心问题

3 BFF应用模式分析

目前业界针对此类的解决方案主要有两种模式,一种是后端BFF模式,另外一种是前端BFF模式。

3.1 后端BFF模式

后端BFF模式指的是BFF由后端同窗负责,这种模式目前最普遍的实践是基于GraphQL搭建的后端BFF方案,具体是:后端将展现字段封装成展现服务,经过GraphQL编排以后暴露给前端使用。以下图所示:

图3 后端BFF模式

这种模式最大的特性和优点是,当展现字段已经存在的状况下,后端不须要关心前端差别性需求,按需查询的能力由GraphQL支持。这个特性能够很好地应对不一样场景存在展现字段差别性这个问题,前端直接基于GraphQL按需查询数据便可,后端不须要变动。同时,借助GraphQL的编排和聚合查询能力,后端能够将逻辑分解在不一样的展现服务中,所以在必定程度上可以化解BFF这层的复杂性。

可是基于这种模式,仍然存在几个问题:展现服务颗粒度问题、数据图划分问题以及字段扩散问题,下图是基于当前模式的具体案例:

图4 后端BFF模式(案例)

1)展现服务颗粒度设计问题

这种方案要求展现逻辑和取数逻辑封装在一个模块中,造成一个展现服务(Presentation Service),如上图所示。而实际上展现逻辑和取数逻辑是多对多的关系,仍是之前文提到的例子说明:

背景:有两个展现服务,分别封装了商品标题和商品标签的查询能力。
情景:此时PM提了一个需求,但愿商品在某个场景的标题以“[类型]+商品标题”的形式展现,此时商品标题的拼接依赖类型数据,而此时类型数据商品标签展现服务中已经调用了。
问题:商品标题展现服务本身调用类型数据仍是将两个展现服务合并到一块儿?

以上描述的问题的是展现服务颗粒度把控的问题,咱们能够怀疑上述的示例是否是由于展现服务的颗粒度太小?那么反过来看一看,若是将两个服务合并到一块儿,那么势必又会存在冗余。这是展现服务设计的难点,核心缘由在于,展现逻辑和取数逻辑自己是多对多的关系,结果却被设计放在了一块儿

2)数据图划分问题

经过GraphQL将多个展现服务的数据聚合到一张图(GraphQL Schema)中,造成一个数据视图,须要数据的时候只要数据在图中,就能够基于Query按需查询。那么问题来了,这个图应该怎么组织?是一张图仍是多张图?图过大的话,势必带来复杂的数据关系维护问题,图太小则将会下降方案自己的价值。

3)展现服务内部复杂性 + 模型扩散问题

上文提到过一个商品标题的展现存在不一样拼接逻辑的状况,在商品展现场景,这种逻辑特别广泛。好比一样是价格,A行业展现优惠后价格,B行业展现优惠前价格;一样是标签位置,C行业展现服务时长,而D行业展现商品特性等。那么问题来了,展现模型如何设计?以标题字段为例,是在展现模型上放个title字段就能够,仍是分别放个titletitleWithCategory?若是是前者那么服务内部必然会存在if…else…这种逻辑,用于区分title的拼接方式,这一样会致使展现服务内部的复杂性。若是是多个字段,那么能够想象,展现服务的模型字段也将会不断扩散。

总结:后端BFF模式可以在必定程度上化解后端逻辑的复杂性,同时提供一个展现字段的复用机制。可是仍然存在未决问题,如展现服务的颗粒度设计问题,数据图的划分问题,以及展现服务内部的复杂性和字段扩散问题。目前这种模式实践的表明有Facebook、爱彼迎、eBay、爱奇艺、携程、去哪儿等等。

3.2 前端BFF模式

前端BFF模式在Sam Newman的文章中的"And Autonomy"部分有特别的介绍,指的是BFF自己由前端团队本身负责,以下示意图所示:

图5 前端BFF模式

这种模式的理念是,原本能一个团队交付的需求,不必拆成两个团队,两个团队自己带来较大的沟通协做成本。本质上,也是一种将“敌我矛盾”转化为“人民内部矛盾”的思路。前端彻底接手BFF的开发工做,实现数据查询的自给自足,大大减小了先后端的协做成本。可是这种模式没有提到咱们关心的一些核心问题,如:复杂性如何应对、差别性如何应对、展现模型如何设计等等问题。除此以外,这种模式也存在一些前提条件及弊端,好比较为完备的前端基础设施;前端不只仅须要关心渲染、还须要了解业务逻辑等。

总结:前端BFF模式经过前端自主查询和使用数据,从而达到下降跨团队协做的成本,提高BFF研发效率的效果。目前这种模式的实践表明是阿里巴巴。

4 基于GraphQL及元数据的信息聚合架构设计

4.1 总体思路

经过对后端BFF和前端BFF两种模式的分析,咱们最终选择后端BFF模式,前端BFF这个方案对目前的研发模式影响较大,不只须要大量的前端资源,并且须要建设完善的前端基础设施,方案实施成本比较高昂。

前文提到的后端GraphQL BFF模式代入咱们的具体场景虽然存在一些问题,可是整体有很是大的参考价值,好比展现字段的复用思路、数据的按需查询思路等等。在商品展现场景中,有80%的工做集中在数据的聚合和集成部分,而且这部分具备很强的复用价值,所以信息的查询和聚合是咱们面临的主要矛盾。所以,咱们的思路是:基于GraphQL+后端BFF方案改进,实现取数逻辑和展现逻辑的可沉淀、可组合、可复用,总体架构以下示意图所示:

图6 基于GraphQL BFF的改进思路

从上图可看出,与传统GraphQL BFF方案最大的差异在于咱们将GraphQL下放至数据聚合部分,因为数据来源于商品领域,领域是相对稳定的,所以数据图规模可控且相对稳定。除此以外,总体架构的核心设计还包括如下三个方面:1)取数展现分离;2)查询模型归一;3)元数据驱动架构。

咱们经过取数展现分离解决展现服务颗粒度问题,同时使得展现逻辑和取数逻辑可沉淀、可复用;经过查询模型归一化设计解决展现字段扩散的问题;经过元数据驱动架构实现能力的可视化,业务组件编排执行的自动化,这可以让业务开发同窗聚焦于业务逻辑的自己。下面将针对这三个部分逐一展开介绍。

4.2 核心设计

4.2.1 取数展现分离

上文提到,在商品展现场景中,展现逻辑和取数逻辑是多对多的关系,而传统的基于GraphQL的后端BFF实践方案把它们封装在一块儿,这是致使展现服务颗粒度难以设计的根本缘由。思考一下取数逻辑和展现逻辑的关注点是什么?取数逻辑关注怎么查询和聚合数据,而展现逻辑关注怎么加工生成须要的展现字段,它们的关注点不同,放在一块儿也会增长展现服务的复杂性。所以,咱们的思路是将取数逻辑和展现逻辑分离开来,单独封装成逻辑单元,分别叫取数单元和展现单元。在取数展现分离以后,GraphQL也随之下沉,用于实现数据的按需聚合,以下图所示:

图7 取数展现分离+元数据描述

那么取数和展现逻辑的封装颗粒度是怎么样的呢?不能过小也不能太大,在颗粒度的设计上,咱们有两个核心考量:1)复用,展现逻辑和取数逻辑在商品展现场景中,都是能够被复用的资产,咱们但愿它们能沉淀下来,被单独按需使用;2)简单,保持简单,这样容易修改和维护。基于这两点考虑,颗粒度的定义以下:

  • 取数单元:尽可能只封装1个外部数据源,同时负责对外部数据源返回的模型进行简化,这部分生成的模型咱们称之为取数模型。
  • 展现单元:尽可能只封装1个展现字段的加工逻辑。

分开的好处是简单且可被组合使用,那么具体如何实现组合使用呢?咱们的思路是经过元数据来描述它们之间的关系,基于元数据由统一的执行框架来关联运行,具体设计下文会展开介绍。经过取数和展现的分离,元数据的关联和运行时的组合调用,能够保持逻辑单元的简单,同时又知足复用诉求,这也很好地解决了传统方案中存在的展现服务的颗粒度问题

4.2.2 查询模型归一

展现单元的加工结果经过什么样的接口透出呢?接下来,咱们介绍一下查询接口设计的问题。

1)查询接口设计的难点

常见查询接口的设计模式有如下两种:

  • 强类型模式:强类型模式指的是查询接口返回的是POJO对象,每个查询结果对应POJO中的一个明确的具备特定业务含义的字段。
  • 弱类型模式:弱类型模式指的是查询结果以K-V或JSON模式返回,没有明确的静态字段。

以上两种模式在业界都有普遍应用,且它们都有明确的优缺点。强类型模式对开发者友好,可是业务是不断迭代的,与此同时,系统沉淀的展现单元会不断丰富,在这样的状况下,接口返回的DTO中的字段将会越来越多,每次新功能的支持,都要伴随着接口查询模型的修改,JAR版本的升级。而JAR的升级涉及数据提供方和数据消费两方,存在明显效率问题。另外,能够想象,查询模型的不断迭代,最终将会包括成百上千个字段,难以维护。

而弱类型模式刚好能够弥补这一缺点,可是弱类型模式对于开发者来讲很是不友好,接口查询模型中有哪些查询结果对于开发者来讲在开发的过程当中彻底没有感受,可是程序员的天性就是喜欢经过代码去理解逻辑,而非配置和文档。其实,这两种接口设计模式都存在着一个共性问题——缺乏抽象,下面两节,咱们将介绍在接口返回的查询模型设计方面的抽象思路及框架能力支持。

2)查询模型归一化设计

回到商品展现场景中,一个展现字段有多种不一样的实现,如商品标题的两种不一样实现方式:1)商品标题;2)[类目]+商品标题。商品标题和这两种展现逻辑的关系本质上是一种抽象-具体的关系。识别这个关键点,思路就明了了,咱们的思路是对查询模型作抽象。查询模型上都是抽象的展现字段,一个展现字段对应多个展现单元,以下图所示:

图8 查询模型归一化 + 元数据描述

在实现层面一样基于元数据描述展现字段和展现单元之间的关系,基于以上的设计思路,能够在必定程度上减缓模型的扩散,可是还不能避免扩展。好比除了价格、库存、销量等每一个商品都有的标准属性以外,不一样的商品类型通常还会有这个商品特有的属性。好比密室主题拼场商品才有“几人拼”这样的描述属性,这种字段自己抽象的意义不大,且放在商品查询模型中做为一个单独的字段会致使模型扩张,针对这类问题,咱们的解决思路是引入扩展属性,扩展属性专门承载这类非标准的字段。经过标准字段 + 扩展属性的方式创建查询模型,可以较好地解决字段扩散的问题。

4.2.3 元数据驱动架构

到目前为止,咱们定义了如何分解业务逻辑单元以及如何设计查询模型,并提到用元数据描述它们之间的关系。基于以上定义实现的业务逻辑及模型,都具有很强的复用价值,能够做为业务资产沉淀下来。那么,为何用元数据描述业务功能及模型之间的关系呢?

咱们引入元数据描述主要有两个目的:1)代码逻辑的自动编排,经过元数据描述业务逻辑之间的关联关系,运行时能够自动基于元数据实现逻辑之间的关联执行,从而能够消除大量的人工逻辑编排代码;2)业务功能的可视化,元数据自己描述了业务逻辑所提供的功能,以下面两个示例:

团单基础售价字符串展现,例:30元。
团单市场价展现字段,例:100元。

这些元数据上报到系统中,能够用于展现当前系统所提供的功能。经过元数据描述组件及组件之间关联关系,经过框架解析元数据自动进行业务组件的调用执行,造成了以下的元数据架构:

图9 元数据驱动架构

总体架构由三个核心部分组成:

  • 业务能力:标准的业务逻辑单元,包括取数单元、展现单元和查询模型,这些都是关键的可复用资产。
  • 元数据:描述业务功能(如:展现单元、取数单元)以及业务功能之间的关联关系,好比展现单元依赖的数据,展现单元映射的展现字段等。
  • 执行引擎:负责消费元数据,并基于元数据对业务逻辑进行调度和执行。

经过以上三个部分有机的组合在一块儿,造成了一个元数据驱动风格的架构。

5 针对GraphQL的优化实践

5.1 使用简化

1)GraphQL直接使用问题

引入GraphQL,会引入一些额外的复杂性,好比会涉及到GraphQL带来的一些概念如:Schema、RuntimeWiring,下面是基于GraphQL原生Java框架的开发过程:

图10 原生GraphQL使用流程

这些概念对于未接触过GraphQL的同窗来讲,增长了学习和理解的成本,而这些概念和业务领域一般没有什么关系。而咱们仅仅但愿使用GraphQL的按需查询特性,却被GraphQL自己拖累了,业务开发同窗的关注点应该聚焦在业务逻辑自己才对,这个问题如何解决呢?

著名计算机科学家David Wheeler说了一句名言,"All problems in computer science can be solved by another level of indirection"。没有加一层解决不了的问题,本质上是须要有人来对这事负责,所以咱们在原生GraphQL之上增长了一层执行引擎层来解决这些问题,目标是屏蔽GraphQL的复杂性,让开发人员只须要关注业务逻辑。

2)取数接口标准化

首先要简化数据的接入,原生的DataFetcherDataLoader都是处在一个比较高的抽象层次,缺乏业务语义,而在查询场景,咱们可以概括出,全部的查询都属于如下三种模式:

  • 1查1:根据一个条件查询一个结果。
  • 1查N:根据一个条件查询多个结果。
  • N查N:一查一或一查多的批量版本。

由此,咱们对查询接口进行了标准化,业务开发同窗基于场景判断是那种,按需选择使用便可,取数接口标准化设计以下:

图11 查询接口标准化

业务开发同窗按需选择所须要使用的取数器,经过泛型指定结果类型,1查1和1查N比较简单,N查N咱们对其定义为批量查询接口,用于知足"N+1"的场景,其中batchSize字段用于指定分片大小,batchKey用于指定查询Key,业务开发只须要指定参数,其余的框架会自动处理。除此以外,咱们还约束了返回结果必须是CompleteFuture,用于知足聚合查询的全链路异步化。

3)聚合编排自动化

取数接口标准化使得数据源的语义更清晰,开发过程按需选择便可,简化了业务的开发。可是此时业务开发同窗写好Fetcher以后,还须要去另外一个地方去写Schema,并且写完Schema还要再写SchemaFetcher的映射关系,业务开发更享受写代码的过程,不太愿意写完代码还要去另一个地方取配置,而且同时维护代码和对应配置也提升了出错的可能性,可否将这些冗杂的步骤移除掉?

SchemaRuntimeWiring本质上是想描述某些信息,若是这些信息换一种方式描述是否是也能够,咱们的优化思路是:在业务开发过程当中标记注解,经过注解标注的元数据描述这些信息,其余的事情交给框架来作。解决思路示意图以下:

图12 注解元数据描述Schema和RuntimeWiring

5.2 性能优化

5.2.1 GraphQL性能问题

虽然GraphQL已经开源了,可是Facebook只开源了相关标准,并无给出解决方案。GraphQL-Java框架是由社区贡献的,基于开源的GraphQL-Java做为按需查询引擎的方案,咱们发现了GraphQL应用方面的一些问题,这些问题有部分是因为使用姿式不当所致使的,也有部分是GraphQL自己实现的问题,好比咱们遇到的几个典型的问题:

  • 耗CPU的查询解析,包括Schema的解析和Query的解析。
  • 当查询模型比较复杂特别是存在大列表时候的延时问题。
  • 基于反射的模型转换CPU消耗问题。
  • DataLoader的层级调度问题。

因而,咱们对使用方式和框架作了一些优化与改造,以解决上面列举的问题。本章着重介绍咱们在GraphQL-Java方面的优化和改造思路。

5.2.2 GraphQL编译优化

1)GraphQL语言原理概述

GraphQL是一种查询语言,目的是基于直观和灵活的语法构建客户端应用程序,用于描述其数据需求和交互。GraphQL属于一种领域特定语言(DSL),而咱们所使用的GraphQL-Java客户端在语言编译层面是基于ANTLR 4实现的,ANTLR 4是一种基于Java编写的语言定义和识别工具,ANTLR是一种元语言(Meta-Language),它们的关系以下:

图13 GraphQL语言基本原理示意图

GraphQL执行引擎所接受的SchemaQuery都是基于GraphQL定义的语言所表达的内容,GraphQL执行引擎不能直接理解GraphQL,在执行以前必须由GraphQL编译器翻译成GraphQL执行引擎可理解的文档对象。而GraphQL编译器是基于Java的,经验代表在大流量场景实时解释的状况下,这部分代码将会成为CPU热点,并且还占用响应延迟,SchemaQuery越复杂,性能损耗越明显。

2)Schema及Query编译缓存

Schema表达的是数据视图和取数模型同构,相对稳定,个数也很少,在咱们的业务场景一个服务也就一个。所以,咱们的作法是在启动的时候就将基于Schema构造的GraphQL执行引擎构造好,做为单例缓存下来,对于Query来讲,每一个场景的Query有些差别,所以Query的解析结果不能做为单例,咱们的作法是实现PreparsedDocumentProvider接口,基于Query做为Key将Query编译结果缓存下来。以下图所示:

图14 Query缓存实现示意图

5.2.3 GraphQL执行引擎优化

1)GraphQL执行机制及问题

咱们先一块儿了解一下GraphQL-Java执行引擎的运行机制是怎么样的。假设在执行策略上咱们选取的是AsyncExecutionStrategy,来看看GraphQL执行引擎的执行过程:

图15 GraphQL执行引擎执行过程

以上时序图作了些简化,去除了一些与重点无关的信息,AsyncExecutionStrategyexecute方法是对象执行策略的异步化模式实现,是查询执行的起点,也是根节点查询的入口,AsyncExecutionStrategy对对象的多个字段的查询逻辑,采起的是循环+异步化的实现方式,咱们从AsyncExecutionStrategyexecute方法触发,理解GraphQL查询过程以下:

  1. 调用当前字段所绑定的DataFetcherget方法,若是字段没有绑定DataFetcher,则经过默认的PropertyDataFetcher查询字段,PropertyDataFetcher的实现是基于反射从源对象中读取查询字段。
  2. 将从DataFetcher查询获得结果包装成CompletableFuture,若是结果自己是CompletableFuture,那么不会包装。
  3. 结果CompletableFuture完成以后,调用completeValue,基于结果类型分别处理。

    • 若是查询结果是列表类型,那么会对列表类型进行遍历,针对每一个元素在递归执行completeValue
    • 若是结果类型是对象类型,那么会对对象执行execute,又回到了起点,也就是AsyncExecutionStrategy的execute

以上是GraphQL的执行过程,这个过程有什么问题呢?下面基于图上的标记顺序一块儿看看GraphQL在咱们的业务场景中应用和实践所遇到的问题,这些问题不表明在其余场景也是问题,仅供参考:

问题1PropertyDataFetcherCPU热点问题,PropertyDataFetcher在整个查询过程当中属于热点代码,而其自己的实现也有一些优化空间,在运行时PropertyDataFetcher的执行会成为CPU热点。(具体问题可参考GitHub上的commit和Conversion:https://github.com/graphql-java/graphql-java/pull/1815

图16 PropertyDataFetcher成为CPU热点

问题2:列表的计算耗时问题,列表计算是循环的,对于查询结果中存在大列表的场景,此时循环会形成总体查询明显的延迟。咱们举个具体的例子,假设查询结果中存在一个列表大小是1000,每一个元素的处理是0.01ms,那么整体耗时就是10ms,基于GraphQL的查机制,这个10ms会阻塞整个链路。

2)类型转换优化

经过GraphQL查询引擎拿到的GraphQL模型,和业务实现的DataFetcher返回的取数模型是同构,可是全部字段的类型都会被转换成GraphQL内部类型。PropertyDataFetcher之因此会成为CPU热点,问题就在于这个模型转换过程,业务定义的模型到GraphQL类型模型转换过程示意图以下图所示:

图17 业务模型到GraphQL模型转换示意图

当查询结果模型中的字段很是多的时候,好比上万个,意味着每次查询有上万次的PropertyDataFetcher操做,实际就反映到了CPU热点问题上,这个问题咱们的解决思路是保持原有业务模型不变,将非PropertyDataFetcher查询的结果反过来填充到业务模型上。以下示意图所示:

图18 查询结果模型反向填充示意图

基于这个思路,咱们经过GraphQL执行引擎拿到的结果就是业务Fetcher返回的对象模型,这样不只仅解决了因字段反射转换带来的CPU热点问题,同时对于业务开发来讲增长了友好性。由于GraphQL模型相似JSON模型,这种模型是缺乏业务类型的,业务开发直接使用起来很是麻烦。以上优化在一个场景上试点测试,结果显示该场景的平均响应时间缩短1.457ms,平均99线缩短5.82ms,平均CPU利用率下降约12%。

3)列表计算优化

当列表元素比较多的时候,默认的单线程遍历列表元素计算的方式所带来的延迟消耗很是明显,对于响应时间比较敏感的场景这个延迟优化颇有必要。针对这个问题咱们的解决思路是充分利用CPU多核心计算的能力,将列表拆分红任务,经过多线程并行执行,实现机制以下:

图19 列表遍历多核计算思路

5.2.4 GraphQL-DataLoader调度优化

1)DataLoader基本原理

先简单介绍一下DataLoader的基本原理,DataLoader有两个方法,一个是load,一个是dispatch,在解决N+1问题的场景中,DataLoader是这么用的:

图20 DataLoader基本原理

总体分为2个阶段,第一个阶段调用load,调用N次,第二个阶段调用dispatch,调用dispatch的时候会真正的执行数据查询,从而达到批量查询+分片的效果。

2)DataLoader调度问题

GraphQL-Java对DataLoader的集成支持的实如今FieldLevelTrackingApproach中,FieldLevelTrackingApproach的实现会存在怎样的问题呢?下面基于一张图表达原生DataLoader调度机制所产生的问题:

图21 GraphQL-Java对DataLoader调度存在的问题

问题很明显,基于FieldLevelTrackingApproach的实现,下一层级的DataLoaderdispatch是须要等到本层级的结果都回来以后才发出。基于这样的实现,查询总耗时的计算公式等于:TOTAL = MAX(Level 1 Latency)+ MAX(Level 2 Latency)+ MAX(Level 3 Latency)+ … ,总查询耗时等于每层耗时最大的值加起来,而实际上若是链路编排由业务开发同窗本身来写的话,理论上的效果是总耗时等于全部链路最长的那个链路所耗的时间,这个才是合理的。而FieldLevelTrackingApproach的实现所表现出来的结果是反常识的,至于为何这么实现,目前咱们理解多是设计者基于简单和通用方面的考虑。

问题在于以上的实如今有些业务场景下是不能接受的,好比咱们的列表场景的响应时间约束一共也就不到100ms,其中几十ms是由于这个缘由搭进去的。针对这个问题的解决思路,一种方式是对于响应时间要求特别高的场景独立编排,不采用GraphQL;另外一种方式是在GraphQL层面解决这个问题,保持架构的统一性。接下来,介绍一下咱们是如何扩展GraphQL-Java执行引擎来解决这个问题的。

3)DataLoader调度优化

针对DataLoader调度的性能问题,咱们的解决思路是在最后一次调用某个DataLoaderload以后,当即调用dispatch方法发出查询请求,问题是咱们怎么知道哪一次的load是最后一次load呢?这个问题也是解决DataLoader调度问题的难点,如下举个例子来解释咱们的解决思路:

图22 查询对象结果示意图

假设咱们查询到的模型结构以下:根节点是Query下的字段,字段名叫subjectssubject引用的是个列表,subject下有两个元素,都是ModelA的对象实例,ModelA有两个字段,fieldAfieldBsubjects[0]fieldA关联是ModelB的一个实例,subjects[0]fieldB关联多个ModelC实例。

为了方便理解,咱们定义一些概念,字段、字段实例、字段实例执行完、字段实例值大小、字段实例值对象执行大小、字段实例值对象执行完等等:

  • 字段:具备惟一路径,是静态的,和运行时对象大小没有关系,如:subjectssubjects/fieldA
  • 字段实例:字段的实例,具备惟一路径,是动态的,跟运行时对象大小有关系,如:subjects[0]/fieldAsubjects[1]/fieldA是字段subjects/fieldA的实例。
  • 字段实例执行完:字段实例关联的对象实例都被GraphQL执行完了。
  • 字段实例值大小:字段实例引用对象实例的个数,如以上示例,subjects[0]/fieldA字段实例值大小是1,subjects[0]/fieldB字段实例值大小是3。

除了以上定义以外,咱们的业务场景还知足如下条件:

  • 只有1个根节点,且根节点是列表。
  • DataLoader必定属于某个字段,某个字段下的DataLoader应该被执行次数等于其下的对象实例个数。

基于以上信息,咱们能够得出如下问题分析:

  • 在执行字段实例的时候,咱们能够知道当前字段实例的大小,字段实例的大小等于字段关联DataLoader在当前实例下须要执行load的次数,所以在执行load以后,咱们能够知道当前对象实例是不是其所在字段实例的最后一个对象。
  • 一个对象的实例可能会挂在不一样的字段实例下,因此仅当当前对象实例是其所在字段实例的最后一个对象实例的时候,不表明当前对象实例是全部对象实例中的最后一个,当且仅当对象实例所在节点实例是节点的最后一个实例的时候才成立。
  • 咱们可从字段实例大小推算字段实例的个数,好比咱们知道subjects的大小是2,那么就知道subjects字段有两个字段实例subjects[0]subjects[1],也就知道字段subjects/fieldA有两个实例,subjects[0]/fieldAsubjects[1]/fieldA,所以咱们从根节点能够往下推断出某个字段实例是否执行完。

经过以上分析,咱们能够得出,一个对象执行完的条件是其所在的字段实例以及其所在的字段全部的父亲字段实例都执行完,且当前执行的对象实例是其所在字段实例的最后一个对象实例的时候。基于这个判断逻辑,咱们的实现方案是在每次调用完DataFetcher的时候,判断是否须要发起dispatch,若是是则发起。另外,以上时机和条件存在漏发dispatch的问题,有个特殊状况,当当前对象实例不是最后一个,可是剩下的对象大小都为0的时候,那么就永远不会触发当前对象关联的DataLoaderload了,因此在对象大小为0的时候,须要额外再判断一次。

根据以上逻辑分析,咱们实现了DataLoader调用链路的最优化,达到理论最优的效果。

6 新架构对研发模式的影响

生产力决定生产关系,元数据驱动信息聚合架构是展现场景搭建的核心生产力,而业务开发模式和过程是生产关系,所以也会随之改变。下面咱们将会从开发模式和流程两个角度来介绍新架构对研发带来的影响。

6.1 聚焦业务的开发模式

新架构提供了一套基于业务抽象出的标准化代码分解约束。之前开发同窗对系统的理解极可能就是“查一查服务,把数据粘在一块儿”,而如今,研发同窗对于业务的理解及代码分解思路将会是一致的。好比展现单元表明的是展现逻辑,取数单元表明的是取数逻辑。同时,不少冗杂且容易出错的逻辑已经被框架屏蔽掉了,研发同窗可以有更多的精力聚焦于业务逻辑自己,好比:业务数据的理解和封装,展现逻辑的理解和编写,以及查询模型的抽象和建设。以下示意图所示:

图23 业务开发聚焦业务自己

6.2 研发流程升级

新架构不只仅影响了研发的代码编写,同时也影响着研发流程的改进,基于元数据架构实现的可视化及配置化能力,现有研发流程和以前研发流程相比有了明显的区别,以下图所示:

图24 基于开发框架搭建展现场景先后研发流程对比

之前是“一杆子捅到底”的开发模式,每一个展现场景的搭建须要经历过从接口的沟通到API的开发整个过程,基于新架构以后,系统自动具有多层复用及可视化、配置化能力。

状况一:这是最好的状况,此时取数功能和展现功能都已经被沉淀下来,研发同窗须要作的只是建立查询方案,基于运营平台按需选择须要的展现单元,拿着查询方案ID基于查询接口就能够查到须要的展现信息了,可视化、配置化界面以下示意图所示:

图25 可视化及文案按需选用

状况二:此时可能没有展现功能,可是经过运营平台查看到,数据源已经接入过,那么也不难,只须要基于现有的数据源编写一段加工逻辑便可,这段加工逻辑是很是爽的一段纯逻辑的编写,数据源列表以下示意图所示:

图26 数据源列表可视化

状况三:最坏的状况是此时系统不能知足当前的查询能力,这种状况比较少见,由于后端服务是比较稳定的,那么也无需惊慌,只须要按照标准规范将数据源接入进来,而后编写加工逻辑片断便可,以后这些能力是能够被持续复用的。

7 总结

商品展现场景的复杂性体如今:场景多、依赖多、逻辑多,以及不一样场景之间存在差别。在这样的背景下,若是是业务初期,怎么快怎么来,采用“烟囱式”个性化建设的方式没必要有过多的质疑。可是随着业务的不断发展,功能的不断迭代,以及场景的规模化趋势,“烟囱式”个性化建设的弊端会慢慢凸显出来,包括代码复杂度高、缺乏能力沉淀等问题。

本文以基于对美团到店商品展现场景所面临的核心矛盾分析,介绍了:

  • 业界不一样的BFF应用模式,以及不一样模式的优点和缺点。
  • 基于GraphQL BFF模式改进的元数据驱动的架构方案设计。
  • 咱们在GraphQL实践过程当中遇到的问题及解决思路。
  • 新架构对研发模式产生的影响呈现。

目前,笔者所在团队负责的核心商品展现场景都已迁入新架构,基于新的研发模式,咱们实现了50%以上的展现逻辑复用以及1倍以上的效率提高,但愿本文对你们可以有所帮助。

8 参考文献

9 招聘信息

美团到店综合研发中心长期招聘前端、后端、数据仓库、机器学习/数据挖掘算法工程师,坐标上海,欢迎感兴趣的同窗发送简历至:tech@meituan.com(邮件标题注明:美团到店综合研发中心—上海)。

阅读美团技术团队更多技术文章合集

前端 | 算法 | 后端 | 数据 | 安全 | 运维 | iOS | Android | 测试

| 在公众号菜单栏对话框回复【2020年货】、【2019年货】、【2018年货】、【2017年货】等关键词,可查看美团技术团队历年技术文章合集。

| 本文系美团技术团队出品,著做权归属美团。欢迎出于分享和交流等非商业目的转载或使用本文内容,敬请注明“内容转载自美团技术团队”。本文未经许可,不得进行商业性转载或者使用。任何商用行为,请发送邮件至tech@meituan.com申请受权。

相关文章
相关标签/搜索