复用性风控:软件复用成本的量化管理
阿里妹导读
一、复用性的理想与现实
1.1 复用定义:从代码到系统
1.2 复用风险:复杂度和成本
诚然,通过组件的复用可以提高软件开发效率和质量,但复用不是银弹,复用也会有一些副作用:
3.增加了开发和维护成本。
首先,兼容性/安全性/性能等这几类问题,是针对可复用组件的使用方来说的,一般来说,在决策是否复用之前就可以评估,其指标和过程也比较清晰,这里就不具体展开了。
其次,复用会增加系统依赖。依赖关系是软件的基本组成部分,无法消除,但软件设计的目标之一是尽可能消除依赖关系,并使依赖关系尽可能简单和明显。当我们引入外部组件进行复用时,软件组件之间的依赖关系会导致组件变更范围的扩大以及组件认知负荷的增加,前者是针对组件维护方而言的,即看似简单的变更需要在许多不同的地方修改代码,随着消费者数量的增长,在不同需求之间进行平衡变得越来越困难;后者是对于组件使用方而言的,即开发人员需要了解大量组件领域知识才能实现有效的组件复用。比如,需要了解待使用接口中若干入参的设计意图、是否存在隐式依赖传递从而导致依赖冲突等。依赖的增加会为系统引入更多的复杂性,而我们知道,构建软件系统的核心挑战就是管理复杂性,复用组件只会在一定程度上转移复杂性,但并不能消除复杂性。因此,我们需要在「复用组件降低成本」和「复用组件引入依赖(复杂性)」之间取得平衡。
最后,复用会增加各项成本。包括开发的成本、变更的成本、集成的成本、领域知识迁移的成本。对于一个面向复用设计的组件来说,实现正确抽象和通用框架的设计和开发成本,比一次性的解决方案高得多,对于组件的后续维护者来说,这样的可复用框架和库通常也会带来陡峭的学习曲线(因为文档一般是缺失的),组件会逐渐走向腐化,最后不得不推倒重来。此外,对于可复用组件的使用方来说,其理解和集成组件的成本通常也是被忽略的,一些强推的业务层的「伪复用框架」给前台集成的同学带来了巨大的集成、学习和维护成本。
上述复用带来问题,有一些是可以规避的,如兼容性、性能、容量等的匹配度,有一些是无法避免的,如设计通用化组件的开发成本、不合理的抽象导致的代码腐化、不合理的复用导致的维护成本等。事实上,无论我们在技术上做多么精妙的设计,技术的创新永远滞后于系统的腐化速度。
为了最大程度的降低复用带来的风险,本文提出一套从类比于安全风险管理的「复用性风险」应对模型,从事前评估、事中缓释、事后迭代三个阶段出发,最大程度地降低我们在开发可复用组件、使用可复用组件中遇到的各类风险。需要说明的是,上面以及后面指的「复用性风险」,定义为「由于不合理的复用决策,导致依赖和复杂度膨胀过快,从而导致软件维护成本过高」的问题,除了成本风险外,由于复用组件的不合理使用或存在的缺陷而导致的兼容性、安全性、性能等方面的风险,其风险更为显著和易于治理,因此不是本文论述的重点。此外,复用开发过程中的开发目标偏移、迭代和发布计划的延期、人员短缺等风险,限于篇幅也不在这里展开。
第二部分首先会介绍导致「复用提升软件开发效率」这一原则失效的几类主要原因,第三部分会重点介绍用于评估复用性的若干工具,有了对复用性本质的认识后,再第四部分我们会简要介绍复用性风险管理模型。
二、复用性风险根因分析
2.1 现实挑战:正确和错误的抽象
我们复用组件的一个初衷,除了是为了提升研发效率之外,也是希望可复用组件可以将领域的复杂性隔离在一个我们永远看不到的地方,从而整体降低组件使用方的系统复杂度。因此,一个可复用的组件,无论其规模大小,其设计过程就是对某个领域高度抽象的过程。在设计组件时,向上面对当前或潜在的需求,需要我们做一定的前向通用设计,向下尽可能屏蔽掉组件的实现细节,抽象的结果直接决定了后续该组件可复用性程度的高低(可复用性的度量将在下一个章节详述)。但遗憾的是,良好的抽象能力对于大部分开发者来说是一个稀缺的产物,它需要对问题进行清晰的定义、简化和分解,同时识别和利用通用模式,将子问题的解法组合起来形成一个整体解决方案,依赖对设计模式、开源的库和框架、数据结构和算法以及大量生产项目的长期实践和思考。
在日常的代码中,我们不乏抽象,但大部分都是不合理的抽象。错误的抽象造成的危害甚于不抽象,比如常见的一个现象:对设计模式的适用范围知之甚少,仅仅为了炫技而滥用设计模式,导致代码的可读性和可维护性下降。
除了对抽象能力的要求外,很多时候需求紧迫度、开发资源、责任心以及组件所在领域职责的变更等因素,都会导致可复用组件从出生就带着「高成本」的原罪,其后续的使用成本和维护成本会急剧上升,这里就不一一展开了。
2.2 认知谬误:复用不是设计目标
一个对于复用性的认知谬误就是,把「不重复」等效为「复用」,这两个概念之间有相似之处,但还是有一些微妙的差别。「不重复」即我们所熟知的 DRY 原则(Don’t Repeat Yourself),其目标是通过减少重复建设从而避免承担副本不一致的维护成本,而 Reusability 是从所有代码中找到重复的部分,然后在复杂度可控的前提下,努力抽象出可复用的东西。一堆不重复的代码,并不代表存在可复用的组件。
复用只是实现不重复目标的一种手段,「不重复」才是我们设计软件系统时的目标,单纯追逐「复用性」很多时候会出现一些本末倒置的现象。如出现了一些接入成本非常高的自动测试框架、业务中台框架,一味追逐「(我)一次开发,(你)随处使用」,殊不知在使用方需要消耗大量的精力去内化框架设计者的设计初衷,面对十几个接入参数或配置文件一筹莫展。
举个例子,偶尔会看到我们在业务层代码中,部分同学会把简单的新增和修改逻辑抽象为一个方法,美其名曰「提供给接入层复用」,如下面的 insertOrUpdate 方法中,初看是复用了领域对象转换和用户对象是否存在的代码,符合 DRY 原则,但实际上却是混用了两个不同的业务语义,会给后续的维护带来较高的成本,如变更用户信息时,需要做更个性化的用户属性处理,这时候调整领域对象转换处的代码,将会影响新增逻辑。
更合理的实现是,将明显不同语义的代码进行拆分,虽然看上去存在一定程度上的代码重复,但其设计会更利于后续的功能迭代,也更符合代码的「单一职责」设计原则。
2.3 决策偏差:复用的决策权在哪
代码的复用更多的时候是软件开发者自发完成的,但我们无法忽视的一点是,如何集成、是否复用、如何复用、是否是同一个功能、使用什么粒度的复用,很多时候是由业务架构决定的,「康威定律」还是无法回避的。
比如,在一个新的场景里,产品要求把「PPT 上与其名字相同的一个功能」进行复用,以快速上线,虽然他们除了名字相同,其产品形态、业务流程、环境依赖等都不一样。最终强行「复用」的结果就是代码逻辑里出现了大量的分支判断,底层技术架构变得臃肿。由于对于领域的理解不同,出现这种情况在所难免。虽然很多时候软件复用的决策权并不在开发者这里,但出于技术情怀也好,责任心也罢,开发者有义务去做这种纠偏,最大程度地消除这种差异性。但需要认识到技术的作用在这里并不是决定性的,卓越的技术是复用成功的必要非充分条件。
2.4 工具缺失:如何计算复用成本
复用性度量,主要分为两个部分:
2.复用成本:组件集成方、组件所在的组织,决定实行复用策略后的 ROI 如何计算?
通过复用度和复用成本两个指标,我们可以进行一定程度上的复用性定量分析,做出更为长远的技术决策。比如,可以了解到一个复用性高的组件,其特征有哪些?引入一个新的第三方组件时,除了基础的功能性组件外,我还需要考虑哪些?相较于使用已经存在的组件,是否考虑重新造一个轮子?「复用」和「造轮子」间成本有多大?关于复用性的度量工具,第三部分将重点论述。
三、复用性的形式化度量
3.1 组件度量:可复用水平的评估
我们在设计一段代码/一个类/一个模块等可复用的组件时,一些可衡量的软件指标共同决定了组件的可复用性水平的高低。这些指标包括:可靠性(Reliability)、可读性(Understandability)、可维护性(Maintainability)、通用性(Generality)与可迁移性(Portability),如下图所示。每一个指标可由各类代码度量属性决定,如组件的可迁移性由「组件的独立性」和「耦合性」两个属性决定,大部分的度量属性都是可以通过形式化定义并计算出来。不同指标的决定因子及度量值(括号中)如下:
5.可迁移性:组件的独立性(依赖数)、耦合性(类间耦合度)。
为了度量整个组件的的可复用性,有必要定义一个可复用性计算模型。该模型基于上图所示的复用性属性模型。主要的可复用性属性、影响这些属性的因素以及度量这些因素的量度之间的关系显示在这个模型中。理论上,软件组件的可复用性(用 Reusability 表示)可以用表达式来计算:
Reusability = w1*M + w2*R + w3*P + w4*U + w5*G
其中 w1 ~ w5 为不同指标的权重值,指标 M(Maintainability)、R(Reliability)、P(Portability)、U(Understandability)、G(Generality) 值进行归一化(0 ... 1)后,乘以每个指标不同的权重值,通过计算得到最终的组件的可复用度。
在上面的分析过程中,存在部分度量无法进行定量分析的情况,但不同因子组合计算还是有意义的,我们可以拿这些指标去评估我们目前的系统,存在的问题的严重程度。当下次别人问我们为什么要复用组件A而不是组件B时,我们可以给出更令人信服的理由,而不仅仅是「我觉得」、「A比B好很多」等论述。
3.2 组织度量:复用的投入产出比
对组件的复用性有了一个感性认知后,更加一步地,让我们从经济的角度去思考复用性背后的成本问题。首先,我们先定义几个变量 RL、NUC、RCR、RCWR。
RL(Reuse Level):可复用组件在应用中的比例,即 RL=复用的组件中代码行数/应用总的代码行数; NUC(Not Use Cost):应用开发过程中完全不使用可复用组件的成本,注意不包括后续的维护成本; RCR(Relative Cost of Reuse):复用既有的组件与重新造一个相似的轮子,这两者之间工作量的比值,一般在0.03~0.4之间,经验值为20%,即这意味着复用所花费的成本大约是编写新组件所投入的20%; RCWR(Relative Cost of Writing for Reuse):开发可复用的组件与开发一次性使用的模块,这两者之间工作量的比值,一般在1.0~2.2之间,经验值为 1.5,即这意味着编写可复用软件需要大约50%的额外成本。
举例,如果复用度 RL = 40%, RCR = 0.2,则软件集成方节约成本占比 = 0.64,即节省了64%的成本。同时我们可以得到一个简单的结论,对于组件的集成方来说,如果想要提升成本占比,则需要:可复用组件在项目中的复用度越高越好,同时可复用组件的 RCR 应较低。这意味着可复用组件拓展性、可读性需要保持在一个较高的水平,这样集成方在集成时的二次开发和适配成本会较低,这个结论也是契合我们研发时的直觉的。
对于组织而言,假如某可复用组件的N个场景被使用了,则组织复用收益 OROI 可计算如下:
举例:如果复用度 RL = 40%,RCR = 0.2,RCWR = 1.5,复用次数为5次,则 组织收益 OROI = 167%,这意味着开发一个可复用的组件,同时在多个场景进行复用,是有超额回报的。但是不是只要复用了就会有收益呢?另 OROI = (N*(1-RCR) - RCWR)/RCWR > 0 可以得到 N > RCWR/(1-RCR),带入上面预设的 RCR = 0.2,RCWR = 1.5 这两个值,得到 N > 2,这意味着需要两个或两个以上的场景复用了此组件,我们此次研发活动才会取得正向的收益。与此同时,我们可以从上面的公式得到以下几个关于提升组织复用 ROI 的结论:可复用组件在项目中复用度越高越好,开发可复用组件时,RCWR 和 RCR 越低越好。RCWR 低意味着不要去过度设计,组件的泛化性需要在领域内得到一定的控制,RCR 低意味着可复用组件可读性好、拓展性高,集成时的成本不高。
马丁·福勒(Martin Fowler)在《重构》一书提出了一条代码重构经验法则「Rule of Three 」,即我们可以复制和粘贴一次代码,但是当复制相同的代码三次时,应将其提取到新过程中进行抽象以便于复用,法则里面的最小重复次数3,其值亦符合上述 N > RCWR/(1-RCR) 的结论。
3.3 重复度量:复用和复制的边界
回到我们第二节中所提到的问题:为什么说 DRY 原则不等价于复用?假设以下场景:1. 项目中设计了全局的字符串常量类,所有的公共常量都放在此处,其他模块中的类都引用此常量,这是一个好的实践吗,是不是定义模块内的常量类或类中的常量字段会更好?2. 我需要进行字符串判重逻辑,是自己重写一个字符串工具类,还是直接使用如 commons-lang 或 guava 包中的代码呢?上面的场景都没有绝对的答案,但就我目前看到的情况来看,在很多开发者的编码习惯中,因为过度去追逐「复用性」,出现了一些没有必要的依赖负担,如使用全局常量类,出现没必要的类加载,第三方包的随意使用,造成应用包膨胀或者集成时的包冲突问题。有时候,复制一些类似的代码比尝试泛化再实例要好得多,过度使用抽象只会模糊真正关键的问题。
那什么时候可以复制,什么时候不建议呢?除了2.2中提到的语义不一致时的适度复制(就不是复用),当我们真实使用的代码占可复用组件整体代码逻辑的比例较低时(譬如只使用了 commons-lang 包中的 StringUitls 类),可以考虑重写一份,进行适度的复制粘贴,实现该处逻辑和集成方「自治」。对应前面的结论,这种情况下意味着 RL 较低,同时 RCR 较高,比如 RL = 0.01,RCR = 0.8,则 软件集成方节约成本占比只有 RL*(1-RCR) = 0.2%,这一点收益同后续可能潜在的风险(包膨胀和包冲突)相比,复制可能是一个更好的选择。
四、复用性风险管理模型
有了前面两个部分的铺垫,我们再回头去审视因为复用引入的成本风险,应该采取哪些措施能使得风险最小化呢?在业务风控和数据安全等泛信息安全的业务中,我们对风险管理的抽象,都会强调事前、事中、事后的风险控制流程。相似地,我们可以在代码研发过程中,通过建立事前评估、事中缓释及事后迭代的复用性风险管控手段,降低风险发生的可能性及其造成的影响,并根据业务架构和技术架构的发展趋势采取规避、降低和转移风险的措施,将风险控制到团队可承受的水平之内,最大程度地避免或延缓因为复用导致的维护成本高、系统快速腐化等问题。
事前评估、事中缓释、事后迭代形成的全生命周期复用性风险管理模型如下图所示:
4.1 事前评估:成本与启发式决策
风控的事前阶段(评估+分析),一般基于某些黑样本出发,挖掘出适用于后续风险对抗阶段的某些风险行为特征或模型,并基于历史样本计算出准确率和召回率。在复用性风险的事前阶段,我们也可以通过定性和定量的评估手段,尽早发现各种复用时的「坏味道」,立即予以纠正或防范,把风险消灭在萌芽状态,避免因为错误的复用引入过多技术债。评估的流程主要分为:可复用组件评估、复用成本和收益的度量、启发式决策这三个阶段,具体地:
3.如果步骤1和2都得不到一个最终的结论,下面还有一些启发式的经验帮助我们决定是否真的需要复用。
可能需要复用的场景(抽象组件或复用既有组件):
5.需要即时共享且对不一致性容忍度较低的一些业务逻辑单元,如表的元信息。
可能无需复用的场景(那就再造一个轮子吧):
9.最后一点:如果决策时觉得可用可不用,那大概率也是不需要复用的,相信自己的第一判断。
通过成本和收益估算,以及若干启发式的决策经验,大多数的场景我们都可以评估得到一个清晰的是否复用的答案。软件复用可能会在短期内提高生产力,但它可能会产生长期后果,所以这一步需要慎之又慎。
4.2 事中缓释:HCLC&测试&文档
事中缓释阶段是控制复用性风险的核心环节,它主要聚焦在可复用组件的开发阶段,通过一系列的关键步骤将复用风险在开发或正式使用前尽可能地降低,主要包括下面几个要点:
高内聚低耦合
单元测试和回归测试
完整且有效的文档化
完整且有效的文档。「好的代码是自解释性的」,这句话不完全对。首先,无论是我们的架构设计抑或是代码设计,很多东西是无法在代码中体系出来的,如对于领域抽象的取舍、决策的思考过程等,即便是我们的的接口、成员变量、实现,其命名和设计过程已经到了一个非常高的水平,代码中「隐藏信息」还是会损失,而注释可以尽可能去弥补这部分损失。其次,需要认识到:人类的感知与沟通速度是很慢且低效的,需要通过文档去填补双方沟通时的这一道鸿沟。当然,这里讨论的是一般情况,依托「无文档化」构建核心「竞争力」的行为模式不应归入此类。最后,一个正常的组织,人员是会流失的,大部分人最终都会离开这个组织,可复用组件的关键设计者如果不在组织里了,这种知识性的损失将是永久性的,文档(注释、设计)起到了一个备份领域知识的作用。
单元测试和回归测试。复用理论之所以成立,出发点是我们希望使用已经存在的、成熟的软件资产来提升研发效率,同时降低系统缺陷,一套全面的自动化回归测试,不仅有利于集成方,也会让后续和此可复用组件相关的每一个人受益。如果我们开发的可复用组件没有自动化的回归测试,那这样的组件是不合格的,是不应该发布公共仓库的。缺少自动化测试或核心流程自动化测试覆盖度较低的组件,对于集成方和组件后续的维护者来说是一场灾难,它给系统引入的巨大的技术债甚于完全没有配套文档的可复用组件。
4.3 事后迭代:捕捉领域变化&组织
在开发可复用组件时,如果一开始就大刀阔斧地投入研发资源,最终可能会创建与直接需求无关的软件资产,并由于设计、开发和测试时间的增加而产生重大的进度风险,相反,通过多次迭代改进可复用组件来降低这些风险,一个良好的组件、框架和软件架构需要时间来设计、实现、优化、验证、应用、维护和增强。与此同时,第一阶段的开发和集成结束后,在迭代的过程中进行持续性的风险管理,可以使得可复用组件的风险保持在一个较低的水位,尽可能地延长组件的生命力,需要做的主要事项包括:持续捕捉领域变化以及相应的组织支持。
捕捉领域变化。上面提到了,代码中有部分内隐的知识,事实上,一个可复用的组件就是开发者对于某个领域思考的结果,无论它是以类文件、模块还是系统的方式呈现。而领域都是会变化的,变化包括:领域的边界会拓展、领域内部分实体内涵会变化、不同领域之间的边界会重叠或者融合等。领域变化后,如果在这其中的可复用组件没有进行适当的调整,就会出现技术和业务配速失效的问题。可复用组件在封装了领域知识的同时,也一定程度上屏蔽了复杂度,当组件不足以承担起领域的实体或功能出现偏差时,就会出现「复杂度泄漏」的问题。
捕捉域变化的两个关键动作:统一领域上下文以及关注上游需求池。统一领域上下文重要性不言而喻,很多时候各方意见出现偏差的根本原因是大家没有形成统一沟通的语言,无法简单、准确且清晰地描述各自的诉求。我在进行某风险域架构治理时,做的第一件事情就是拉上了业产研三方,统一大家对「规则」和「策略」两个概念的内涵和边界的认知。其次,开发人员和架构师需紧密关注需求池,从需求本身出发,区分领域中可变性和通用性的关键来源。识别出问题域中的所有变化是不现实的,我们可以关注一些关键问题,如面对一个新的需求,可以考虑:
7.......
是否有一种指导原则,可以让我们在跟踪这些域变化的时候,进行更合理的设计与取舍呢?熵增原理告诉我们:一个孤立的热力学系统的熵不减。对于系统的可逆过程熵不变,不可逆过程熵增加。因此,类比软件工程领域,在组件的事后迭代阶段,一个尽可能消除代码设计/软件架构中熵增的设计原则:在既有组件中新增的功能点需要存在逆向的删除机制,这样就可以尽可能让可复用组件跳出逐渐混乱无法维护的宿命。功能可逆的具体操作具体可以表现为:SPI 机制(Service Provider Interface )、面向接口的编程、通过模块隔离随机的或一次性的需求等。
组织和配套的文化。首先,组织是业务架构的投射,当复用组件内的领域实体和组织负责的领域实体出现偏差时,就会出现因错位产生的技术债,结果无非是两种:一种是之前的可复用组件直接被抛弃,任由其自生自灭;另一种缺少破釜沉舟进行重构勇气与担当,既然不是我负责的,那就改一改重新用,原先统一均衡的结构会快速打破。其次,可复用组件和框架的好坏取决于构建和使用它们的人,我们需要能评估风险和机遇的管理者,需要能识别领域本质复杂度和偶然复杂度、同时能很好掌握设计模式和架构模式的架构师,以及,在开发原则、模式和实践上经验丰富的开发人员,组件是否可复用、可以复用多久,很大程度上是具备良好设计和经验丰富的开发人员的副产品。再者,在事后迭代阶段,我们需要专门的团队或负责人为此可复用资产负责,不断监控平台代码库的健康,跟踪和修复错误,坚持正确抽象,不断完善文档。当然上述只是理想情况,更多的时候,这样的人或团队是不存在的,或者即便存在,相应的组织激励也是缺失的,在一个没有复用的文化土壤中,组件腐化只是时间问题。最后,有了正确的组织和优秀的人,长期的信心、热情、激励以及管理层的支持与响应,也都是成功的复用必不可少的条件。
五、关于复用的一点感想
本文想重点去表达的几个观点:不要过度去追逐「复用」、可复用的水平以及复用投入产出比是可量化的、可复用资产是内隐的领域知识、适度的重复也是可接受的、文档可以弥补领域知识的损失、架构演进中新增功能需可逆。
撰写此篇文章的初衷,一方面源于近几年来在指导新同学时,发现出现较多的「伪复用」现象,例如为了减少代码,将共享的方法签名放在接口中,形成「过程式接口」,另一方面,自己也写过一些「为了复用」而设计的组件或模块,从中间件到业务组件大概有十几个了。但最近逐渐开始意识到,很多时候为了后续的可迁移性,一些架构或代码层的的前向防御性设计作用并不大,过度抽象反而是给使用方造成了一些理解上的困难。到底哪些真的需要复用,哪些可以妥协,梳理完这篇文章后,坚定了一部分想法(例如全程文档化),也给一些既有观念做了纠偏。
上面也一直在传递一个观点,好的软件资产是一个优秀团队的副产品。当把复用的目光从软件聚焦到人,我们自己身上,哪些是可以复用的,哪些又是平台或组织赋予我们的?去除掉那些光怪陆离的虚幻部分,不可变的部分又有哪些?授权后的高价值专利算一种,其有效期为二十年。思考过程中沉淀可能算另一种,它们或多或少且阶段性地概述了当时的所思所想,无论内容是否全面、正确,也涂抹上了时光的颜色,这也是这篇文章产生的另一个动机。
欢迎加入【阿里云开发者公众号】读者群
微信扫码关注该文公众号作者