type
Post
status
Published
date
Sep 25, 2022
slug
seven-design-principles-of-design-patterns
summary
tags
设计原则
category
设计模式
icon
password
零、设计模式概述
1、设计模式特点
- 重复问题的解决方案
- 高内聚低耦合,代码解耦
- 易维护和拓展,可重用性高,可读性强
- 代码流程标准化,效率化
2、设计模式分类
- 创建型模式
用于描述“怎样创建对象”,它的主要特点是“将对象的创建与使用分离”。包括单例、原型、工厂方法、抽象工厂、建造者等 5 种创建型模式。
- 结构型模式
用于描述如何将类或对象按某种布局组成更大的结构,包括代理、适配器、桥接、装饰、外观、享元、组合等 7 种结构型模式。
- 行为型模式
用于描述类或对象之间怎样相互协作共同完成单个对象无法单独完成的任务,以及怎样分配职责。包括模板方法、策略、命令、职责链、状态、观察者、中介者、迭代器、访问者、备忘录、解释器等 11 种行为型模式。
3、UML 类图各关系表示
- 关联关系:是依赖关系的特例
- 单向一对一
- 双向一对一
- 自关联:如链表
- 聚合关系:是关联关系特例,强调整体和部分,整体和部分可以分开
- 组合关系:是关联关系特例,强调整体和部分,整体和部分不可以分开
- 依赖关系:本类用到了其他类
- 类的成员属性
- 方法的返回类型
- 方法的参数类型
- 方法中使用到
- 继承(泛化)关系:父子类
- 实现关系:接口与实现类
4、设计思想
- 基于接口而非实现编程:将接口和实现相分离,封装不稳定的实现,暴露稳定的接口
- 函数的命名不能暴露任何实现细节,例如
uploadToAliyun()改为更加抽象的命名方式upload() - 封装具体的实现细节
- 为实现类定义抽象的接口
- 是否需要为每个类定义接口:某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了
- 多用组合少用继承
- 继承:is a 关系,继承关系深、继承关系复杂、破坏一定的封装性、不易于维护、可读性低
- 组合:has a 关系,接口和实现类 + 成员变量;
- 如何判断该用组合还是继承
- 组合意味着要做更细粒度的拆分,类和接口会增多,增加代码复杂度和可维护性
- 类继承结构稳定,继承层次不深,继承关系不复杂可以使用 继承
- 反之,使用 组合
- 设计模式中:装饰者模式、策略模式、组合模式等都使用了组合关系,而模板模式使用了继承关系
- 有些地方必须使用继承:例如修改一个外部类的encode方法,只能使用继承重写实现
5、设计原则
经典设计原则包括,SOLID、KISS、YAGNI、DRY、LOD 等:
- S:单一职责原则
- O:开闭原则
- L:里氏代换原则
- I:接口隔离原则
- D:依赖反转原则
- 迪米特法则
- KISS
- YAGNI
- DRY
一、开闭原则 OCP
Open Closed Principle
对扩展开放,对修改关闭。
想要达到这样的效果,我们需要使用接口和抽象类。
关于如何理解开闭原则,请看下方API 接口监控告警的一个例子!
1、原始代码
2、功能拓展
这样的代码修改实际上存在很多问题:
对接口进行了修改,调用这个接口的代码都要做相应的修改、相应的单元测试都需要修改!
我们需要重构一下之前的 Alert 代码,让它的扩展性更好一些:
- 第一部分是将 check() 函数的多个入参封装成 ApiStatInfo 类
- 第二部分是引入 handler 的概念,将 if 判断逻辑分散在各个 handler 中
3、结构改进优化
- 引入 handler ,各种 if 判断的逻辑处理交给 handler 使用循环统一处理
- 入参封装为一个类,便于拓展
- 定义 AlertHandler 抽象类,规定 check 接口
- 增加继承自 AlertHandler 的各个功能子类 handler
4、Alert 初始化及使用
ApplicationContext 是一个单例类,负责 Alert 的创建、组装(alertRule 和 notification 的依赖注入)、初始化(添加 handlers)工作:
测试代码:
5、对于增加新功能的改动
- 第一处改动是:在 ApiStatInfo 类中添加新的属性 timeoutCount。
- 第二处改动是:添加新的 TimeoutAlertHander 类。
- 第三处改动是:在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的timeoutAlertHandler。
- 第四处改动是:在使用 Alert 类的时候,需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。
6、修改代码就意味着违背开闭原则吗
先来看了解一下基本原则:
我们也没必要纠结某个代码改动是“修改”还是“扩展”,更没必要太纠结它是否违反“开闭原则”。我们回到这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。
针对上面的四处改动分别谈谈:
- 第一处改动,增加新的属性,对类来说是修改;在方法和属性层面是拓展
- 第二处改动,添加新的 handler ,属于拓展
- 第三四处改动,方法内部进行的改动,从哪个层面来说(类、属性、方法)都是修改
- 都不修改是不可能的
- 尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
二、里氏代换原则 LSP
Liskov Substitution Principle
里氏代换原则:子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。
按照协议来设计:子类在设计的时候,要遵守父类的行为约定(或者叫协议)。
- 父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。
- 这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。
如下方第二个子类的实现,是没有遵守父类的行为约定的,即 该子类抛出了父类不允许抛出的异常!
违反里氏代换原则的例子:
- 子类违背父类声明要实现的功能
- 子类违背父类对输入、输出、异常的约定
- 子类违背父类注释中所罗列的任何特殊说明
实际上,有没有发现,里式替换这个原则是非常宽松的。一般情况下,我们写的代码都不怎么会违背它。
三、单一职责原则 SRP
Single Responsibility Principle
一个类或者模块只负责完成一个职责(或者功能)。
换个角度来讲就是,一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。
如何判断类的职责是否足够单一?
结合应用场景,具体分析!需要时可以进行拆分为更细粒度的类,随着业务的发展进行持续重构!
类的职责是否设计得越单一越好?
不是,若是关联紧密的一个类,拆分后,可能更容易出现代码逻辑错误、维护性变低等情况!
四、接口隔离原则 ISP
Interface Segregation Principle
客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。客户端不应该依赖它不需要的接口,即 所依赖的接口中不要出现一定不会使用的方法!
这里的接口可以指:
- 类中的各个方法(不保留一定不用的)
- 方法的逻辑单一(一个方法功能不够单一)
接口隔离原则跟单一职责原则有点类似,不过稍微还是有点区别:
- 单一职责原则:针对的是模块、类、接口的设计
- 接口隔离原则:相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考的角度不同。它提供了一种判断接口是否职责单一的标准:
- 通过调用者如何使用接口来间接地判定
- 如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
五、依赖反转原则 DIP
1、控制反转(IOC)
- 控制:指的是对程序执行流程的控制
- 反转:指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程可以通过框架来控制。流程的控制权从程序员“反转”到了框架
- 实现控制反转的方法:有下方例子中所示的类似于模板设计模式的方法之外,还有马上要讲到的依赖注入等方法
控制反转并不是一种具体的实现技巧,而是一个比较笼统的设计思想,一般用来指导框架层面的设计。
由程序员控制流程:
由框架控制流程:
框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行。
2、依赖注入(DI)
依赖注入跟控制反转恰恰相反,它是一种具体的编码技巧。
一句话来概括就是:不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。
非依赖注入方式:
依赖注入方式:
将创建对象的步骤移动到了更上层(调用者)处创建罢了,进一步降低耦合度!
通过依赖注入的方式来将依赖的类对象传递进来,这样就提高了代码的扩展性,我们可以灵活地替换依赖的类。
我们还可以把 MessageSender 定义成接口,基于接口而非实现编程:
3、依赖注入框架(DI Framework)
当依赖许多类时,类对象的创建和依赖注入会变得非常复杂。如果这部分工作都是靠程序员自己写代码来完成,容易出错且开发成本也比较高。而对象创建和依赖注入的工作,本身跟具体的业务无关,我们完全可以抽象成框架来自动完成。
我们只需要通过依赖注入框架提供的扩展点,简单配置一下所有需要创建的类对象、类与类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。
Spring 框架的控制反转主要是通过依赖注入来实现的!
4、依赖反转原则
依赖反转:高层模块不要依赖低层模块。高层模块和低层模块应该通过抽象来互相依赖。除此之外,抽象不要依赖具体实现细节,具体实现细节依赖抽象。
在调用链上,调用者属于高层,被调用者属于低层。在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。实际上,这条原则主要还是用来指导框架层面的设计,跟前面讲到的控制反转类似。
六、迪米特法则
利用这个原则,能够帮我们实现代码的“高内聚、松耦合“!
1、何为“高内聚、松耦合”
- 是一个非常重要的设计思想,能够有效地提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。
- “高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。
- 高内聚有助于松耦合,松耦合又需要高内聚的支持。
高内聚?
指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,代码容易维护。单一职责原则是实现代码高内聚非常有效的设计原则。
松耦合?
在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。依赖注入、接口隔离、基于接口而非实现编程,以及迪米特法则,都是为了实现代码的松耦合!
2、迪米特法则
Law of Demeter
它还有另外一个更加达意的名字,叫作最小知识原则!
迪米特法则:每个模块只应该了解那些与它关系密切的模块的有限知识。或者说,每个模块只和自己的朋友“说话”,不和陌生人“说话”。
换句话说:不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口(也就是定义中的“有限知识”)。
从上面的描述中,我们可以看出,迪米特法则包含前后两部分,这两部分讲的是两件事情:
2.1 不该有直接依赖关系的类之间,不要有依赖
下方例子实现了简化版的搜索引擎爬取网页的功能:
- NetworkTransporter 类负责底层网络通信,根据请求获取数据;
- HtmlDownloader 类用来通过 URL 获取网页;
- Document 表示网页文档,后续的网页内容抽取、分词、索引都是以此为处理对象。
这段代码虽然“能用”,能实现我们想要的功能,但是它不够“好用”!
NetworkTransporter 类:作为一个底层网络通信类,我们希望它的功能尽可能通用,而不只是服务于下载 HTML,所以,我们不应该直接依赖太具体的发送对象 HtmlRequest。从这一点上讲,NetworkTransporter 类的设计违背迪米特法则,依赖了不该有直接依赖关系的 HtmlRequest 类。
有个形象的比喻。假如你现在要去商店买东西,你肯定不会直接把钱包给收银员,让收银员自己从里面拿钱,而是你从钱包里把钱拿出来交给收银员。这里的 HtmlRequest 对象就相当于钱包,HtmlRequest 里的 address 和 content 对象就相当于钱。我们应该把 address 和 content 交给 NetworkTransporter,而非是直接把 HtmlRequest 交给 NetworkTransporter。根据这个思路,NetworkTransporter 重构之后的代码如下所示:
HtmlDownloader 类:这个类的设计没有问题。不过,我们修改了 NetworkTransporter 的 send() 函数的定义,而这个类用到了 send() 函数,所以我们需要对它做相应的修改,修改后的代码如下所示:
Document 类:这个类的问题比较多,主要有三点。
- 构造函数中的 downloader.downloadHtml() 逻辑复杂,耗时长,不应该放到构造函数中,会影响代码的可测试性。
- HtmlDownloader 对象在构造函数中通过 new 来创建,违反了基于接口而非实现编程的设计思想,也会影响到代码的可测试性。
- 从业务含义上来讲,Document 网页文档没必要依赖 HtmlDownloader 类,违背了迪米特法则。
修改之后的代码如下所示:
2.2 有依赖关系的类之间,尽量只依赖必要的接口
Serialization 类负责对象的序列化和反序列化:
单看这个类的设计,没有一点问题。在一定的应用场景里,就还有继续优化的空间。
假设在我们的项目中,有些类只用到了序列化操作,而另一些类只用到反序列化操作。
那基于迪米特法则后半部分“有依赖关系的类之间,尽量只依赖必要的接口”,
根据这个思路,我们应该将 Serialization 类拆分为两个更小粒度的类,一个只负责序列化(Serializer 类),一个只负责反序列化(Deserializer 类)。拆分之后的代码如下所示:
尽管拆分之后的代码更能满足迪米特法则,但却违背了高内聚的设计思想。
高内聚要求相近的功能要放到同一个类中,这样可以方便功能修改的时候,修改的地方不至于过于分散。对于刚刚这个例子来说,如果我们修改了序列化的实现方式,比如从 JSON 换成了 XML,那反序列化的实现逻辑也需要一并修改。在未拆分的情况下,我们只需要修改一个类即可。在拆分之后,我们需要修改两个类。显然,这种设计思路的代码改动范围变大了。
如果我们既不想违背高内聚的设计思想,也不想违背迪米特法则,那我们该如何解决这个问题呢?
实际上,通过引入两个接口就能轻松解决这个问题,具体的代码如下所示:
2.3 拆分序列化类是否过度设计
具体问题具体分析,结合场景变动!
对于刚刚这个 Serialization 类来说,只包含两个操作,确实没有太大必要拆分成两个接口。
但是,如果我们对 Serialization 类添加更多的功能,实现更多更好用的序列化、反序列化函数,我们来重新考虑一下这个问题。修改之后的具体的代码如下:
此时只依赖序列化或只依赖反序列化的场景下,显然拆分开更加合适!
七、KISS原则和YAGNI原则
KISS原则
KISS 原则的英文描述有好几个版本,比如下面这几个:
- Keep It Simple and Stupid.
- Keep It Short and Simple.
- Keep It Simple and Straightforward.
即尽量保持简单!
KISS 原则就是保持代码可读和可维护的重要手段!
代码行数越少就越“简单”吗?
不是,正则检验IP合法性代码很少,但并不简单,正则本身复杂,容易出bug!
代码逻辑复杂就违背 KISS 原则吗?
在小规模字符串匹配问题中,使用KMP算法就属于违背原则;但在vim、word等场景下的大文本匹配使用KMP则不属于违背原则!
YANGI原则
YAGNI 原则的英文全称是:You Ain’t Gonna Need It。
直译就是:你不会需要它。不要去设计当前用不到的功能;不要去编写当前用不到的代码。
实际上,这条原则的核心思想就是:不要做过度设计。
例如:同事为了避免开发中 library 包缺失而频繁地修改 Maven 或者 Gradle 配置文件,提前往项目里引入大量常用的 library 包。实际上,这样的做法也是违背 YAGNI 原则的。
KISS 原则讲的是“如何做”的问题(尽量保持简单),而 YAGNI 原则说的是“要不要做”的问题(当前不需要的就不要做)。
八、DRY 原则
Don’t Repeat Yourself:不要写重复的代码。
三种典型的代码重复情况:
- 实现逻辑重复
- 功能语义重复
- 代码执行重复
1、实现逻辑重复
isValidUserName() 函数和 isValidPassword() 函数。重复的代码被敲了两遍,看起来明显违反 DRY 原则。进行重构,将 isValidUserName() 函数和 isValidPassword() 函数,合并为一个更通用的函数 isValidUserNameOrPassword()。
经过重构之后,代码行数减少了,也没有重复的代码了,是不是更好了呢?
答案是否定的!
合并之后的 isValidUserNameOrPassword() 函数,负责两件事情:验证用户名和验证密码,违反了“单一职责原则”和“接口隔离原则”!
- 代码实现逻辑上是重复的,但是语义(功能)上并不重复
- 若修改校验密码逻辑,合并后的逻辑显然不能使用
- 尽管代码的实现逻辑是相同的,但语义不同,我们判定它并不违反 DRY 原则
- 对于包含重复代码的问题,我们可以通过抽象成更细粒度函数的方式来解决。如抽取校验方法
2、功能语义重复
两个同时分别开发了校验IP合法性的方法,语义重复,逻辑不重复,违反了 DRY 原则!增加代码可读性!规则改变两处都得改变!
应该统一一种实现思路,所有用到判断 IP 地址是否合法的地方,都统一调用同一个函数。
3、代码执行重复
违反了 DRY 原则,代码中存在“执行重复”!
- email 的校验逻辑被执行了两次
- login() 函数并不需要调用 checkIfUserExisted() 函数,只需要调用一次 getUserByEmail() 函数
- 作者:NotionNext
- 链接:https://tangly1024.com/article/seven-design-principles-of-design-patterns
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

