SOLID设计原则

36 阅读14分钟

软件系统的价值

【行为价值】

软件的核心价值,包括需求的实现,以及可用性保障(功能性bug、性能、稳定性)。

【架构价值】

因为业务通常是不明确和快速发展的,架构价值体现在软件的灵活性:

  • 当需求变更时,所需的软件变更必须简便
  • 变更的实施难度与变更的范畴成等比,而与变更的具体形状无关

在四象限矩阵中,实现行为价值的需求通常是产品经理提出的,都比较紧急,但并不总是特别重要;架构价值的工作内容,通常是开发同学提出的,都很重要但基本不是很紧急。行为价值的事情落在重要且紧急、不重要但紧急;而架构价值落在重要不紧急。

架构工作的目标

用最少的人力成本满足构建和维护该系统的需求,支撑起软件系统的全生命周期,让系统便于理解、易于修改、方便维护、轻松部署。

编程范式(paradigm)

编程范式指的是程序的编写模式,与具体的编程语言关系相对较小。它是最基础的限制,限制控制流和数据流:

  • 结构化编程(Edsger Wybe Dijkstra 于1968年 提出)限制和规范了控制权的直接转移
  • 面向对象编程(Ole Johan Dahl 和Kriste Nygaard 于1966年在论文中总结出来)限制和规范了控制权的间接转移
  • 函数式编程(Alonzo Church 于发明1936年发明的 \lambda 演算的直接衍生物)限制和规范了赋值

结构化编程

Bohm 和Jocopini 证明了开发者可以用顺序结构、分支结构、循环结构这三种结构(时序图)构造出任何程序。结构化编程就此诞生。

那怎么证明结构化编程的正确性呢?

Dijkstra 通过形式化的、欧几里得式的数学推导来证明 => 通过枚举法证明了顺序结构和分支结构的正确性,用数学归纳法证明了循环结构的正确性。但大部分人不会真的按照欧几里得结构为每个小函数书写冗长复杂的正确性证明过程。没几个程序员会认为形式化验证是产出高质量软件的必备条件。Dijkstra 的梦想破灭了。

科学证明法 => 可以被证伪,但是没有办法被证明。

结构化编程对控制权的直接转移进行了限制,其实就是限制了goto 语句。因为goto 语句的一些用法会导致某个模块无法被递归拆分成更小的、可证明的单元。而采用分解法将大型问题拆分正是结构化编程的核心价值。

前端 => test case --- jest/mocha/vitest => 证明项目无bug,正常的执行流程,将程序一步步创建执行下去

面向对象编程

封装 多态 继承

面向对象编程语言并没有提供更好的封装性。早在面向对象编程语言被发明之前,对继承性的支持就已经存在很久了,但并不像如今的继承这样便利易用。

多态让开发者更方便、安全地通过函数调用的方式进行组件间通信。

在非面向对象的编程语言中,通过函数指针在互相解耦的组件间实现函数调用。这种通过函数指针进行组件间通信的方式非常脆弱,工程师必须严格按照约定初始化函数指针,并严格地按照约定来调用这些指针,只要一个人没有遵守约定,整个程序都会产生极其难以跟踪和消除的Bug。所以面向对象编程限制了函数指针的使用,通过接口–实现、抽象类–继承等多态的方式来替代。

面向对象编程对控制权的间接转移进行了限制,其实就是限制了函数指针的使用。代码在原来的流程里不继续执行了,转而去执行别的代码,但不知道具体执行了什么代码,毕竟只调了个函数指针或者接口。

函数式编程

函数式编程从架构的角度,只关注它的没有副作用和不修改状态。

函数式编程对赋值进行了限制是指函数要保持独立,所有功能就是返回一个新的值,没有其他行为,尤其是不得修改外部变量的值。

函数式编程可以让组件更加高效而稳定。

设计原则

SOLID 原则是面向对象class 设计的5 条原则。它们是设计class 结构时应该遵守的准则和最佳实践。

SOLID 原则先由Robert C·Martin 2000 年在他的论文中提出的。但SOLID 缩略词是由Michael Feathers 稍晚些使用的。

单一职责原则 - Single Responsibility Principle

【实现方法】

在JS 中需要用到类的场景并不太多,单一职责原则更多地是被运用在对象或者方法级别上,体现为:一个对象(方法)只做一件事情。

SRP 原则在很多设计模式中都有着广泛的运用,例如:代理模式、迭代器模式、单例模式和装饰者模式等。

SRP 原则是所有原则中最简单也最难正确运用的原则之一。要明确的是,并不是所有的职责都应该一一分离。

  • 如果随着需求的变化,有两个职责总是同时变化,那就不必分离他们。如在ajax 请求的时候,创建xhr 对象和发送xhr 请求几乎总是在一起的,那么创建xhr 对象的职责和发送xhr 请求的职责就没有必要分开。
  • 职责的变化轴线仅当它们确定会发生变化时才具有意义,即使两个职责已经被耦合在一起,但它们还没有发生改变的征兆,那么也许没有必要主动分离它们,在代码需要重构的时候再进行分离也不迟
 // 单一职责原则
 // 通过解耦让每一个模块职责更加独立
 // 目标:一个功能模块只做一件事
 ​

 // game store
 class ForestManager {
     openDialog() {
         // 弹框
         // 计算金额
         setPrice();
     }
 }
 const game = new ForestManager();
 game.openDialog(); // 弹框之后计算金额,弹框和计算金额两个模块耦合
 ​
 // refactor
 // gameManager.js - 业务
 class ForestManager {
     constructor(command) {
         this.command = command;
     }
     openDialog(price) {
         // calculate price
         this.command.setPrice(price);
     }
 }
 ​
 // optManager.js - 核心库
 class PricManager {
     setPrice(price) {
         // 配置金额
     }
 }
 ​
 // main.js
 const exe = new PricManager();
 const pubg = new ForestManager(exe);
 pubg.openDialog(15);

【优缺点】

优点:

  • 降低了单个类或者对象的复杂度,按照职责把对象分解成更小的粒度,这有助于代码的复用,也有利于进行单元测试。当一个职责需要变更的时候,不会影响到其他的职责

缺点:

  • 最明显的是会增加编写代码的复杂度。当按照职责把对象分解成更小的粒度之后,实际上也增大了这些对象之间互相联系的难度。

开闭原则 - Open Close Principle

对扩展开放,对修改关闭。

由Bertrand Meyer 提出,他在1988 年的著作《Object Oriented Software Construction》中提出:Software entities should be open for extension, but closed for modification。

【实现方法】

通过“抽象约束、封装变化”来实现开闭原则。使用抽象类或接口为实体定义一个稳定的抽象层,而可变的因素都封装在具体的实现类中。所以当软件发生变化时,只需要根据需求重新派生出一个实现类来扩展,严格遵守开闭原则。

 // 开闭原则 OCP
 // sprint 1 - 节日活动森林要高亮 + LOL 需要弹出折扣

 if (game === 'forest') {
     // ...highlight
 } else {
     // ...
 }
 ​
 // event
 if (game === 'LOL') {
     // pop up discount dialog
 } else {
     // payment
 }
 ​
 // sprint 2 - 要对部分游戏进行置灰 + 其付款页面要显示停止发售(Minecraft)
 // render
 if (game === 'forest') {
     // ...highlight
 } else if (game === 'Minecraft') {
     // ...gray
 } else {
     // ...
 }
 ​
 // event
 if (game === 'forest') {
     // pop up discount dialog
 } else if (game === 'Minecraft') {
     // break + notice to stop publish
 } else {
     // payment
 }
 ​
 // refactor
 // render
 gameManager(game).SetColor();
 ​
 // event
 gameManager(game).openDialog();
 ​
 // game library
 function gameManager(game) {
     return `${game}Manager`;
 }
 ​
 // guide
 const LOLManager = {
     setColor() {
         // normal
     },
     openDialog() {
         // discount dialog
     }
 };
 ​
 const FORESTManager = {
     setColor() {
         // highlight
     },
     openDialog() {
         // payment
     }
 }
 ​
 // refactor 2
 class game {
     constructor(name) {
         this.name = name;
     }
     setColor() {}
     openDialog() {}
 }
 ​
 class LOL extends game {
     openDialog() {
         // ...discount
     }
 }
 ​
 class Forest extends game {
     setColor() {
         // ...highlight
     }
 }
 ​
 class Minecraft extends game {
     setColor() {
         // ...gray
     }
 ​
     openDialog() {
         // break
     }
 }
 ​
 // 场景:Vue minix

【优缺点】

优点:

  • 对软件测试的影响:OCP 原则是扩展设计,所以原有功能保持不变,那么软件测试时可以只测试扩展的部分
  • 提高代码的可复用性:粒度越小,被复用的可能性就越大
  • 提供软件的可维护性:开闭原则使得软件的稳定性和延续性更高,这样就更易扩展和维护;

缺点:

  • 实现类多的话,容易导致需要过多维护不同具体类
  • 容易导致衍生过度设计

里氏替换原则 - Liskov Substitution Principle

【实现方式】

如果S 是T 的子类,则T 的对象可以替换为S 的对象,而不会破坏程序。所有引用其父类对象方法的地方,都可以透明的替换为其子类对象。

应用程序中任何父类对象出现的地方,都可以用其子类的对象来替换,并且可以保证原有程序的逻辑行为和正确性。

1、里氏替换原则是针对继承而言的,如果继承是为了实现代码重用,也就是为了共享方法,那么共享的父类方法就应该保持不变,不能被子类重新定义。子类只能通过新添加方法来扩展功能,父类和子类都可以实例化,而子类继承的方法和父类是一样的,父类调用方法的地方,子类也可以调用同一个继承得来的逻辑和父类一致的方法,这时用子类对象将父类对象替换时,当然逻辑一致。

2、如果继承的目的是为了多态,而多态的前提就是子类覆盖并重新定义父类的方法,为了符合LSP,应该将父类定义为抽象类,并定义抽象方法,让子类重新定义这些方法,当父类是抽象类时,父类就是不能实例化,所以也不存在可实例化的父类对象在程序里。也就不存在子类替换父类实例(根本不存在父类实例了)时逻辑不一致的可能。

不符合LSP 的最常见的情况是,父类和子类都是可实例化的非抽象类,且父类的方法被子类重新定义,这一类的实现继承会造成父类和子类间的强耦合,也就是实际上并不相关的属性和方法牵强附会在一起,不利于程序扩展和维护。

 // 里氏替换原则
 // 要求:子类能够覆盖父类;父类能够出现的地方子类就能够出现
 ​
 // sprint1
 class Game {
     start() {}
     shutdown() {}
     play() {}
 }
 const game = new Game();
 game.play();
 ​
 // sprint2
 class MobileGame extends Game {
     tombStore() {}
     play() {}
 }
 const mobileGame = new MobileGame();
 mobileGame.play();
 ​
 // refactor
 class Game {
     start() {}
     shutdown() {}
 }
 class MobileGame extends Game {
     tombStore() {}
     play() {}
 }
 class PCGame extends Game {
     speed() {}
     play() {}
 }

【优缺点】

优点:

  • 合理的用类的继承关系,提高了代码的复用性,但也增强了类与类之间的耦合性
  • 通过建立抽象,运行过程中具体实现取代抽象,保证了系统的可扩展性

缺点:

  • 只要继承父类就拥有父类的全部属性和方法,这样减少了代码重复创建量,共享了代码但也约束了子类的行为,降低了系统灵活性。

接口隔离原则 - Interface Segregation Principle

【实现方法】

不应该强迫客户依赖于它们不用的方法。

当用户依赖的接口方法即便只被别的用户使用而自己不用,那它也得实现这些接口。一个用户依赖了未使用但被其他用户使用的接口,当其他用户修改该接口时,依赖该接口的所有用户都将受到影响。这显然违反了开闭原则。

接口隔离原则ISP 和单一职责有点类似,都是用于聚集功能职责的,实际上ISP 可以被理解成具有单一职责的程序转化到一个具有公共接口的对象。

ISP 与SRP 对比

1、从原则约束:ISP 更关注的是接口依赖程度的隔离;而SRP 更加注重的是接口职责的划分;

2、从接口的细化程度:SRP 对接口的划分更加精细,而ISP。注重的是相同功能的接口的隔离。ISP 里面的最小接口有时可以是多个单一职责的公共接口;

3、SRP 更加偏向对业务的约束:ISP 更加偏向设计架构的约束。职责是根据业务功能来划分的,所以SRP 更加偏向业务;而ISP 更多是为了“高内聚”,偏向架构的设计。

// ISP
// 目标:多个专业的接口比单个大而全接口好用

// 需求
// 开发游戏,实现游戏中台 --- 快速生产游戏
// PUBG LOL run shot mega
class Game {
    constructor(name) {
        this.name = name;
    }
    run() {}
    shot() {}
    mega() {}
}
class LOL extends Game {
    constructor() {}
}
class PUBG extends Game {
    constructor() {}
}

const pubg1 = new PUBG('pubg');
pubg1.run();
pubg1.shot();


// refactor - 拆分多个专业接口,每个接口服务于单个功能模块
class Game {
    constructor(name) {
        this.name = name;
    }
    run() {}
}
class FPS {}
class MOBA {
    constructor() {}
    mega() {}
}
class LOL extends Game {
    constructor() {}
    mega() {}
}
class PUBG extends Game {
    constructor() {}
    shot() {}
}

【优缺点】

优点:

  • 将臃肿庞大的接口分解为多个粒度小的接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性
  • 接口隔离提高了系统的内聚性,减少了对外交互,降低了系统的耦合性

缺点:

  • 颗粒度定义:如果接口的粒度大小定义合理,能够保证系统的稳定性;然而,如果定义过小,则会造成接口数量过多,使设计复杂化;如果定义太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险
  • 过多的冗余代码:能减少项目工程中的代码冗余。过大的大接口里面通常放置许多不用的方法,当实现这个接口的时候,被迫设计冗余的代码

依赖倒置原则 - Dependence Inversion Principle

【实现方法】

高级模块不应当依赖于低级模块。它们都应当依赖于抽象;抽象不应当依赖于实现,实现应当依赖于抽象。

首先会针对需求进行抽象。它的工作原理一般是在一个人与系统交互的复杂环境中,隐藏当前级别下的更复杂的实现细节,只关注核心功能。这样在与一个以高级层面作为抽象的系统协作时,仅需要在意能做什么而不是如何做。

DIP 原则存在的真正意义是指需要将一些对象解耦,它们的耦合关系需要达到当一个对象依赖的对象做出改变时,对象本身不需要更改任何代码。

这样的架构可以实现一种松耦合的状态的系统,因为系统中所有的组件,彼此之间都了解很少或者不需要了解系统中其余组件的具体定义和实现细节。它同时实现了一种可测试和可替换的系统架构,因为在松耦合的系统中,任何组件都可以被提供相同服务的组件所替换。

// 依赖倒置原则
// 目标:面向对象进行coding,而不是对实现进行coding,降低需求与技术底层的耦合

// 分享功能
class Store {
    constructor() {
        this.share = new Share();
    }
}

class Share {
    shareTo {
        // 分享到不同的平台
    }
}

const store = new Store();
store.share.shareTo('wx');

// 评分功能
class Store {
    constructor() {
        this.share = new Share();
        this.rate = new Rate();
    }
}

class Share {
    shareTo() {
        // 分享到不同的平台
    }
}

class Rate {
    star(stars) {
        // 评分
    }
}
const store1 = new Store();
store1.rate.star(5);

// refactor
// 暴露挂载 => 动态挂载
class Store {
    // 维护模块名单
    static modules = new Map();

    constructor() {
        // 遍历名单实现初始化挂载全模块
        for (let module of Store.modules.values()) {
            module.init(this);
        }
    }

    // 注入功能模块
    static inject(module) {
        Store.modules.set(module.constructor.name, module);
    }
}

class Share {
    init(store) {
        store.share = this;
    }
    shareTo(platform) {
        // 分享
    }
}

class Rate {
    init(store) {
        store.rate = this;
    }
    star(stars) {
        // 评分
    }
}
const rate = new Rate();
Store.inject(rate);

const store = new Store();
store.rate.star(5);

【优缺点】

优点:

  • 降低类间的耦合性
  • 提高系统的稳定性
  • 降低并行开发引起的风险

缺点:

  • 增加了一层抽象层,增加实现难度
  • 对一些简单的调用关系来说,可能是得不偿失的
  • 对一些稳定的调用关系,反而增加复杂度,是不正确的

【参考资料】

《架构整洁之道》