背景
众所周知,软件开发效率、维护成本与自身复杂度成正比,而客户端软件复杂度则主要体现在业务规模上。
京东支付Android SDK从2015年启动以来,已历经五个春秋,如今发展到纯支付业务代码7.5W行的规模(不含支付团队内部基础组件库和兄弟团队生物识别、安全等近10个SDK)。为应对每年618、11.11大促考验,内置各种降级逻辑致使部分功能要准备至少两种技术实现方案,复杂度不言而喻。虽然久经沙场,然而步履愈发沉重。究其原因,无外乎技术圈这些司空见惯的槽点:
-
业务发展太快,早期技术架构已经不能很好的适应变化,而业务需求又繁重,架构升级计划一次次被延后,最后不了了之。
-
既然架构不能支持新业务,就只能通过各种“旁门左道”的方式破坏架构来解决问题,以至于进化成没有架构,只有各位前辈高人馈遗的祖传套路,谓之“祖宗家法不可变”。
-
没有实际价值的业务代码一直苟延残喘的留在系统里,变成长期的维护负担。
-
设计文档、接口文档、代码注释缺失或更新不及时,致使涉及多系统交互的代码后人往往只能因循将就,不敢轻言优化。
有鉴于此,为使京东支付SDK未来能轻快地奔跑,从容应对变化,我们决定重构。目标:实现软件复杂度增长低于业务复杂度增长的目标。
一、支付业务组成
常言道:脱离业务的架构都属于自嗨。
为实现重构目标,我们需要:
-
先梳理清楚业务特点,做业务层抽象;
-
找出当前软件系统痛点所在,做技术层分析;
-
结合业务层抽象与技术层分析,设计新解决方案;
上图比较宏观的把SDK划分为几大组成单元,特点是:
-
所有组成单元之间都是双向依赖,任何一个业务单元都可以作为其他业务单元的前置流程,也可以成为其他业务单元的下一步流程,很多业务单元内部还存在互相依赖。而这种循环、交叉的依赖,重构之难可想而知,修改一处影响一片。每当试图把重构拆分成多个小任务来迭代执行时就会发现,粒度实难控制,因为改着改着就涉及上百个文件了…
-
业务变种众多,举个例子,仅短信验证一个功能就有内单、外单、支付验证、风控加验、白条开通、证书安装、全屏页、半屏页、特殊业务等诸多变种,这些变种彼此组合才能完成一个短信验证操作,如“内单+风控加验+半屏”这几个组合就是一种常见的短信验证流程,而“外单+风控加验+全屏”又是另一种组合,依此类推。
-
异常流程繁杂,为了尽可能使用户完成支付,必须识别并区别处理各种失败情况。如:忘记密码的要引导用户找回密码、余额不足的要引导用户更换支付方式等等。异常流程往往伴随着多次支付流程重试行为,也就是说已经执行过的流程,部分数据要保留,部分数据要替换,因此,确保模块重新执行时入参和出参的精准性也是一大难题。
二、经典架构模式能否解决问题?
京东支付SDK一直以来使用的是MVP模式,它的优势在于分离UI与业务逻辑,即关注单个页面及相关数据、业务代码如何构建。其核心聚焦于“点”上。而对支付业务而言,任何一个单一页面都算不上复杂,它的复杂性体现在如何把这些简单的页面(点)串联起来组成一个可执行的业务链(线)。同理,MVC、MVVM等经典模式同样也无法解决由点到线的问题。而VIPER模式有人把它比喻为搭乐高,可以串联各个模块,它里面包含的R(Router)确实是处理模块跳转用的,这么看似乎有机会解决点到线的问题,那么可否一战呢?我们来进一步分析。
这是网上流传很广泛的一张图,View和Presenter无需多说,Router负责模块(页面)跳转,而Entity和Interactor大体上是把传统的Model职责拆开,纯数据对象作为Entity(Bean),Interactor用来管理调度数据。但是,问题在于怎么来管理数据?我们考虑有两种可能:
1、将Interactor设计为Presenter级别数据管理器
这样的话,那么支付这种模块众多且交叉、循环耦合的业务,谁来处理模块间数据流转的准确性呢?如图所示,Interactor与Router并没有直接交互,而是通过Presenter来处理。这就使得单个模块的Presener可能需要知道其他模块所需的数据来自哪里,以及如何组装出下个模块的入参,如此一来,Presenter难免感知、耦合其他模块。当一个模块耦合了一堆其他模块之时,牵一发动全身就不难理解了。不幸的是,京东支付SDK重构前就存在这种情况,各种验证工具模块更是重灾区,因为几乎每种验证工具的Presenter中都包含了一堆业务场景的定制逻辑。举个例子:
密码验证Presenter由A、B、C业务调用时的入参、出参各不相同,下一步流程也不一样,这种情况下如果Router的数据由密码验证Presenter来提供的话,势必要耦合前后各种不同的业务逻辑。那么,如果给每种业务场景提供专属Presenter怎么样呢?支付SDK重构前也是这么做的,仅短信验证至少就有8种对接不同业务的Presenter实现,然而并不能彻底解决问题,因为每种验证方式都可能衔接N种后续流程,所以在短信验证Presenter里构建Router数据还是免不了把其他流程的逻辑乱入进来。这也是多年以来一直困扰支付SDK的一大问题:让一个模块只做自己这一件事儿,太难了。
2、将Interactor设计为全局数据管理器
其实Interactor作为数据管理器最重要的功能是调度数据,而拥有更高更广的视角似乎也更有利于完成这项工作。同时,作为全局调度器,收纳并管控各种流程特定数据、调用逻辑,看起来也是理所应当。因此,我们设想把所有模块做成类似系统Widget一样的组件,暴露出各种原子级别API,自身只负责UI渲染和处理内部交互,所有涉及外部的交互全部抛出去,使模块达到不知自己从哪来,更不知自己上哪去的目标(传说中的高内聚、低耦合)。
三、Scene与Interactor,DDD设计实践
由于支付SDK是单Activity多Fragment设计,Router本身并没有太多复杂性可言,而繁重的逻辑主要集中在数据管理和流程调度中。因此,我们决定把VIPER中I和R的职责合为一体,再按照DDD设计思路将业务场景和用户交互的职责重新划分成Scene和Interactor。
- Scene是整个业务流的核心,类似于DDD中领域层,管理并调度影响主干流程的所有数据,它与UI无关,但它任何时候都可以根据所持有的数据知道当前业务流执行到哪一步了,以及下一步要做什么、需要哪些数据。
- Interactor是Scene的辅助,与VIPER中Interactor定位不同,它定位为DDD中UI层与应用层的结合,面向业务场景(即用例),负责处理业务流上用户主动触发的关键交互事件(如:页面之间的跳转、需要其他模块协作的),并交由Scene来处理业务逻辑,再把结果反馈给用户。
如图所示,Current Business Unit即当前正在执行任务的模块,假设它是密码验证模块,交互如下:
-
用户输入密码后,该模块将输入数据封装成一个Event事件发出来;
-
Interactor识别并接收这个Event,把它交给Scene中处理密码输入的方法进行处理;
-
Scene的密码处理方法去调用服务端接口验证密码
验证失败,把错误信息封装成Event发出来,密码模块接收并处理;
验证成功,Scene根据持有的流程数据判断下一步做什么,并将数据组装好,交给Interactor;
-
Interactor收到Scene处理后的数据,完成模块跳转。
这种设计的好处在于所有模块互不相关,响应用户交互的代码和数据也是分离的,业务流程全权由Scene处理,每种业务只需开发自己的Scene和Interactor,即可快速组合已有模块完成业务需求。
四、UserCase
虽然Scene拥有决定业务流走向的所有数据,但面对复杂业务流时,想定位当前运行到哪一步了,仍然不是件容易的事儿。
简单而常见的做法是在代码里加各种状态标记,但状态标记过多,尤其还需要组合使用的时候,就会变成后期没人敢碰的恶毒机关。如:A模块改变某个变量值,可能影响到B业务的逻辑。众所周知,数据源越分散,代码逻辑越看清。
考虑到支付业务流通常以One By One这种链式运行,倘若我们把业务流上每个业务单元当成一个节点,整个业务流当成一条链,那么,理论上每种业务都可以构建出一条业务链,我们把这条链定义成一个UserCase。UserCase上的每一个业务单元按顺序执行即可完成业务流:
new UserCase()
.business(createBusinessA(), JPPRuntime.getAsyncWorker())
.business(createBusinessB(), JPPRuntime.getMainWorkder())
.business(createBusinessC(), JPPRuntime.getAsyncWorker())
.business(createBusinessD(), JPPRuntime.getMainWorkder())
.execute(new Observer() {
@Override
public void onComplete(@NonNull UserCase userCase) {
}
@Override
public void onError(@NonNull Throwable throwable) {
}
});
与RxJava调用形式类似,UserCase上每个业务单元都在指定Worker线程运行,通常情况下,一个任务执行完成后会调用UserCase的next()方法执行下一任务。整个业务流的进度是由UserCase来管理的,所以不需要任何数据也能知道当前正在执行哪个业务单元。而UserCase自身又是以双向链表结构存储各业务单元的,也就是说每个业务单元都可以通过UserCase查找到上一个业务单元是谁,下一个又是谁,这种设计的好处在于:
- 运行时可以回溯业务流调用链,轻松知道用户操作过程
- 某个业务单元出错,可以快速地回退到上一个正确的业务单元上重新执行,给用户以最小代价重试的机会,而不必从头重来。
- 对于存在业务流循环调用的场景,不必为循环额外做什么,UserCase支持重定向到任意业务单元上继续顺序执行,使实现A->B->C->B->C->D这种业务流成为很简单的事儿。
为了使UserCase支持定向跳转和流程回溯,每个业务单元被设计为拥有ID(UserCase内唯一)和入参、出参(Input/Output)的组成形式:
public interface Business<I, O> {
int getId();
I getInput();
void setInput(@Nullable I input);
O getOutput();
void onExecute(@NonNull UserCase userCase, @Nullable Business prev);
}
- 定向跳转时UserCase通过ID在业务链上查找业务单元。
- 业务单元执行的入参(Input)由外部传入,所以允许set,而执行后的出参(Output)则是只读的,这样每次业务单元执行后的入参、出参就可以形成一份数据快照,UserCase回溯流程时便有迹可循。为保证每个业务单元数据快照的稳定性,避免引用型入参、出参被外部修改的问题,我们还开发了一个数据深拷贝工具,实现一行代码复制任何对象(包括对象内所有层级的子对象)。
五、业务模版
重构以后,支付SDK每个业务场景都有一个特定的Scene、Interactor和众多业务单元,如图:
-
每个BusinessUnit都实现了Business接口,其中内聚了该业务相关的入参、出参和ID;
-
BusinessScene和BusinessInteractor是配对关系,彼此互相引用紧密协作;
-
BusinessScene集成了特定业务场景所需的所有BusinessUnit(如:密码验证、收银台、绑卡等模块);
-
BusinessInteractor在createUserCase()时,从BusinessScene中获取这些BusinessUnit并编排业务链,生成该业务的UserCase;
-
onEvent()接收并处理各BusinessUnit与用户交互过程中需要BusinessScene/BusinessInteractor配合的事件,如:需要验证密码时,当前BusinessUnit发出请求验证密码事件,BusinessInteractor接收到以后请求BusinessScene根据当前流程状态决定展示何种密码验证页,BusinessScene把结果(密码验证页入参)告知BusinessInteractor,并由BusinessInteractor启动密码验证页;
六、京东支付SDK新架构
如前文所述,此次重构专注于重组SDK业务逻辑,使新架构能更好的支持业务需求迭代,提升开发效率。总结起来如下:
-
首先,根据业务流来重新组织代码,每个业务流就是一套Scene+Interactor+UserCase的组合,可以理解为一个业务沙箱,沙箱内是完整的业务运行时环境,不支持的功能,不会存在于沙箱中,也就不会在运行时意外乱入,而整个业务流由Scene+Interactor+UserCase组合来决策;
-
其次,业务单元Widget化,只做自己本职工作,绝不插手业务流程;
-
再次,充分利用事件驱动模型来解耦业务单元间的依赖关系,承担全局消息总线职责;
-
最后,为了满足宿主App对SDK功能、体积的要求,重构后把非标业务或功能做了成动态模块,通过Gradle在编译时一键配置是否集成进SDK中。动态模块另外一个好处是,可以支持定制化需求,又不必深度入侵标准业务。
七、重构收益
我们以同一版本京东App为宿主,分别把新、老两个SDK集成进去,在相同入口用相同订单测试:
1、启动时长对比
启动时长指:从京东支付SDK主Activity启动到第一个接收用户交互的Fragment响应onResume()生命周期这段时间,其间包含了一次后端接口调动,但多次测试使用的参数是一样的。
重构前 | 重构后 | |
---|---|---|
第一次时长(ms) | 6619 | 3549 |
第二次时长(ms) | 7809 | 4265 |
平均时长(ms) | 7214 | 3907 |
2、纯业务(Java)代码量对比
重构前 | 重构后 | |
---|---|---|
代码总行数 | 75778 | 35820 |
文件个数 | 604 | 355 |
总大小(kB) | 3574 | 1686 |
单个最大(kB) | 155 | 101 |
3、资源文件(XML)对比
重构前 | 重构后 | |
---|---|---|
代码总行数 | 14204 | 7688 |
文件个数 | 238 | 143 |
总大小(kB) | 681 | 398 |
单个最大(kB) | 38 | 30 |
关于重构,我们总是不好量化收益,因为代码是否更易于维护,无法量化,用户也感受不到。但是我们可以很容易理解的是:代码量大幅缩减,运行时执行的代码就变少了,性能理所当然会提升。
本文作者:京东科技 王超
更多技术最佳实践&创新成果,请关注“京东数科技术说”微信公众号
转载:https://blog.csdn.net/JDDTechTalk/article/details/113513226