【开发】长期项目与代码质量,对抗软件工程复杂度(设计、重构、规范)

文章目录

      • 一、设计模式与设计原则
      • 二、历史债务与代码重构
        • 1、技术债务的来源
        • 2、重构—无奈之举
        • 3、工程一致性:有效控制技术债务积累的主要手段

一、设计模式与设计原则

设计模式(Design pattern)代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的

23种设计模式:菜鸟教程-设计模式

根据设计模式的参考书 Design Patterns – Elements of Reusable Object-Oriented Software(中文译名:设计模式 – 可复用的面向对象软件元素) 中所提到的,总共有 23 种设计模式。这些模式可以分为三大类:创建型模式(Creational Patterns)、结构型模式(Structural Patterns)、行为型模式(Behavioral Patterns)。当然,我们还会讨论另一类设计模式:J2EE 设计模式。

序号模式 & 描述包括
1创建型模式 这些设计模式提供了一种在创建对象的同时隐藏创建逻辑的方式,而不是使用 new 运算符直接实例化对象。这使得程序在判断针对某个给定实例需要创建哪些对象时更加灵活。工厂模式(Factory Pattern)抽象工厂模式(Abstract Factory Pattern)单例模式(Singleton Pattern)建造者模式(Builder Pattern)原型模式(Prototype Pattern)
2结构型模式 这些模式关注对象之间的组合和关系,旨在解决如何构建灵活且可复用的类和对象结构。适配器模式(Adapter Pattern)桥接模式(Bridge Pattern)过滤器模式(Filter、Criteria Pattern)组合模式(Composite Pattern)装饰器模式(Decorator Pattern)外观模式(Facade Pattern)享元模式(Flyweight Pattern)代理模式(Proxy Pattern)
3行为型模式 这些模式关注对象之间的通信和交互,旨在解决对象之间的责任分配和算法的封装。责任链模式(Chain of Responsibility Pattern)命令模式(Command Pattern)解释器模式(Interpreter Pattern)迭代器模式(Iterator Pattern)中介者模式(Mediator Pattern)备忘录模式(Memento Pattern)观察者模式(Observer Pattern)状态模式(State Pattern)空对象模式(Null Object Pattern)策略模式(Strategy Pattern)模板模式(Template Pattern)访问者模式(Visitor Pattern)
4J2EE 模式 这些设计模式特别关注表示层。这些模式是由 Sun Java Center 鉴定的。MVC 模式(MVC Pattern)业务代表模式(Business Delegate Pattern)组合实体模式(Composite Entity Pattern)数据访问对象模式(Data Access Object Pattern)前端控制器模式(Front Controller Pattern)拦截过滤器模式(Intercepting Filter Pattern)服务定位器模式(Service Locator Pattern)传输对象模式(Transfer Object Pattern)

设计模式的优点

  • 提供了一种共享的设计词汇和概念,使开发人员能够更好地沟通和理解彼此的设计意图。
  • 提供了经过验证的解决方案,可以提高软件的可维护性、可复用性和灵活性。
  • 促进了代码的重用,避免了重复的设计和实现。
  • 通过遵循设计模式,可以减少系统中的错误和问题,提高代码质量。

设计模式中的6大设计原则

  • 1、开闭原则(Open Close Principle)
    开闭原则的意思是:对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。简言之,是为了使程序的扩展性好,易于维护和升级。想要达到这样的效果,我们需要使用接口和抽象类,后面的具体设计中我们会提到这点。

  • 2、里氏代换原则(Liskov Substitution Principle)
    里氏代换原则是面向对象设计的基本原则之一。 里氏代换原则中说,任何基类可以出现的地方,子类一定可以出现。LSP 是继承复用的基石,只有当派生类可以替换掉基类,且软件单位的功能不受到影响时,基类才能真正被复用,而派生类也能够在基类的基础上增加新的行为。里氏代换原则是对开闭原则的补充。实现开闭原则的关键步骤就是抽象化,而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。

  • 3、依赖倒转原则(Dependence Inversion Principle)
    这个原则是开闭原则的基础,具体内容:针对接口编程,依赖于抽象而不依赖于具体。

  • 4、接口隔离原则(Interface Segregation Principle)
    这个原则的意思是:使用多个隔离的接口,比使用单个接口要好。它还有另外一个意思是:降低类之间的耦合度。由此可见,其实设计模式就是从大型软件架构出发、便于升级和维护的软件设计思想,它强调降低依赖,降低耦合。

  • 5、迪米特法则,又称最少知道原则(Demeter Principle)
    最少知道原则是指:一个实体应当尽量少地与其他实体之间发生相互作用,使得系统功能模块相对独立。

  • 6、合成复用原则(Composite Reuse Principle)
    合成复用原则是指:尽量使用合成/聚合的方式,而不是使用继承。

研发过程:

  • 业务建模编写用例规约(路径及步骤、字段列表、业务规则、设计约束)
  • 分析概念模型(概念模型、分析序列图、状态图、LDM)
  • 设计模型(流程AO,领域AO,DAO)
  • 业务实现,测试验证

持续高效能交付的关键设计能力要求:面向 测试,部署,监控,扩展,失效设计

  • 为测试而设计 :测试左移
  • 为部署而设计: 面向运维
  • 为监控而设计: 面向功能的监控
  • 为扩展而设计: 业务方便扩展
  • 为失效而设计: 可用性

二、历史债务与代码重构

1、技术债务的来源

软件研发过程的本质

  • 软件版本演进

    版本演进,人数增长,代码的量并没有相应增长,每行代码变更的成本越来越高。

    研发效能不断在下降,团队成本不断在增长,需求积压越来越严重

  • 软件研发的本质:知识手工业者的大规模协作

    “个人英雄主义”的石器时代已经结束,目前是群体协作时代,但本质上依然是手工业。

    很大程度上依赖于个人的能力 以及 不同技能同学的团体协作

    规模小的时候,手工业可以,大了就不行了

  • 协作过程体现在两个方面:产研协作 + 研发内部协作

    产研协作过程:大量非标协作导致返工频繁和效能低下

    研发过程:大量人工协作导致 一致性差 和 效能低下

    产品想不清楚需求,研发帮忙出点子(有时反而帮倒忙)

    产品需求中出现大量技术术语(产品不应该管实现)

    验收时长长,期间多次反复,研发需要全程陪同

    研发内部问题,让产品去传递信息、推动环境和数据问题的解决

    所谓的“项目赶”,让上述的变形动作更加低质量

软件复杂度&技术债务的 必然性

  • 软件是生长出来的,而不是设计出来的。

    软件系统很难一开始就做出完美的设计,只能通过一个个功能模块衍生迭代,系统才会逐步成型,然后随着需求变多,再逐渐演进迭代,所以软件本质上是一点点生长出来的,期间就伴随着复杂性的不断累积和增长。

    无论现在看起来是多么复杂的软件系统,都要从第一行开始开发。都要从几个核心模块开始开发,这时架构只能是一个简单的、少量程序员可以维护的简单组成。

  • 软件架构师和建筑架构师有着巨大的差异。

    建筑图纸设计好了,需要多少材料,需要多少人力,工期和进度基本就能确定了,而且当设计需要变更的时候,往往是发生在设计图纸阶段,也就是说,建筑架构的设计和生产活动是可以分开的。

    但是软件特殊性在于,“设计活动”与“制造活动”彼此交融,你中有我,我中有你,无法分开,软件架构只能在实现过程中不断迭代,复杂度也在不断积累。

    建筑架构师不会轻易给一个盖好的高楼增加阳台,但是软件架构师却经常在干这样的事,并且总有人会对你说,“这个需求很简单”。往外扩建一些就行了,这确实不复杂,但我们往往面临的真实场景其实是:没人知道扩建阳台原来的楼会不会开裂,甚至倒塌。

  • 之前看了一本书《从工业化到城市化》,里面提出了一个有洞见的观点,说**“工业是无机体,可以批量复制,而城市是有机体,只能慢慢生长”。**

    “工业化可以看做是一个‘复制’的过程。可以想象一下复印店里的复印机,有机器,有原件,有纸和墨,就能开始一张张地复印,速度是非常快的。工业化也是类似的,有了技术、资金、劳动力这几个条件,就可以进行大规模的工业生产。但是城市化就不是一个能快速‘复制’的过程,而是一个需要‘生长发育’的过程。城市不仅是钢筋水泥、道路桥梁,更是一套复杂的网络,城市中的生活设施、消费习惯、风土人情等等,这些都需要一定的生长时间。”

    我认为建筑架构更像是工业化的无机体,可以非常规整,而软件架构更像是城市的发展,需要时间的洗礼,其复杂性和不确定性就特别高。所以,维护大型软件的核心要素是控制复杂度。

  • 用书面化的方式表述:

    软件具有复杂性:组件/服务数量多,内外部依赖多,规模庞大等等。

    软件具有不一致性:如果系统是一致的,则意味着相似的事情以相似的方式完成,但是为了保持向前兼容,不一致性会长期存在,而且在演进过程中还会持续恶化。

    软件具有可变性:如果软件不变,复杂性就没有了意义。软件越成功,用户要的就更多。用户要的更多,潜在的变更就越多。变更越多,参与研发的人就越多。参与研发的人越多,一致性就更没保证。

    软件具有不可见性:软件的客观存在不具有空间的形体特征。不同的关注点,会有不同的图。综合叠加是困难的。强行可视化的效果是异常复杂,会失去可视化的价值。

2、重构—无奈之举

不重构等死,重构找死。重构其实是无奈之举,不得已为之的。

如何面对技术债务(短期主义/长期主义)

  • 短期主义的战术编程就是没有太多设计,简单粗暴快速实现

    目标:尽可能快地交付,完成任务

    结果:没有设计,复杂度聚集

  • 长期主义的战略编程则是需要做良好的设计,短时间内可能会降低工作效率,但是长期看,会增加系统的可维护性和迭代效率

    目标:更好的设计

    结果:为了将来的变更做简化,减少复杂度的积累

  • 要有投资的心态,用20-30%的用于实现设计是值得的,这些投资会在短期内让您放慢脚步,但从长远来看会加快您的速度

    如果用战术编程的方法,一开始业务交付的确会快一些,但是,大约6~12个月后,战术编程的负面效果会很明显的展现出来,严重降低开发速度和质量。unknow unknowns变多。

  • 如果是创业公司的话,战术编程的确是一个选项,毕竟几个月后你的公司是不是还活着都是一个问题;

    但如果一个创业公司熬过了这段时间,还选择一直战术编程的话,会很危险。

    很多初创公司是战术性编程。Facebook曾经也是战术性编程的文化,后来改了,Google和VMWare一直是战略性编程,所以比较成功

  • 破窗效应 :一个建筑,当有了一个破窗而不及时修补,这个建筑就会被侵入住认为是无人居住的、风雨更容易进来,更多的窗户被人有意打破,很快整个建筑会加速破败。这就是破窗效应。当我们在设计评审、代码评审时面临一个个选择时,每一个投机取巧、似乎都显得没那么有危害:就是增加了一点点复杂度而已,就是一点点风险而已。但是每一个失败的系统的问题都是这样一点点积累起来的。

  • 但凡曾经选择过短期主义,那么就会积累大量的原始历史债务,加上复杂度增长带来的风险往往是后知后觉的。 看看技术债务的不同阶段,可以发现,当债务积累到问题出现时,往往漏洞已经形成一段时间,为时已晚,特别是架构腐化。所以需要阶段性、及时的对历史的代码进行重构。

如何进行代码重构

  • 大型重构 :
    • 对顶层代码设计的重构,包括:系统、模块、代码结构、类与类之间的关系等的重构,重构的手段有:分层、模块化、解耦、抽象可复用组件等等。
    • 这类重构的工具就是我们学习过的那些设计思想、原则和模式。这类重构涉及的代码改动会比较多,影响面会比较大,所以难度也较大,耗时会比较长,引入bug的风险也会相对比较大。
  • 小型重构 :
    • 对代码细节的重构,主要是针对类、函数、变量等代码级别的重构,比如规范命名和注释、消除超大类或函数、提取重复代码等等。小型重构更多的是使用统一的编码规范。
    • 这类重构要修改的地方比较集中,比较简单,可操作性较强,耗时会比较短,引入bug的风险相对来说也会比较小。什么时候重构 新功能开发、修bug或者代码review中出现“代码坏味道”,我们就应该及时进行重构。持续在日常开发中进行小重构,能够降低重构和测试的成本。
  • 重构技巧:
    • 提炼方法:多个方法代码重复、方法中代码过长或者方法中的语句不在一个抽象层级。方法是代码复用的最小粒度,方法过长不利于复用,可读性低,提炼方法往往是重构工作的第一步。
    • 以函数对象取代函数:将函数放进一个单独对象中,如此一来局部变量就变成了对象内的字段。然后你可以在同一个对象中将这个大型函数分解为多个小型函数。
    • 引入参数对象:方法参数比较多时,将参数封装为参数对象。
    • 移除对参数的赋值
    • 将查询与修改分离
    • 移除不必要临时变量
    • 引入解释性变量
    • 使用卫语句替代嵌套条件判断
    • 使用多态替代条件判断断
    • 使用异常替代返回错误码
    • 引入断言:某一段代码需要对程序状态做出某种假设,以断言明确表现这种假设。
    • 引入Null对象或特殊对象:当使用一个方法返回的对象时,而这个对象可能为空,这个时候需要对这个对象进行操作前,需要进行判空,否则就会报空指针。
    • 提炼类:根据单一职责原则,一个类应该有明确的责任边界。但在实际工作中,类会不断的扩展。当给某个类添加一项新责任时,你需要分离出一个单独的类。
    • 组合优先于继承:与方法调用不同的是,继承打破了封装性。子类依赖于其父类中特定功能的实现细节,如果父类的实现随着发行版本的不同而变化,子类可能会遭到破坏,即使他的代码完全没有改变。
    • 接口优于抽象类
    • 优先考虑泛型:泛型的本质是参数化类型,通过一个参数来表示所操作的数据类型,并且可以限制这个参数的类型范围。泛型的好处就是编译期类型检测,避免类型转换。
    • 不要使用原生态类型
    • 要尽可能地消除每一个非受检警告
    • 利用有限制通配符来提升API的灵活性
    • 静态成员类优于非静态成员类
    • 匿名类:没有名字,声明的同时进行实例化,只能使用一次。
    • 局部类、静态成员类、非静态成员类
    • 优先使用模板/工具类
    • 分离对象的创建与使用
    • 可访问性最小化
    • 可变性最小化
3、工程一致性:有效控制技术债务积累的主要手段

时刻控制债务增量的成本,是远低于重构的。

如何做到一致性——每个环节都向规范靠近

  • “幸福的家庭都是相似的,不幸的家庭各有各的不幸。” 托尔斯泰信奉基督宗教,结合神学思想:善与恶并非是对立的,恶是善的不完整状态。

  • 幸福的家庭需要满足某些核心的条件,而家庭只要某个核心条件不能得到满足,就会沦为不幸,而不幸的方式根据条件的缺失而形态不一。

  • 这类似柏拉图在《理想国》里论述完美的城邦需要满足:理智,勇敢,节制,正义。缺乏其中一样或几样,理想正体就会变态为斯巴达正体,寡头正体,民主正体,僭主正体。

可持续的工程一致性

  • 工具、人员、流程,确保软件工程开发的每个环节,都流程化,规范化,尽可能的保证一致性,降低偶然复杂性带来的风险。最好再确立质量度量指标的一致性,做到整个流程的闭环。包括结果指标,过程指标等等,发现问题,并推动改进。

  • 需求:需求名词、需求格式模板、需求交付件、需求层级、需求验收标准、需求颗粒度、需求质量Checklist

  • 设计:统一概念和名词、用例规范与一致性、设计评审流程、设计变更流程、模块与子域划分、设计交付物标准、分层与解耦、设计模式与风格

  • 组件:组件分类、组件引入、组件升级与变更、组件运营数据、组件文档、组件设计、组件版本

  • 数据:数据模型设计、数据分级管控、数据审计、数据流转、敏感数据定义

  • 开发:代码管理与规范、分支模型与代码提交、静态代码检查、代码评审、组件使用、接口契约、错误码使用、统一编译

  • 测试:测试策略设计、自动化测试、自测的质量门禁、测试范围、测试环境、测试配置、测试执行

  • 构建:统一构建、制品版本、制品管理、制品可追溯

  • 验收:验收流程、验收标准、验收执行、验收留痕

  • 部署&发布:单一可信数据源、发布流程、审批流程、回滚流程、灰度策略、业务监控、资源监控

  • 运营:运营事故管理流程、应急响应流程、闭环与复盘、监管控一体化

参考资料:1,2,3,4,5,6,7,8,9