京东支付 SDK 重构设计与实现
众所周知,软件开发效率、维护成本与自身复杂度成正比,而客户端软件复杂度则主要体现在业务规模上。
京东支付 Android SDK 从 2015 年启动以来,已历经五个春秋,如今发展到纯支付业务代码 7.5W 行的规模(不含支付团队内部基础组件库和兄弟团队生物识别、安全等近 10 个 SDK)。为应对每年 618、11.11 大促考验,内置各种降级逻辑致使部分功能要准备至少两种技术实现方案,复杂度不言而喻。虽然久经沙场,然而步履愈发沉重。究其原因,无外乎技术圈这些司空见惯的槽点:
业务发展太快,早期技术架构已经不能很好的适应变化,而业务需求又繁重,架构升级计划一次次被延后,最后不了了之。
既然架构不能支持新业务,就只能通过各种“旁门左道”的方式破坏架构来解决问题,以至于进化成没有架构,只有各位前辈高人馈遗的祖传套路,谓之“祖宗家法不可变”。
没有实际价值的业务代码一直苟延残喘的留在系统里,变成长期的维护负担。
设计文档、接口文档、代码注释缺失或更新不及时,致使涉及多系统交互的代码后人往往只能因循将就,不敢轻言优化。
有鉴于此,为使京东支付 SDK 未来能轻快地奔跑,从容应对变化,我们决定重构。目标:实现软件复杂度增长低于业务复杂度增长的目标。
一、支付业务组成
常言道:脱离业务的架构都属于自嗨。为实现重构目标,我们需要:
先梳理清楚业务特点,做业务层抽象;
找出当前软件系统痛点所在,做技术层分析;
结合业务层抽象与技术层分析,设计新解决方案;
所有组成单元之间都是双向依赖,任何一个业务单元都可以作为其他业务单元的前置流程,也可以成为其他业务单元的下一步流程,很多业务单元内部还存在互相依赖。而这种循环、交叉的依赖,重构之难可想而知,修改一处影响一片。每当试图把重构拆分成多个小任务来迭代执行时就会发现,粒度实难控制,因为改着改着就涉及上百个文件了…
业务变种众多,举个例子,仅短信验证一个功能就有内单、外单、支付验证、风控加验、白条开通、证书安装、全屏页、半屏页、特殊业务等诸多变种,这些变种彼此组合才能完成一个短信验证操作,如“内单+风控加验+半屏”这几个组合就是一种常见的短信验证流程,而“外单+风控加验+全屏”又是另一种组合,依此类推。
异常流程繁杂,为了尽可能使用户完成支付,必须识别并区别处理各种失败情况。如:忘记密码的要引导用户找回密码、余额不足的要引导用户更换支付方式等等。异常流程往往伴随着多次支付流程重试行为,也就是说已经执行过的流程,部分数据要保留,部分数据要替换,因此,确保模块重新执行时入参和出参的精准性也是一大难题。
二、经典架构模式能否解决问题?
京东支付 SDK 一直以来使用的是 MVP 模式,它的优势在于分离 UI 与业务逻辑,即关注单个页面及相关数据、业务代码如何构建。其核心聚焦于“点”上。而对支付业务而言,任何一个单一页面都算不上复杂,它的复杂性体现在如何把这些简单的页面(点)串联起来组成一个可执行的业务链(线)。同理,MVC、MVVM 等经典模式同样也无法解决由点到线的问题。而 VIPER 模式有人把它比喻为搭乐高,可以串联各个模块,它里面包含的 R(Router)确实是处理模块跳转用的,这么看似乎有机会解决点到线的问题,那么可否一战呢?我们来进一步分析。
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 无关,但它任何时候都可以根据所持有的数据知道当前业务流执行到哪一步了,以及下一步要做什么、需要哪些数据。
如图所示,Current Business Unit 即当前正在执行任务的模块,假设它是密码验证模块,交互如下:
用户输入密码后,该模块将输入数据封装成一个 Event 事件发出来;
Interactor 识别并接收这个 Event,把它交给 Scene 中处理密码输入的方法进行处理;
Scene 的密码处理方法去调用服务端接口验证密码;
Interactor 收到 Scene 处理后的数据,完成模块跳转。
这种设计的好处在于所有模块互不相关,响应用户交互的代码和数据也是分离的,业务流程全权由 Scene 处理,每种业务只需开发自己的 Scene 和 Interactor,即可快速组合已有模块完成业务需求。
四、UserCase
虽然 Scene 拥有决定业务流走向的所有数据,但面对复杂业务流时,想定位当前运行到哪一步了,仍然不是件容易的事儿。
简单而常见的做法是在代码里加各种状态标记,但状态标记过多,尤其还需要组合使用的时候,就会变成后期没人敢碰的恶毒机关。如:A 模块改变某个变量值,可能影响到 B 业务的逻辑。众所周知,数据源越分散,代码逻辑越看清。
考虑到支付业务流通常以 One By One 这种链式运行,倘若我们把业务流上每个业务单元当成一个节点,整个业务流当成一条链,那么,理论上每种业务都可以构建出一条业务链,我们把这条链定义成一个 UserCase。UserCase 上的每一个业务单元按顺序执行即可完成业务流:
与 RxJava 调用形式类似,UserCase 上每个业务单元都在指定 Worker 线程运行,通常情况下,一个任务执行完成后会调用 UserCase 的 next()方法执行下一任务。整个业务流的进度是由 UserCase 来管理的,所以不需要任何数据也能知道当前正在执行哪个业务单元。而 UserCase 自身又是以双向链表结构存储各业务单元的,也就是说每个业务单元都可以通过 UserCase 查找到上一个业务单元是谁,下一个又是谁,这种设计的好处在于:
运行时可以回溯业务流调用链,轻松知道用户操作过程。
某个业务单元出错,可以快速地回退到上一个正确的业务单元上重新执行,给用户以最小代价重试的机会,而不必从头重来。
对于存在业务流循环调用的场景,不必为循环额外做什么,UserCase 支持重定向到任意业务单元上继续顺序执行,使实现 A->B->C->B->C->D 这种业务流成为很简单的事儿。
为了使 UserCase 支持定向跳转和流程回溯,每个业务单元被设计为拥有 ID(UserCase 内唯一)和入参、出参(Input/Output)的组成形式:
定向跳转时 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 新架构
首先,根据业务流来重新组织代码,每个业务流就是一套 Scene+Interactor+UserCase 的组合,可以理解为一个业务沙箱,沙箱内是完整的业务运行时环境,不支持的功能,不会存在于沙箱中,也就不会在运行时意外乱入,而整个业务流由 Scene+Interactor+UserCase 组合来决策;
其次,业务单元 Widget 化,只做自己本职工作,绝不插手业务流程;
再次,充分利用事件驱动模型来解耦业务单元间的依赖关系,承担全局消息总线职责;
最后,为了满足宿主 App 对 SDK 功能、体积的要求,重构后把非标业务或功能做了成动态模块,通过 Gradle 在编译时一键配置是否集成进 SDK 中。动态模块另外一个好处是,可以支持定制化需求,又不必深度入侵标准业务。
七、重构收益
我们以同一版本京东 App 为宿主,分别把新、老两个 SDK 集成进去,在相同入口用相同订单测试:
1、启动时长对比
启动时长指:从京东支付 SDK 主 Activity 启动到第一个接收用户交互的 Fragment 响应 onResume()生命周期这段时间,其间包含了一次后端接口调动,但多次测试使用的参数是一样的。
2、纯业务(Java)代码量对比
3、资源文件(XML)对比
关于重构,我们总是不好量化收益,因为代码是否更易于维护,无法量化,用户也感受不到。但是我们可以很容易理解的是:代码量大幅缩减,运行时执行的代码就变少了,性能理所当然会提升。
原文链接:京东支付SDK重构设计与实现
评论