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