理解设计模式之“道”

在什么维度变化,就在什么维度抽象。


引语

设计模式是进入系统设计殿堂的入门明灯。我也是通过学习设计模式开始接触软件设计的。许多业务开发同学应该也听说过设计模式,了解和学习过部分设计模式。关于设计模式的书籍和文章也有很多,不过大多停留在“技”的层面,即:它看上去是什么样子,却鲜有深入到“道”的层面,即:它为何而诞生、它与其它模式的区别和关联。

本文从自己的学习、实践和理解,来谈谈设计模式之“道”。理解了“道”的层面,技法便如招式,万变不离其宗。

关注点分离(SOC)

要理解设计模式,就不能不提到一个统摄全局的思想: 关注点分离。

软件中弥漫着不计其数的大大小小的关注点。这些关注点可分为技术关注点和业务关注点。

技术关注点侧重于解决某一类技术性的通用性问题。比如缓存、并发、事务、代理、幂等、切面、异步、对象池、消息订阅与发送、海量数据存储与检索、RPC 通信、数据交换、负载均衡、限流、熔断器、健康检查、配置、日志、分页、文本正则匹配、模型-控制器-视图框架等。

业务关注点,侧重于描述某个业务点。比如订单已发货、订单全额退款、获取某个 K8S 集群下的所有 Pod 等都是业务关注点。业务关注点可大可小,小至一个业务常量,大至一个下单的基本流程。

“代码抽象与分层” 一文列举了代码里很多细小的抽象和关注点。

关注点分离是根本性的设计思想。可以推导出“语义与细节”分离思想,可以推导出“单一事实”(SRP)和“开闭原则”(OCP),可以推导出 “基于接口设计与编程”思想,可以推导出“封装、隐藏和多态”思想,可以推导出“组件与模块化”思想。

纵观各种设计模式,即是将关注点分离、组织和连接。从问题、领域、需求、事物中细致地分离出一个个细小的关注点(对象与行为),再将其合理有序地组织起来,是软件设计的基本功。

程序员的设计思维能力,体现在关注点的分离和组合上。普通程序员对关注点的思考是混沌的,挤在一团,因此写出的代码也是缠结在一起的面团;优秀程序员对关注点的思考是清晰有序细致的,因此写出来的代码简洁清晰。


SOLID

SOC 可推导出软件设计的五大原则: SOLID。 如果说 SOC 是软件设计的底层思考能力,那么 SOLID 是针对具体问题求解的思考指南和行动准则。

  • S:单一职责原则;一个方法只做一件事,一个事实或知识只出现一次。
  • O:开闭原则;对扩展开放,对修改关闭。当需求发生变化时,只需要新增方法或类,而不是修改已有方法或类。
  • L: 里氏替换原则;子类可以替换任何基类出现的地方。
  • I: 接口隔离原则;接口作为功能的抽象,通过接口的交互来构建应用的骨架。不同行为的抽象应当定义不同的接口。复合行为应当通过接口的组合来实现,而不是放在一个接口里。
  • D: 依赖倒置原则;高层模块不应该直接依赖低层模块的实现,而应该依赖低层模块提供的抽象,底层模块来实现这种抽象。

“【转载】一些软件设计原则 ” 一文是陈皓老师总结的关于软件设计的一些基本原则,推荐一看。

为什么要谈 SOC 和 SOLID ?设计模式和技法是建立在核心设计思想的基础上。理解了核心设计思想,模式和技法就自然明了于心。


基于接口设计与编程

如果说 SOC 和 SOLID 是战略层面的指导思想,那么基于接口设计与编程,就是实际的技法和战术。定义恰当的接口,正确实现接口,组织行为的交互,完成职责的闭环,这就是设计模式的基本实现套路。基于接口设计与编程,是实现“开闭原则”、“接口隔离原则”、“依赖倒置原则”、“封装与隐藏”的基本方法。

业务系统中的 Service, Repository 就是基于接口设计与编程的典型范例。虽然从日常开发角度来看,似乎有过度设计之嫌,但从技术重构和性能优化的角度来说,是有益的。比如,之前用 MySQL 关系型数据库来存储业务数据,后面要改用 NoSQL 来存储,那么,用 Repository 接口来解耦数据存储层面,就是很值得的。再比如,之前用简单方式实现某个关键业务逻辑,随着业务发展和数据量的大幅上涨,后续要替换成效率更高的实现。

“实现可扩展代码的四步曲 ” 一文给出了基于接口设计与编程来实现业务可扩展性的示例。


设计模式详述

很多设计模式在形式结构上都比较相似,导致许多童鞋分不清这些设计模式有什么区别和联系,学习起来不免有很多困惑。要理解设计模式,可以从它的场景、意图、隔离变化的角度来理解。“软件设计要素初探:基础设计模式概览 ” 一文给出了 24 种基础设计模式的全景式的概览。

设计模式的基本元素是对象和行为,以及行为的交互。下文说法中,“行为”、“算法”、“操作”、“职责”、“方法”,是从不同角度来看待对象的行为。“行为”是模式通用语,“算法”是实现步骤,“操作”是行为的效果,“职责”是行为的意图,“方法”则是特定编程语言实现。

策略模式

策略模式使得算法的实现与使用分离

策略模式是直接体现“基于接口设计与编程”的设计模式,也是设计模式初学者比较常用的“亲民模式”。由于结构简单明了,可以在很多设计模式里看到策略模式的影子。

策略模式的实现也很简单:定义一个策略行为接口,然后实现该接口在不同场景下的具体策略对象;在使用的地方,用该接口(而不是具体策略对象)调用即可。策略模式需要一个选择器,根据场景参数来选择合适的策略对象。这个选择器可以使用工厂模式来实现。

典型的应用场景是排序。定义一个排序接口,然后实现多种排序算法(冒泡排序、选择排序、归并排序、快速排序、堆排序等),针对具体排序场景的需求,选择合适的排序算法策略。


工厂模式

工厂模式使得对象的创建与使用相分离

所谓工厂,就是创造和管理对象的。比如,框架要使用一些业务对象,管理它们的依赖,但这些业务对象的创建是根据具体业务场景而定。工厂模式允许将这些业务对象的创建延迟到工厂的子类去实现,而框架只需持有工厂的对象创建接口即可。

实际业务系统会有大量的全局对象,这些对象之间可能有比较复杂的依赖关系。如何根据对象的属性和依赖自动化创建和返回这些全局对象,就是工厂要做的事情。Spring Bean 工厂就是工厂模式的范例。


策略模式与工厂模式

实际业务系统常常需要针对不同场景做相似事情。比如信用卡,有普通信用卡,有国际信用卡,都可以用来计费,但计费方式有所不同。新手往往会用 if-else 来实现。可以用策略模式来实现扩展。

有策略模式,就必然有策略对象。策略对象谁来创建,如何根据不同业务场景来返回相应的策略对象呢?这就是工厂模式要干的事情。

因此,工厂与策略模式,就是一对兄弟。工厂模式为策略模式提供创建对象的支撑。客户端只要持有和实现工厂接口,传入场景,就能拿到策略对象,通过策略接口来调用策略的实现。

状态模式与策略模式

状态模式使得对象的行为可以随着对象的状态而变化

状态模式看上去有点像策略模式。可以把状态模式看成策略模式在对象的多状态场景下的一种特化。具有行为的多个状态可以看成不同的策略对象。

与策略模式所不同的是,状态模式的状态对象是相互迁移转换的关系,TCP 状态机就是状态模式的典型应用。策略模式里的策略对象是相互独立的、可替换的关系。


抽象工厂模式

抽象工厂使得对象的不同风格、外观与对象本身的属性和行为相分离

抽象工厂模式是怎么回事?抽象工厂,即是对不同维度的同样对象创建的抽象。

比如 UI 外观。UI 有窗口和滚动条。Mac 风格的有 Mac 风格的窗口和滚动条, Windows 风格有 Windows 风格的窗口和滚动条,Gnome 风格有 Gnome 风格的窗口和滚动条。不同主题有不同主题的窗口和滚动条。UI 外观有一个主题风格的选择。那么,如果在应用主题的时候就能将所有 UI 组件的外观都设置为一致呢?

如果说工厂模式可以创建一系列相关对象 (A, B, C);抽象工厂模式是为了创建不同维度或风格的系列相关对象, (A', B', C'), (A'', B'', C'') 等。这些对象在指定维度保持一致的逻辑。抽象工厂接口提供创建对象的基本接口,具体工厂会在指定维度创建对应的对象。抽象工厂模式的工厂可以通过工厂模式来创建。

软件设计,就是一层一层之上的抽象。抽象设计至关重要。

生成器模式

生成器模式使得对象的表示与对象的构造过程相分离,相同的构造过程可以用于不同的表示,且保证复杂对象创建的完整性约束。组合模式的复合对象可以采用生成器模式来构造。

有时会看到,代码里有一些类,才两三个字段,也要用生成器模式搞一把。这是生成器模式的滥用。

为什么会有生成器模式?

生成器模式的主要场景是:复杂对象的生成。这种复杂对象往往包含大量子对象,且复杂对象的完整性依赖于部分必要子对象的完整性,而这些子对象的生成,往往分散在多个地方,需要逐步生成。只有这种复杂场景,才有必要使用生成器模式。如果不使用生成器模式,生成出的对象是残缺不全的,会影响应用程序的正确运行。

而代码里两三个字段使用生成器模式,好处是写代码看上去爽了,弊端是真正有用的代码被大量无用代码污染了。


原型模式

原型模式将同一类对象的差异分离

我很懒,但我很温柔。

原型模式的应用场景是: 有一个比较复杂的对象,只有极少数字段或行为发生变更了。我想克隆一个对象,设置这些变更的字段,或者设置新的行为, 就能得到一个新对象。抽象工厂可以使用原型模式来构造各种不同的具体工厂。

原型模式的一个实际应用是,生成有状态的 ProtoType Bean 对象。之前有遇到过一次线上生产故障,就是一个有状态的 Bean 没有设置为 @Scope("prototype") (Spring bean 默认是单例的),结果并发场景下,数据串了。

这种错误很隐微。你只能看出写出来的代码的问题,很难看出没有写出来的代码的问题。


单例

单例提供应用所依赖的服务的唯一的全局访问点

策略模式、外观模式、工厂模式、抽象工厂模式等都可以使用单例。

单例在概念上容易理解,主要是应用和实现的问题。什么时候用单例?你能写出几种单例的实现?你能正确写出在并发环境下的单例实现么?


组合模式与装饰器模式

组合模式和装饰器模式的结构很相似。组合模式中的对象具有相同的接口,可以统一无差别处理,装饰器模式的对象也具有相同的接口,可以统一无差别处理。那么,两种模式有什么差别和联系?

组合模式通常用于整体与局部的统一处理,整体和局部是有结构联系的(典型的是父子结构),处理结构往往是嵌套递归型的。组合模式完成指定整体功能目标; 而装饰器模式中,功能对象没有特定的结构联系,这些功能对象可以灵活添加,行为可以动态叠加组合。装饰器模式完成灵活可定制的功能目标

树形视图是组合模式的典型应用场景,而 IO 功能封装则是装饰器模式的典型应用场景。

组合模式与装饰器模式可以联用。可以在组合模式处理整体局部时动态添加额外的行为。

装饰器模式与代理模式

装饰器模式和代理模式,都是通过委托模式为目标对象封装额外行为。 两者有什么区别呢?

  • 装饰器模式,可以仅为单个对象动态增加行为,而不会影响到这个类的其它所有对象,致力于实现“个性化”、“定制化”需求。
  • 代理模式,通常是给类的所有对象都增加行为,会影响到类的所有对象。致力于实现“隐藏和保护”、“延迟加载”等通用需求。

装饰器可以和代理模式联用。在代理的基础上,动态添加额外行为。

职责链模式与命令模式

职责链模式使得请求的发送与处理相分离。有一类请求和一系列可以处理该类请求的处理器。请求发送者不知道谁能处理请求,或无需知道谁来处理这个请求,从而不用将请求的发起和特定的处理器强绑定在一起。职责链模式通常是链式结构,允许有多个处理器去处理同一个请求。 web 请求和异常处理机制使用了职责链模式。

命令模式使得请求的调用与处理相分离。将请求和接收对象封装成命令对象,传给调用者。调用者无需知道请求的具体细节及如何处理,只需要在合适的时候触发命令对象,命令对象会去调用接收对象处理自身的请求。命令模式是回调结构。DB 事务操作和 UI 菜单操作使用了命令模式。

这两种模式是可以组合使用的。可以把请求和接收对象封装成命令对象,用命令模式去调用,而接收对象去处理请求,又可以在内部采用职责链模式。命令模式也可以使用组合模式来构造更高抽象维度的命令。


适配器模式

适配器模式使得一种实现可以适配多个接口。适配器模式,其实就是“移花接木”之术。有一个已有类能够实现功能,但它的接口不是客户想要的。这时,可以实现一个适配类,这个适配类调用已有类的接口来实现客户指定的接口。

适配器模式有接口适配器和类适配器。两种适配器只是实现的不同,一个是委托模式,一个是继承模式。各有优缺点。而这种优缺点,与模式无关,而是与委托和继承模式有关。

前面说到关注点分离,一个意思是说,要把不同层次的东西分开。比如这个模式的优缺点,究竟是模式本身的问题,还是模式的具体实现方式导致的问题。这就是关注点分离的益处所在。


桥接器模式与适配器模式

桥接器模式在一套抽象操作定义和多套实现之间建立关联

桥接器模式与适配器模式有点相似。两者都是在接口与实现之间建立关联。适配器是在客户端层适配(应用层),桥接器是在底层或某个中间层连接(框架层)。桥接器不是适配,而是在定义与实现之间建立关联。

桥接器模式的典型场景就是驱动器选择。一个 JDBC 驱动实现,可以有不同的厂商实现(MySQL, Mongo, SQLite 等)。怎么去选择用哪个 JDBC 实现呢?就需要在底层去选择一个实现。选择具体实现用到了 SPI 技术。

外观模式和代理模式

外观模式和代理模式,都是为内部系统或内部对象提供对外的访问接口。区别在于:

  • 外观模式的内部是系统与子系统的关联(整体与局部),通常是为了屏蔽内部子系统的复杂性,对内部多个子系统或内部接口的组合,提供一个友好易用的统一接口;比如 Gateway ,API;Java 的编译类 Pattern.compile 也是如此。外观模式是迪米特法则的生动演绎:你只需要知道最少的知识就能使用一个复杂系统的能力,就像只需要会按按钮就能控制火箭升天。
  • 代理模式的内部是对象或服务级别的关联(平级对等),通常是基于某种缘由而提供的间接访问(不是组合,而是代理),比如保护内部对象、封装对象行为和新增代理行为、延迟访问等。典型应用场景是: 代理服务器、懒加载、切面。代理模式将封装行为与对象自身行为相分离

观察者模式

观察者模式使得变化的产生与变化的处理相分离。观察者模式解决的是变化级联场景: 一个对象发生变化了,与之关联的其它对象也需要发生相应的变化。

观察者模式往往会有一个分发器。这个分发器注册一系列监听器,监听对象变化。当对象发生变化时,这个分发器就会将变化分发给各个监听器去触发其各自的处理。观察者模式是“订阅-推送”消息中间件, MVC 框架的基础。

模板方法模式与策略模式

模板方法模式使得流程中的通用部分与差异部分相分离

流程中的差异部分通常会定义成抽象方法,使用子类来实现。模板方法模式与策略模式可以联用。模板流程里的差异部分可以采用策略模式来实现。


迭代器模式与访问者模式

迭代器模式和访问者模式都可用来访问聚合对象里面的元素,隐藏聚合对象内部的实现细节和复杂性。

迭代器模式使得容器对象与元素访问机制相分离。迭代器模式通常用于访问容器对象。容器对象里的元素通常是同一类型。同一迭代器接口可以适用于多种不同的容器对象,同一种容器对象可以使用不同的元素访问机制(比如顺序、逆序、随机序)。JDK 里的每一个容器都实现了迭代器。

访问者模式使得对象的操作容易扩展。对一个聚合对象内部的多个不同类型的子对象做不同的操作,并将这些操作的结果组合。子对象的操作接口被定义成访问者接口,子对象则为访问者接口的方法参数。这样,当需要针对这些子对象定义不同操作时,只要新增访问者即可,无需改变原有代码。不过,如果要新增子对象,则需要修改已有访问者。访问者模式的关键在于操作的扩展。

访问者模式的典型应用是 SQL 解析器。针对 SQL 的不同的解析子对象(from/where/group/orderby 等子句对象),定义不同的操作(值抽取、语法分析、代码生成等),最终将操作组合成目标代码。这些子对象是相对固定的,而操作则会不断扩展。


访问者模式与策略模式

在访问者模式里,不同的访问者对于同样的对象可以定义不同的操作,这些操作是可组合的;策略模式也可以对同样的对象定义不同的实现,但这些操作是相互独立的可替换的。

看上去,访问者模式也可以使用策略模式来实现。那么,两者的区别在哪里呢?变化维度不同。当对象经常发生变化、而操作极少变化时使用策略模式,因为在策略模式里,操作是相对固定的而对象是可扩展的;当操作经常发生变化、而对象极少变化时,此时使用访问者模式。因为访问者模式很容易针对已有对象增加新的操作,而策略模式则必须在每个已有策略类中增加新的行为,不符合开闭原则。这体现了开闭原则的核心思想:

在什么维度变化,就在什么维度抽象


中介者模式

中介者模式是对多对多关系的中心化管理。典型应用场景是服务注册中心和消息中间件。

中介者模式往往需要注册器、监听器和分发器,与观察者模式联用。当一个对象发生变化时,通知中介者,中介者将这个变化广播给所有监听器。

享元模式

享元模式是对大量公用的不可变的原子对象的共享。其应用场景是:需要创建大量的对象,而这些对象包含很多公用的原子对象,把这些原子对象提取出来,再组合成实际的对象,就能节省很多存储开销。

比如文档编辑软件,要创造很多文本。但这些文本公用 26 个大小写字符、10 个数字和一些特殊字符。这些字符就是享元对象,可以反复被复用,而无需每次都创建。


备忘录模式

提供历史操作的保存、记录、撤销、回溯等功能。


模式关联

将不同模式的关注点分离出来之后,就可以看出模式之间的关联关系。如下图所示。

  • 工厂模式和单例模式可以用来创建应用中所需的各种对象,原型模式可以便捷地从原有对象中创建新对象,享元模式可以在对象间共享大量原子对象;
  • 策略模式可以增加不同实现的灵活性和可替换性,模板模式可以将流程中的通用和差异分离出来,其中差异部分可以使用不同策略对象来实现,而工厂模式可以用来创建和选择策略对象。
  • 组合模式可以将原子对象组合成复合对象,统一无差别处理;装饰器模式可以为(原子或复合)对象动态添加行为,代理模式可以将目标对象隐藏在背后,提供封装行为;访问者模式可以为复合对象里的子对象添加扩展行为,迭代器可以为复合对象提供访问元素的能力,备忘录模式可以为复杂的访问过程提供访问历史记录和回退;
  • 观察者模式可以将变化在依赖关系中进行传播,中介者模式则可以对依赖关系进行集中管理,实现传播作用,中介者又可以通过策略模式提供多种传播实现;
  • 外观模式可以把多个复杂内部接口简化成一个统一易用的外部接口,适配器可以使得一个实现在多个接口间进行适配,桥接器可以在一套接口与多个实现建立关联,灵活地选择不同的实现,这其中又可运用到策略模式和工厂模式。

何时应用设计模式

几乎所有的设计模式都源于实际应用场景,是从实际应用场景中提炼出来的形式结构。纵观各种设计模式,其核心在于:对象职责和行为的定义与交互。指导思想是关注点分离, 技法是基于接口设计与编程。首先,将变化和不变的关注点分离出来,将变化的关注点通过接口定义来抽象,然后提供子类来实现抽象。

不必刻意追求应用设计模式。面对一个问题,先运用 SOC 思考和分离关注点,将关注点定义成接口和实现,然后组合起来解决问题。如果发现解决起来比较困难,或者发现写出来的代码很难扩展和维护,那么,这时候才是真正应用设计模式的时候。

好代码往往都是无模式的,感知不到自己应用了某种模式。


小结

SOC 和 SOLID 是理解设计模式的核心思想,基于接口设计与编程则是实现设计模式的基本套路。

设计模式要刻意练习,重点是培养设计思考能力;而真正实战时,则不必刻意追求设计模式。


参考文献

posted @ 2022-04-24 08:10  琴水玉  阅读(244)  评论(0编辑  收藏  举报