一个软件系统从交付第一天起,就开始走向老化。
起初,新功能像泉水一样涌出,迭代速度飞快。但不知从哪个节点开始,每加一个功能都像在伤口上撒盐——解决一个问题,却由此生出好几个新问题。代码散发着臭味,团队士气低沉,仿佛没有人愿意再碰这个系统。
这就是架构老化的开始。但它并非不可逆转。
所以我们最终的一个文章就是从思维上要意识到,架构也会老化。
架构老化的根源,在于功能代码逸出框架的范围之外。
当我们不断给系统添加新功能时,需求的实现方式往往不在当初框架设定的范围之内。于是,代码像藤蔓一样攀附在核心系统的各个角落,把原本清晰的结构绞得支离破碎。
这是理想与现实之间的鸿沟。如果从第一天起就能坚持"最小化的核心系统 + 多个相互正交的周边系统"这个指导思想,代码老化确实很难发生。但现实有太多干扰因素:
代码老化的标志是什么?添加功能越来越难,迭代效率越来越低。这是每个架构师都不愿面对却迟早要面对的局面。
先说添加新功能。重构毕竟是系统性工程,而日常工作中更多的还是持续迭代。
当你加入一个已有大量历史代码沉淀的项目时,通常应该把自己要添加的功能定位为周边功能。第一个想法是"少给核心系统添加麻烦,能少改就少改"——这还不够。
实际上,当你把视角放在周边系统时,它本身也应该被视为一个独立的业务系统。这样一想,要求就变了:新功能的代码要与既有系统解耦,能够不依赖就尽量不依赖。
这个"不依赖"是有讲究的。不依赖核心的含义是业务不依赖。新功能的绝大部分代码独立于既有业务系统,只有少量桥接代码是耦合的。
对于任何被正交分解的周边系统 B 与核心系统 A,理想情况最终得到的是三个模块:A、B(与 A 无关部分)、A 与 B 的桥接代码(与 A 相关的部分)。虽然归属上 A 与 B 桥接代码通常放到 B 模块,但它应该尽可能小,且尽可能独立于与核心系统无关的代码。
理解这一点至关重要。只有这样,今天开发新功能的投入产出才能最大程度保留。未来万一需要做重构,重构成本也能够最小化。
举一个做办公软件时的例子。既有代码有几百万行,我第一个做的读盘存盘之外的新功能是电子表格的智能填充。用户选择一个区域,移动鼠标到右下角,鼠标变成十字时按住左键不放并移动鼠标来自动填充单元格内容。填充方向上下左右都可以。
做法是:首先实现一个纯算法的模块,输入一个值矩阵和要预测的序列个数,输出预测的值矩阵。填充方向在这里消失了,因为按填充方向构建值矩阵,而不是用户屏幕上直观的矩阵。
然后抽象核心系统的两个接口:取一个区域的单元格数据(包括值和格式)、设置一个单元格的值和格式。基于这个抽象接口,实现完整的自动填充逻辑。最后用最少量的对接代码把它与既有业务系统串起来。
这就是做新功能的正确思路:尽可能与既有系统剥离,从独立业务视角实现,抽象对环境的依赖。
聊完添加新功能,我们谈谈局部调整。它的目标是优化某个功能与核心系统的耦合关系。
局部调整看似收效甚微,但它的好处是可以快速推动。日拱一卒,坚持下来最后的效果远比想象的好。
局部调整有两种常见做法。
第一种是重写,或者叫局部重构。 从系统中彻底移除与该功能相关的代码,重新写一份新的。这和开发一个新功能没什么两样,最多看看被移除的代码里有哪些函数设计比较合理可以直接拿过来用。
但不能太热衷于做局部重构。它一定要发生在你对这块代码的业务比较了解的情形——比如你已经维护过它一阵子了。更重要的是,一定要把老代码清理干净,不要残留不必要的代码在系统里。
第二种是依赖优化。 它关注的重心不是某项功能本身的实现,而是它与系统之间的关系。
依赖优化整体上做的是代码的搬运工。怎么搬?和删除代码类似,要找到和该功能相关的所有代码。但我们做的不是删除,而是将散落在系统中的代码集中起来。把对系统的每处修改变成一个函数,比如叫 doXXX_yyyy。这里 XXX 是功能代号,yyyy 依据这段代码的语义命名。
这个名字看起来很丑,但某种程度上是故意的。它代表团队的约定俗成:此处待重新考虑边界。
它不是说需要重新思考正在做代码优化的功能边界,而是说要重新考虑核心系统的边界。如果某个地方有好几个功能都加了 doXXX_yyyy 这样的调用,就意味着这里需要提供一个事件机制,以便这些功能能够监听。而一旦做了这件事,核心系统就变得更稳定了,不再需要因为添加功能而修改代码。这不正是开闭原则(OCP)所追求的么?
依赖优化之后,有多少个 doXXX_yyyy,就有多少对系统的伤害值。如果伤害值不大,代表耦合在合理范围,暂时不再往下走是可接受的。如果耦合过多,那就需要考虑推动局部重构了。
所以,局部重构不应该很盲目,而应依赖于基于"伤害值"的客观判断。
依赖优化的好处很明显:工作量小,做的是代码搬运不改变任何业务逻辑;可以不必深入功能细节,只需要找到该功能的所有相关代码然后集中起来。把非核心功能都基于依赖优化的方式独立出去,核心系统与周边系统的耦合就理清楚了。
完成这些局部改善,下一步就是核心系统重构。
对于一个积弊已久的系统,要成功完成整体重构是非常艰难的。一上来就重构核心系统,风险太高:牵一发而动全身,无法保证交付周期;没有谁对全局有足够了解,重构会过于盲目。
确定要对核心系统进行重构,最高优先级是确定它的边界,也就是使用界面(接口)。
周边系统对核心系统的依赖有两类:一是核心系统提供的功能,表现为系统调用接口;二是核心系统提供的事件,让周边系统能够介入它的业务流程。对所有周边模块进行依赖优化的整理,细加分析后可以初步确定核心系统需要暴露的事件集合。
进一步要做的是把核心系统的系统调用接口也抽象出来。这一步比较复杂,包含两件事:
可以分步骤做。可以先做实现依赖到接口依赖的转变——把周边模块独立出去,将它与核心系统的依赖关系全部调整为接口。这样,不管抽离出来的系统调用接口是否合理,至少它代表了当前系统的模块边界。这一步做完,理论上模拟一个核心系统出来与周边系统对接也可行。
接下来,就是最重要的时刻:对核心系统的接口进行重新设计。
这一步的难点在于两点。第一,我们对业务的理解有了长足进步,抽象的业务接口有了更精炼符合业务本质的表达方式——而不是换汤不换药,否则就要质疑这次重构的必要性。第二,对周边系统切换到新接口的成本要有充足预计。虽然理论上让核心系统维护两套系统调用接口同时存在是可行的,但过渡期不能太长,否则容易让人困惑。
完成了接口改造,剩下来就简单了。核心系统与每一个周边系统彼此完全独立,可以单独调整和优化。嫌当前的核心系统太糟糕?那就重新搞。
为什么可以这么轻松决策?因为就算要重新写核心系统,要做的事情也很收敛,不会影响大局。
这与那些边界不清的业务系统截然不同——边界不清的系统要改核心系统的代码,不要命了么?
架构老化的过程,可以用量化的方式理解。以下 Python 代码模拟一个简化系统,展示代码耦合如何逐步积累,以及依赖优化如何识别出需要重构的区域。
doXXX_yyyy 聚集之处,意味着需要事件机制,这是核心系统走向稳定的关键一步。