防止代码变质的思考与方法
1. 软件长期运营存在什么问题
在大规模客户端软件的生命周期中,我们可以将其划分为两个主要阶段:一是前期的搭建阶段(从无到有);二是搭建完成后的稳定运营阶段。第二个时期才是最关键的,在此期间我们会持续叠加需求、优化功能,但同时也是代码逐渐“变质”的时期。
在这个阶段,你可能会发现以下问题:
- 模块耦合严重,牵一发而动全身;
- 每个版本都会涌现出老功能的 BUG,甚至未变动的模块也会出错;
- 修复一个小问题却引发许多其他问题;
- 缺乏扩展性,往老模块添加新功能非常痛苦;
- 程序崩溃率越来越高;
- 新员工接手老模块时,因无法理解原有设计思想而改坏代码;
- 移植一个 DLL 到另一个软件时,发现必须连带移植十几个 DLL。
本文将分享对于这些问题的思考与解决方法。
2. 软件的积木模型
运营型客户端软件旨在长期运营,需要不断叠加功能,而非两三年就重写一次。这样的软件就像堆积木一样。
软件刚开始编写两千行代码时,可能设计得非常好,模块化、扩展性及性能都很优秀。但两三年后,就会出现像积木一样不稳定的结构,容易崩塌。所谓重构,形象地说,就是发现某个积木不稳定,需要往里塞一塞、加固一下。因此,整个开发过程就是一个不断迭代、不断优化、不断重构的过程。
对于这个积木模型,我们需要思考如何防止一些木条“跑出来”。我们是否可以先围四面墙,然后在墙里面再去搭积木?

3. 导致代码变质的两大因素
团队中存在的问题最终总是影响到代码朝着不良方向发展。我们可以将这些因素抽象为两大类:
- 人的因素:例如架构设计不合理、需求没考虑清楚、项目进度压力、沟通问题、缺少文档与培训等。这是由于人本身的素质或疏忽导致的。
- 时间的因素:例如人员的变动、需求的长期叠加和变更等。这是由于时间的长期推进导致的,即使人的素质很高,也必然会出现时间因素带来的问题。
4. 代码变乱的微观原因
在上述两大类因素的长期作用下,代码会越来越乱。如果从微观角度剖析,这与依赖有着很大的关系。代码变乱的根本原因,是由于太多不良依赖或者模块失去单一性所致。
依赖的方式
如下图所示,如果组件 A 依赖于 B,B 依赖于 C,则 A 也是隐含地依赖于 C 的。组件 A 不能单独使用,必须同 B 和 C 一起使用。在现实的代码中,可能存在着非常长的依赖链。

依赖的方式也可能是多种多样的:单向依赖、双向依赖、环状依赖或者一个依赖于多个。下图是一些示例,现实的代码中可能是由各种依赖方式组成的非常复杂的网状结构。

依赖的变化
在两大类因素的作用下,依赖会发生变化。最常见的变化是依赖的箭头越来越多,网状结构变得越来越复杂。如果没有增加新的组件,下图中左边的图往往会变成右边的图。
起初设计良好的代码可能是左边的样子,模块具有很好的独立性和可移植性。随着时间、需求、人的变化,很可能由开发人员很随意的一行代码,就变成了右边的图——一条红线出来了。两个模块变成相互依赖,上面那个模块就不再有独立性和移植性。

我们的代码从设计之初到现在,中间经过了几年的时间,代码变得越来越乱,很大的原因是因为这种“红线”的持续出现。本来有很多独立性很好的模块,变成了错综复杂的网状结构。
前面是没有引入新组件的情况,如果引入了新组件,必然会引入新的依赖,那么就要好好地去界定,引入的新组件是属于哪个层面的。像下面第一个图,新引入的组件依赖于原来两个组件是在最上层;第二个图新引入的组件是在中间层;第三个图新引入的组件被另外两个组件依赖在最底层。

引入新组件,其实应该做好充分的考虑,而不是让开发人员随意地引入。需要充分思考引入的新组件应该放在哪一层面才是最合理的,才有利于以后的扩展和移植。
读者可能会遇到这种情况:一个功能编译没有问题,测试也没有问题,发布后一两年也没有问题。但当我们要把这个功能移植出来的时候,才发现问题大了。你想移植一个组件到另一个软件时,必须连带也移植十几个组件。
5. 如何解决依赖
组件网图
要解决依赖,首先要发现哪些是不正确的依赖。下图就是一个具有良好层次的依赖关系图,我们称之为“组件网图”。对于我们现实的软件中,非常需要这样一张图将整个软件所有组件的依赖关系绘制出来,以便于我们发现其中的错误依赖进行解决。

如果组件网图中存在错误的依赖关系,或者如果有需求要求图中的组件 H 依赖于 G,应该怎么办?可以通过下面的“分解适配”和“升级降级”的方法进行解决。
分解适配(单一职责)
分解适配是指将一个功能复杂的模块分解为多个具有单一职责的模块,那么模块间的依赖关系也会变得单纯。读者可以结合下面的案例理解这个方法。
升级降级
我们经常会做重构,对于上面那张组件网图来说,重构就是将不合理的依赖断开,把更通用的逻辑抽出来放在底层,将不能用的逻辑放在上层。重构其实就是不断升级和降级的过程。
比如说我们前面的图,如果 H 依赖于 G 了,那么可能考虑将 G 进行分解适配,将 G 分为 G1 和 G2,将 G2 和 H 合并为一个新组件。这样就完成了一个分解适配和升级降级的过程。

6. 处理依赖的方法论
通用的模块不要依赖于不通用的模块
我们进行层次划分,通常是通用的模块放到底层,不通用的模块放在上层,不通用的模块依赖通用的模块是合情合理的。反过来,如果通用的模块依赖于不通用的模块,那么这个通用的模块也会变得不通用。
之前的创建模块尽量不要依赖于后创建的模块
根据时间轴以及产品的发展,较早开发的需求一般都是通用的或者是基础性质的需求,而后开发的需求是业务型的需求为主。根据这个性质,后开发的需求应该大部分依赖于之前的特性,比较少的情况是让之前的需求依赖于一个后来的需求(当然一些需求变更可能会引发这个现象)。
后创建的模块虽然可以依赖之前创建的模块,但是尽量不要去修改原来创建的模块。如果出现这种情况,也要考虑一下这个修改是不是合理的。
需要进行微观分层(组件网图)
日常开发中,需要有一张组件网图展现在开发人员的面前,使得开发人员能意识到哪些依赖是不应该出现的。当然,在开发一个功能之前,也应该进行微观层次的设计,之后再进行代码的编写。
7. 增加功能三步法
我们拿到一份需求,需要增加一个功能,应该怎么做?如果新功能与原先的模块有依赖的时候,经验欠缺的同事通常只集中于能不能把需求实现,而不是考虑架构上合不合理。团队就应该有规范去约束经验欠缺的同事不去犯错误。
这里有一个增加功能的三步法供读者参考。这些方法可能不完善,读者可能有更好的方法,应该寻找适合自己团队的解决办法。
不修改依赖,不修改或增加接口
假设原来就有两个模块,一个在上层一个在底层。如果需要新写一个功能,第一步需要先考虑的是:我能不能在上层写代码,不修改两个模块的依赖,不修改也不增加接口,我的需求能不能满足?
假如说已经有现成的接口和现成的依赖,首先就要考虑能不能利用现成的接口来完成需求。在没有规范约束的情况下,可能很多时候这个模块改一下,那个模块也改一下,就把需求做完了。
不修改依赖,但增加接口
如果第一步不满足需求的情况下,我们才考虑第二步:不要修改依赖,但是修改接口。这个接口可能就是一个比较通用的,而不是针对特定需求的。新增接口需要考虑扩展性和通用性。很多场景其实到这一步都可能满足的。
修改内部依赖
如果第二步还不能满足需求,必然会导致模块的耦合。底层如果依赖于上层,就要重新考虑将组件依赖图进行一些调整,就必须做一些重构,进行升级降级,完全耦合的两个模块甚至可以合二为一。
8. 组件网图的自动化监控
随着时间的推移,代码中的依赖越来越多,如何将代码依赖的变化有效地监控起来?建议团队开发一个监控组件网图变化的工具,一旦有开发人员把依赖搞乱,工具就会发出邮件进行报警。
一个依赖层次正常的组件网图,是不会出现环状依赖的。我们可以将环状依赖作为代码变乱的一个客观依据。所以组件网图工具可以做成只要发现环状依赖,就发出邮件报告给开发人员进行重构。组件网图工具应该每天夜里定期运行,找到当天新修改的代码中是否引出新的依赖和环状依赖,及时修改。
9. 让架构去保证开发人员不犯错
防止代码变乱,我们可以进行各种培训提高开发人员的素质,开发前的设计评审,开发后的代码检视,或者是监控工具每天的检查。更重要的应该是从架构上去保证开发人员不会犯错误。就像前面提到的积木模型,先将四面墙围起来再进行积木的搭建。
我们怎么在架构上让开发人员方便地进行解耦?比如我们有一个通用的界面,界面上会插入各种业务图标,我们不能让一个通用的界面去依赖于各个具体的业务,所以应该设计一套插入体系:在界面上留了一些位置,让业务插进来。这就从架构上防止这种耦合,后续开发人员需要继续加图标,他不会在通用界面上去调用业务的接口获取图标,因为现有机制很难这样做。所以只要架构上设计考虑充分,是可以让后来的开发人员不要犯错误的。
说明:本文配图链接显示其原始发布时间约为 2012 年,文中提到的部分技术场景(如 DLL 移植)可能更偏向于传统客户端架构。核心架构思想依然适用,但具体工具链与监控手段可结合现代 DevOps 体系进行升级。
版权声明:本文为原创文章,版权归 戴老师的博客 所有,转载请联系博主获得授权。
本文地址:https://1diff.fun/archives/fang-zhi-dai-ma-bian-zhi-di-si-kao-yu-fang-fa.html
如果对本文有什么问题或疑问都可以在评论区留言,我看到后会尽量解答。