架构的重要性在于日常的积累与细致打磨。无论是不断完善我们的架构范式,还是处理架构老化所需的经验积累,都离不开日常工作的不懈努力。我们不能奢望架构水平会在某一天突然有所飞跃。
架构能力的提升完全依赖于日常持续的反思和精益求精的打磨。今天,我们要探讨的主题是架构老化和重构。
架构老化是如何产生的呢?在我们不断向系统添加新功能的过程中,常常会遇到实现功能需求的方法已超出了最初设定的框架范围,导致许多功能代码脱离了框架的约束。这些散落在各处的代码使系统变得支离破碎。久而久之,代码就会老化并散发出异味。
代码老化的迹象包括添加新功能变得越来越困难,迭代效率降低,问题不断涌现,解决一个问题却会引发多个新问题。在理想情况下,如果我们坚持以“最小化的核心系统 + 多个相互正交的周边系统”这一指导思想构建应用,那么代码老化的情况就会大大减少。然而,这只是理想情况。
在现实情况下,有许多原因会导致架构老化难以避免,例如:软件工程师的技术能力不足,只注重完成功能,而不考虑项目的长期维护成本;公司缺乏架构评审环节,系统的代码质量得不到持续有效的关注;需求理解不够深入,最初的架构设计无法满足迭代发展的需要;架构迭代不及时,导致大量赶时间产生的补丁式代码等。
那么,我们该如何应对架构老化呢?这个问题可以从两个视角来思考:一是如何重构系统,使软件重新焕发活力?二是在重构系统之前,我们应该如何进行局部改进?在增加新功能时又该如何考虑?
首先,让我们先探讨后者,因为重构系统听起来更像是一项系统性工程,而添加新功能和进行局部调整则是日常工作中经常发生的事情。
老系统怎么添加新功能
当我们要添加新功能时,特别是当我们加入项目组较晚且已有大量历史代码时,通常会将要添加的功能定位为周边功能。在考虑周边功能时,我们通常会着重考虑如何最小化对核心系统的影响,尽量减少对核心系统的修改。然而,这种考虑还不够。
实际上,当我们将视角放在周边系统时,它本身应被视为独立的业务系统。这样一来,我们自然会提出新的要求:如何让新功能的代码与既有系统解耦,尽量减少依赖关系。这里的“不依赖”有其含义。在这里,不依赖核心系统的意思是业务逻辑不应该依赖于核心系统。新功能的大部分代码应该与既有业务系统相互独立,只有少量桥接代码与核心系统耦合。
对于任何被正交分解的周边系统 B 与核心系统 A,理想情况下我们最终应该得到三个模块:A、B(与 A 无关的部分)、A 与 B 桥接代码(与 A 相关的部分)。尽管 A 与 B 桥接代码通常也会被放到 B 模块中,但它应尽可能小且尽可能独立于与核心系统无关的代码。理解这一点至关重要。只有这样,我们才能保护我们的投资,最大限度地保留今天开发新功能的投入产出,以及在未来进行重构时将重构成本最小化。
另一个重要的“不依赖”话题是是否依赖公司内部的基础库。对于这一点,需要进行辩证看待,不能简单地回答是或否。完全不依赖意味着放弃生产力。在这里,基本的判断标准是,成熟度越高的基础库越值得依赖。成熟度的评估依赖于个人经验。首先应该评估的是模块规格的成熟度,因为实现上的问题可以通过时间来解决。模块规格是否符合预期,以及经过了多少用户使用的打磨,这些是评估成熟度的依据。
架构的局部优化
有两种主要的方法来应对架构老化:重写(局部重构)和依赖优化。
- 重写(局部重构):这种方法相当于彻底移除与要添加功能相关的代码,然后重新编写一份新的代码。这与开发新功能并没有太大区别,只是在移除的代码中可能会有一些函数设计是合理的,可以直接引用或稍作修改以使其更符合规范。然而,我们不应该过于热衷于进行局部重构。局部重构应该发生在你对这部分代码的业务比较了解的情况下,比如你已经维护了一段时间。此外,局部重构必须确保清理干净旧代码,不要留下不必要的代码。剩下的工作可以按照我之前提到的实现新功能的方法来执行。
- 依赖优化:这种方法关注的重点不是单个功能本身的实现,而是该功能与系统之间的关系。依赖优化实际上是对代码进行整体搬迁。搬迁代码的方法类似于删除代码,我们需要找到与该功能相关的所有代码。但是,我们不是删除这些代码,而是将它们集中到一起。我们将对系统的每处修改都转换为一个函数,例如命名为 doXXX_yyyy,其中XXX是功能代号,yyyy是对这段代码语义的描述。
核心系统的重构
对于一个长期存在积弊的系统,要成功地完成整体重构是非常艰难的。一开始就直接重构核心系统存在着极高的风险。首先,改动核心系统可能会引发全局性的变化,我们无法保证项目的交付周期。其次,由于没有人能够充分了解系统的全貌,重构可能会过于盲目,导致项目执行风险无法控制。
确定要对核心系统进行重构时,最高优先级是确定其边界,也就是使用界面(接口)。最好的情况是能够在不修改实现的情况下调整核心系统的使用界面以满足我们的需求。周边系统对核心系统的依赖主要分为两类:一是核心系统的功能,表现为它提供的 DOM 接口;二是核心系统提供的事件,让周边系统能够介入其业务流程。
为了确定核心系统需要暴露的事件集合,首先需要对所有周边模块进行依赖优化的整理,并进行细致的分析。然后,需要将核心系统的 DOM 接口抽象出来。这一步比较复杂,包括将周边系统对核心系统的依赖转变为接口依赖,以及审视核心系统功能的 DOM 接口的合理性,明确出期望的接口设计。
接下来,是最关键的一步。我们需要对核心系统的接口进行重新设计。这一步的难点在于,一方面我们对业务的理解已经有了长足的进步,我们抽象的业务接口应该更加精炼、符合业务本质的表达方式;另一方面,需要充分预计周边系统切换到新接口的成本。对于周边系统来说,这是从老接口过渡到新接口的过程。
完成接口改造后,剩下的工作就相对简单了。核心系统和每个周边系统都彼此独立,可以单独进行调整和优化。如果觉得当前的核心系统太糟糕,那就开始改进。这样轻松决策的原因在于,即使我们要重新编写核心系统,要做的事情也很清晰,不会影响到整体。这与那些边界不清晰的业务系统不同。要改动核心系统的代码?不要命了吗?