前言

软件开发是一门技术,更是一门艺术。在学习开发的过程中,不管是阅读书籍和博客,还是浏览一些框架和语言的底层源码,我们常常发现设计模式贯穿其中。例如,在HTTP框架中的拦截器(或中间件)的设计,就是典型的职责链设计模式;Java中常见的各种Builder就是典型的建造者设计模式。

然而,对于为什么要这么用这些模式,这么用的好处是什么,以后在哪些场景可以也使用这种模式,我一直没有一个很清晰的概念。这些设计模式的知识点只是零零散散地出现在某些代码片段,一直没有系统的归纳和梳理过。

因此我打算开辟一个设计模式的专栏,将常见设计模式的使用场景、UML图、代码实现、典型案例等内容做一些归纳总结,也算是系统学习一遍设计模式。在这个专栏中,我将参照《设计模式的艺术》(刘伟.著)这本书,结合一些网络上的资料和自己开发过的系统,进行整理归纳,所有的引用资料会在文末标注。

设计模式的前世今生

由于本文是《动手学设计模式》系列的第一篇,因此会涉及到较多的概念部分,这些概念部分我都经过了压缩去除了一些重复性和废话的部分。

什么是模式?

在展开具体的设计模式之前,我们需要先了解究竟什么是模式。

模式是在特定环境下人们解决某类重复问题的一套成功有效的解决方案,20世纪90年代“四人组”(Gang of Four,简称GoF)归纳了常见的23种软件开发常见的设计模式,将模式的概念引入了软件开发领域。

软件模式的基础结构由四部分构成,分别是:问题描述、前提条件、解决办法、效果。实际上,软件模式并非仅限于设计模式,还包含架构模式、分析模式和过程模式等。每一个软件开发的生命周期都存在一些被认同的模式。

什么是设计模式?

狭义的设计模式,指的是GoF在其设计模式经典书籍中提到的23种设计模式。

广义上,设计模式是一套被反复使用、多数人知晓的、经过分类编目的代码设计经验的总结。设计模式方便程序员之间的沟通,一方面我们自己写的代码容易被别人理解,另一方面代码的复用性和可靠性也会得到提高。

设计模式由模式名称、问题、解决方案和效果组成。例如,单例模式是为了解决类实例化的问题,它可以保证整个程序只有一个对象实例。

设计模式的分类

在《设计模式的艺术》这本书中,作者将设计模式分为三类:创建型、结构型和行为型。

  • 创建型模式:主要用于描述如何创建对象,常见有5种。
  • 结构型模式;主要用于描述如何实现类或对象的组合,常见有7种。
  • 行为型模式:主要用于描述类或对象怎样交互以及怎样分配职责,常见有11种。

这本书还给出了各个设计模式的学习难度和使用频率,很有参考意义,这里直接引用原书的图片:
image.png

设计模式的用处与学习方式

学习设计模式的好处多多:

  1. 运用优秀专家总结的智慧提高自己的开发效率,避免重复性工作。
  2. 相当于程序员之间的语言,方便开发者之间沟通。
  3. 大部分设计模式兼顾了复用性和可扩展性,方便我们做系统设计。

如何学设计模式:

  1. 不只是看代码,更要看这个设计模式要解决的问题、什么时候可以使用、如何解决了问题、关键代码、UML图、典型案例。
  2. 多实践,用自己擅长的编程语言实践一遍。
  3. 不要滥用模式,比如做出用设计模式改造老项目的行为。

学习设计模式的神器:UML

UML简介

UML(Unified Modeling Language,统一建模语言)是一种面向对象程序设计的建模语言,它通过一些标准的图形符号和文字来对系统进行建模,用于对软件进行描述、可视化处理、构造和建立软件系统制品的文档。

UML由视图(View)、图(Diagram)、模型元素(Model Element)、通用机制(General Mechansim)四个部分组成。

  • View:包含用户视图(核心,描述需求)、结构视图(静态行为)、行为视图(动态行为,交互)、实现视图(物理文件的关系)和环境视图(物理元素的分布)。
  • Diagram:UML2.0包含13种图。(用例图、类图、对象图、包图、状态图、组合结构图、活动图、顺序图、通信图、定时图、交互概览图、组件图、部署图)
  • Model Element:UML图中的概念,如对象、关系。
  • General Mechanism:扩展机制,允许用户拓展UML。

类的UML图示

类的概念无须多说,OOP的核心就在于类。类存在职责之说,类的属性即类的数据职责,类的操作即类的行为职责。

在UML中,类使用包含类名、属性和操作且带有分隔线的长方形来表示:
image.png

注:上述图片中,+表示公有,-表示私有,#表示受保护。

类之间的关系

类之间的关系可以分为:关联关系、依赖关系、泛化关系以及接口与实现关系。

关联关系:成员变量

关联关系指的是一个类和另外一个类有联系,常表现为“一个对象作为另一个对象的成员变量”,它又可以继续细分为下面几种关联关系。

双向关联

例如顾客与商品有“购买”和“被卖给”的关系,在数据库中是多对多的关系。

image.png

单向关联

一对一的关系,例如一个顾客拥有一个收货地址。

image.png

自关联

常见如递归结构,如各种树型结构。

image.png

多重关联

也叫重数性关联关系,表示两个关联对象在数量上的关系。例如一个表单含有0到多个按钮。

image.png

聚合关系

整体和部分的关系,并且成员可以脱离整体独立存在。如汽车和零部件的关系。常通过构造注入或setter注入。

image.png

组合关系

整体和部分的关系,成员无法脱离整理而存在。例如嘴巴和头之间的关系。成员常在整体的构造方法中通过new实例化。

image.png

依赖关系:函数参数

依赖(Dependency)关系是一种使用关系,在需要表示一个事物使用另一个事物时使用依赖关系。理解依赖关系是我们理解依赖注入原则的基础,而理解依赖注入是我们理解SpringBoot的IoC容器以及Go的总线的基础。

大多数情况下,依赖关系体现在某个类的方法使用另一个类的对象作为参数。在UML中,依赖关系用带箭头的虚线表示,由依赖的一方指向被依赖的一方。

依赖关系的三种实现方式:

  1. 被依赖对象作为方法参数传入依赖者。如Driver对象有一个drive(Car car)方法。
  2. 一个类的方法中将另一个类的对象作为其局部变量
  3. 在一个类的方法中调用另一个类的静态方法

image.png

泛化关系:继承

泛化(Generalization)关系也就是继承关系,用于描述父类与子类之间的关系。

在UML中,泛化关系用带空心三角形的直线来表示。

image.png

接口与实现关系

可以直接用Java中的interface理解,在UML中,类与接口之间的实现关系用带空心三角形的虚线来表示。

image.png

七大面向对象设计原则

七大面向对象设计原则包括:

  • 单一职责原则(SRP,Single Responsibility Principle)
  • 开闭原则(OCP,Open-Closed Principle)
  • 里氏代换原则(LSP,Liskov Substitution Principle)
  • 依赖倒转原则(DIP,Dependency Inversion Principle)
  • 接口隔离原则(ISP,Interface Segregation Principle)
  • 合成复用原则(CRP,Composite Reuse Principle)
  • 迪米特法则(LoD,Law of Demeter)

单一职责原则

单一职责原则(Single Responsibility Principle,SRP)比较好理解:

  • 定义:一个类只负责一个功能领域中的相应职责。
  • 核心思想:类的职责越多,复用可能性越小,耦合度越大。因此,单一职责原则是实现高内聚、低耦合的指导方针,需要设计人员发现类的不同职责并将其分离。
  • 做法:根据职责拆分为多个类。
  • 案例:我们使用Golang开发一个资源预约系统,但是只是定义一个结构体ReserveCenter,将DB连接、DB增删改查、甚至Web层都作为这个类的成员方法。
    • 分析:这个类职责过重,任何一个部分需要修改时都会修改到整个类,因此需要用单一职责方式重构。
    • 类拆分:ReserveWeb负责HTTP请求,ReserveConn负责DB连接,ReServerDAO负责增删改查。

开闭原则

开闭原则(Open-Closed Principle,OCP):OOP的设计目标

  • 定义:一个软件实体应当对扩展开放,对修改关闭。即软件实体应尽量在不修改原有代码的情况下进行扩展。
  • 核心思想:如果一个软件设计符合开闭原则,那么可以非常方便地对系统进行扩展,而且在扩展时无须修改现有代码,使得软件系统在拥有适应性和灵活性的同时具备较好的稳定性和延续性。
  • 做法:抽象化设计,分离抽象层和实现层,用新增实现层实现扩展则无需修改其它代码。

里氏代换原则

里氏代换原则(Liskov Substitution Principle,LSP):可以理解为开闭原则的实现方式之一。

  • 定义:所有引用基类(父类)的地方必须能透明地使用其子类的对象。
  • 核心思想:里氏代换原则表明,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立。
  • 做法:在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。

依赖倒转原则

依赖倒转原则(Dependency Inversion Principle,DIP):OOP的实现机制

  • 定义:抽象不应该依赖于细节,细节应该依赖于抽象。换言之,要针对接口编程,而不是针对实现编程。
  • 核心思想:传递参数时或在关联关系中,尽量引用层次高的抽象层类。
  • 做法:依赖注入是指当一个对象要与其他对象发生依赖关系时,通过抽象来注入所依赖的对象。常用的注入方式有3种:构造注入、设值注入(Setter注入)和接口注入

案例

下面我们用一个案例说明这三种原则的运用。

当我们的业务中有多种数据存储介质时,我们的DAO层就需要接收到底是用DB存,还是用File存储。刚开始的设计方案如下:DAO层接收的参数是不同存储介质的实现类实例,每次需要新增存储介质或者修改存储介质,都需要修改DAO层的源码,这显然不符合开闭原则。

image.png

为了解决这个问题,我们可以引入抽象层。此时DAO层针对抽象层提供的接口编程,再将具体的类名存储到配置文件中,动态获取配置文件并加载实例。这样一来,修改实现层仅需要修改配置文件,新增实现层也不用动现有代码,符合开闭原则。

image.png

此三者的关系:开闭原则是目标,里氏代换原则是基础,依赖倒转原则是手段,它们相互补充,相辅相成,目标一致,只是分析问题时所站角度不同而已。

接口隔离原则

接口隔离原则(Interface Segregation Principle,ISP):

  • 定义:使用多个专门的接口,而不使用单一的总接口,即客户端不应该依赖那些它不需要的接口。
  • 核心思想:如果接口理解为一个类的方法集合,那么这个原则可以理解为划分角色,每个角色负责对应的工作;如果接口理解为语言级别的interface,那就是只提供客户端需要的接口,即定制化接口而非大接口。
  • 做法:一般而言,接口中仅包含为某一类用户定制的方法即可。

SRP关注的是类的设计,确保每个类都有清晰的责任边界。ISP关注的是接口的设计,确保客户端只需要知道它们实际需要的服务。

合成复用原则

合成复用原则(Composite Reuse Principle,CRP):

  • 定义:尽量使用对象组合,而不是继承来达到复用的目的。
  • 核心思想:继承的耦合度太高了,复用时要尽量使用组合/聚合关系(关联关系),少用继承。

白箱复用和黑箱复用

  • 白箱复用:继承复用会破坏系统的封装性,因为继承会将基类的实现细节暴露给子类,所以这种复用又称“白箱”复用。
  • 黑箱复用:聚合关系中,新对象可以调用已有对象的功能,这样做可以使得成员对象的内部实现细节对于新对象不可见。

Has-A的关系,应使用组合或聚合;如果是Is-A关系,可使用继承。

迪米特法则

迪米特法则(Law of Demeter,LoD):引入中间件

  • 定义:一个软件实体应当尽可能少地与其他实体发生相互作用。
  • 核心思想:如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用;如果其中一个对象需要调用另一个对象的方法,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度。
  • 做法
    • 在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改。
    • 在类的设计上,只要有可能,一个类应当设计成不变类。
    • 在对其他类的引用上,一个对象对其他对象的引用应当降到最低。

参考资料

  1. 《设计模式的艺术》(刘伟.著 清华大学出版社)