设计模式

109 阅读19分钟

基础概念

设计模式其实就是针对设计问题的通用解决方案

设计模式的好处还是很多的:

  • 有利于代码复用
  • 有利于代码稳定可拓展
  • 有利于代码可读性提升

软件系统的价值和架构工作的目标

软件系统的价值

架构是软件系统的⼀部分,所以要明⽩架构的价值,⾸先要明确软件系统的价值。软件系统的价值有两方面,行为价值和架构价值

行为价值:
是软件的核心价值,包括需求的实现,以及可用性保障(功能性 bug 、性能、稳定性)。如果业务是明确的、稳定的,架构的价值就可以忽略不计,但业务通常是不明确的、⻜速发展的,这时架构就⽆⽐重要,因为架构的价值就是让我们的软件更软。

架构价值:

  1. 当需求变更时,所需的软件变更必须简单方便;
  2. 变更实施的难度应该和变更的范畴(scope)成等⽐,⽽与变更的具体形状(shape)⽆关

- > 如何处理好行为价值和架构价值的关系。

可以从著名的紧急重要矩阵出发
重要紧急矩阵中,做事的顺序是:
1.重要且紧急>2.重要不紧急>3.不重要但紧急>4.不重要且不紧急 实现行为价值的需求通常是PD提出的,都比较紧急,但并不总是特别重要;
架构价值的⼯作内容,通常是开发同学提出的,都很重要但基本不是很紧急,短期内不做也死不了。所以⾏为价值的事情 落在1和3(重要且紧急、不重要但紧急),⽽架构价值落在2(重要不紧急)。
我们在敲代码之前,一定要把杂糅在一起的1和3分开,把我们架构⼯作插进去。

架构工作的目标

正如上⾯说的,追求架构价值就是架构⼯作的⽬标,说⽩了,就是⽤最少的⼈⼒成本满⾜构建和维护该 系统的需求,再细致⼀些,就是⽀撑软件系统的全⽣命周期,让系统便于理解、易于修改、⽅便维护、 轻松部署。 对于⽣命周期⾥的每个环节,优秀的架构都有不同的追求:

  • 开发阶段:组件不要使⽤⼤量复杂的脚⼿架;不同团队负责不同的组件,避免不必要的协作;
  • 部署阶段:部署⼯作不要依赖成堆的脚本和配置⽂件;组件越多部署⼯作越繁重,⽽部署⼯作本身 是没有价值的,做的越少越好,所以要减少组件数量;
  • 运⾏阶段:架构设计要考虑到不同的吞吐量、不同的响应时⻓要求;架构应起到揭示系统运⾏的作⽤:⽤例、功能、⾏为设置应该都是对开发者可⻅的⼀级实体,以类、函数或模块的形式占据明显位置,命名能清晰地描述对应的功能;
  • 维护阶段:减少探秘成本和风险。探秘成本是对现有软件系统的挖掘⼯作,确定新功能或修复问题 的最佳位置和⽅式。⻛险是做改动时,可能衍⽣出新的问题;

编程范式

其实所谓架构就是限制,限制源码放在哪⾥、限制依赖、限制通信的⽅式, 但这些限制⽐较上层。

编程范式是最基础的限制,它限制我们的控制流数据流

  • 结构化编程 :限制了控制权的直接转移,
  • 面向对象编程 :限制了控制权的间接转移,
  • 函数式编程 :限制了赋值的方式

结构化编程

结构化编程证明了⼈们可以⽤顺序结构、分支结构、循环结构这三种结构构造出任何程序,并限制了 goto 的使⽤。遵守结构化编程,就可以像数学家⼀样对⾃⼰的程序进行推理证明,⽤代码将⼀些已证明可⽤的结构串联起来,只要⾃⾏证明这些额外代码是确定的,就可以推导出整个程序的正确性。

前面提到结构化编程对控制权的直接转移进行了限制,其实就是限制了 goto 语句。

- > 什么叫做控制权的直接转移?

就是函数调⽤或者 goto 语句,代码在原来的流程⾥不继续执⾏了,转⽽去执⾏别的代码,并且 你指明了执⾏什么代码。

- > 为什么要限制 goto 语句?

因为 goto 语句的⼀些⽤法会导致某个模块⽆法被递 归拆分成更小的、可证明的单元。⽽采⽤分解法将大型问题拆分正是结构化编程的核心价值。

面向对象编程

⾯向对象编程包括封装、继承和多态。从架构的角度,这⾥只关注多态。多态让我们更方便、安全地通过函数调用的方式进行组件间通信

在非面向对象的编程语⾔中,我们如何在互相解耦的组件间实现函数调⽤?答案是函数指针
⽐如采⽤C 语⾔编写的操作系统中,定义了如下的结构体来解耦具体的IO设备, IO 设备的驱动程序只需要把函数指针指到⾃⼰的实现就可以了。

struct FILE {
    void (*open)(char* name, int mode);
    void (*close)();
    int (*read)();
    void (*write)(char);
    void (*seek)(long index, int mode);
}

这种通过函数指针进⾏组件间通信的⽅式⾮常脆弱,必须严格按照约定初始化函数指针,并严格地按照约定来调⽤这些指针,只要⼀个⼈没有遵守约定,整个程序都会产⽣极其难以跟踪和消除的 Bug。所以⾯向对象编程限制了函数指针的使用,通过接口-实现、抽象类-继承等多态的方式来替代。

前⾯提到⾯向对象编程对控制权的间接转移进行了限制,其实就是限制了函数指针的使用

- > 什么叫做控制权的间接转移?

就是代码在原来的流程⾥不继续执行了,转⽽去执行别的代码,但具体执行了啥代码你也不知道,你只调了个函数指针或者接⼝。

函数式编程

函数式编程有很多种定义很多种特性,这里从架构的角度,只关注它的无副作用和不修改状态。函数式编程中,函数要保持独⽴,所有功能就是返回⼀个新的值,没有其他⾏为,尤其是不得修改外部变量的值。前面提到函数式编程对赋值进行了限制,指的就是这个特性。

在架构领域所有的竞争问题、死锁问题、并发问题都是由可变变量导致的。如果有⾜够⼤的存储量和计算量,应⽤程序可以⽤事件溯源的⽅式,⽤完全不可变的函数式编程,只通过事务记录从头计算状态, 就避免了前⾯提到的⼏个问题。⽬前要让⼀个软件系统完全没有可变变量是不现实的,但是我们可以通过将需要修改状态的部分和不需要修改的部分分隔成单独的组件,在不需要修改状态的组件中使用函数式编程,提高系统的稳定性和效率。

总结

  1. 没有结构化编程,程序就⽆法从⼀块块可证伪的逻辑搭建;
  2. 没有⾯向对象编程,跨越组件边界会是⼀个⾮常麻烦⽽危险的过程;
  3. 函数式编程可以让组件更加⾼效⽽稳定
  4. 没有编程范式,架构设计将无从谈起;

设计原则

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

  1. 开闭原则: 开 - 拓展;闭 - 修改
  2. 单一职责原则: 岗位职责单一,互不重叠
  3. 依赖倒置原则: 上层不应依赖下层的实现
  4. 接口隔离原则
  5. 里氏替换原则:子类可以拓展,但是不能改变父类属性

开闭原则(OCP)

软件实体的行为应当不是修改实体,而是对实体进行扩展(对扩展开发,对修改关闭)

实现方法

通过 “抽象约束、封装变化” 来实现开闭原则。使⽤抽象类或接⼝为实体定义⼀个稳定的抽象层,⽽可变 的因素都封装在具体的实现类中。

//情景1:在节假日期间,PUBG显示高亮 + LOL 要弹出提示折扣框
if (game === 'PUBG') {//render
    // ……⾼亮 
} else {
    // ……
}

if (game === 'LOL') { //event
    // 弹出折扣框 
} else {
    // 付款 
}

//情景2:要针对部分游戏进行置灰,在付款页面里停止发售
if (game === 'PUBG') {
    // ……⾼亮 
} else if (game === 'xxx') { 
    // ……灰⾊ 
} else { 
    // ……
}

if (game === 'LOL') {
    // 弹出折扣框 
} else if (game === 'xxx') { 
    // 提示停⽌发售 
} else { 
    // 付款 
}
//上面这样写没有问题,但是后续出现其他情景的时候,if-else语句就会越来越多
//那我们可以根据 开闭原则 进行优化(对扩展开发,对修改关闭)
//重构1
gameManager(game).setColor();   //render
gameManager(game).openDialog(); //event

function gameManager(game){
    return `${game}Manager`;
}

const LOLManager ={
    setColor(){
        //展示正常
    },
    openDialog(){
        //展示有折扣框
    },
};

const PUBGManager={
    setColor(){
        //高亮
    },
    openDialog(){
        //付款
    },
};

const XXXManager={
    setColor(){
        //展示置灰
    },
    openDialog(){
        //停止售卖
    },
};
//这种写法确实比最开始的if-else更好,但是还是有个问题:
//它确实能体现 对扩展开放;但是却不符合 对修改关闭(没有限制住 setColor()和openDialog()这两个方法)
//所以总的来说,它其实没有完全符合 开闭原则
//重构2通过类去实现(完全符合 开闭原则)
class game{
    constructor(name){
        this.name=name;
    }
    setColor(){}
    openDialog(){}
}

class LOL extends game{
    openDialog(){ //重写
        //折扣
    }
}

class PUBG extends game{
    setColor(){ //重写
        //高亮
    }
}

class xxx extends game { 
    setColor() {
        // ……置灰
    }
    openDialog() {
        // break 
    }
}

优点:

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

缺点:

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

单一职责原则(SRP)

实现方法

在 JavaScript 中,需要⽤到类的场景并不太多,单⼀职责原则更多地是被运⽤在对象或者⽅法级别上,通过解耦让每一个模块职责更独立。体现为:⼀个对象(方法 模块)只做⼀件事情

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

class PUBGManager { 
    openDialog() { 
        // 弹框  计算⾦额 
        setPrice();
    }
} 
const game = new PUBGManager(); 
game.openDialog(); // 弹框之后计算⾦额 所以它包含 弹框 & 计算⾦额 两部分能力(两个模块耦合) 
//所以它是不符合我们的 单一职责原则 的

// 重构  对这两个模块进行拆分
// gameManager.js - 业务
class PUBGManager {
    constructor(command) {
        this.command = command;
    } 
    openDialog(price) { 
        // 计算⾦额,这个功能其实是 optManager.js去实现的,所以和它是分隔开的
        this.command.setPrice(price); 
    } 
} 
// optManager.js - 核⼼库 
class PriceManager { 
    setPrice(price) { 
        // 配置⾦额 
    } 
} 
// main.js 
const exe = new PriceManager();
const pubg = new PUBGManager(exe);
pubg.openDialog(15);

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

  1. 如果随着需求的变化,有两个职责总是同时变化,那就不必分离他们。⽐如在 ajax 请求的时候,创建 xhr 对象和发送 xhr 请求⼏乎总是在⼀起的,那么创建 xhr 对象的职责和发送 xhr 请求的职责就没有必要分开;
  2. 职责的变化轴线仅当它们确定会发⽣变化时才具有意义,即使两个职责已经被耦合在⼀起,但它们还没有发⽣改变的征兆,那么也许没有必要主动分离它们,在代码需要重构的时候再进⾏分离也不迟;

所以,尽管在我们的常规思维中,总是习惯性地把⼀组相关的⾏为放到⼀起,如何正确地分离职责不是⼀件容易的事情:

  1. 我们受设计原则的指导;
  2. 我们未必要在任何时候都⼀成不变地遵守原则;

在方便性与稳定性之间要有⼀些取舍。具体是选择方便性还是稳定性,并没有标准答案,而是要取决于具体的应⽤环境。

优缺点

优点:
降低了单个类或者对象的复杂度,按照职责把对象分解成更⼩的粒度,这有助于代码的复⽤,也有利于进⾏单元测试。当⼀个职责需要变更的时候,不会影响到其他的职责;

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

依赖倒置原则(DIP)

面向抽象进行cosing,而不是对实现进行coding,降低需求与技术底层的耦合

实现方法

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

首先,我们会针对需求进行抽象。它的⼯作原理⼀般是在⼀个⼈与系统交互的复杂环境中,隐藏当前级别下的更复杂的实现细节,只关注核⼼功能。这样,当我们在与⼀个以⾼级层⾯作为抽象的系统协作时,我们仅仅需要在意,我们能做什么, 而不是我们如何做。(将一些需要的底层能力进行排列组合,最终得到我们最终需求的功能)

另外,你会针对你的抽象,有一些低级别的模块或者具体实现逻辑。这些东西与抽象是相反的。它们是被用于解决某些特定问题所编写的代码。它们的作⽤域仅仅在某个单元和⼦系统中。比如,建立⼀个与 MySQL数据库的连接就是⼀个低级别的实现逻辑,因为它与某个特定的技术领域所绑定

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

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

// 需求 
// 分享功能 
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();
store.rate.star(5);

// 重构 
// 暴露挂载 => 动态挂载
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 store2 = new Store( ); 
store.rate.star(5) ;

优缺点

优点:

  • 降低类间的耦合性;
  • 提升系统的稳定性;
  • 降低并⾏开发引起的⻛险;

缺点:

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

接口隔离原则(ISP)

多个专业的接口比单个大而全接口好用

实现方法

不应该强迫客户依赖于它们不⽤的⽅法。

当⽤户依赖的接⼝⽅法即便只被别的⽤户使⽤⽽⾃⼰不⽤,那它也得实现这些接⼝,换⽽⾔之,⼀个⽤户依赖了未使⽤但被其他⽤户使⽤的接⼝,当其他⽤户修改该接⼝时,依赖该接⼝的所有⽤户都将受到影响。这显然违反了开闭原则,也不是我们所期望的。

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

ISP与SRP对⽐:

  1. 从原则约束来看:
    接口隔离原则更关注的是接⼝依赖程度的隔离
    单⼀职责原则更加注重的是接⼝职责的划分
  2. 从接⼝的细化程度来看:
    单⼀职责原则对接⼝的划分更加精细,
    接⼝隔离原则注重的是相同功能的接⼝的隔离
    接⼝隔离⾥⾯的最⼩接⼝有时可以是多个单⼀职责的公共接⼝;
  3. 单⼀职责原则更加偏向对业务的约束:接⼝隔离原则更加偏向设计架构的约束。这个应该好理解, 职责是根据业务功能来划分的,所以单⼀原则更加偏向业务;⽽接⼝隔离更多是为了“⾼内聚”,偏向架 构的设计;
// 需求 
// 开发游戏,快速生产游戏 
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();
// 重构 - 拆分多个专业接⼝,每个接⼝服务于单个功能模块 
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() {} 
}

优缺点

优点:

  1. 将臃肿庞⼤的接⼝分解为多个粒度⼩的接⼝,可以预防外来变更的扩散,提⾼系统的灵活性和可维护性
  2. 接⼝隔离提⾼了系统的内聚性,减少了对外交互,降低了系统的耦合性;

缺点:

  1. 颗粒度定义如果模糊:如果接⼝的粒度⼤⼩定义合理,能够保证系统的稳定性;然⽽,如果定义过⼩,则会造成接⼝数量过多,使设计复杂化;如果定义太⼤,灵活性降低,无法提供定制服务,给整体项⽬带来⽆法预料的⻛险;
  2. 过多的冗余代码:能减少项⽬⼯程中的代码冗余。过⼤的⼤接⼝⾥⾯通常放置许多不⽤的⽅法,当 实现这个接⼝的时候,被迫设计冗余的代码;

里氏替换原则(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() { 
        // tombStore
    }
    play() { 
        // 开始移动端游戏 
    }
}
const mobileGame = new MobileGame(); 
mobileGame.play();

// 重构 
class Game { 
    start() { 
        // 开机 
    }
    shutdown() {
        // 关机 
    }
}
class MobileGame extends Game { 
    tombStore() { 
        // tombStore 
    }
    play() { 
        // 开始移动端游戏 
    }
}
class PCGame extends Game { 
    speed() { 
        // speed
    }
    play() { 
        // 开始PC游戏 
    }
}

优缺点

优点:

  1. 合理得⽤类的继承关系,提⾼了代码的复⽤性,但也增强了类与类之间的耦合性;
  2. 通过建⽴抽象,运⾏过程中具体实现取代抽象,保证了系统的可拓展性;

缺点:
只要继承⽗类就拥有⽗类的全部属性和⽅法,这样减少了代码重复创建量共享了代码但也约束了⼦ 类的⾏为,降低了系统灵活性;