防止代码变质的思考与方法
1、软件长期运营存在什么问题
一个大规模的客户端软件的生命周期中,我们可以把它分为两个比较粗的时期。一个是前期的搭建软件的时期,即从无到有的时期;第二个是搭建完成之 后,进入的一个稳定的运营时期。第二个时期才是最关键的,在这个时期我们会持续的迭加需求,持续的优化功能,而且第二个时期也是代码在慢慢变质的时期。 在这个时期,你可能会发现:我们的软件慢慢出现模块耦合严重,牵一发而动全身;每个版本都会涌现出老功能的 BUG,你没动过的模块也会出 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. 让架构去保证开发人员不犯错
防止代码变乱,我们可以进行各种培训提高开发人员的素质,开发前的设计评审,开发后的代码检视,或者是监控工具每天的检查。更重要的应该是从架 构上去保证开发人员不会犯错误,就像前面提到的积木模型,先将四面墙围起来再进行积木的搭建。 我们怎么在架构上让开发人员方便的进行解耦?比如我们有 一个通用的界面,界面上会插入各种业务图标,我们不能让一个通用的界面去依赖于各个具体的业务,所以应该设计一套插入体系:在界面上留了一些位置,让业务 插进来。这就从架构上访止这种耦合,后续开发人员需要继续加图标,他不会在通用界面上去调用业务的接口获取图标,因为现有机制很难这样做。所以只要架构上 设计考虑充份,是可以让后来的开发人员不要犯错误的。