设计模式
是软件设计中常见问题的典型解决方案 每个模式就像一张蓝图 你可以通过对其进行定制来解决代码中的特定设计问题- 模式是针对软件设计中常见问题的解决方案工具箱 它们定义了一种让你的团队能更高效沟通的通用语言
- 不同设计模式在其复杂程度、细节层次以及应用范围等方面各不相同 此外 我们可以根据模式的目的来将它们划分为三个不同的组别
创建型模式
提供对象的创建机制 增加已有代码的灵活性和复用性结构型模式
介绍如何将对象和类组装成较大的结构 并同时保持结构的灵活和高效行为型模式
负责对象间的高效沟通和职责委派
创建型模式
- 在Go语言中 创建型模式(Creational Patterns)是一类用于处理对象创建的设计模式
- 它们的主要目标是提供一种灵活的方式来创建对象 同时隐藏对象创建的具体细节 从而降低代码的耦合度 并提高代码的可复用性和可维护性
- 比如标准库:
http.NewRequest()
bytes.NewReader()
md5.New()
等等 … - 创建型模式的核心思想是将对象的创建与使用分离 使得系统不依赖于具体的对象创建方式 而是依赖于抽象
单例模式(Singleton) ✔
- 单例模式(singleton) 保证一个类只有一个实例 并提供一个访问它的全局访问点
- 通常让一个全局变量成为一个对象被访问 但不能防止实例化多个对象
- 常见做法:让类自身负责保证它的唯一实例 这个类可以保证没有其他实例可以被创建 并且可以提供一个访问该实例的方法
适用场景
- 当某个对象只能存在一个实例的情况下使用单例模式 比如:
- 全局Config配置对象
- 全局Logger日志对象
- …
- 优点:
- 提供了对唯一实例的受控访问
- 由于在系统内存中只存在一个对象 因此可以节约系统资源 对于一些需要频繁创建和销毁的对象 单例模式可以提高系统性能
实现方式及解析
饿汉模式
- 在程序加载时就创建对象实例
- Go语言中我们可以放到
init
函数中 保证线程安全- 会减慢程序启动速度(
init
启动时会先加载) - 如果实例不被调用 则会浪费一段内存空间 特别是该对象比较大的时候
- 会减慢程序启动速度(
|
|
懒汉模式
- 在获取对象实例时 如果实例为空则创建
- 避免饿汉模式的内存空间浪费
- 在Go中并发非常容易 容易被不正确使用 导致忽略并发安全
|
|
- 在上述情况下 多个goroutine都可以执行第一个检查
- 并且它们都将创建该singleton类型的实例并相互覆盖 无法保证它将在此处返回哪个实例 并且对该实例的其他进一步操作可能与开发人员的期望不一致
- 如果有代码保留了对该单例实例的引用 则可能存在具有不同状态的该类型的多个实例 从而产生潜在的不同代码行为 这也成为调试过程中的一个噩梦 并且很难发现该错误
- 因为在调试时 由于运行时暂停而没有出现任何错误 这使非并发安全执行的可能性降到了最低 并且很容易隐藏开发人员的问题
双重检查模式(激进加锁)
- 解决懒汉模式的非线程安全 避免资源竞争导致数据不一致
- 潜在问题:
- 如果实例存在 没有必要加锁
- 每次请求都加锁(执行过多的锁定) 降低性能/增加瓶颈
|
|
- 优化:检查对应是否为空之后再加锁
|
|
- 上面的方式仍然不太完美 因为判断实例是否为空的过程仍然不是完全原子的
- 我们可以使用
sync/atomic
包 原子化加载并设置一个标志 该标志表明我们是否已初始化实例
|
|
Sync.Once
- Go语言单例惯用实现解析
- 上面实现原子操作的写法稍显繁琐
- Go标准库
sync
中的Once
类型 它能保证某个操作仅且只执行一次 sync.Once
源码
|
|
- 通过上面源码发现 我们可以借助这个实现只执行一次某个函数/方法
- once.Do()的用法如下
|
|
完整示例
- 使用sync.Once包是安全地实现此目标的首选方式
|
|
简单工厂模式(Simple Factory) ✔
- 简单工厂并不是一个正式的设计模式 而是一种编程习惯 它通过一个工厂类来封装对象的创建逻辑 客户端只需要传递参数给工厂类 由工厂类决定创建哪种对象
特点
- 只有一个工厂类 负责创建所有产品
- 通道条件判断(如
switch
或if-else
)来决定创建哪种产品
适用场景
- 产品种类少 且创建逻辑简单的场景
优点
- 简单易用 适合小型项目
缺点
- 不符合开闭原则(OCP) 新增产品时需要修改工厂类
开闭原则: 当需求发生变化时 可以通过增加新的代码来扩展系统的功能 而不是修改现有的代码
示例
|
|
工厂方法模式(Factory Method) ✔
- 上面的简单工厂模式 一个工厂就负责了多个产品的生产
- 工厂方法模式则是定义了一个创建对象的接口 但将具体的创建逻辑延迟到子类中 每个子类负责创建一种具体的产品
特点
- 每个产品对应一个工厂类
- 符合
开闭原则
新增产品时只需增加新的工厂类 无需修改现有代码 - 将对象的实例化推迟到用于创建实例的专用函数
适用场景
- 产品种类多 且创建逻辑复杂的场景
优点
- 符合开闭原则 扩展性强
- 每个工厂只负责一种产品的创建 职责单一
缺点
- 类的数量会增加 系统复杂度提高
示例
|
|
抽象工厂模式(Abstract Factory) ✘
- 抽象工厂模式提供一个创建一系列相关或相互依赖对象的接口 而无需指定它们的具体类 它适用于需要创建一组相关产品的场景
特点
- 每个工厂类可以创建多个相关产品
- 强调产品族的概念 例如GUI库中的不同风格组件(Windows风格、Mac风格)
适用场景
- 需要创建一组相关对象的场景
优点
- 可以创建一组相关对象 保证对象之间的兼容性
- 符合开闭原则 扩展性强
缺点
- 类的数量会增加 系统复杂度提高
- 新增产品族或产品等级结构时 需要修改抽象工厂接口及其所有实现类
示例
|
|
建造者模式(Builder) ✔
- 用于分步构建复杂对象
- 建造者模式的核心思想是将一个复杂对象的构建过程与其表示分离 使得同样的构建过程可以创建不同的表示
- 建造者模式特别适用于以下场景
- 对象的构建过程非常复杂 包含多个步骤
- 对象的构建过程需要支持不同的配置或表示
示例
|
|
原型模式(Prototype) ✘
- 它通过复制现有对象来创建新对象 而不是通过新建类的方式
- 原型模式的核心思想是利用对象的克隆能力 避免重复初始化 特别适用于创建成本较高的对象
示例
|
|
注意事项
- 使用原型模式 如果有引用类型 则需要考虑深拷贝和浅拷贝的问题
- 浅拷贝只复制对象本身而不复制其引用的对象 深拷贝则会递归地复制整个对象图
- 这需要根据需求选择适当的拷贝方式
结构型模式
- 结构型模式
Structural Pattern
它主要关注如何将类或对象组合成更大的结构 以便在不改变原有类或对象的情况下 实现新的功能或优化系统结构 - 结构型模式的核心思想是通过组合
Composition
而不是继承Inheritance
来实现代码的复用和扩展 - 它们帮助开发者设计出灵活、可扩展的系统结构 同时降低类与类之间的耦合度
外观模式(Facade) ✔
- 根据迪米特法则 如果两个类不必彼此直接通信 那么这两个类就不应当发生直接的相互作用
- Facade模式也叫外观模式 是由GoF提出的23种设计模式中的一种
- Facade模式为一组具有类似功能的类群 比如类库/子系统等等 提供一个一致的简单的界面
- 这个一致的简单的界面被称作facade
适用场景
- 复杂系统需要简单入口使用
- 客户端程序与多个子系统之间存在很大的依赖性
- 在层次化结构中 可以使用外观模式定义系统中每一层的入口 层与层之间不直接产生联系 而通过外观类建立联系 降低层之间的耦合度
优点
- 它对客户端屏蔽了子系统组件 减少了客户端所需处理的对象数目 并使得子系统使用起来更加容易
- 通过引入外观模式 客户端代码将变得很简单 与之关联的对象也很少
- 它实现了子系统与客户端之间的松耦合关系 这使得子系统的变化不会影响到调用它的客户端 只需要调整外观类即可
- 一个子系统的修改对其他子系统没有任何影响
缺点
- 不能很好地限制客户端直接使用子系统类 如果对客户端访问子系统类做太多的限制则减少了可变性和灵活性
- 如果设计不当 增加新的子系统可能需要修改外观类的源代码 违背了开闭原则
示例
|
|
- 示例2
|
|
适配器模式(Adapter) ✔
- 将一个类的接口转换成客户希望的另外一个接口
- 使得原本由于接口不兼容而不能一起工作的那些类可以一起工作
优点
- 将目标类和适配者类解耦 通过引入一个适配器类来重用现有的适配者类 无须修改原有结构
- 增加了类的透明性和复用性 将具体的业务实现过程封装在适配者类中 对于客户端类而言是透明的 而且提高了适配者的复用性 同一个适配者类可以在多个不同的系统中复用
- 灵活性和扩展性都非常好 可以很方便地更换适配器 也可以在不修改原有代码的基础上增加新的适配器类 完全符合开闭原则
缺点
- 适配器中置换适配者类的某些方法比较麻烦
示例
|
|
代理模式(Proxy) ✔
- 它通过提供一个代理对象来控制对另一个对象的访问
- 代理模式的核心思想是在不改变原始对象的情况下 通过代理对象来增强或限制对原始对象的访问
适用场景
延迟初始化
当对象的创建成本较高时 可以通过代理延迟对象的初始化访问控制
通过代理对象限制对原始对象的访问权限日志记录
通过代理对象记录对原始对象的访问日志缓存
通过代理对象缓存原始对象的结果 避免重复计算
示例
|
|
组合模式(Composite) ✘
- 它允许你将对象组合成树形结构来表示
部分-整体
的层次关系 - 组合模式让客户端可以统一地处理单个对象和对象组合
适用场景
- 文件系统:如目录和文件的管理 可以通过组合模式将文件夹视为组合节点 文件视为叶子节点
- 组织结构:如公司内部的部门和员工关系 可以通过组合模式将部门视为组合节点 员工视为叶子节点
优点
- 简化客户端代码:客户端可以一致地对待单个对象和对象组合 而不需要关心它们的具体类型
- 增强灵活性:可以在不修改现有代码的情况下轻松添加新的组件或修改现有组件的结构
- 提高可扩展性:支持递归组合 使得复杂的层次结构易于构建和维护
示例
|
|
享元模式(Flyweight) ✘
- 它通过共享对象来减少内存使用和提高性能
- 享元模式的核心思想是将对象的共享部分(内部状态)与不可共享部分(外部状态)分离 从而减少重复对象的创建
- 享元模式的核心思想
共享对象
享元模式通过共享相同的内在状态(内部状态)来减少内存使用分离状态
将对象的状态分为内部状态(可共享)和外部状态(不可共享)工厂管理
使用工厂模式来管理和复用享元对象
示例
- 在数据库操作中 创建和销毁连接是非常耗时的操作
- 为了提高性能 通常会使用连接池来管理数据库连接 连接池的核心思想是:
- 复用连接:从连接池中获取连接 使用完后将连接释放回连接池 而不是销毁
- 减少开销:避免频繁创建和销毁连接 从而提高性能
|
|
装饰模式(Decorator) ✔
- 它允许你动态地为对象添加行为或职责 而不需要修改对象的原始类
- 通过引入装饰者类 可以在运行时灵活地组合不同的功能 而不需要创建大量的子类
- 装饰者模式的核心思想是将对象包装在一个或多个装饰者中 每个装饰者都可以在调用被装饰对象的方法之前或之后添加额外的行为
优点
- 动态扩展功能
- 你可以在运行时为请求处理器添加日志记录和性能监控功能 而无需修改核心请求处理器的代码
- 单一职责原则
- 每个装饰器只负责一个特定的功能(如日志记录或性能监控) 符合单一职责原则
- 灵活性
- 可以轻松地添加或移除装饰器 而不会影响其他部分的代码
示例
- 假设你正在开发一个Web服务 其中有一个核心功能是处理用户请求
- 现在 你需要在不修改核心功能代码的情况下 为请求处理添加以下功能
- 日志记录:记录每个请求的详细信息
- 性能监控:记录每个请求的处理时间
- 使用装饰器模式 你可以轻松地实现这些功能 而无需修改原始请求处理逻辑
|
|
桥模式(Bridge) ✘
- 它的核心思想是将抽象部分与实现部分分离 使它们可以独立变化
- 通过这种方式 桥接模式能够避免类的数量爆炸(即类的组合呈指数增长) 同时提高代码的可扩展性和可维护性
示例
- 假设你有两台电脑:一台 Mac 和一台 Windows 还有两台打印机:爱普生和惠普
- 这两台电脑和打印机可能会任意组合使用
- 刚开始功能会这样写 windows组合爱普生和惠普 mac组合爱普生和惠普 打印就调用自己的print方法
|
|
- 但是 如果引入新的打印机 代码量会成倍增长
- 所以 我们需要把计算机和打印机解耦 计算机的print的方法 就桥接到选择的打印机的print方法上
|
|
优点
- 解耦
- 抽象部分和实现部分可以独立变化 互不影响
- 修改实现部分不会影响抽象部分的代码
- 灵活性
- 可以动态地切换实现部分(例如 运行时更换实现)
- 可扩展性
- 新增抽象部分或实现部分时 不需要修改现有代码
- 避免类爆炸
- 通过组合代替继承 避免了类的数量呈指数增长
行为型模式
- 行为型模式
Behavioral pattern
它主要关注对象之间的职责分配和通信方式 - 行为型模式的核心思想是通过定义对象之间的交互方式 来更好地实现系统的功能 同时降低对象之间的耦合度
中介者模式(Mediator)
- 它通过引入一个中介者对象来封装一组对象之间的交互
- 中介者模式的核心思想是 将对象之间的复杂交互集中到一个中介者对象中 从而减少对象之间的直接耦合
优点
- 减少耦合:对象之间不直接通信 而是通过中介者进行交互 减少了对象之间的耦合
- 集中控制:将对象之间的交互逻辑集中到中介者中 便于维护和扩展
- 简化对象职责:每个对象只需要关注自己的行为 而不需要知道其他对象的存在
示例
- 假设我们有一个聊天室系统 用户(同事类)之间通过聊天室(中介者)发送消息
- 用户之间不直接通信 而是通过聊天室来转发消息
|
|
观察者模式(Observer) ✔
- 它定义了对象之间的一对多依赖关系
- 当一个对象(被观察者)的状态发生改变时 所有依赖它的对象(观察者)都会收到通知并自动更新
- 观察者模式的核心思想是 解耦被观察者和观察者 使得它们可以独立变化
优点
- 解耦:被观察者和观察者之间是松耦合的 它们可以独立变化
- 支持动态注册和移除观察者:可以在运行时动态地注册和移除观察者
- 符合开闭原则:新增观察者时 不需要修改被观察者的代码
示例
- 假设我们有一个天气站(被观察者) 当天气数据更新时 通知多个显示设备(观察者)更新显示内容
|
|
命令模式(Command) ✔
- 它将请求封装为一个对象 从而使你可以用不同的请求对客户进行参数化 并且支持请求的排队、记录日志、撤销操作等功能
核心思想
- 将请求封装为对象:将每个请求(如方法调用)封装为一个独立的对象
- 解耦请求的发送者和接收者:发送者不需要知道接收者的具体实现 只需要通过命令对象来执行请求
- 支持扩展:可以轻松地添加新的命令 而不需要修改现有代码
示例
|
|
迭代器模式(Iterator)
- 它提供了一种方法顺序访问一个聚合对象中的各个元素 而又不需要暴露该对象的内部表示
- 迭代器模式的核心思想是将遍历逻辑从聚合对象中分离出来 使得聚合对象和遍历逻辑可以独立变化
适用场景
- 集合类库:如Go的 slice、map 等集合类型 可以通过迭代器模式提供统一的遍历接口
- 数据库查询结果:数据库查询结果可以封装为一个聚合对象 并提供迭代器遍历查询结果
- 文件系统遍历:文件系统中的目录和文件可以封装为一个聚合对象 并提供迭代器遍历文件系统
- 树形结构遍历:树形结构(如二叉树、多叉树)可以提供多种遍历方式(如深度优先、广度优先)
示例
- 假设我们有一个集合类BookCollection 它包含多本书
- 我们需要实现一个迭代器 用于遍历这个集合
|
|
模板方法模式(Template Method) ✔
- 它定义了一个算法的骨架 并将某些步骤延迟到子类中实现
- 模板方法模式的核心思想是 将算法的通用部分放在父类中 而将可变部分交给子类实现
- 通过模板方法模式 可以避免代码重复 并确保算法的结构不变
适用场景
框架设计
如Web框架中的请求处理流程(初始化、处理请求、返回响应等)工作流引擎
如订单处理流程(创建订单、支付订单、发货订单等)测试框架
如测试用例的执行流程(初始化、执行测试、清理资源等)游戏开发
如游戏角色的行为流程(移动、攻击、防御等)
优点
代码复用
将算法的通用部分放在父类中 避免了代码重复扩展性
新增子类时 只需要实现抽象方法 而不需要修改父类的代码控制算法结构
父类控制算法的结构 确保算法的步骤不会被子类改变
示例
- 假设我们有一个饮料制作系统 支持制作咖啡和茶
- 咖啡和茶的制作过程有一些共同的步骤(如烧水、倒入杯子) 也有一些不同的步骤(如冲泡咖啡、泡茶)
|
|
策略模式(Strategy) ✔
- 它定义了一系列算法 并将每个算法封装起来 使它们可以互相替换
- 策略模式的核心思想是将算法的使用与算法的实现分离 从而使得算法可以独立于客户端而变化
- 通过策略模式 可以在运行时动态地选择算法 而不需要修改客户端代码
适用场景
- 支付系统:如示例所示 支持多种支付方式
- 排序算法:支持多种排序算法(如快速排序、归并排序、冒泡排序等)
- 压缩算法:支持多种压缩算法(如ZIP、RAR、7z等)
- 路由算法:支持多种路由算法(如最短路径、最快路径、最少收费路径等)
优点
- 解耦:将算法的使用与算法的实现分离 使得算法可以独立于客户端而变化
- 易于扩展:新增算法时 只需要添加新的策略类 而不需要修改现有代码
- 动态选择算法:可以在运行时动态地选择算法 而不需要修改客户端代码
示例
- 假设我们有一个支付系统 支持多种支付方式(如信用卡支付、支付宝支付、微信支付等) 我们可以使用策略模式来实现支付方式的选择
|
|
状态模式(State)
- 它允许对象在其内部状态改变时改变其行为
- 状态模式的核心思想是将对象的状态封装成独立的类 并将对象的行为委托给当前状态对象
- 通过状态模式 可以将复杂的条件逻辑分散到多个状态类中 从而使得代码更加清晰和易于维护
优点
- 清晰的状态转换逻辑:将状态转换逻辑分散到各个状态类中 避免了复杂的条件语句
- 符合开闭原则:新增状态时 只需要添加新的状态类 而不需要修改现有代码
- 易于扩展:可以轻松地添加新的状态和行为
示例
|
|
备忘录模式(Memento)
- 它允许在不破坏封装性的前提下 捕获并外部化一个对象的内部状态 以便稍后可以将该对象恢复到之前的状态
- 备忘录模式的核心思想是 将对象的状态保存到一个备忘录对象中 并在需要时从备忘录中恢复状态
优点
- 这种方式非常适合需要支持撤销操作或状态管理的场景
- 比如文本编辑器、游戏存档、事务回滚等
- 在Go语言中 备忘录模式可以通过接口和结构体的组合来实现 代码简洁且易于扩展
示例
|
|
解释器模式(Interpreter)
- 它定义了一种语言的语法表示 并提供了一个解释器来解释这种语法
- 解释器模式通常用于处理类似编程语言、查询语言、规则引擎等场景
- 核心思想
- 定义语法规则:将语言的语法规则表示为一个抽象语法树(AST)
- 解释执行:通过解释器遍历抽象语法树 执行相应的操作
抽象语法树
- 抽象语法树(Abstract Syntax Tree-AST)是编译器和解释器中的一个核心概念
- 它是一种树形数据结构 用于表示源代码的语法结构
- AST 之所以被称为
抽象语法树
是因为它抽象掉了源代码中的一些具体细节 只保留了程序的逻辑结构 - 为什么叫 抽象语法树
- 抽象(Abstract)
- AST 并不包含源代码中的所有细节 比如括号、分号、空格等 它只关注程序的逻辑结构
- 例如 表达式 2 * (3 + 4) 的
AST
会抽象掉括号 只保留运算符和操作数的层次关系
- 语法(Syntax)
- AST 表示的是源代码的语法结构 而不是语义(即程序的含义)
- 例如 AST 可以表示一个
if
语句的语法结构 但不会解释if
语句的具体行为
- 树(Tree)
- AST是一种树形数据结构 每个节点表示一个语法结构(如表达式、语句、函数等)
- 树的层次结构反映了源代码的嵌套关系
- 抽象(Abstract)
- 比如:
|
|
- 对应的AST如下:
|
|
适用场景
- 数据库查询引擎:如 MySQL、PostgreSQL 等数据库的查询解析和执行
- 规则引擎:如业务规则引擎 用于解析和执行规则
- 模板引擎:如Go的
text/template
用于解析和执行模板 - 数学表达式计算:如计算器应用程序 用于解析和计算数学表达式
示例
- 以Go语言里面的模板字符串为例
|
|
- 这个是解释器模式的应用 我们需要了解内部的原理 然后自己手搓一个这样的模板字符串解析器出来
|
|
职责链模式(Chain of Responsibility)
- 是一种行为设计模式 它允许多个对象有机会处理请求 从而避免请求的发送者与接收者之间的耦合
- 责任链模式将这些对象连成一条链 并沿着这条链传递请求 直到有对象处理它为止
核心思想
- 解耦请求发送者和接收者:请求的发送者不需要知道具体由哪个对象处理请求 只需要将请求发送到链上即可
- 动态组合处理逻辑:可以动态地组合处理者 灵活地调整处理顺序或增加新的处理者
- 每个处理者只关心自己的职责:每个处理者只处理自己能处理的请求 不能处理的请求会传递给下一个处理者
适用场景
- 当一个请求需要经过多个对象处理 但具体由哪个对象处理不确定时
- 当需要动态指定处理请求的对象时
- 当希望避免请求发送者与接收者之间的紧密耦合时
- 实际应用场景
- 中间件
- 审批流程
示例
- 实现一下gin的中间件功能 中间件自己可以处理请求 可以拦截 可以放行
|
|
访问者模式(Visitor)
- 它允许你将算法与对象结构分离
- 访问者模式的核心思想是 将操作(算法)从对象结构中分离出来 使得可以在不修改对象结构的情况下定义新的操作
- 通过访问者模式 可以将对象结构的元素与作用于这些元素的操作解耦 从而使得操作可以独立变化
优点
- 解耦:将操作与对象结构分离 使得操作可以独立变化
- 扩展性:新增操作时 只需要添加新的访问者 而不需要修改对象结构。
- 符合开闭原则:对象结构对扩展开放 对修改关闭
示例
- 假设我们有一个文档对象模型(DOM) 包含多种元素(如文本、图片、表格等) 我们需要对这些元素进行不同的操作(如导出为PDF、导出为HTML等)
|
|
其余常用模式
- 除开23种标准模式及简单工厂模式之外的常用设计模式
对象池模式(Object Pool Pattern) ✔
- 创建型模式
- 对象池创建设计模式用于根据需求预期准备和保留多个实例
实现
|
|
用法
- 下面给出了一个关于对象池的简单生命周期示例
|
|
经验法则
- 对象池模式在对象初始化比对象维护成本更高的情况下非常有用
- 如果需求出现峰值 而不是稳定需求 则维护开销可能会超过对象池的优势
- 由于对象是预先初始化的 因此它对性能有积极影响
性能分析模式(Profiling Patterns)
Timing Functions
- 包装函数并记录执行
- 在优化代码时 通常需要快速简单的时间测量 以此来验证假设 而不是使用profiler工具/框架
- 可以使用
time
包和defer
语句执行时间测量
用法
|
|