文章目录
- [第23章 管理架构债务](#第23章 管理架构债务)
第23章 管理架构债务
与 Yuanfang Cai 合作
有些债务在你欠下的时候是有趣的,但当你着手偿还它们的时候,就没有一个是有趣的了。
---奥格登·纳什(Ogden Nash)
如果不加以仔细关注并投入的努力,设计会随着时间的推移变得越来越难以维护和演进。我们将这种形式的熵称为 "架构债务",它是一种重要且代价高昂的技术债务形式。十多年来,技术债务这一广泛领域得到了深入研究 ------ 主要聚焦于代码债务。架构债务通常比代码债务更难检测且更难消除,因为它涉及非局部性的问题。那些对发现代码债务很有效的工具和方法(代码审查、代码质量检查器等等)通常在检测架构债务方面效果不佳。
当然,并非所有债务都是负担,也并非所有债务都是不良债务。有时在进行有价值的权衡时也会违反原则 ------ 例如,为了提高运行时性能或缩短上市时间而牺牲低耦合或高内聚。
本章介绍一种分析现有系统中架构债务的流程。这个流程为架构师提供了识别和管理这种债务的知识和工具。它通过识别具有问题设计关系的架构上相互关联的元素,并分析它们的维护成本模型来起作用。如果该模型表明存在问题,通常由异常高的变更量和错误数量所指示,这就意味着存在架构债务的区域。
一旦确定了架构债务,如果情况足够糟糕,就应该通过重构来消除它。如果没有定量的回报证据,通常很难让项目利益相关者同意这一步骤。没有架构债务分析的业务案例是这样的:"我将用三个月的时间重构这个系统,并且不给你任何新功能。" 哪个经理会同意呢?然而,有了我们在这里介绍的这些分析方法,你可以向你的经理提出一个完全不同的方案,一个用投资回报率和提高生产力来表述的方案,能够在短时间内回报重构的努力,甚至更多。
我们所倡导的流程需要三种类型的信息:
- 源代码。这用于确定结构依赖关系。
- 从项目的版本控制系统中提取的修订历史。这用于确定代码单元的共同演进。
- 从问题控制系统中提取的问题信息。这用于确定变更的原因。
用于分析债务的模型识别架构中错误率和变动率(提交的代码行数)异常高的区域,并试图将这些症状与设计缺陷联系起来。
23.1 确定你是否存在架构债务问题
在我们管理架构债务的流程中,我们将关注架构元素的物理表现形式,即存储其源代码的文件。我们如何确定一组文件在架构上是否相互关联呢?一种方法是确定项目中文件之间的静态依赖关系 ------ 例如,这个方法调用那个方法。你可以使用静态代码分析工具找到这些依赖关系。第二种方法是捕获项目中文件之间的演化依赖关系。当两个文件一起发生变化时,就会出现一个 "演化依赖关系",你可以从你的版本控制系统中提取此信息。
我们可以使用一种特殊的邻接矩阵,称为设计结构矩阵(DSM)来表示文件依赖关系。虽然其他表示方式当然也是可能的,但 DSM 在工程设计中已经使用了几十年,并且目前有许多工业工具支持它。在 DSM 中,感兴趣的实体(在我们的例子中是文件)既放置在矩阵的行上,又以相同的顺序放置在列上。矩阵的单元格被标注以指示依赖关系的类型。
我们可以用信息标注 DSM 单元格,以表明行上的文件继承自列上的文件,或者它调用列上的文件,或者它与列上的文件共同变化。前两种标注是结构性的,而第三种是演化(或历史)依赖关系。
重复一下:DSM 中的每一行代表一个文件。一行上的条目显示了这个文件对系统中其他文件的依赖关系。如果系统具有低耦合性,你会期望 DSM 是稀疏的;也就是说,任何给定的文件将只依赖于少量的其他文件。此外,你会希望 DSM 是下三角矩阵;也就是说,所有的条目都出现在对角线以下。这意味着一个文件只依赖于较低层次的文件,而不依赖于较高层次的文件,并且在你的系统中没有循环依赖关系。
图 23.1 展示了来自 Apache Camel 项目(一个开源集成框架)的 11 个文件及其结构依赖关系(分别用 "dp"、"im" 和 "ex" 表示依赖、实现和扩展)。例如,图 23.1 中第 9 行的文件 "MethodCallExpression.java" 依赖并扩展了第 1 列的文件 "ExpressionDefinition.java",第 11 行的文件 "AssertionClause.java" 依赖于第 10 列的文件 "MockEndpoint.java"。这些静态依赖关系是通过对源代码进行逆向工程提取出来的。
图 23.1 Apache Camel 的设计结构矩阵(DSM)展示结构依赖关系
图 23.1 中所示的矩阵非常稀疏。这意味着这些文件在结构上彼此没有高度耦合,因此,你可能会期望相对容易独立地更改这些文件。换句话说,这个系统似乎架构债务相对较少。
现在考虑 图 23.2,它在图 23.1 的基础上覆盖了历史共同变化信息。历史共同变化信息是从版本控制系统中提取的。这表明两个文件在提交中一起变化的频率。
图 23.2 带有演化依赖关系覆盖的 Apache Camel 的设计结构矩阵
图 23.2 展示了关于 Camel 项目的一幅非常不同的画面。例如,第 8 行第 3 列的单元格标有 "4":这意味着 "BeanExpression.java" 和 "MethodNotFoundException.java" 之间没有结构关系,但在修订历史中发现它们一起变化了四次。一个既有数字又有文本的单元格表示这一对文件既有结构耦合关系又有演化耦合关系。例如,第 22 行第 1 列的单元格标有 "dp, 3":这意味着 "XMLTokenizerExpression.java" 依赖于 "ExpressionDefinition.java",并且它们一起变化了三次。
图 23.2 中的矩阵相当密集。虽然这些文件通常在结构上彼此没有耦合,但它们在演化上有很强的耦合。此外,我们在矩阵对角线上方的单元格中看到很多标注。因此,耦合不仅是从高层次文件到低层次文件,而是在各个方向上都存在。
实际上,这个项目存在高架构债务。架构师们证实了这一点。他们报告说,项目中的几乎每一次变更都既昂贵又复杂,并且预测新功能何时准备好或错误何时被修复是具有挑战性的。
虽然这种定性分析本身对架构师或分析师可能有价值,但我们可以做得更好:我们实际上可以量化我们的代码库已经承载的债务的成本和影响,并且我们可以完全自动地做到这一点。为此,我们使用 "热点" 的概念 ------ 具有设计缺陷的架构区域,有时也被称为架构反模式或架构缺陷。
23.2 发现热点
如果你怀疑你的代码库存在架构债务 ------ 也许错误率在上升,功能开发速度在下降 ------ 你需要确定造成这种债务的具体文件及其有缺陷的关系。
与基于代码的技术债务相比,架构债务通常更难识别,因为其根本原因分布在几个文件及其相互关系中。如果你的系统中存在循环依赖,并且依赖循环涉及六个文件,那么你的组织中不太可能有人完全理解这个循环,而且它也不容易被观察到。对于这些复杂的情况,我们需要自动化形式的帮助来识别架构债务。
我们将对系统维护成本做出巨大贡献的元素集合称为 "热点"。架构债务由于高耦合和低内聚而导致高维护成本。因此,为了识别热点,我们寻找导致高耦合和低内聚的反模式。这里强调了六种常见的反模式 ------ 几乎在每个系统中都会出现:
- 不稳定接口。一个有影响力的文件 ------ 代表系统中的一个重要服务、资源或抽象 ------ 与其依赖文件频繁一起变化,这在修订历史中有所记录。"接口" 文件是其他系统元素使用该服务或资源的入口点。由于内部原因、其 API 的变化或两者兼而有之,它经常被修改。要识别这种反模式,寻找一个有大量依赖文件且经常与其他文件一起被修改的文件。
- 违反模块化。结构上解耦的模块经常一起变化。要识别这种反模式,寻找两个或更多结构上独立的文件 ------ 即彼此之间没有结构依赖关系的文件 ------ 但它们经常一起变化。
- 不健康的继承 。一个基类依赖于它的子类,或者一个客户端类同时依赖于基类和它的一个或多个子类。要确定不健康的继承实例,在设计结构矩阵(DSM)中寻找以下两组关系之一:
- 在继承层次结构中,父类依赖于它的子类。
- 在继承层次结构中,类层次结构的一个客户端同时依赖于父类和它的一个或多个子类。
- 循环依赖或聚集。一组文件紧密连接。要识别这种反模式,寻找形成强连通图的文件集合,其中图的任何两个元素之间都存在结构依赖路径。
- 包循环依赖。两个或更多包相互依赖,而不是像它们应该的那样形成层次结构。检测这种反模式与检测聚集类似:通过发现形成强连通图的包来确定包循环。
- 交叉依赖。一个文件既具有大量的依赖文件,又有大量它所依赖的文件,并且它与其依赖文件和它所依赖的文件频繁一起变化。要确定交叉依赖中心的文件,寻找一个与其他文件既有高入度又有高出度并且与这些其他文件有大量共同变化关系的文件。
并非热点中的每个文件都会与其他每个文件紧密耦合。相反,一组文件可能彼此紧密耦合,而与其他文件解耦。每个这样的集合都是一个潜在的热点,并且是通过重构去除债务的潜在候选。
图 23.3 是基于 Apache Cassandra(一个广泛使用的 NoSQL 数据库)中的文件的设计结构矩阵。它展示了一个聚集(依赖循环)的例子。在这个 DSM 中,你可以看到第 8 行的文件("locator.AbstractReplicationStrategy")依赖于文件 4("service.WriteResponseHandler")并聚合文件 5("locator.TokenMetadata")。文件 4 和 5 反过来又依赖于文件 8,从而形成一个聚集。
图23.3 聚集的例子
Cassandra 的第二个示例展示了不健康的继承反模式。图 23.4 中的依赖结构矩阵(DSM)显示了io.sstable.SSTableReader
类(第 14 行)继承自io.sstable.SSTable
(第 12 行)。在 DSM 中,继承关系通过 "ih" 标记表示。然而,请注意,io.sstable.SSTable
依赖于io.sstable.SSTableReader
,如单元格(12,14)中的 "dp" 注释所示。这种依赖关系是一种调用关系,这意味着父类调用子类。请注意,单元格(12,14)和(14,12)都标有数字 68。根据项目的修订历史,这表示io.sstable.SSTable
和io.sstable.SSTableReader
共同提交变更的次数。这种过高的共同变更次数是一种债务形式。可以通过重构来消除这种债务,即将一些功能从子类移到父类中。
图 23.4 Apache Cassandra 中的架构反模式
问题跟踪系统中的大多数问题可以分为两大类:错误修复和功能增强。错误修复以及与错误相关和与变更相关的代码变动都与反模式和热点高度相关。换句话说,那些参与反模式并需要频繁进行错误修复或频繁变更的文件很可能是热点。
对于每个文件,我们确定错误修复和变更的总数,以及该文件经历的代码变动总量。接下来,我们将每个反模式中文件经历的错误修复、变更和代码变动相加。这就根据每个反模式对架构债务的贡献为其赋予了一个权重。通过这种方式,可以识别所有背负债务的文件以及它们的所有关系,并对其债务进行量化。
基于这个过程,一个减少债务的策略(通常通过重构实现)很简单。了解与债务有关的文件以及它们有缺陷的关系(由已确定的反模式决定),使架构师能够制定并证明重构计划的合理性。例如,如果存在一个团,就需要移除或反转一个依赖关系,以打破依赖循环。如果存在不健康的继承,就需要移动一些功能,通常是从子类移动到父类。如果发现违反了模块性,文件之间共享的未封装的 "秘密" 就需要封装为它自己的抽象。等等。
23.3 示例
我们用一个案例研究来说明这个过程,我们将其称为 SS1,是与跨国软件外包公司 SoftServe 一起进行的。在进行分析时,SS1 系统包含 797 个源文件,我们记录了它在两年期间的修订历史和问题。SS1 由六名全职开发人员和更多的临时贡献者维护。
识别热点
在我们研究 SS1 的期间,在其 Jira 问题跟踪器中记录了 2756 个问题(其中 1079 个是bug),在 Git 版本控制存储库中记录了 3262 次提交。
我们使用刚才描述的过程来识别热点。最后,确定了三个在架构上相关的文件集群,它们包含了项目中最有害的反模式,因此也是项目中债务最多的。这三个集群的债务总共代表了 291 个文件,在整个项目的 797 个文件中,略多于项目文件的三分之一。与这三个集群相关的缺陷数量占项目总缺陷的 89%(265 个)。
项目的首席架构师同意这些集群存在问题,但难以解释原因。当看到这个分析时,他承认这些是真正的设计问题,违反了多个设计规则。然后,架构师制定了一些重构方案,重点是修复热点中确定的文件之间有缺陷的关系。这些重构是基于消除热点中的反模式,因此架构师在如何进行重构方面有很多指导。
但是进行这种重构是否 "值得" 呢?毕竟,并非所有的债务都值得偿还。这是下一节的主题。
量化架构债务
由于分析建议的修复措施非常具体,架构师可以很容易地根据热点中的反模式确定的每个重构所需的人月数进行估计。成本效益方程的另一面是重构带来的好处。为了估计节省的成本,我们做一个假设:重构后的文件在未来的错误修复数量将与过去平均文件的错误修复数量大致相同。这实际上是一个非常保守的假设,因为过去的平均错误修复数量被已确定热点中的那些文件夸大了。此外,这个计算没有考虑错误的其他重大成本,如声誉损失、销售损失以及额外的质量保证和调试工作。
我们根据为错误修复提交的代码行数来计算这些债务的成本。这个信息可以从项目的修订控制和问题跟踪系统中检索到。
对于 SS1,我们进行的债务计算如下:
- 架构师估计重构三个热点所需的工作量为 14 个人月。
- 我们计算了整个项目每年每个文件的平均错误修复次数为 0.33。
- 我们计算了热点文件每年的平均错误修复次数为 237.8。
- 根据这些结果,我们估计重构后热点文件的每年错误修复次数将为 96。
- 热点文件实际的代码变动量与重构后预期的代码变动量之间的差异就是预期的节省。
重构文件的估计年度节省(使用公司平均生产率数字)为 41.35 个人月。考虑步骤 1 至 5 的计算,我们可以看到,花费 14 个人月的成本,项目每年可以预期节省超过 41 个人月。
在一个又一个案例中,我们看到了这样的投资回报。一旦确定了架构债务,就可以偿还它们,并且项目在功能速度和错误修复时间方面会明显变得更好,其方式足以弥补所涉及的努力。
23.4 自动化
这种形式的架构分析可以完全自动化。在 第 23.2 节 中介绍的每一种反模式都可以以自动化的方式识别出来,并且可以将工具集成到持续集成工具套件中,以便持续监测架构债务。这个分析过程需要以下工具:
- 一个从问题跟踪器中提取一组问题的工具。
- 一个从修订控制系统中提取日志的工具。
- 一个对代码库进行逆向工程的工具,以确定文件之间的语法依赖关系。
- 一个从提取的信息中构建依赖结构矩阵(DSM)并遍历 DSM 以寻找反模式的工具。
- 一个计算每个热点相关债务的工具。
这个过程所需的唯一专门工具是构建 DSM 和分析 DSM 的工具。项目可能已经有了问题跟踪系统和修订历史,并且有很多逆向工程工具可用,包括开源选项。
23.5 小结
本章介绍了一种在项目中识别和量化架构债务的流程。架构债务是一种重要且代价高昂的技术债务形式。与基于代码的技术债务相比,架构债务通常更难识别,因为其根本原因分布在多个文件及其相互关系中。
本章概述的流程包括从项目的问题跟踪器、版本控制系统和源代码本身收集信息。利用这些信息,可以识别架构反模式并将其分组为热点,并且可以量化这些热点的影响。
这种架构债务监测流程可以自动化并集成到系统的持续集成工具套件中。一旦识别出架构债务,如果情况足够糟糕,就应该通过重构来消除它。这个流程的输出提供了向项目管理提出重构业务案例所需的定量数据。
23.6 扩展阅读
目前,技术债务领域有丰富的研究文献。"技术债务" 一词由沃德・坎宁安(Ward Cunningham)在 1992 年创造(尽管当时他只是简单地称之为 "债务"[Cunningham 92])。这个想法被许多其他人完善和阐述,其中最著名的是马丁・福勒(Martin Fowler) [Fowler 09] 和史蒂夫・麦康奈尔(Steve McConnell) [McConnell 07]。乔治・费尔班克斯(George Fairbanks)在他的《IEEE Software》文章 "Ur-Technical Debt"[Fairbanks 20] 中描述了债务的迭代性质。在 [Kruchten 19] 中可以找到对管理技术债务问题的全面研究。
本章中使用的架构债务定义借鉴自 [Xiao 16]。SoftServe 案例研究发表在 [Kazman 15] 中。
一些用于创建和分析依赖结构矩阵(DSMs)的工具在 [Xiao 14] 中进行了描述。用于检测架构缺陷的工具在 [Mo 15] 中进行了介绍。
架构缺陷的影响在包括 [Feng 16] 和 [Mo 18] 在内的几篇论文中进行了讨论和实证研究。
23.7 问题讨论
1. 你如何区分有架构债务的项目和一个正在实施大量功能的 "忙碌" 项目?
2. 找到经历过重大重构的项目的例子。用于激励或证明这些重构的证据是什么?
3. 在什么情况下积累债务是一种合理的策略?你如何知道你已经达到了债务过多的地步?
4. 架构债务与其他类型的债务(如代码债务、文档债务或测试债务)相比,是更有害还是危害较小?
5. 与第 21 章中讨论的方法相比,讨论这种架构分析的优缺点。