设计模式的使用套路
简介
是什么
设计模式是针对软件开发中经常遇到的一些设计问题,归纳出来的一套实用的解决方案或者设计思路。
局限性
设计模式本身是经验性的归纳,归纳法本身便容易发生对规律不完全归纳的情况。
每种设计模式是特定场景下较优的解决方案,不是语法规定,没有对与错,只有合不合适或者好与不好。
作用边界
作用 | 效果 |
---|---|
复用性 | ✅ |
可维护性 | ✅ |
可读性 | ✅ |
稳健性 | ✅ |
安全性 | ✅ |
运行效率 | ❌ |
难点
什么时候用,怎么用,该不该用
先说结论
不需要拿着23种设计模式往代码上套,而是应该在需要组织抽象、解耦结构时,像查字典一样查找需要使用的设计模式。
代码设计流程如下,主要关注虚线框中【查阅设计模式】的部分
实现问题的方式
标准的软件开发中,功能实现部分主要流程是 需求分析->总体设计->详细设计->代码实现,但具体到一个方法,应如何套用这套流程?
自底向上和自顶向下
同样完成一项任务,通常有自底向上和自顶向下两种方法,以实现斐波那契数列为例(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)
...两种方法分别如何实现?
- 对于自底向上的写法,每项公式都要单独实现一遍
对于自顶向下写法,需要重构的仅仅是主要代码,而主要代码是一个递归式,继续自顶向下拆分递归,可以将递归分为以下三部分:
- 判断是否停止
- 停止时的回调
- 不停止则继续执行递归
- 目标是创建生产不同公式代码的工具,可以联想到抽象工厂
- 分析以上内容,可以发现主要代码的行为逻辑有迹可循
- 抽象工厂 + 固定生产模式 = 建造者模式
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
需要A
、B
两个模块,解耦后的A
、B
影响因子分别为n/2
;再将A
拆分为AA
、AB
(影响因子分别为n/4
)...
递归的进行拆分后,最后需要的任务为n
个t
任务,影响因子分别为1,最后的复杂度为n(实现n个子任务t) + nlogn(logn次组织n个子任务)
,即nlogn
自顶向下的实现方式,并不能减少工作量,但每项子任务的实现都不需要考虑太多其他影响因素,总体而言对心智的消耗更低。
抽象和普遍规律
回头看再看之前提到的标准开发流程,其实是一套从抽象到具体的流程,无论概要设计中的分层,还是详细设计中模块的解耦,都是为了降低单个层、模块的影响因子,减少实现时的心智消耗。
从上面的例子可以看出,相较于自底向上,自顶向下的方式在实现逻辑上更为清晰,也更贴近从抽象到具体的实现方式;当一开始就拥有一个抽象的模型时,更容易提取出其中不变的部分,后续有新的需求拓展时,抽象的部分基本不需要太大的改动。
具体到业务开发时,对于新需求的不断拓展,通常有两种迭代模式:
- 先实现新的需求,后续优化过程中总结需求的相似点,重构代码
- 一开始做好设计,新需求仅需要注入功能代码,不需要对老代码作较大改动
第一种迭代模式,往往在最开始实现功能时,都没有采用自顶向下的实现方式,但即使采用了自顶向下的实现方式,也需要利用设计模式进行解耦,才能实现第二种迭代模式。
设计模式基本原则
设计的目的
对机器而言,代码的好坏由时间、空间的复杂度决定。对人而言,良好的抽象和封装降低了代码对人的复杂度。
代码设计的目的是为了写出对人而言,更简洁、更容易复用的代码。
简洁性
对于一个任务,如果有着清晰的输入和输出,需要考虑的外部因素尽可能少,且职责单一,那么可以任务这项任务足够简洁。
高复用
复用包括面向现有需求(可复用)和面向未来需求(可拓展)。前面提到了归纳普遍规律,普遍规律无疑是可复用的,因此在设计之初就应当遵守自顶向下从抽象到具体的设计方式,以便更好的归纳出可复用的点
基本原则
明确设计的目的后,再看七大基本原则:
开闭原则
- 对扩展开放,对修改关闭
- 以之前[自底向上和自顶向下]()章节例子来说,自顶向下法最后生成的公式建造器无疑是符合开闭原则的,无论后续需要生成什么递推式,都无需修改建造器内容,只需要拓展公式模板
- 面对复杂业务时,如果能做到抽象出一条主线,再将主线中不确定的部分(变化的部分)交由外部实现,主线看做封闭部分,需要外部实现的内容作为开发部分,最后的结构都会符合开闭原则
里氏代换原则
- 只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为
- 对实现抽象化的具体步骤的规范。父类的定义是大纲,子类的具体实现不应超过大纲规定的范围
依赖倒转原则
- 具体依赖于抽象,而不能反过来
- 自顶向下的实现方式是一个从抽象到具体的过程,每一层抽象都只能依赖于上层的抽象,而不能依赖下一层的具体实现。
接口隔离原则
- 使用多个隔离的接口,比使用单个接口要好
迪米特法则(最少知道原则)
- 一个实体应当尽量少的与其他实体之间发生相互作用
单一职责原则
- 一个类只负责一个功能领域中的相应职责
合成复用原则
- 组合优于继承
归纳
前面总结了设计的目的是为了简洁性和高复用,若将七大基本原则进行划分,可以大致理解为:
提高可复用性
- 包含原则:开闭原则、里氏代换原则、依赖倒转原则。
- 面对复杂业务时,先按照开闭原则,理清主线,将内容划分成不变的部分(主线,抽象的业务)和变化的部分(具体的业务)。
- 为了抽象业务和具体业务最终都要转化成代码,我们需要一层层的具体实现,实现过程中,遵循里氏代换原则可保证实现不偏离规划,依赖倒转原则可以保证整体逻辑清晰。
提升简洁性
- 包含原则:接口隔离原则、迪米特法则、单一职责原则,合成复用原则。
- 业务拆分时,拆分后的子业务应保证职责单一,功能独立,输入输出固定,这样的子任务更容易整合和实现。
工具整理
基本思路
自顶向下法,一层一层从抽象到具体
基本原则
七大基本原则
该不该用
设计模式主要是解决重复性和可读性问题,好比管理方法,只有当手下足够多(重复性)、所处位置足够高(抽象层次高)时,才需要使用
分层技巧
遇事不决加一层
假设业务需求是要将大象放入冰箱,先不管能否实现,我们可将过程分为如下三层:
- 打开冰箱门
- 把大象放进冰箱
- 关闭冰箱门
问题在一二层之间,因此可以加一层,使其结构如下:
- 打开冰箱门
- 处理大象
- 把大象放进冰箱
- 关闭冰箱门
一开始分层时其实并不需要考虑层次之间是否能合并,画出完备的状态转移图后,可以用优化状态转移图的方法去优化层次的状态转移图,例如,用hopcroft算法进行状态合并:
模块解耦技巧
模块解耦其实是设计模式的应用,总体而言,解耦方式可以分为两类:
- 多个模块存在相互耦合时,添加一个中间模块处理耦合关系(例:中介者模式、IOC)
- 只处理主要流程,具体操作由外部传入(例:责任链模式、访问者模式)
根据实际情况,可以预设对应的解耦方式,并在下面设计模式字典中查找对应的设计模式。
例子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 层次分析
假设已经实现了读取所有文件,此时我们开始打包,打包过程中,必然会涉及到代码的一系列优化,例如去除一些无用的空格和换行,简化变量的命名等,但文件以文本的形式读取,不同类型的文件如.xml
、.js
、.json
解析的方式不一样
对此,可以加一层解析层去解析不同的文件,将其转换成打包层能识别的统一类型。
那么解析层应该放在哪?
├── 读取文件
└── 打包文件
├── 解析文件
└── 正式打包
或者
├── 读取文件
├── 解析文件
└── 打包文件
参考合成复用原则原则,上面的方案解析文件层继承自打包文件层,两者逻辑上并没有完整的继承关系,因此用组合的方式更好,应选择下面一种方案。
输出分析后的层次:
- 读取文件
- 解析文件
- 打包文件
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. 如何组织主线
组织主线良好可让开发部分更容易实现,假设不对打包主线进行组织,我们希望解析文件之前,打印开始解析
,解析文件完毕之后,打印解析完毕
,那么我们需要给解析文件层传入两个策略,分别位于数组的第一个和最后一个,而这两个策略的目的都是为了标记当前所处阶段,类型相同,我们应该将其内聚为一个策略。因此若不对主线进行组织,原本的结构难以实现高内聚低耦合。
组织主线属于行为型,因此可以参考前面的设计模式字典,查找行为型设计模式中有没有对应的解决方案,容易发现,这类有明显时间线的行为,可以用观察者模式进行组织。
每一层执行时,暴露对应的生命周期,同一类型的策略,可以分别在不同生命周期中执行不同的操作,以保证单个策略的高内聚。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。