设计模式的使用套路

简介

是什么

设计模式是针对软件开发中经常遇到的一些设计问题,归纳出来的一套实用的解决方案或者设计思路。

局限性

设计模式本身是经验性的归纳,归纳法本身便容易发生对规律不完全归纳的情况。

每种设计模式是特定场景下较优的解决方案,不是语法规定,没有对与错,只有合不合适或者好与不好。

作用边界

作用效果
复用性
可维护性
可读性
稳健性
安全性
运行效率

难点

什么时候用,怎么用,该不该用

先说结论

不需要拿着23种设计模式往代码上套,而是应该在需要组织抽象、解耦结构时,像查字典一样查找需要使用的设计模式。

代码设计流程如下,主要关注虚线框中【查阅设计模式】的部分

设计流程.png

实现问题的方式

标准的软件开发中,功能实现部分主要流程是 需求分析->总体设计->详细设计->代码实现,但具体到一个方法,应如何套用这套流程?

自底向上和自顶向下

同样完成一项任务,通常有自底向上和自顶向下两种方法,以实现斐波那契数列为例(f(n) = f(n-1) + f(n-2)):

  • 自底向上

    function fib(n) {
      let pprev = 1;
      let prev = 1;
      if(n<=2) return 1
      for(let i=2; i<n; i++) {
          const oldPprev = pprev;
          pprev = prev;
          prev = pprev + oldPprev;
      }
      return pprev + prev
    }
  • 自顶向上

    // 缓存装饰器方法
    // js中的装饰器写法可参考: https://es6.ruanyifeng.com/#docs/decorator
    function cache(fn) {
      const cache = {}
      const proxyFunc = (n) => {
          if(n in cache) return cache[n];
          const rst = fn(n);
          cache[n] = rst;
          return rst;
      }
      this[fn.name] = proxyFunc;
      return proxyFunc;
    }
    // 主要代码
    @cache
    function fib(n) {
      if(n<3) return 1;
      return fib(n-1)+fib(n-2)
    }

    仅看此案例,自底向上的写法或许更清晰,且空间复杂度更低。

Question: 但若在此基础上,需要实现公式f(n) = f(n-1) + f((n-2)/2)

  • 自底向上的写法需要重构整个代码逻辑,自顶向上的写法只需要将主代码中的公式改成f(n) = f(n-1) + f(n/2)即可。

Question: 更进一步,需要实现一堆类似的公式,诸如f(n) = f((n-1)/3) + f((n-2)/2)...两种方法分别如何实现?

  • 对于自底向上的写法,每项公式都要单独实现一遍
  • 对于自顶向下写法,需要重构的仅仅是主要代码,而主要代码是一个递归式,继续自顶向下拆分递归,可以将递归分为以下三部分:

    1. 判断是否停止
    2. 停止时的回调
    3. 不停止则继续执行递归
    • 目标是创建生产不同公式代码的工具,可以联想到抽象工厂
    • 分析以上内容,可以发现主要代码的行为逻辑有迹可循
    • 抽象工厂 + 固定生产模式 = 建造者模式
interface Template<Args extends unknown [], R extends unknown> {
  isStop: (...args: Args)=>boolean;
  stopCallback: (...args: Args)=>R;
  recursiveCallback: (fn: (...args: Args)=>R, ...args: Args) => R
}

/**
 * @param template Template<[number], number>
 * @returns number
 */
function funcBuilder(template) {
    const {isStop, stopCallback, recursiveCallback} = template;
    @cache
    function func(n) {
        if(isStop(n)) {
            return stopCallback(n)
        }
        return recursiveCallback(func, n)
    }
    return func;
}

// 例子
const fibTemplate = {
    isStop: (n) => n<3,
    stopCallback: () => 1 ,
    recursiveCallback: (fn, n) => fn(n-1) + fn(n-2)
}
const fib = funcBuilder(fibTemplate);
// consr otherFunc = funcBuilder(otherTemplate);

两种实现方式对人的复杂度分析

假设一项任务工作量为n,这项任务有n个影响因子。

若采用自底向上的实现方式,没个工作量为1的单位,都要考虑n个影响因子,单项工作复杂度为1*n , 总体实现下来复杂度为n^2

若采用自定向下的实现方式,设工作为T, 假设实现T需要AB两个模块,解耦后的AB影响因子分别为n/2;再将A拆分为AAAB(影响因子分别为n/4)...
递归的进行拆分后,最后需要的任务为nt任务,影响因子分别为1,最后的复杂度为n(实现n个子任务t) + nlogn(logn次组织n个子任务),即nlogn

自顶向下的实现方式,并不能减少工作量,但每项子任务的实现都不需要考虑太多其他影响因素,总体而言对心智的消耗更低。

抽象和普遍规律

回头看再看之前提到的标准开发流程,其实是一套从抽象到具体的流程,无论概要设计中的分层,还是详细设计中模块的解耦,都是为了降低单个层、模块的影响因子,减少实现时的心智消耗。

从上面的例子可以看出,相较于自底向上,自顶向下的方式在实现逻辑上更为清晰,也更贴近从抽象到具体的实现方式;当一开始就拥有一个抽象的模型时,更容易提取出其中不变的部分,后续有新的需求拓展时,抽象的部分基本不需要太大的改动。

具体到业务开发时,对于新需求的不断拓展,通常有两种迭代模式:

  1. 先实现新的需求,后续优化过程中总结需求的相似点,重构代码
  2. 一开始做好设计,新需求仅需要注入功能代码,不需要对老代码作较大改动

第一种迭代模式,往往在最开始实现功能时,都没有采用自顶向下的实现方式,但即使采用了自顶向下的实现方式,也需要利用设计模式进行解耦,才能实现第二种迭代模式。

设计模式基本原则

设计的目的

对机器而言,代码的好坏由时间、空间的复杂度决定。对人而言,良好的抽象和封装降低了代码对人的复杂度。

代码设计的目的是为了写出对人而言,更简洁、更容易复用的代码。

简洁性

对于一个任务,如果有着清晰的输入和输出,需要考虑的外部因素尽可能少,且职责单一,那么可以任务这项任务足够简洁。

高复用

复用包括面向现有需求(可复用)和面向未来需求(可拓展)。前面提到了归纳普遍规律,普遍规律无疑是可复用的,因此在设计之初就应当遵守自顶向下从抽象到具体的设计方式,以便更好的归纳出可复用的点

基本原则

明确设计的目的后,再看七大基本原则:

  1. 开闭原则

    • 对扩展开放,对修改关闭
    • 以之前[自底向上和自顶向下]()章节例子来说,自顶向下法最后生成的公式建造器无疑是符合开闭原则的,无论后续需要生成什么递推式,都无需修改建造器内容,只需要拓展公式模板
    • 面对复杂业务时,如果能做到抽象出一条主线,再将主线中不确定的部分(变化的部分)交由外部实现,主线看做封闭部分,需要外部实现的内容作为开发部分,最后的结构都会符合开闭原则
  2. 里氏代换原则

    • 只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为
    • 对实现抽象化的具体步骤的规范。父类的定义是大纲,子类的具体实现不应超过大纲规定的范围
  3. 依赖倒转原则

    • 具体依赖于抽象,而不能反过来
    • 自顶向下的实现方式是一个从抽象到具体的过程,每一层抽象都只能依赖于上层的抽象,而不能依赖下一层的具体实现。
  4. 接口隔离原则

    • 使用多个隔离的接口,比使用单个接口要好
  5. 迪米特法则(最少知道原则)

    • 一个实体应当尽量少的与其他实体之间发生相互作用
  6. 单一职责原则

    • 一个类只负责一个功能领域中的相应职责
  7. 合成复用原则

    • 组合优于继承

归纳

前面总结了设计的目的是为了简洁性高复用,若将七大基本原则进行划分,可以大致理解为:

  1. 提高可复用性

    • 包含原则:开闭原则、里氏代换原则、依赖倒转原则。
    • 面对复杂业务时,先按照开闭原则,理清主线,将内容划分成不变的部分(主线,抽象的业务)和变化的部分(具体的业务)。
    • 为了抽象业务和具体业务最终都要转化成代码,我们需要一层层的具体实现,实现过程中,遵循里氏代换原则可保证实现不偏离规划,依赖倒转原则可以保证整体逻辑清晰。
  2. 提升简洁性

    • 包含原则:接口隔离原则、迪米特法则、单一职责原则,合成复用原则。
    • 业务拆分时,拆分后的子业务应保证职责单一,功能独立,输入输出固定,这样的子任务更容易整合和实现。

工具整理

基本思路

自顶向下法,一层一层从抽象到具体

基本原则

七大基本原则

该不该用

设计模式主要是解决重复性和可读性问题,好比管理方法,只有当手下足够多(重复性)、所处位置足够高(抽象层次高)时,才需要使用

分层技巧

遇事不决加一层

假设业务需求是要将大象放入冰箱,先不管能否实现,我们可将过程分为如下三层:

  1. 打开冰箱门
  2. 把大象放进冰箱
  3. 关闭冰箱门

问题在一二层之间,因此可以加一层,使其结构如下:

  1. 打开冰箱门
  2. 处理大象
  3. 把大象放进冰箱
  4. 关闭冰箱门

一开始分层时其实并不需要考虑层次之间是否能合并,画出完备的状态转移图后,可以用优化状态转移图的方法去优化层次的状态转移图,例如,用hopcroft算法进行状态合并:

层合并.png

模块解耦技巧

模块解耦其实是设计模式的应用,总体而言,解耦方式可以分为两类:

  1. 多个模块存在相互耦合时,添加一个中间模块处理耦合关系(例:中介者模式、IOC)
  2. 只处理主要流程,具体操作由外部传入(例:责任链模式、访问者模式)

根据实际情况,可以预设对应的解耦方式,并在下面设计模式字典中查找对应的设计模式。

例子1:

假设n个用户,各自都有计算需求,但只能共用3台计算器。

分析用户之间的关系,由于每次计算前需要判断其他用户是否计算完,直接处理的话,初始关系错综复杂。因此采取方案1,添加一个中间层去管理资源,示例代码如下:

class Calcutor {
    constructor(machineName) {
        this._machineName = machineName
    }
    getName() {
        return this._machineName
    }
    async calc(expression) {
        return await expression()
    }
}

class CalculatorManager {
    constructor(maxSize) {
        this._maxSize = maxSize;
        this._calculatorCount = 0;
        this._calculartorPool = [];
        this._quene = []
    }
    get _isValid() {
        return this._calculatorCount < this._maxSize
    }
    _createCalcutorInstance() {
        const calcutor = new Calcutor("机器" + this._calculatorCount);
        this._calculatorCount++;
        calcutor.decoCalcutor = async (...args) => {
            const res = await calcutor.calc(...args)
            this._calculartorPool.push(calcutor);
            this._allocation();
            return res;
        }
        return calcutor;
    }
    _allocation() {
        if(this._isValid) {
            this._calculartorPool.push(this._createCalcutorInstance())
        }
        if(this._calculartorPool.length && this._quene.length) {
            const activeCalculator = this._calculartorPool.shift();
            const demander = this._quene.shift();
            demander(activeCalculator)
        }
    }
    _getCalcutor() {
       return new Promise((resolve) => {
           this._quene.push(resolve);
           this._allocation()
       })
    }
    async calc(expression) {
        const calcutor = await this._getCalcutor();
        console.log(calcutor.getName());
        await calcutor.decoCalcutor(expression);
    }
}
// 测试...
const sleep = (time) => new Promise((resolve)=>{
    setTimeout(resolve, time)
})
const manager = new CalculatorManager(3);
let anchor = +new Date()
const getDiffTime = ()=>{
    console.log((~~(((+new Date()) - anchor)/100))/10)
}

manager.calc(async ()=>{
    console.log("task1")
    await sleep(3000);
});
manager.calc(async ()=>{
    console.log("task2")
    await sleep(2000);
});
manager.calc(async ()=>{
    console.log("task3")
    await sleep(1500);
});
manager.calc(async ()=>{
    console.log("task4")
    await sleep(2500);

});
manager.calc(async ()=>{
    console.log("task5")
    await sleep(1000);
});
例子2

如果想修改代码的某些结构,可以按照将代码转换为ast树 -> 遍历ast树 -> 转换代码的流程进行操作。

实际操作时,转换ast、遍历、转换代码都能由现成的库完成,用户只需要按照规范实现转换插件,如例子给未定义变量加上定义

树的操作常常使用访问者模式,用户的行为是无法预测的,但可以在对应阶段将关键内容暴露给用户,用户按照需求操作关键内容。

如下所示的简单操作,用户能自定义对树的节点进行各种操作,包括获取节点及路径信息,控制遍历是否停止等。

const traverseDFS = (root, visit) => {
    const paths = []
    const traverse = (node) => {
        if(!node) return;
        const bail = visit(node, [...paths]);
        if(bail) return;
        paths.push(node);
        (node.children || []).forEach(traverse);
        paths.pop();
    }
    traverse(root);
}
例子3

实际项目中,多个模块之间的耦合,常会用到IOC模式,而IOC也是 【添加中间层】+【抛出变化部分】两种方案的结合:

  • 多模块之间相互耦合:添加中介者
  • 中介者也无法事先预估模块之间的关系:中介者仅处理xml文件的解析,用户按照模块间的耦合关系,将其以xml模板的形式传入中介者

设计模式字典(可跳过)

设计模式的各类书籍和资料整理出了常见的23种设计模式,抽象过程中遇到的问题可以看做是钉子,常见的设计模式可以看做大小不一的锤子,应该遇见钉子时去找合适的锤子,而不是拿着锤子去找钉子。

具体参考文档:https://refactoringguru.cn/design-patterns

按目的分类

创建型: 这类模式提供创建对象的机制, 能够提升已有代码的灵活性和可复用性。

结构型:这类模式介绍如何将对象和类组装成较大的结构, 并同时保持结构的灵活和高效。

行为型:这类模式负责对象间的高效沟通和职责委派。

创建型结构型行为型
工厂方法模式适配器模式责任链模式
抽象工厂模式桥接模式命令模式
建造者模式组合模式迭代器模式
原型模式装饰模式中介者模式
单例模式外观模式备忘录模式
享元模式观察者模式
代理模式状态模式
策略模式
模板方法模式
访问者模式
解释器模式
按功能划分
创建型结构型行为型
对象创建工厂方法模式
抽象工厂模式
建造者模式
原型模式
单例模式
接口适配 适配器模式
桥接模式
外观模式
对象去耦 中介者模式
观察者模式
抽象集合 组合模式迭代器模式
行为拓展 装饰模式访问者模式
责任链模式
算法封装 模板方法模式
策略模式
命令模式
性能与对象访问 享元模式
代理模式
对象状态 备忘录模式
状态模式
其他 解释器模式

实例

假设需要让我们自己实现一个打包工具

1. 需求梳理

1.1 简单划分

自定向下梳理打包文件的过程,我们可以简单分成两块:

  1. 读取文件
  2. 打包文件
1.2 层次分析

假设已经实现了读取所有文件,此时我们开始打包,打包过程中,必然会涉及到代码的一系列优化,例如去除一些无用的空格和换行,简化变量的命名等,但文件以文本的形式读取,不同类型的文件如.xml.js.json解析的方式不一样

对此,可以加一层解析层去解析不同的文件,将其转换成打包层能识别的统一类型。

那么解析层应该放在哪?

├── 读取文件
└── 打包文件
    ├── 解析文件
    └── 正式打包

或者

├── 读取文件
├── 解析文件
└── 打包文件

参考合成复用原则原则,上面的方案解析文件层继承自打包文件层,两者逻辑上并没有完整的继承关系,因此用组合的方式更好,应选择下面一种方案。

输出分析后的层次:

  1. 读取文件
  2. 解析文件
  3. 打包文件
1.3 进一步具体
读取文件

文件之间存在依赖关系,读取入口文件后,需要沿着引用链向上查找依赖文件,由于存在循环引用,文件之间的依赖关系是呈网状,而非树结构;图的遍历和树的遍历,最大的区别是需要一个banList去判定该路径是否已经走过,因此该层可粗略划分为如下结构

├── 读取文件
    ├── 解析路径
    ├── 遍历依赖
    └── 记录已遍历的路径(整理依赖关系)
解析文件

以前端打包为例,这一层的目的是为了将所有读取的文件转换成.js文件, 无法所有会遇到的文件类型,因此暂且划分为如下结构

├── 解析文件
    ├── if(isJson()) then json解析器
    ├── if(isXML) then XML解析器
    └── ......
打包文件

得到.js类型的文件后,同样暂时无法预测此阶段会进行什么操作,暂且划分如下结构

└── 打包文件
    ├── 未知处理A
    ├── 未知处理B
    └── ......

2. 抽离主线,分离变化部分和不变部分

无论打包何种项目,主线基本上都是读取文件、解析文件和打包文件,因此我们可以将此流程作为主线,也就是封闭开发的封闭部分。

但这个过程中遇到了一些问题,解析文件和打包文件中存在着未知的部分,无法在抽离主线时处理可能遇到的所有问题(例如遇到特殊类型的文件,文件需要执行哪些处理操作)

此时可以参考前面的设计模式字典,看现有的设计模式能否解决这类问题。

由于解析文件和打包文件中,未知的部分是对指定文件的处理,抽象出来就是一堆if...else的判定,策略模式便是抽离这类判定的典型方法。

我们制定一个模板:

interface Template {
    include: (string|RegExp)[] | (id: string) => boolean;
    exclude: (string|RegExp)[] | (id: string) => boolean;
    handler: (fileData: string) => string
}

解析文件打包文件阶段不执行具体的操作,仅根据传入的模板,判定是否处理文件、如何处理文件,这样便将主线中变化的部分剔除,保证了主线的不变性。

3. 如何组织主线

组织主线良好可让开发部分更容易实现,假设不对打包主线进行组织,我们希望解析文件之前,打印开始解析,解析文件完毕之后,打印解析完毕,那么我们需要给解析文件层传入两个策略,分别位于数组的第一个和最后一个,而这两个策略的目的都是为了标记当前所处阶段,类型相同,我们应该将其内聚为一个策略。因此若不对主线进行组织,原本的结构难以实现高内聚低耦合。

组织主线属于行为型,因此可以参考前面的设计模式字典,查找行为型设计模式中有没有对应的解决方案,容易发现,这类有明显时间线的行为,可以用观察者模式进行组织。

每一层执行时,暴露对应的生命周期,同一类型的策略,可以分别在不同生命周期中执行不同的操作,以保证单个策略的高内聚。


goblin_pitcher
590 声望30 粉丝

道阻且长