作者:yeconglu
企业微信本地部署版(下文简称为本地版)是从2017年起,脱胎于企业微信的一款产品。本地版的后台服务能独立部署在政府或者大型企业的本地服务器上。在一个已经迭代了7年的大型Android系统中,企业微信本地版不可避免地会暴露出一些遗留系统的特点。本文将探讨我们在实践中采用的一些行之有效的重构案例,以及如何让一个大型软件系统持续保持活力。
一、遗留系统的特点
Martin Fowler 曾经说过这样一句话:
Let’s face it, all we are doing is writing tomorrow’s legacy software today.
你现在所写的每一行代码,都是未来的遗留系统。
很多人以为存在时间很长的就是遗留系统,但这其实是个误区。时间长短并不能作为衡量遗留系统的标准。判断遗留系统的几个维度是:代码、架构、测试、DevOps以及技术和工具。代码质量差、架构混乱、没有测试、纯手工的 DevOps(或运维)、老旧的技术和工具,才是遗留系统的真正特点。
看看下面这 6 个问题是否在你的项目中也曾经遇到过。
如果你的产品也有类似的一些问题,符合的“症状”越多,你的产品就越趋近于一个遗留系统。当遗留系统这个泥球越滚越大时,我们对它投入的改造成本就会越来越高。遗留系统就像一辆老破旧的小汽车,不知道啥时候会出问题,维修成本也越来越高,想快也快不起来。
二、重构的类型和收益
重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
小型重构是指对单个类内部的重构优化。通常包括对方法名称、方法参数数量、方法大小等内容的修改。中型重构是对多个类间的重构优化,通常的一些修改包括提取接口、超类、委托等调整。大型重构是对整个系统的架构进行重构优化,比如组件化、应用中台架构升级等,通常在做大型重构时也会伴随中小型的代码重构。以组件化为例,通过提取公用的基础组件和业务组件,来提高代码的可复用性,同时让业务能独立演进,就是一种大型重构。重构的目的是在不改变软件可观察行为的前提下,重点提高其可理解性,降低其修改成本。因此计算重构收益的方式很简单,从商业的角度来看,收益 = 软件价值 - (研发 + 维护成本)。
如果我们只注重业务上的价值而忽略了软件的研发维护成本,那么长此以往就会来到拐点 1。当研发维护成本超出业务价值,收益就开始负增长了。很多企业往往也是到这个拐点才意识到重构的重要性。
通常来说,重构需要一段时间的投入,来慢慢降低研发维护成本,有的需要几个月,有的甚至超过一年。但如果能坚持下来,就会来到拐点 3,此时收益开始正向增长。
三、遗留系统重构策略
3.1 绞杀者模式
3.1.1 定义
这个模式是指我们在替换一个软件系统时,在旧系统旁边搭建一个新系统,让它缓慢增长,与旧系统同时存在,逐步地“绞杀”旧系统。这个“逐步”的意思,其实就是增量演进。“同时存在”指的是并行运行。
它有三个优势:第一,不会遗漏原有需求;第二,可以稳定地提供价值,频繁地交付版本,更好地监控其改造进展。第三,避免“闭门造车”。
劣势主要来自迭代的风险和成本,绞杀的时间跨度会很大,存在一定风险,而且还会产生一定的迭代成本。
3.1.2 案例:启动任务重构
3.1.2.1 问题
最开始时,我们的启动任务逻辑全部都写在 Application 的 onCreate 中。随着启动逻辑越来越复杂,这部分代码越来越难以维护,而且很难监控每个版本中由于启动任务逻辑的变化,而带来的启动速度变化。
下图所示是一部分重构前的启动任务逻辑,各种业务的启动任务都写在 initMainProcess() 中。
3.1.2.2 方案
重构后我们引入了启动任务管理框架,不同业务的启动任务划分到不同的Task中,按照顺序装载到对应的进程。
每个Task实现一个相对比较内聚的功能:
但是由于启动任务逻辑的复杂性,我们没有一次性把所有启动逻辑都重构成Task的形式,而是新逻辑使用Task的形式,旧逻辑逐步迁移。这种新旧写法共存的情况维持了相当长的一段时间,直到所有启动逻辑都最终迁移到了新的启动框架中,后续 Application 的 onCreate 中也不允许再增加新的启动逻辑。
3.1.2.3 效果
重构后,启动Task的功能职责单一化,达到了高内聚、低耦合的目标。而且对于新增的启动任务,以及每个任务的速度劣化,都更方便监控了。
3.2 修缮者模式
3.2.1 定义
绞杀植物模式适合用于新的系统和服务,替换旧的系统或旧系统中的一个模块。在旧系统内部,也可以使用类似的思想来替换一个模块,只不过这个模块仍然位于旧系统中,而不是外部。我们把这种方式叫做修缮者模式。
修缮者模式是对于现有系统新增一层进行封装,然后在保证新层对外提供功能不变的情况下,对系统内部进行改造。
3.2.2 案例:云服务重构--中间分发层重构
3.2.2.1 问题
本地版客户端除了能连接到本地版的服务器,还能连接到Saas的云端服务器,实现这部分能力的模块称为云服务模块。
为了在同一个UI页面,同时支持使用本地版服务和云服务,我们基于这两个底层服务构建了一个中间分发层。中间分发层能够根据不同的情况,适当地将请求分发给本地版服务或者云服务。
Android开发中使用maven依赖其他模块时,有implementation和api两种方式,它们的区别是:
implementation关键字用于将依赖的库隐藏在当前模块内,只能在当前模块中访问,不会传递给其他依赖该模块的模块。
api关键字用于将依赖的库的公共接口暴露给其他模块,可以在其他依赖该模块的模块中直接访问。
但是由于分发层的隔离不够严格,使用了api依赖云服务模块,导致业务层也可以绕过分发层,直接调用本地版服务或者云服务。业务层开发需要根据具体情况,考虑应该在什么情况调用哪个服务,增加了维护成本和出错的概率。
3.2.2.2 方案
我们针对分层不够清晰的问题,重构了一套严格编译隔离的云服务底层:业务层调用者必须通过中间分发层调用本地版服务或者云服务,无法绕过中间层。
具体的做法是改成使用maven的implementation依赖方式,使得云服务底层的maven依赖不会传递给业务模块,保证只有中间层能够调用云服务底层,而业务层只能依赖中间层。
重构的过程是可以小步进行的:先把一个业务模块对云服务底层的依赖改成只依赖中间层,然后编译,接着逐个处理编译错误。处理完一个业务模块就可以提测这个模块。
3.2.2.3 效果
在保证新的中间层对外提供功能不变的情况下,我们渐进式地对中间层进行了重构,逐个模块地把非预期中的跨层依赖都剥离掉。
整个过程修改的Java文件数量 超过800+,从此业务层只能通过中间层调用本地版通用底层或者Saas通用底层,跨层调用都会直接报编译错误 。
3.3 拆迁者模式
3.3.1 定义
基于原有的业务,新写一套系统,然后一次性将旧系统的数据和功能,迁移到新系统上。
3.3.2 案例:生命周期重构
3.3.2.1 问题
我们本地版原来已经有一套页面生命周期的监控模块,后来又引入了一套Saas的页面生命周期监控模块。两个模块的功能大部分重复又不完全相同,维护的成本很大,比如开发做一个功能可能得同时修改两个模块的代码,而且两个模块的修改都是类似的。
3.3.2.2 方案和效果
虽然这个模块的改动影响很大,但是为了彻底解决遗留代码带来的问题,我们在一次迭代中合并了两个模块的代码,一次性切到新的唯一一个生命周期监控模块中。
四、架构重构
在本节中,我会介绍两个架构重构的案例:组件化和云服务ProtoBuf定义统一。一般来说,挑战可以归纳成两大类。首先是普遍性挑战,比如组件化重构,我将会展示我们是如何深入理解业务需求,找到量身定制的组件化重构方案。其次,是特有的业务挑战。以本地版为例,我们面临的是历史遗留问题,比如本地版和Saas两种冲突的PB定义共存的情况。这种独特的挑战要求我们不仅要有技术上的广度,还需要深度和创造性地思考。接下来,我将分享我们如何安全小步地实施架构重构,同时保持系统持续迭代。
4.1 组件化
4.1.1 意义
单体架构是常见的架构模式之一。通常所有开发人员基于单个模块进行开发,所有业务功能都集成在一起打包发布。单体架构非常适合团队规模小、业务复杂度低的产品,在项目起始阶段能快速迭代进行验证。
随着业务的持续演进,代码不断地膨胀和腐坏,所以代码内部的耦合度很高。在这样的基础上修改代码,非常容易牵一发而动全身:修改一个 Bug,又引起另外一个 Bug;开发一个功能,又引起另外一个功能的异常。
4.1.2 重构过程
4.1.2.1 方案
这里先简单讲述一下企业微信组件化的技术方案,但是不会涉及太多细节。
组件间的通信方案使用接口,即每个模块各自提供一批对外的api接口,其它模块只能访问到这些api,如图:
工程结构上使用Module这种官方的形式进行工程结构拆分,各组件之间能只能访问到对方的api,通过只依赖api而不依赖本体的形式来实现的代码隔离。
组件化方案确定后,解耦遗留代码的过程是漫长而琐碎的。这里我更想着重叙述下本地版是如何推进组件化项目的进度,以及提高组件化实施的效率的。
4.1.2.2 进度管理
一个完整的组件化重构步骤如下图所示:
划分出不同功能模块的分界线后,我们把不同模块的解耦任务分给对应负责的开发。然后我们做了一个网站自动监控每个模块的解耦进度:
统计每日的解耦类和api数量:
我们通过这种方式持续推进这个维持了一年多的组件化大型重构项目,让每个模块的解耦进度和组件化程度都可以一目了然。
4.1.2.3 自动化重构脚本
组件化的过程中,最常见的操作就是把更为内聚的一些类移动到同一个组件内,以及为隔离的组件提供对外的API接口。为了提高组件化的效率,我们开发了许多解耦代码的脚本,用于抽取组件API、移动类、移动资源,大大提高了全组开发实施组件化的效率。
自动化重构脚本分析和移动基础库ui_foundation的执行示例:
4.1.3 效果
抽取基础库40+,类1700+
抽取业务模块30+,抽取接口数2200+
4.2 云服务ProtoBuf定义统一
4.2.1 问题:两套相似又不相同的ProtoBuf定义共存
由于本地版的历史需求和Saas既有相同也有不同的地方,所以造成了两者的ProtoBuf有大量相同重合的地方,但是少量字段又并不是完全一样的。而且在开始没有开发规范的情况下,产生了冲突的数据字段,也就是在同一个Message结构体的相同位置的字段,在本地版和Saas中的类型或者含义是不一样的。
虽然后面我们已经意识到这个问题,对本地版需求新增加的ProtoBuf字段索引都增加了1000,以此避免冲突,但是历史已经放出去的版本也无法再修改。如果没有一个兼容旧版本的方案,那冲突的字段只能一直保留着。
在本地版的业务层中,本地版的ProtoBuf和Saas的ProtoBuf一起编译,由于不能存在包名和类名都一样的类,所以本地版的ProtoBuf包名都从wework修改成了weworklocal。 因此业务开发需要关注当前使用的是哪套ProtoBuf,而选择引入不同的包名,大大增加了代码的理解成本和开发成本。
4.2.2 方案:统一ProtoBuf定义
4.2.2.1 冲突类型
为了实现两套通用底层的PB统一,最大的问题是如何兼容两份PB的冲突字段。本地版PB和SaasPB的字段冲突类型,主要有4种:
类型相同,但名字不同,实际业务含义不同
类型不同,名字不同
本地版独有字段
enum值冲突
4.2.2.2 分层设计
为了解决PB字段冲突的问题,我们增加了一个冲突转换层:
上层UI统一使用Saas的PB结构
在 本地版 通用底层和UI之间,增加一层转换层,负责把冲突的PB字段重新赋值
本地版的底层继续使用原有的本地版PB
4.2.2.3 自动化重构脚本
针对重复性工作,我们使用脚本对比Proto,找出冲突字段并进行自动化处理,提高效率,流程如下:
自动化重构脚本方案收益如下:
无需手动对齐Proto文件 Proto文件数量470+,以处理一个文件15分钟计算,可节省工作量约5人日。
自动生成转换代码 冲突字段110+,每个冲突需实现3个转换函数,总计可以少写6000+行代码。
出现新冲突时,可以重复生成新的转换代码。
4.2.3 效果
对组件化的收益:可以消除约50%云服务需求导致的接口差异。
减少了开发的理解和维护成本:后续维护的开发都不需要过多关注当前是需要使用本地版还是Saas的协议。
五、代码重构
5.1 过大类重构
将大型的单体遗留系统重构为组件化架构后,我们有了更加低耦合、高内聚的组件。但是回到组件内部,代码质量对开发也非常重要。我相信你在过去的代码里一定会遇到一种典型的代码坏味道,那就是“过大类”。在产品迭代的过程中,由于缺少规范和守护,单个类很容易急剧膨胀,有的甚至达到几万行的规模。过大的类会导致发散式的修改问题,只要需求有变化,这个类就得做相应修改。
随着业务需求和代码规模的不断膨胀,我们针对过大类的重构策略就是 分而治之 。通过分层将不同维度的变化控制在独立的边界中,使之能够独立的演化,从而减少修改代码时彼此之间产生的影响。
5.2 会话列表重构
5.2.1 业务分析和代码分析
对于遗留系统来说,比较常见的问题就是需求的上下文中容易存在断层,所以第一步就是尽可能地了解、分析原有的业务需求。只有更清楚地挖掘原有的需求设计,才不会因为理解上的差异出现错误的代码调整。接下来我们以会话列表页面为例,讲述我们重构过大类的过程。下图是我们对会话列表涉及的业务功能进行的梳理:
下图是会话列表页面的示意图:
5.2.2 架构设计
分析完之后,接下来就是进行架构设计了。这一步让我们在开始动手重构前,想清楚重构后的代码将会是什么样子,以终为始才能让我们的目标更加清晰,让过程更加可度量。
现在主流APP框架都是用一套MVP或者MVVM框架来解耦。企微还是传统的MVC方案,由于历史原因修改成MVP或者MVVM都会有非常大的成本。
于是我们创建了一个新的MVCs的框架。MVCs的主要理念是将View和Model的交互,变成一个可插拔的抽象的逻辑。所以一个Controller描述的是一组View与一组Model的关系,从理念上,它应与业务无关。MVCs架构在面对企微这种复杂度的场景下,已经可以较好得支撑实际面临的业务需求。
5.2.3 小步安全重构
建立一个IController,抽象出和 Activity/Fragment相同的生命周期,然后在Activity/Fragment相同的生命周期执行。同时一个IController可以包含多个IController,先执行本身的逻辑,然后再执行子IController逻辑,同时提供懒加载方案LazyController ,当达到一定条件的时候才会加载,保证性能和效率。
基于MVCs,我们将页面重构成了各个不同的Controller,把旧页面超大类中的一个个业务逐步拆分到独立的Controller中:
每个controller对应一个具体的业务场景,例如:
ConversationLogController对应日志相关的逻辑。
ConversationDataInitController对应数据初始化。
ConversationHeaderStatusBarViewInitController对应头部状态相关逻辑。
5.2.4 效果
在采用MVCs框架进行重构后,平均每个Controller的代码行数降低到了约365行。这意味着我们成功地实现了Controller功能职责的单一化,达到了高内聚、低耦合的目标。
通过这些改进,我们的代码变得更加清晰、易读,降低了维护成本。同时,由于各个功能模块之间的耦合度降低,我们可以更加灵活地对现有功能进行修改和扩展,以满足不断变化的业务需求。这些成果充分证明了MVCS框架在实际项目中的有效性和可行性,为后续重构其他大型页面提供了有益的借鉴。
六、DevOps重构
6.1 Bazel编译
企业微信本地版有大量的网络通讯、数据库存储等底层通用能力是使用C++实现的,之前是以典型的Android.mk作为构建工具来构建动态库。Bazel则是更为现代化的构建工具:Bazel能够缓存所有以前完成的工作,并跟踪对文件内容和构建命令的更改,因此Bazel在构建时只对需要重建的部分进行构建;同时,Bazel支持项目以高度并行和增量的方式构建,能够进一步加快构建速度。
目前,本地版Android端的底层动态库已经全量换成使用Bazel构建,下面是其中一个构建脚本的例子:
6.2 分支管理
因为本地版需要面向很多大型政企用户,不同的政企可能会有不同的包名、不同的发布分支、不同的发布计划,而且这些发布计划还会并行发布。为了让各个角色的成员都能清晰了解和管理当前正在发布的分支和迭代,我们开发了专门的分支管理页面,自动化拉取、合并不同的迭代分支,以及管理迭代的生命周期。
6.3 流水线管理
本地版客户端的模块众多,不同的模块可能是由不同的团队负责开发的。下面是我们依赖的一些跨仓库组件的示意图:
不同的组件由不同团队维护的流水线构建,最后以maven的形式集成到本地版企业微信APP中。当分支管理工具拉出一条新分支时,就会自动实例化各个业务组件的子流水线。这样我们可以做到即使在多团队、多仓库、多分支开发的情况下,组件编译和集成编译都可以全自动进行。
七、总结
冰冻三尺非一日之寒 ,遗留系统不是一天就产生,也不单纯因为一次提交就演化而来,而是随着不断的版本更迭、人员变换、代码不断累积腐化而导致的。
遗留系统的技术债务就像一座冰山,虽然表面平平无奇,但是底下却是纵横交错。可怕的是很多时候我们却只看到了表面,而却无法真正发现阻碍产品快速演进的元凶。
主动、持续地改进甚至重构系统,才能适应变化。遗留系统重构的最终目标是构建一个具有可扩展性、高性能、高可用性的系统架构,提高系统的开发效率和产品的迭代速度。