众所周知,软件开发效率、维护成本与自身复杂度成正比,而客户端软件复杂度则主要体现在业务规模上。
京东支付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用来管理调度数据。但是,问题在于怎么来管理数据?我们考虑有两种可能:
这样的话,那么支付这种模块众多且交叉、循环耦合的业务,谁来处理模块间数据流转的准确性呢?如图所示,Interactor与Router并没有直接交互,而是通过Presenter来处理。这就使得单个模块的Presener可能需要知道其他模块所需的数据来自哪里,以及如何组装出下个模块的入参,如此一来,Presenter难免感知、耦合其他模块。当一个模块耦合了一堆其他模块之时,牵一发动全身就不难理解了。不幸的是,京东支付SDK重构前就存在这种情况,各种验证工具模块更是重灾区,因为几乎每种验证工具的Presenter中都包含了一堆业务场景的定制逻辑。举个例子:
密码验证Presenter由A、B、C业务调用时的入参、出参各不相同,下一步流程也不一样,这种情况下如果Router的数据由密码验证Presenter来提供的话,势必要耦合前后各种不同的业务逻辑。那么,如果给每种业务场景提供专属Presenter怎么样呢?支付SDK重构前也是这么做的,仅短信验证至少就有8种对接不同业务的Presenter实现,然而并不能彻底解决问题,因为每种验证方式都可能衔接N种后续流程,所以在短信验证Presenter里构建Router数据还是免不了把其他流程的逻辑乱入进来。这也是多年以来一直困扰支付SDK的一大问题:让一个模块只做自己这一件事儿,太难了。
其实Interactor作为数据管理器最重要的功能是调度数据,而拥有更高更广的视角似乎也更有利于完成这项工作。同时,作为全局调度器,收纳并管控各种流程特定数据、调用逻辑,看起来也是理所应当。因此,我们设想把所有模块做成类似系统Widget一样的组件,暴露出各种原子级别API,自身只负责UI渲染和处理内部交互,所有涉及外部的交互全部抛出去,使模块达到不知自己从哪来,更不知自己上哪去的目标(传说中的高内聚、低耦合)。
由于支付SDK是单Activity多Fragment设计,Router本身并没有太多复杂性可言,而繁重的逻辑主要集中在数据管理和流程调度中。因此,我们决定把VIPER中I和R的职责合为一体,再按照DDD设计思路将业务场景和用户交互的职责重新划分成Scene和Interactor。
如图所示,Current Business Unit即当前正在执行任务的模块,假设它是密码验证模块,交互如下:
用户输入密码后,该模块将输入数据封装成一个Event事件发出来;
Interactor识别并接收这个Event,把它交给Scene中处理密码输入的方法进行处理;
Scene的密码处理方法去调用服务端接口验证密码
验证失败,把错误信息封装成Event发出来,密码模块接收并处理;
验证成功,Scene根据持有的流程数据判断下一步做什么,并将数据组装好,交给Interactor;
Interactor收到Scene处理后的数据,完成模块跳转。
这种设计的好处在于所有模块互不相关,响应用户交互的代码和数据也是分离的,业务流程全权由Scene处理,每种业务只需开发自己的Scene和Interactor,即可快速组合已有模块完成业务需求。
虽然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支持定向跳转和流程回溯,每个业务单元被设计为拥有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);
}
重构以后,支付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业务逻辑,使新架构能更好的支持业务需求迭代,提升开发效率。总结起来如下:
首先,根据业务流来重新组织代码,每个业务流就是一套Scene+Interactor+UserCase的组合,可以理解为一个业务沙箱,沙箱内是完整的业务运行时环境,不支持的功能,不会存在于沙箱中,也就不会在运行时意外乱入,而整个业务流由Scene+Interactor+UserCase组合来决策;
其次,业务单元Widget化,只做自己本职工作,绝不插手业务流程;
再次,充分利用事件驱动模型来解耦业务单元间的依赖关系,承担全局消息总线职责;
最后,为了满足宿主App对SDK功能、体积的要求,重构后把非标业务或功能做了成动态模块,通过Gradle在编译时一键配置是否集成进SDK中。动态模块另外一个好处是,可以支持定制化需求,又不必深度入侵标准业务。
我们以同一版本京东App为宿主,分别把新、老两个SDK集成进去,在相同入口用相同订单测试:
启动时长指:从京东支付SDK主Activity启动到第一个接收用户交互的Fragment响应onResume()生命周期这段时间,其间包含了一次后端接口调动,但多次测试使用的参数是一样的。
重构前 | 重构后 | |
---|---|---|
第一次时长(ms) | 6619 | 3549 |
第二次时长(ms) | 7809 | 4265 |
平均时长(ms) | 7214 | 3907 |
重构前 | 重构后 | |
---|---|---|
代码总行数 | 75778 | 35820 |
文件个数 | 604 | 355 |
总大小(kB) | 3574 | 1686 |
单个最大(kB) | 155 | 101 |
重构前 | 重构后 | |
---|---|---|
代码总行数 | 14204 | 7688 |
文件个数 | 238 | 143 |
总大小(kB) | 681 | 398 |
单个最大(kB) | 38 | 30 |
关于重构,我们总是不好量化收益,因为代码是否更易于维护,无法量化,用户也感受不到。但是我们可以很容易理解的是:代码量大幅缩减,运行时执行的代码就变少了,性能理所当然会提升。