如何避免软件腐朽
回首一看,毕业已近八年,前前后后也经历了几家公司,有BAT之流的大厂,也有初上市的创业公司,其间也接触了不少项目,但总结来看,债务缠身的屎山居多,结构良好的项目凤毛麟角。所以很早就开始反思为什么代码会不可避免的滑向腐朽的深渊。我相信每个项目在设立之初,都凝聚了初代目们不少心血,但世事变迁,大多项目都走向了破败不堪的结局。
这其中的固有排期紧张,团队合作等原因,我们不做分析,这里只从工程技术的角度上去讨论一下,是否有办法来减缓代码腐朽的速度?
为什么代码不可避免的会腐朽?
一个项目如果业务功能开发完成之后,不再做任何修改,显然是不会腐朽的。即使初期的设计不精良,其固有的技术债务也不会膨胀。但做过软件开发的朋友应该都清楚,这几乎是不可能的。软件之所以称之为软件,就是因为其相比于硬件的易变性,产品要创新,需求要迭代,技术要发展,所有的软件都无可避免的会进行更新迭代,不论是主动还是被动。所谓迭代,就是在已经发布的软件结构基础之上,添加新的逻辑分支。
需求的变化无法有效的预测,所以可能在深度仅仅几米的地基上盖起了数十米的高楼,这是软件腐朽的本质原因。我们初期设计出来的软件结构抑或是业务的模型抽象,无法满足当下的需求迭代,工期要求紧张的情况下自难以重构原有的结构,于是只能折中的满足当期需求,留待的技术债务只能相信后人的智慧。而随着需求迭代,产生的技术债务日积月累,乃至可能的团队成员离职更新,项目几经易主,其庞杂的历史背景再难以有人能说清道明,最终要么只能在臃肿的代码上做些修修补补的雕花补救,要么就得刮骨疗毒,洗骨伐髓的大重构,不论如何抉择,都充满荆棘。
我们能够避免软件的这种腐朽过程吗?我的答案是不能,根源还是在于软件的变化性上。你无法在项目设计的初期预测到所有的迭代可能,就无法避免后期的需求与初期设计产生的阻抗。但好消息是,我们有办法来减缓软件腐化的过程。这就引出下一个问题。
如何在设计上兼容变化?
Uncle Bob在《Clean Architecture》中提到一个观点:架构设计的本质是延迟所有的决策。使用什么样的数据库,选用什么样的框架,用什么样的协议和其他组件交互等等,这些在常规架构设计视为关键决策的东西,其实都是技术细节,真正的业务逻辑,即所谓的core domain,应该是和技术细节无关的。这其实和依赖反转是相同的思路。依赖反转原则告诉我们:上层的逻辑依赖于抽象而不应该依赖实现。这里所谓的抽象,简单来讲就是OOP编程范式下的一组接口。
在OOP的编程范式下,我们通过接口描述一个显示的对象,而对象具体的实现,调用接口方是不应该知道的。耗子叔有个很形象的比喻:你去超市买了一瓶三块钱的可乐,是把钱包交给收营员让她自己从你的钱包里找三块钱呢?还是自己从钱包里拿三块钱交给收营员?答案是显而易见的,但在软件世界里,选择第一种方式的也不在少数(对类的成员变量不做访问性限制或者直接通过接口返回内部变量的引用)。接口的意义在于明确双方的职责边界,只通过接口交互保证最小知道,接口下具体的实现方式就不会对接口的调用者产生影响,减轻调用方的认知负担,这是其带来的隔离性好处。
接口抽象的另外一个好处是方便测试,上层的调用只看到接口而看不到实现,那么实现就可以被替换。很多人开发的过程中无法进行单测,一定要等底层的数据库,或者消息队列都准备好了,或者上层的接口协议都明确了,所有周边的依赖都准备妥当了,才能启动测试,从最外层的接口触发,一层一层的测试调试。如果通过接口将各层的依赖解耦,那么就可以轻易的实现接口的伪实现,即所谓的mock,通过mock依赖的接口,所有的组件都可以单独地进行单测。《Microservices Patterns》里提到将测试分为四层:
- 单元测试: 函数,类,接口级别的测试
- 集成测试: 和依赖的基础设施的连接测试,包括数据库,缓存,配置中心等等外部依赖组件
- 组件测试: 服务和服务之间的接口调用测试
- 端到端测试: 完整的从前端接口发起的业务调用测试
从上到下,测试的粒度越来越粗,测试的成本也越来越高。我遇到过很多项目都没有单元测试和集成测试,低头猛写到差不多的状态,再从组件测试开始,从外部的接口构造请求测试,光主链路走通就要调个几天,如果不幸的发现结构性问题,又是低头一整猛改,如此反复,工期如何能不紧张?测试的地方距离代码越远,修改调试的成本就越高,这里的距离从两个层面理解,一是测试距离写代码的时间,二是调用到目标代码的函数调用栈。这有些类似传统的浴室里冷热水调节的水阀距离出水口的越远,调节水阀的反馈延迟就越大,延迟越大,就容易矫枉过正,要么太烫,要么太冷。
如此种种,归结起来一句话:用接口搭建框架,用实现拓展细节。这里的接口可以分为两种:一种是对外提供能力的接口,比如网络发送的接口,数据库查询的接口,表示我有这种能力,具体谁来调用,我不关心,可以称为输入接口。另外一种是回调通知接口,比如服务端收到请求要通知后端,观察者模式里通知观察者,表示我要通知的接口,具体通知到谁,我不关心,可以称为输出接口。通过这两种接口的模型来组织代码,即可解耦上下游的依赖,极大的增强代码的拓展性。
如何保证工程质量?
这里再提到SOLID里的另外一个原则,开闭原则:软件结构应该对修改关闭,对拓展开放。通俗而言,就是需求的迭代应该通过添加新的类,新的文件来实现,而不是通过修改旧的类和文件。这样做的好处是显而易见的,其一新增加的逻辑不修改旧的逻辑,那么修改范围就是可控的,不会因为新的功能导致旧功能的失效,其二新增的逻辑不会和旧的逻辑耦合在一起,测试就会很容易,不用过多的考虑和原有逻辑的兼容问题。
我常常见到一些巨大而臃肿的实现,一个包罗万象的类,一些几百行甚至上千行的函数。代码逻辑平铺直叙,没有任何封装和修饰,几乎不可能有效的测试,其内部复杂的分支判断如果需要覆盖测试的话,构造的入参恐怕比函数本身的代码还复杂臃肿。这种代码怎么可能避免腐朽?任何修改都必须像内科手术一样小心翼翼的安插到巨大的函数体中,避免影响到其他组织,自然也无法对自己所做的修改进行有效测试。
而测试是保证代码质量的重要手段。《A Philosophy Of Software Design》这本书中提到一个观点:软件开发过程其实就是和复杂性做斗争的过程,而复杂性表现为:
- 修改膨胀,一个简单的需求涉及到多处修改。
- 认知负担,软件设计不够职责内聚和解耦,修改一处代码需要知道的信息太多,少知道一点就没办法正确修改。
- 不知道不知道,软件表达的知识不够显式,无法准确的识别软件意图,导致不知道自己不知道的什么。
我开始以为作者会介绍什么高级的设计技巧和架构原则,但通读全书,却并没有什么高深理论,反倒是像老母亲一样絮絮叨叨了一些大家早就听说过的内容:如何添加注释,怎么变量命名,接口语义要明确,封装的逻辑要深(内聚而自治,调用接口的人不需要知道内部的实现)... ... 要保证软件质量,不在于多么高级的技术,多么细致的流程,要保证代码能被别人看懂是前提条件。有人说我自己的代码自己看懂不就得了,这很好反驳:组织的发展必然伴随着结构调整,每个人都会主动或被动的拥抱变化,再者人的脑容量是有限的,这个要读代码的人也可能是很久之后的自己。而想要代码被人看懂,更重要的是写代码的人要站在看代码人的角度来写代码,时刻谨记代码不仅仅是交给计算机来执行的,更重要的是要用代码和人交流,首先是观念转变,然后才可能落实。有同学可能会说防御性编程的重要性,现在的大环境如何我们不做鸡汤式的讨论,凡事都具有两面性,如何一个项目离了某个人就玩不转,那么这个项目也绑定了这个人,那些让人感到安全的措施往往也限制了人的进步。
越是了解软件架构的知识,越感觉到看山还是山,看水还是水。那些在软件行业流传甚广,甚至有些陈词滥调的内容:注释,命名,SOLID原则,设计模式... ...,能初步使用,软件质量便能显著改善,若能融会贯通,那自不会有尾大不掉的债务问题。