设计模式概述
Go不像Java那样,宗教式地完全面向对象设计。完全面向对象的设计有时并不能很好地描述这个世界。
- Java就好比手里握着锤子,看什么都是钉子,这与现实世界不符。
- 类描述单个事物还可以,一旦表达多个事物间的交互和复杂关系,其表现力就会遇到挑战,最后通过设计模式来弥补。
- 设计模式不是因为语言优秀带来的副产品,而是因为语言表现力不足而不得不依赖经验积累进行弥补。
- Go是面向工程的实用主义者,其继承了面向对象、函数式和过程式等多个范式语言的优点,使用函数、接口、类型组合、包等简单的语言特性,组合产生强大的表现力,可以轻松地构建大规模程序。
- Go将“函数”作为“第一公民”,使用类型函数可以简化设计模式的实现。
- 函数是一种类型,函数类型变量可以像其他类型变量一样使用,可以作为其他函数的参数或返回值,也可以直接调用执行。
- 函数支持多值返回。
- 支持闭包。
- 函数支持可变参数。
设计模式是针对软件开发中经常遇到的一些设计问题,总结出来的一套解决方案或者设计思路。大部分设计模式要解决的都是代码的可扩展性问题。设计模式相对于设计原则来说,没有那么抽象,而且大部分都不难理解,代码实现也并不复杂。这一块的学习难点是了解它们都能解决哪些问题,掌握典型的应用场景,并且懂得不过度应用。
设计模式要干的事情就是解耦。创建型模式是将创建和使用代码解耦,结构型模式是将不同功能代码解耦,行为型模式是将不同的行为代码解耦
经典的设计模式有 23 种。随着编程语言的演进,一些设计模式(比如 Singleton)也随之过时,甚至成了反模式,一些则被内置在编程语言中(比如 Iterator),另外还有一些新的模式诞生(比如 Monostate)。
1. 创建型
常用的有:单例模式、工厂模式(工厂方法和抽象工厂)、建造者模式。
不常用的有:原型模式。
- 单例模式
- 实现单例,要考虑的点:
- 声明为全局变量,且访问权限是私有的
- 考虑对象创建时的线程安全问题
- 考虑是否支持延迟加载
- 考虑GetInstance()性能是否高(是否加锁)
- 饿汉式:将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那我们在程序启动的时候就可以发现这个问题,而不是等到运行一段时间后突然发现。
- 懒汉式:支持延迟加载,比较省内存。
- 为何不推荐使用单例模式?
- 单例对OOP特性的支持不友好,违背了基于接口而非实现的设计原则。一旦该单例被修改了,那所有使用到这个单例的代码都要同步修改。
- 单例会隐藏类之间的依赖关系: 单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。
- 单例对代码的扩展性不友好:如果未来某一天,需要将单例类改为普通类呢?
- 在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。
- 但之后我们发现,系统中有些 SQL 语句运行得非常慢。这些 SQL 语句在执行的时候,长时间占用数据库连接资源,导致其他 SQL 请求无法响应。
- 为了解决这个问题,我们希望将慢 SQL 与其他 SQL 隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池。
- 单例对代码的可测试性不友好:单例的可变成员,会让写单元测试变得困难。
- 解决办法: 通过工厂模式来保证单例,或者程序员自己来保证(编写代码的时候自己保证不要创建两个类对象)
- 如何实现线程唯一单例:维护一个以线程id作为key,以单例对象为value的map。
- 如何实现集群唯一单例:把这个单例对象序列化并存储到外部共享存储区(比如文件)。
- 进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。
- 为了保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。
- 实现单例,要考虑的点:
- 原型模式:
- 如果对象的创建成本比较大,而同一个类的不同对象之间差别不大(大部分字段都相同),在这种情况下,我们可以利用对已有对象(原型)进行拷贝的方式来创建新对象,以达到节省创建时间的目的。
- 这种基于原型来创建对象的方式叫做原型设计模式。
- 何为“对象的创建成本比较大”?
- 对象的数据需要经过复杂的计算才能得到(比如排序、计算哈希值)
- 需要从rpc、网络、数据库、文件系统等非常慢速的IO中读取
- 原型模式的实现方式:深拷贝和浅拷贝
- 浅拷贝:浅拷贝对于指针类数据,只会拷贝地址,不会拷贝数据。
- 深拷贝:深拷贝对于指针类数据,会查找它的数据,并拷贝它们。
- 递归拷贝对象、对象的引用对象以及引用对象的引用对象……直到要拷贝的对象只包含基本数据类型数据,没有引用对象为止。
- 先将对象序列化,然后再反序列化成新的对象。
- 工厂模式:代码中存在if-else分支判断,动态地根据不同的类型创建不同的对象,需要使用工厂模式将这坨if-else代码抽象出来。
- 简单工厂:将所有的创建逻辑都放到一个工厂类中,会导致这个工厂类变得很复杂,适合对象的创建逻辑比较简单的场景。
- 工厂方法:嵌套了两层简单工厂,外层工厂用来创建具体的内层工厂,而内层工厂用来new具体的对象
- 将复杂的创建逻辑拆分到多个工厂类中,让外层的工厂不至于过于复杂,适合对象的创建逻辑比较复杂的场景。
- 抽象工厂:在简单工厂和工厂方法中,类只有一种分类方式,如果出现多种分类方式呢?比如:
针对这种场景,可以让内层工厂创建多个不同类型的对象(IRuleConfigParser、ISystemConfigParser 等),而不是只创建一种 parser 对象针对规则配置的解析器:基于接口IRuleConfigParser JsonRuleConfigParser XmlRuleConfigParser YamlRuleConfigParser PropertiesRuleConfigParser 针对系统配置的解析器:基于接口ISystemConfigParser JsonSystemConfigParser XmlSystemConfigParser YamlSystemConfigParser PropertiesSystemConfigParser
2. 结构型(总结了一些类或对象组合在一起的经典结构,这些经典的结构可以解决特定应用场景的问题)
常用的有:代理模式、装饰者模式(OCP)、桥接模式、适配器模式。
- 代理模式:
- 代理类和原始类实现相同的接口,原始类只负责业务功能,代理类负责在业务代码执行前后附加其他逻辑代码,并通过委托的方式调用原始类来执行业务代码。
- 如果原始类并没有定义接口,并且原始类代码并不是我们开发维护的,我们没有办法修改原始类,给它重新定义一个接口。这种情况下,该如何实现代理?
- 这种情况在golang中不会出现,因为golang的interface是duck-typing类型的。
- 动态代理:为了避免为每个原始类的所有方法重复实现一遍,就需要使用到动态代理。
- 动态代理:不事先为每个原始类编写代理类,而是在运行的时候动态地创建原始类对应的代理类,然后在系统中用代理类替换掉原始类。
- golang暂时不支持aop, 所以不支持动态代理。(能利用golang的反射实现动态代理吗?)
- 插件和中间件机制:golang
- 代理模式常用在业务系统中开发一些非功能性需求,比如:监控、统计、鉴权、限流、事务、幂等、日志,RPC、缓存。
- 代理模式在不改变原始类接口的条件下,为原始类定义一个代理类,主要目的是控制访问,而非加强功能,这是它跟装饰器模式最大的不同。
- 装饰器模式:
- 装饰器模式相对于简单的组合关系,还有两个比较特殊的地方:
- 可以对原始类“嵌套”多个装饰器类。
- 装饰器类是对功能的增强。
- 装饰者模式在不改变原始类接口的情况下,对原始类功能进行增强,并且支持多个装饰器的嵌套使用。
- 装饰器模式相对于简单的组合关系,还有两个比较特殊的地方:
- 桥接模式[最难理解]:
- 将抽象和实现解耦,让它们可以独立变化。
database/sql
本身就相当于“抽象”。注意,这里所说的“抽象”,指的并非“抽象类”或“接口”,而是跟具体的数据库无关、被抽象出来的一套“类库”。具体的github.com/go-sql-driver/mysql
就相当于“实现”。注意,这里所说的“实现”,也并非指“接口的实现类”,而是跟具体数据相关的一套“类库”。database/sql
和github.com/go-sql-driver/mysql
独立开发,通过对象之间的组合关系,组装在一起,database/sql
的所有逻辑操作,最终都委托给github.com/go-sql-driver/mysql
来执行。- 桥接模式的目的是将接口部分和实现部分分离,从而让它们可以较为容易、也相对独立地加以改变。
- 适配器模式:
- 一般来说,适配器模式可以看作一种“补偿模式”,用来补救设计上的缺陷。应用这种模式算是“无奈之举”。如果在设计初期,我们就能协调规避接口不兼容的问题,那这种模式就没有应用的机会了。
- 什么情况下会出现接口不兼容呢?
- 封装有缺陷的接口设计
- 统一多个类的接口设计: 某个功能的实现依赖多个外部系统,由于每个外部系统的接口都是不相同的,为了统一对它们的接口调用,就需要使用到适配器模式。
- 替换依赖的外部系统
- 兼容老版本接口
- 适配不同格式的数据
- 适配器模式是一种事后的补救策略。适配器提供跟原始类不同的接口,而代理模式、装饰器模式提供的都是跟原始类相同的接口。
3. 行为型(类或对象之间的交互)
常用的有:观察者模式、模板模式(OCP)、职责链模式(OCP)、迭代器模式、策略模式(OCP)、状态模式(OCP)。
- 观察者模式:
- 在对象之间定义一个一对多的依赖,当一个对象状态改变的时候,所有依赖的对象都会自动收到通知。
- 根据应用场景的不同,观察者模式会对应不同的代码实现方式:有同步阻塞的实现方式,也有异步非阻塞的实现方式;有进程内的实现方式,也有跨进程的实现方式。
- 模板模式:
- 模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。
- 这里的“算法”,我们可以理解为广义上的“业务逻辑”,并不特指数据结构和算法中的“算法”。
- 这里的算法骨架就是“模板”,包含算法骨架的方法就是“模板方法”,这也是模板方法模式名字的由来。
- 模板方法和回调的区别:
- 相对于普通的函数调用来说,回调是一种双向调用关系。A 类事先注册某个函数 F 到 B 类,A 类在调用 B 类的 P 函数的时候,B 类反过来调用 A 类注册给它的 F 函数。这里的 F 函数就是“回调函数”。A 调用 B,B 反过来又调用 A,这种调用机制就叫作“回调”。
- 回调可以分为同步回调和异步回调(或者延迟回调)。同步回调指在函数返回之前执行回调函数;异步回调指的是在函数返回之后执行回调函数。
- 从应用场景上来看,同步回调看起来更像模板模式,异步回调看起来更像观察者模式。
- 模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。
- 责任链模式:
- 将请求的发送和接收解耦,让多个接收对象都有机会处理这个请求。将这些接收对象串成一条链,并沿着这条链传递这个请求,直到链上的某个接收对象能够处理它为止。
- 在 GoF 的定义中,一旦某个处理器能处理这个请求,就不会继续将请求传递给后续的处理器了。当然,在实际的开发中,也存在对这个模式的变体,那就是请求不会中途终止传递,而是会被所有的处理器都处理一遍。
- 在职责链模式中,多个处理器(也就是刚刚定义中说的“接收对象”)依次处理同一个请求。一个请求先经过 A 处理器处理,然后再把请求传递给 B 处理器,B 处理器处理完后再传递给 C 处理器,以此类推,形成一个链条。链条上的每个处理器各自承担各自的处理职责,所以叫作职责链模式。
- 应用场景:
- 利用职责链模式来过滤敏感词。
- 实现过滤器和拦截器
- 将请求的发送和接收解耦,让多个接收对象都有机会处理这个请求。将这些接收对象串成一条链,并沿着这条链传递这个请求,直到链上的某个接收对象能够处理它为止。
- 迭代器模式:
- 一个完整的迭代器模式,一般会涉及容器和容器迭代器两部分内容。
- 为了达到基于接口而非实现编程的目的,容器又包含容器接口、容器实现类,迭代器又包含迭代器接口、迭代器实现类。
- 容器中需要定义 iterator() 方法,用来创建迭代器。
- 迭代器接口中需要定义 hasNext()、currentItem()、next() 三个最基本的方法。
- 策略模式:
- 定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。
- 策略模式解耦的是策略的定义、创建、使用这三部分。
- 一个完整的策略模式应该包含的这三个部分:
- 策略的定义: 所有的策略类都实现相同的接口
- 策略的创建: 把根据 type 创建策略的逻辑抽离出来,放到工厂类中,以屏蔽创建细节
- 策略的使用: 策略模式包含一组可选策略,客户端代码一般在运行时动态确定使用哪种策略
- 在程序运行期间,根据配置、用户输入、计算结果等这些不确定因素,动态决定使用哪种策略
- 使用“查表法”,根据type查表替代根据type分支判断。
- 状态机:游戏、工作流引擎中常用的状态机是如何实现的?
- 有限状态机, Finite State Machine:
- 状态机有 3 个组成部分:状态(State)、事件(Event)、动作(Action)
- 事件也称为转移条件(Transition Condition)。事件触发状态的转移及动作的执行。
- 动作不是必须的,也可能只转移状态,不执行任何动作。
- 有限状态机的实现方式:
-
分支逻辑法: 对于简单的状态机来说,分支逻辑这种实现方式是可以接受的。但是,对于复杂的状态机来说,这种实现方式极易漏写或者错写某个状态转移。除此之外,代码中充斥着大量的 if-else 或者 switch-case 分支判断逻辑,可读性和可维护性都很差。
-
查表法
- 实际上,除了用状态转移图来表示之外,状态机还可以用二维表来表示(表中的斜杠表示不存在这种状态转移)
E1(Got MushRoot) E2(Got Cape) E3(Got Fire Flower) E4(Met Monster) Small Super/+100 Cape/+200 Fire/+300 / Super / Cape/+200 Fire/+300 Small/-100 Cape / / / Small/-200 Fire / / / Small/-300 - 相对于分支逻辑的实现方式,查表法的代码实现更加清晰,可读性和可维护性更好。
-
状态模式
- 在查表法的代码实现中,事件触发的动作只是简单的积分加减,但是,如果要执行的动作并非这么简单,而是一系列复杂的逻辑操作(比如加减积分、写数据库,还有可能发送消息通知等等),我们就没法用如此简单的二维数组来表示了。
- 状态模式通过将事件触发的状态转移和动作执行,拆分到不同的状态类中,来避免分支判断逻辑。
-
- 实际上,像游戏这种比较复杂的状态机,包含的状态比较多,我优先推荐使用查表法,而状态模式会引入非常多的状态类,会导致代码比较难维护。相反,像电商下单、外卖下单这种类型的状态机,它们的状态并不多,状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能会比较复杂,所以,更加推荐使用状态模式来实现。
- 有限状态机, Finite State Machine: