组件化开发是一种利用可重用的软件构件来设计和开发计算机系统的过程。借助组件化开发可以实现最小化、高效交付。
平台基础体验部将业务逻辑抽象为组件,通过组合组件快速构建商品Feed流,研发效率整体提升2倍。组件化开发不仅带来效率的提升,同时极大地增加了代码复用性、降低了系统的复杂性等等。本文将详细介绍组件化开发的落地过程,为大家揭晓转转App快速迭代的奥秘。
平台基础体验部主要承接转转App、小程序迭代。
2021年底,基于转转定位于“有质检、放心买卖的二手交易平台”的背景,对首页Feed(Feed,即:商品列表页)、主搜Feed整体改造。排期时,我们发现商品卡片渲染投入耗时过多,无法完成春节前交付。短期解决方案,可以额外投入人力,按期交付。但考虑未来还会整体改版,需要寻求一种方式,能够快速承接全平台范围产品迭代。
2022年3月,我们着手策划新方案,并构建出一套组件化开发流程。
2022年9月底,距离转转集团宣布品牌升级还有一个月的时间,App、小程序需要整体换新,其中仅Feed功能点,升级多达20几处。组件化开发借此契机,开始大范围应用,效果得到了印证。
需求正式评审后,进入接口评审阶段,日常对接流程如下图所示。
开发同学小张,根据App首页UI图例定字段后,同步给native以及QA。随后着手另一功能点:App收藏夹推荐,定字段、同步字段。
接口评审后,进入开发,开发流程如下图所示。
RD小张开发首页时,首先获取各业务的RPC数据,然后自上而下写好数据渲染逻辑。完成自测后,提供给native、QA测试环境。紧接着,投入到收藏夹功能点开发,流程类似。
看似十分顺畅的流程,在执行过程中并不顺利,总会有一些“小插曲”,比如:
(1)UI中出现了横排卡片样式,卡片上多了一个心智元素,native小刘要求增加一个userText字段。
(2)同一个UI在小程序上却要使用不同的商品图尺寸,FE小方大喊:给我返回16×16的尺寸!
(3)小张忙得不亦乐乎,小赵过来支援,给商品图字段定义为infoImage,与此前的image字段命名不同,QA小张、native小李很生气,表示我不能接受,字段不统一难于维护。
对接流程问题多多,开发流程当然也没有想象的那么顺利。PM会提出一些临时要求:在某些功能点上增加AB实验,或者调整某处功能点的展示逻辑。RD在实现过程中可能会调整元素的渲染顺序,进而影响全局数据渲染正确性,为了避免代码调整引入额外的缺陷,QA的测试的用例也会变多。
进入开发前,RD基于现有的改动点做出技术评估,输出排期。以App首页、App收藏夹推荐为例,从产品规则上看,只有RPC数据源不同,RD认为大部分逻辑是可以复用的。所以,功能相近的位置可能会适当的减少排期。
但实际开发中,新的代码逻辑与历史逻辑存在冲突,无法顺利的融入到数据渲染代码块。各接口的实体类定义五花八门,复用某些数据渲染方法时,存在很多实体之间的数据转换,代码显得十分臃肿。很多无法预知的“小插曲”导致原本答应产品在10个工作日内完成20个Feed改造,即使加班也无法按时交付。其他职能的进度也会受到影响,给整个项目带来风险。
回顾对接、开发流程,主要暴露以下几个问题:
版本、终端、UI差异增加了代码的复杂性,难以复用:RD为了适配各端不同的样式,或者兼容native产生的线上问题,或者为了兼容不同版本的样式,往往要写很多控制逻辑,使得代码逻辑越来越臃肿,复杂性与维护成本逐渐增加。
接口返回值字段不统一:因为开发人员的命名习惯不一致,随着接口的增多、产品的不断迭代,可能会出现多个字段描述同一个功能点的情况。接口字段数量成爆炸式增长,难以维护。出现问题时,RD很难快速定位字段,沟通成本、认知成本也会大幅增加。
无法适应产品快速迭代:AB方案的加入、产品逻辑的调整使得元素的渲染逻辑过于庞大,元素之间的依赖关系变得更加复杂,RD无法快速交付。
工作内容重复,排期长,易引入延期风险:很多功能点十分相似,但是开发中无法完美复用,使得排期较长。过长的排期外加无法预知的隐藏问题,很有可能带来延期风险。
(1)RPC模块、数据渲染逻辑中,有些内容完全一致,如果将他们划分为模块,将大幅增加代码的复用性。
(2)数据渲染数据是过程化的、自上而下的,如果能够打破这种串行开发模式,各数据渲染逻辑之间的耦合性大幅降低。
(3)深入观察单一数据渲染内部,如果将版本兼容、终端差异、AB实验等从数据渲染逻辑中剥离,只留下核心逻辑,数据模渲染更具复用性。
(4)不同元素的数据渲染逻辑之间难免会存在依赖关系,需要有一种资源编排机制,能够将各数据渲染逻辑按规则组织起来。
结合相关的技术调研以及业界相关的经验,我们引入了《组件化开发》,即:基于组件的开发。组件、组件化开发带来的优势能够达成我们的诉求,组件、组件化开发具体是什么,具有什么优势?接下来将具体展开描述。
组件是一组功能相关的逻辑、数据的聚合。
组件是自包含和完备的,通常以一组完备的API形式开放给使用者,使用者不需要了解组件内部的逻辑,只需要关注API的使用,组件的实现逻辑和依赖由组件自己负责。
按职能可将组件分为:技术组件和业务组件。业务组件是把一组相关的业务逻辑封装为一个组件,也称为管理逻辑组件。
按层次可以分为:框架组件、平台组件、通用管理逻辑组件、领域管理逻辑组件、行业管理逻辑组件、个性化管理逻辑组件等。
按来源可以分为:开源组件、商业组件、自研组件等。
组件需要遵守组件模型协议。组件模型是一组标准。维多利亚大学电子与计算机工程系给出的组件模型如下图所示。
组件模型包括:接口列表、命名、元数据、互通性、自定义组件接口、组合接口、组件演进、打包与部署。
一个设计良好的组件应该具备如下几个特点:
可管理:组件是基于统一的模型进行设计和实现,遵循统一的技术规范,由统一的元数据进行描述,有清晰的分类和分层,可由组件工具进行统一管理,以规范一致的模式进行使用。
可复用:可复用是组件的一个核心特点。通常组件都是经过良好的设计和封装的,可以被多个场景复用。只有能够复用,才能更好地发挥组件的价值。
可配置:为了更好地复用,组件在不同的场景下使用时不需要去修改组件自身,通常组件需要把不同场景下可能会变化的部分作为可变参数,允许使用者通过配置不同的值,来满足不同使用场景下的需求,使组件具有更好的可复用性。可配置的内容通常包括环境信息、参数、规则、模型属性等。
可扩展:在使用组件的过程中,当通过配置也无法满足场景化的使用需求时,组件的可扩展性就变得尤其重要,如果一个组件不具备可扩展性,将极大降低组件的复用价值。组件的扩展性通常可以通过良好的设计来实现,如支持继承,可局部逻辑重写;支持事件,通过前置后置事件,允许使用定制规则和逻辑,就可以更好地满足不同场景下的个性化使用需求,提高组件的可复用性,发挥出组件更大的价值。
组件化开发: 组件化开发,即:基于组件的开发(Component-Based Development,简称:CBD),是一种利用可重用的软件构件(组件)来设计和开发计算机系统的过程。
组件化开发的起源: 组件化开发出现于20世纪90年代末。当时面向对象建模开发(Object-Oriented,简称:OO)没有像最初建议的那样被广泛的重用。OO产生了大量细粒度的类、对象和关系,在这些较小的单元中发现可重用的部件是非常困难的。CBD背后的思想是集成相关的部分并对它们进行集体重用。
组件化开发分为两类:机会式重用、带有开发量的重用。
在日常的使用场景中,因为产品不断地迭代,使用现有组件直接组合成系统的场景较少,带有开发量的组件重用应用场景更为广泛。
最小化交付:基于现有的组件,即可装配成系统。
高效:开发人员可以更集中关注需求变更点,快速完成产品升级。
提升系统质量:开发人员可以有更多的时间来确保系统质量。组件的高质量决定了系统的质量。
减少支出:减少资源投入。
借助组件化开发思想,我们构建了一套组件化开发架构,内部称之为:星环,整体架构如下图所示。
星环体系可以总结为两层一包:声明层、驱动层以及业务组件包。
(1)基础声明:定义了应用名、应用上下文、请求参数、组件的驱动方式等。
(2)RPC模块声明:定义了应用中包含哪些RPC引用,RPC模块之间的依赖关系。
(3)组件声明:定义了应用中包含哪些业务组件、组件的属性、组件的触发条件。
(4)降级触发规则声明:定义了入口层的监控策略、降级触发规则、监控频率等。
(1)RPC驱动层:即复仇者框架,一种自研的基于事件驱动的并发调度模型(了解更多,可查看参考文献第5点),负责解析RPC配置声明,编排RPC资源,执行RPC调用。
(2)组件驱动层:负责组件配置解析,组件命中判定,组件的资源编排以及提供组件驱动入口。
此外,还有周边生态为之赋能,包括:健康监测、辅助生态。
健康监测:检测入口层服务健康度。当指定时间窗口内错误率达到上限,触发熔断与降级。
辅助生态:辅助开发人员快速构建组件应用。包含IDEA XML配置自动补全提示、maven脚手架辅助生成代码框架等。
整个架构是如何逐步构建起来的,构建过程中有哪些需要解决的问题,下面分模块一一介绍。
首先我们要确定什么是组件,在前文中提到:组件是一组功能相关的逻辑、数据的聚合。其中,数据可以理解为接口中的每一个字段,逻辑即为每个字段所对应的产品规则。为什么把每一个字段定义为一个组件呢?这与我们的工作职能密切相关,与中台后端不同,平台后端是服务于前端的后端,即:BFF(Backend For Frontend)。BFF的主要职责是组合、使用底层数据,然后按照产品规则处理展示逻辑,最后返回给前端。
日常对接中,后端根据UI要求定义接口,并根据各终端特性调整接口协议。每个UI元素对应一组产品规则,每个UI元素对应一个字段。字段又作为前后端数据交互的桥梁,把每个字段划归为组件最合适不过。
构成组件的元素是一组标准。标准的建立有助于实现组件的复用性。前文中,我们把字段定义为组件,字段、组件、UI三者之间一一对应。推进组件的标准化,也就是对字段、UI的标准化。
(1)字段命名标准化
因为开发人员的命名习惯不一致,可能会出现多个字段描述同一个功能点的情况。当以字段维度划分不同的组件后,组件会变得非常冗余,在接口对接时也会增加沟通成本。另外,非标准化字段对native的组件化实现不是很友好,nanative需要额外维护非标字段数据与UI元素的映射,增加了开发与维护成本。
(2)UI样式标准化
虽然在不同终端、在不同页面中UI呈现出个性化差异。但有些元素背后对应的产品逻辑是一致的。例如:商品卡片中的官方验成色标签传达的内容一致,在首页、主搜可以统一样式。从产品角度上看,标准化的UI可以在全平台营造统一的用户体验氛围;在技术方面,不仅降低了维护API的成本,同时native根据标准化的UI可以构建出UI组件,进而丰富组件库,提升复用性。
接口返回值中包含着前端展示的各种数据,随着产品的迭代,UI中的元素类型会越来越多,开发人员习惯性地不断在接口返回值中平铺新增的字段。时间周期拉长,字段数量成爆炸性增张,难以维护。因此,返回值结构也需要有一套标准,抑制字段数量增长速度。
(1)按对象内容类型收拢字段
以商品卡中的元素为例,页面上有很多标签元素:官方验成色标签、功能描述标签、埋点标签等,这些字段在表现形式上都为纯图片或者纯文字的形式,可以统一归纳为标签对象。
(2)按功能合并字段
在不同的终端上,图片链接有时要拼接属性参数。以往针对图片是否携带宽高属性,我们会返回两个字段picUrl、picUrlWH,现在统一为:picUrl。是否拼接宽高参数作为组件属性移动到组件中。
(3)融入KV结构
KV结构用来存储一些非标字段,如:埋点字段postIdMap,包含上报的埋点透传字段。
(4)支持返回值VO扩展
暂时无法确认以后是否被定义为标准化字段,先放在VO的子类VOExt中。VO中只包含标准化字段声明。
获取组件的执行结果经历两个步骤:执行RPC调用,然后依据产品逻辑进行数据加工。以实际使用场景为例:
渲染标题时:调用商品服务,获取商品基础信息。
渲染到手价时:调用商品服务,获取商品基础信息;调用促销服务,获取活动促销信息。
渲染划线价时:调用促销服务,获取活动促销信息。
汇总RPC调用与数据渲染的关系,如下图所示。
RPC调用与数据渲染逻辑呈现多对多的关系。再次回看组件的定义,组件是一组功能相关的逻辑、数据的聚合。功能逻辑该怎样去定义呢?若组件的功能逻辑定义为RPC调用与数据渲染逻辑的组合,会引发以下问题:
(1)RPC重复调用:多个组件中可能包含同一RPC数据源,每个组件获取一次RPC数据,重复调用,对下游造成压力。
(2)组件职责不单一,难以复用:若合并多个组件,只调用一次RPC,那么组件的职责不再单一,复用性降低。
(3)难于资源编排:组件间存在依赖关系、RPC模块间也存在依赖关系,且RPC执行顺序与组件的执行顺序没有必然联系,当RPC与数据渲染绑定在一起时,难于资源编排。
综上,RPC调用要独立于数据渲染逻辑,组件的功能逻辑只包含数据渲染。
进一步的,当RPC调用从组件中分离后,需要为组件提供获取RPC数据的方式。可以在应用上下文中提供属性域SynchronizedMap存储RPC结果集,为二者建立通信桥梁。
依据组件组件遵循的组件模型协议,定义组件类包含以下内容:
组件类型(componentType):标识当前组件隶属的功能点。例如:标题组件、商品图组件、到手价组件等等。
依赖的组件列表(dependencyComponentTypeList):标识当前组件依赖于哪些业务组件基础数据。例如:《券信息》数据的有无依赖于《到手价》的数据情况。
以上属性定义在组件顶级接口中,组件顶级接口类结构如下所示。
通用行为doHandle():定义了组件的标准行为,该行为定义在组件顶级接口中。
自定义行为doInnerHandle():定义了组件的自定义行为。与doHandle()的区别是:doHandle()中定义的是组件行为的通用执行逻辑,doInnerHandle()用于实现各组件的个性化业务逻辑。该行为定义在自定义组件的顶级类中,自定义组件的顶级类结构如下图所示。
基础组件类定义好之后,还需要根据业务场景定义:定义父组件,新建业务组件。汇总类图如下图所示。
自定义组件的顶级类中没有声明具体的返回值VO类型。在实际使用时,需要依据场景声明返回值类型,主键类型,参数类型等,这些内容声明在父组件类中。比如在Feed流商品卡片场景,新建FeedComponentHandleService,指定主键类型为Long类型的商品infoId、指定返回值类型为FeedBaseInfoVOExt等。
在父组件类的基础上,可以新建业务组件类,比如:标题组件TitleV1、商品图组件ImageV1、商品图组件ImageV2…
为了提升RPC模块、组件的复用性,在其中会增加可变参数,允许使用者在不同场景配置不同的值。RPC/组件参数值来自于接口参数,当直接使用接口请求参数作为RPC/组件的参数时,因接口请求参数属性域 = {RPC模块参数属性域,组件参数属性域,其他参数},RPC/组件参数中新增了很多无用参数属性。另一方面接口参数类型多样,同一组件难以适配不同场景中,复用性大幅降低。所以RPC/组件应该只关注自身需要使用的参数。
接口请求参数与RPC/组件参数独立管理后,当外界请求到来时,需要RPC/组件建立获取接口参数的通信方式,RPC/组件获取参数属性值的过程如下图所示。
当获取RPC/组件获取参数属性值时,首先获取到RPC/组件请求的代理对象,然后由MethodInterceptor拦截代理对象的方法,转而执行原始请求对象中的方法。
实现思路:
(1)定义IRequestFiledAutoMapped接口,属性域中含有一个ThreadLocal对象用于存储原始请求对象。RPC/组件请求类均实现该接口。
(2)定义拦截器MethodInterceptor,用于拦截IRequestFiledAutoMapped各实现类的方法请求。
(3)定义BeanProcessor,组件Bean生成后,使用Enhancer创建代理对象。
在传统编程模式中,数据渲染中包含了大量的条件判断语句,如图所示。
不同的场景往往只会命中条件语句中的一个路径。将这些条件判断和产品功能逻辑剥离开,组件逻辑中只包含产品功能逻辑,将大幅提升组件的复用性。此外,条件的剥离可以让功能代码块瘦身,开发人员的学习、认知成本将大幅减少。
回看上图中的示例,将条件与产品功能逻辑分离后可以划分为3个组件:
组件1:title = title + content
组件2:title = tinyTitle
组件3:title = title + paramsValue
划分之后,可以根据不同的场景选用不同的组件,组件逻辑更易升级维护。
组件模型中规定了组合组件的接口、规则。组件的命中条件作为规则,与组件配合使用,用来判定某个场景应该选用哪个组件。实现上,我们提供了两种条件的声明形式。
(1)提供一个条件顶级接口IBaseCondition,实现其中的eveluation()方法,在方法中声明命中规则。
(2)使用EL表达式描述命中规则,由Aviator规则引擎加载规则。
如何绑定条件与组件,以及如何执行命中条件,在5.6中将会展开描述。
各类组件准备完毕后,需要将这些组件组织起来,如何组装组件也需要遵循一套标准。以XML文件描述组件的装配信息。使用这种声明方式,各组装点成块状结构,层次清晰,以下为XML的配置样例。
配置文件主要包含四部分:应用声明、RPC模块声明、组件声明、自定义参数声明。各部分声明的内容在第5节的前置介绍中,不再赘述。
转转的微服务由Spring Boot框架支撑,借助AbstractBeanDefinitionParser,重写其中的parseInternal方法,解析XML,生成单例模式Bean。Bean的结构如下图所示。
框架中提供了两种组件条件接入方式:实现IBaseCondition接口或者使用EL表达式。在配置文件中,声明样例如下图。
(1)若实现IBaseCondition接口,则完善conditionClass的属性值,值为IBaseCondition接口实现类的类路径。
(2)若使用EL表达式,则完善conditionEL的属性值,值为EL表达式内容。
当条件与功能逻辑分离后,功能逻辑会演变为多个组件。因为请求场景是未知的、不确定的,将组件按照类型收拢起来为:组件组,如下图所示。
在实际场景中,不同的场景只会命中其中的唯一一个组件,是否命中组件按照如下的规则触发:
(1)自上而下判定组件命中。命中任意组件,则完成该组件组的判定。
(2)优先读取conditionEL配置,规则引擎执行结果返回true则命中该组件,否则不命中;如果配置了conditionClass类路径,执行条件实例中的evaluate方法,若方法返回值为true,则命中该组件,否则不命中。
当完成某一组件组的结果判定,被命中的组件会被收集在集合中,即:Set
收集好组件列表之后,还不能立即执行这些组件的行为。在实际场景中,组件与组件之间存在数据依赖,如下图所示。
《券标签》组件的展示条件依赖于《活动信息》组件、《到手价》组件。所以在组件执行顺序上,需要将《活动信息》、《到手价》组件执行前置。底层实现上,根据组件间的依赖先后关系,排序组件。
组件之间的依赖关系可以主动声明也可通过自动解析获取到。排序实现上,自动解析的方式可通过三色标记的逆向解析法实现组件编排(了解更多,可查看参考文献第10点,4.3.3章节)。我们采取主动声明依赖,有向图拓扑编排的方式。
具体为:
(1)编写组件时,在组件内部声明本组件类型、依赖的组件类型。
(2)将每个组件看作为图的一个节点,依赖关系视为弧,生成一张有向图,有向图使用邻接表存储。
(3)对有向图进行拓扑排序,当有向图存在环状结构时,日志提示存在互相引用;若有向图无环,则输出最终的排序结果。
(4)按序执行组件行为。
经过前述流程,组件模型构造完成。框架需要提供入口供外界调用。以调用场景中的实体数划分场景为:单一视图、多视图。
单一视图:渲染单一同类实体的数据。例如:某个商品卡片,某个商品详情页。
多视图:渲染多个同类实体的数据。例如:多个商品卡片,多个商品详情页。
对于每个场景,提供相应的调用入口、自定义驱动组件列表的方法,如下图所示。
单一视图入口:renderView();多视图入口:renderViewList()。视图入口中提供自定义驱动组件列表的方法。例如:在多视图场景中通过实现driveCardView()方法,实现串行执行组件或并行执行组件。
使用组件化开发方式,构建feed流只需要以下几个步骤:
(1)新建业务组件(可选):如果有新的业务逻辑,则新建业务组件类。
(2)声明配置:在XML配置中声明应用名、RPC模块列表、组件列表。
(3)声明驱动:定义应用Bean、应用上下文、请求参数、组件的驱动方式等。
(4)执行驱动:执行命中的组件列表。
(5)额外的逻辑处理:埋点日志打印等。
(6)结果返回
以我的页面增加推荐Feed场景为例:
研发侧效率整体提升:2.2倍。
衡定过程:组件化模式前工时投入÷组件化模式后工时投入。即:84H÷37H≈2.2。
研发侧投入成本明细如下:
编写组件配置时,经常需要查看组件元信息:组件的属性等内容,影响编写效率。
基于IntelliJ Platform Plugin Template,开发了XML自动补全提示插件。
组件开发模式,可以沉淀一套代码结构。脚手架生成通用代码后,可进一步较少开发投入。
传统的开发模式:受版本、终端、产品迭代等多因素影响,随着时间的推移,代码逻辑越来越复杂,维护成本高,复用性低,无法应对快速的产品迭代。
组件化开发模式:将功能逻辑凝练为组件,代码更具复用性。组合组件即可完成系统的构建,高效交付。
本文详细讲述了组件化开发技术的实现过程,希望对大家有所帮助。欢迎大家在评论区留言,也可添加微信号:zpc_1994
,进一步交流。
[1] 毕伟.组件化软件开发方法,2022,http://mm.chinapower.com.cn/zjqy/gddh/20221207/178571.html
[2] Margaret Rouse.Component-Based Development,2015,https://www.techopedia.com/definition/31002/component-based-development-cbd
[3] UNIC.Component-Based Development,2014,https://www.ece.uvic.ca/~itraore/seng422-06/notes/arch06-7-1.pdf
[4] 阚耀光.appview-auto-degrade-cache,2022.
[5] 陈奇恩.复杂并发场景下的并发调度模型在转转的演进之路,2022,https://mp.weixin.qq.com/s/6o4hQokmRytrb0Hevzly0g
[6] 陆晨,致远,陈琦.GraphQL及元数据驱动架构在后端BFF中的实践,2021,https://mp.weixin.qq.com/s/mhM9tfWBlIuMVkZQ-6C0Tw
[7] Sam Newman.Backends For Frontends,2015,https://samnewman.io/patterns/architectural/bff/
[8] 谷金.通用商品卡片的设计过程,2022.
[9] 陆晨,致远,陈琦.标准化思想及组装式架构在后端BFF中的实践,2022,https://mp.weixin.qq.com/s/7VlXBl9syw2ppiR3x237bA
[10] 乐彬,国梁,玉龙.美团外卖广告平台化的探索与实践,2022,https://mp.weixin.qq.com/s/Iyd_uYkNI5cH2sv_VwT3NA
[11] 严蔚敏,吴伟民.数据结构(C语言版)[M].北京:清华大学出版社,2007.163~183.
框架作者
张鹏程、武翱、阚耀光、赵天明,均来自转转集团研发中心-平台基础体验后端团队。
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。
关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~