首页 > 代码库 > 换种思路去理解设计模式(下)

换种思路去理解设计模式(下)

开写之前,先把前两部分的链接贴上。要看此篇,不许按顺序看完前两部分,因为这本来就是一篇文章,只不过内容较多,分开写的。

换种思路去理解设计模式(上)

换种思路去理解设计模式(中)

 

8       对象行为与操作对象

8.1     过程描述

所谓对象行为和操作对象,需要三方面内容:

操作过程:

一般表现为一个方法。该方法接收一个对象或者组合类型的参数,然后对这个对象或者组合进行操作,例如修改属性、状态或者结构等。

操作的对象或组合:

会作为实参传入操作过程,会被操作过程修改属性、状态或者结构等。

受影响的对象或组合:

由于修改其他对象或者组合,可能会影响到另外的对象或者组合,因此需要考虑这种影响关系,一般会用通知或者消息订阅的方式实现。

 

从上文的对象创建和对象组合两个模块,应该理解出在系统设计中会遇到各种复杂的情况。而对象操作比前两者更加复杂,因为前两者是创建一个静态的结构,而对象操作则是一个动态的变化。在日常的开发工作中,对象操作也是我们付出精力最多的地方。

下面我们就对象操作过程中遇到的一些常见情况做详细的分析。

8.2     情况1:“多配置”操作的优化

  当我们的一个方法因为需要实现多配置而不得不写许多条件判断语句时,我们会考虑将这个方法抽象出来,然后派生不同的子类。这是基本的设计思路。

  现在我们将这个情况复杂化,业务场景多了,一个方法无法实现这些功能,就需要拆分。

  

  如果这种情况下,再出现因为很多配置而不得不写许多条件判断语句时,我们肯定还需要再次考虑抽象和派生。效果如下图:

  

   这就是——模板方法模式

  理解这个模式其实很简单,只要知道根据多配置需要抽象、拆分即可。至于这里的“模板”,可根据实际情况来使用或者改变。

 

8.3     情况2:串行操作的优化

  针对对象进行操作时,类似于流程一样的串行操作,在系统中应用非常广泛。而且各个串行的节点都有相对统一的操作过程,例如工作流的每个审批节点,都会修改对象状态以及设置下级审批人等。

  遇到这种场景,我们最初会思考以下思路:

  

  后来随着系统的升级和变更,代码越来越多,维护越来越困难。我们会先考虑将每一步操作都独立成一个方法:

  

   一般的串行操作,可以用以上代码结构来处理,需要修改处可以再根据实际情况再重构。但如果串行操作中有条件因素,可能就有优化的空间了。如下代码:

  

  当随着我们的条件越来越多,业务关系越来越负责时,维护这段代码就越来越复杂,也可能因为多人维护而带来版本问题。需要优化。

  分析任何问题,都先要从业务上抽象。以上代码抽象出来有两点:“操作”和“条件”。“操作”很容易抽象的,但是“条件”却不好抽象。没关系,职责链模式给了我们灵感,我们可以把“条件”看作“下一步的操作”。

  好了,我们把“操作”和“下一步的操作”抽象出来。然后将每一步的操作处理作为抽象的一个派生类。

  

  如上图,每个子类是一步处理。每一步处理都实现了抽象类的RequestHandler()方法,都继承了抽象类的success属性(即下一步执行者)。这样我们就可以设计ConcreteHandler1的success为ConcreteHandler2,ConcreteHandler2的success为ConcreteHandler3,从而构成了一个“职责链”。

  这就是职责链模式的设计思想。

 

8.4     情况3:遍历对象各元素

  当对一个对象操作时,遍历对象元素是最常见的操作之一,使用Java和C#的人用的最多的就是for和foreach(先放下foreach不说,后文会有解释),C和C++一般用For循环。For循环简单易用,但是有它设计上的缺点,先看一段for循环代码:

  

  代码中obj是个特别刺眼的变量,如果代码较多了,就会出现满篇的obj。这里的代码是客户端,过多的obj就会导致了大量的耦合。如果obj的类型一点有修改,就会可能导致整个代码都要修改。

  设计是为了抽象和分离。我们需要一种设计来封装这里的obj以及对obj的遍历过程。我们定义一个类型,它接收obj,负责遍历obj,并把遍历的接口开放给客户端。

  

  代码中,我们通过Next()和IsDone()就可以完成一个遍历。可以给客户端提供First()、Current()、Last()等快捷接口。

  这样,我们可以用这种方式迭代对象。

  

  代码中只用到一个obj,因为我们如果再有迭代过程,可以用iterator对象,而不是obj。这就是迭代器模式的设计思路。

  

  前文中提到了foreach循环。其实foreach是C#和java已经封装好的一个迭代器,它的实现原理就是上文中讲到的方法。在日常应用中,foreach在大部分情况下能满足我们的需求。但是要真正理解foreach的用以,还需这个迭代器模式的帮助。

 

8.5     情况4:对象状态变化

  改变一个对象的状态,是再常见不过的操作了。例如一个对象的状态变化是:

  

  这几乎是最简单的流程了,我们一般的开发思路如下:

  

  这是最基本的思路,如果再改进,可能会把状态统一维护成一个枚举,而不是硬编码在代码中直接写“编辑”“结束”等。

  

  但是这样改进,代码的逻辑和结构是不变的。仍然存在一个问题——当状态变化的逻辑变复杂时,这段代码将变得非常复杂——大家应该都明白,在真正的系统中,状态变化可比上面那个图片复杂N倍。

  大家可能都注意到了,一旦遇到这种问题,那肯定是违反了开放封闭原则和单一职责原则。要改变这个问题,我们就得重构这段代码,从设计上彻底避免。

  首先要做的还是抽象,然后再去隔离和解耦。当前这个业务场景中,能抽象出来的是“状态”和“状态变化”。

  那么我们就把状态作为一个抽象类,把状态变化这个都做作为抽象类中的抽象方法,然后针对每个状态,都实现成一个子类。结构如下:

  

  然后再把对象关联到状态上,根据依赖倒置原则,对象将依赖于抽象类而非子类。

  

  上图中,Context的状态是一个State类型,所以无论State派生出多少个子类,都能成为Context的状态。至于Context的状态变化,就在State子类的Handle方法中实现。例如ConcreateStateA的handle方法,可以将Context的状态赋值成ConcreteStateB对象,ConcreteStateB的handle方法,可以将Context的状态赋值成ConcreteStateC(图中没有)对象……一次类推。这样就将一个复杂的状态变化链,分解到每一步状态对象中。

  这种设计思路就是——状态模式

8.6     情况5:记录变化,撤销操作

  上文提到如何改变对象的状态,从这里我们可以想到状态的撤销,以及其他各个属性修改之后的撤销操作。撤销操作的主要问题就在于如何去保存、恢复旧数据。

  最简单的思路是直接定义一个额外的对象来存储旧数据,

  

  如果需要撤销,再从存储旧数据的对象中获取信息,重新赋值给主对象。

  

  由此可能发现,上图中客户端的代码非常繁琐,而且客户端几乎查看到了主对象类型和封装对象类型的所有信息,没有了所谓的“封装”。这样带来的后果是,如果主对象属性有变化,客户端立刻就不可用,必须修改。

  其实客户端应该只关注“备忘”和“撤销”这两件事情、这两个操作,不必去关心主对象中有什么属性,以及备忘对象有什么属性。再思考,“备忘”和“撤销”这两个动作都是针对主对象进行的,主对象是这两个动作的执行者和受益者。那么为何不把这两个动作直接交给主对象进行呢?

  根据以上思路重构代码可得:

  

  在原来代码的基础上,我们又给主对象增加了两个方法——创建备忘和撤销。接下来客户端的代码就简单了。

  

  正如我们上面所说的,客户端关注的只是这两个动作,而不关心具体的属性和内容。

  这就是——备忘录模式。看起来很简单,也很好理解。它没有用到面向对象的太多特点,也没有很复杂的代码,仅仅体现了一个设计原则——单一职责原则。利用简单的代码对代码职责就行分离,从而解耦。

8.7    情况6:对象之间的通讯 – 一对多

  一个对象的属性反生变化,或者一个对象的某方法被调用时,肯定会带来一些影响。所谓的“影响”具体到系统中来看,无非就是导致其他对象的属性发生变化或者事件被触发。

  近来在生活中遇到这样的两个场景。第一,白天用手机上的某客户端软件看NBA文字直播,发现只要某个球员有进球或者篮板之类的数据,系统中所有的地方都会更新这个数据,包括两队的总分比分。第二,看jquery源码解读时,jquery的callbacks的应用也是这种情况,事例代码之后贴出。这两种情况都是一对多通讯的情况。

  

  (看以上代码的形式,很像C#中的委托)

  

   先不管上面的代码或者例子。先想想这种一对多的通讯,该如何去设计,然后慢慢重构升级。最简单的当然是在客户端直接写出来,浅显易懂。这样写代码的人肯定大有人在(因为我之前曾是这样写的):

        

  如果系统中这段代码只用一次,这样写是没有问题的。但是如果系统有多地方都是context.Update(),那将导致一旦有修改,每个地方都得修改。耦合太多,不符合开放封闭原则。

  解决这个问题很简单,我们把受影响对象的更新,全部放到主对象的更新中。

  

  再想想还会遇到一个问题:难道我们每次调用Context的Update时,受影响的对象都是固定的吗?有工作经验的人肯定回答否。所以,我们这样盲目的把受影响对象的更新全部塞到Context的Update是有问题的。

  其实我们应该抽象出来的是“更新”这个动作,而应该把具体的哪些对象受影响交给客户端。如下:

  

  上图中,我们把受影响对象的类型抽象出一个Subject类,它有一个Update()方法。在主对象类型中,将保存一个Subject类型的列表,将存储受影响的对象,更新时,循环这个列表,调用Update方法。可见,Context依赖的是一个抽象类——依赖倒置原则。

  这样,我们客户端的代码就成了这样:

  

  这次终于实现了我们的预想。可以对比一下我一开始贴出来的js代码。效果差不多。

  

  这就是大家耳熟但并不一定能详的——观察者模式。最常见的例子除了jquery的callbacks之外,还有.net中的委托和事件。此处不再深入介绍,有机会再把委托和事件如何应用观察者模式的思路介绍一下。

8.8     情况7:对象之间的通讯 – 多对多

  上文中提到一对多的通讯情况,比一对多更复杂的是多对多的通讯。如果用最原始的模式,那将出现这种情况,并且这种情况会随着对象的增加而变得更加复杂。N个对象就会有N*(N-1)个通讯方式。

  

  所以,当你确定系统中遇到这种问题,并且越发不可收拾的时候,就需要重构代码优化设计。思路还是一样的——抽象、隔离、解耦。当前场景的复杂之处是过多的“通讯”链条。我们需要把所有的“通讯”链条都抽象出来,并隔离通讯双方直接联系。所以,我们希望的结构是这样的。

  

  其实这就是中介者模式

  以上只是一个思路,看它是怎么实现的。

 

  

  首先,把所有需要通讯的类型(成为“同事”类型),抽象出一个基类,基类中包含一个中介者Mediator对象,这样所有的子类中都会有一个Mediator对象。子类有Send()方法,用于发送请求;Notify()方法用于接收请求。

  

  其次,中介者Mediator类型,基类中定义Send()抽象方法,子类中要重写。子类中定义了所有需要通讯的对象,然后重写Send()方法时,根据不同情况,调用不同的同事类型的Notify()方法。如下:

  

  这样,在同事类型中,每个同事类的Send()方法,就可以直接调用中介者Mediator的send()方法。如下:

  

  最后,总体的设计结构如下:

  

  越看似简单的东西,就越难用。因为简单的东西具有通用性,而通用就必须适应各种环境,环境不同,应用不同。中介者模式就是这样一种情况。如果不信,可以具体思考一下,你的系统中哪个场景可以用中介者模式,并且不影响其他功能和设计。

  在具体应用中,还是把重点放在这个设计的思路上,不必太拘泥与它的代码和类图,这只是一个demo而已。

8.9     情况8:如何调用一个操作?

  对于这个问题,我想大部分人都会一笑而过:如何调用?调用就是了。一般情况下是触发一个单独的方法或者一个对象的某个方法。但是你应该知道,我既然在这个地方提出这个问题,就肯定不是这样简单的答案。

   难点在于如何分析“操作”这个业务过程。其实“操作”分为以下三部分:

  • 调用者
  • 操作
  • 执行者

  首先,调用者不一定都是客户端,可能是一个对象或者集合。例如我们常见的电视遥控器,就是一个典型的调用者对象。

  其次,操作和执行者不一样。操作时,除了真正执行之外,还可能有其他的动作,如缓存、判断等。

  最后,这样的划分是为了职责单一和充分解耦。当你的需求可以用简单的函数调用解决时,那当然最好。但是如果后期随着系统的升级和变更而变得业务复杂时,就应该考虑用这种设计模式——命令模式

  

  上图是命令模式的类图。左侧是的Command和ConcreteCommand是操作(命令)的抽象和实现,这个不重要,我们可以把这两个统一看成一个“操作”整体。Invoker是调用者,Receiver是真正的执行者。

  调用过程是:Invoker.Excute() -> Command.Excute() -> Receiver.Action()。这样我们还可以在Command中实现一些缓存、判断之类的业务操作。可以按照自己具体的需求编写代码。

  具体的代码这里就不写了,把这个业务过程理解了,写代码也很简单。重点还是在于理解“操作”(命令)的业务过程,以及在复杂过程下对调用者、操作、执行者之间的解耦。

8.10     情况9:一种策略,多种算法

  

  假如上图是我们系统中一个功能的类图,定义一个接口,用两个不同的类去实现。客户端的代码调用为:

  

  有出现了比较讨厌的条件判断,任何条件判断的复杂化都将导致职责的混乱和代码的臃肿。如果想要解决这种问题,我们需要把这些逻辑判断分离出来。先定义一个类来封装客户端和实现类的直接联系。

  

  如此一来,客户端的调用代码为:

  

  这就是——策略模式。类图如下:

  

  

  附:关于这个策略模式,思考了很久也没有想出一个何时的表达方法,我没有真正理解它的用处,感觉它说的很对,但是我觉得它没多少用处。所以,针对这个模式的表述,大家先以参考为主。如果我有了更好的理解方式,再修改。

8.11     情况10:简化一个对象组合的操作

  针对一个对象组合,例如一个递归的树形结构,往往对每个节点都会有相同的操作。代码如下:

  

   如果对象结构较复杂,而且新增功能较多,代码将会变得非常臃肿。

  解决这个问题时,不好直接去抽象。一来是因为现在已经在一个抽象的结构中,二来也因为每个节点新增的功能,不一定都相同。所以,现在我们最好的方式是将“新增功能”这个未来不确定的事情,交给另外对象去做。先去隔离。

  另外定义一个Visitor类,由它来接收一个Element对象,然后执行各种操作。

  

  此时在Element类中,就不需要每次新增功能时,都重写代码。只需要在Element类中加入一个方法,该方法将调用Visitor的方法来实现具体的功能。

  

  这就是——访问者模式,它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

8.12     总结

  注:Interpreter解释器模式不常用,暂不说明。

  本节介绍了对象行为和对象操作过程中的一些常用业务过程以及其中遇到的问题,同时针对每种情况引入了相应的设计模式。

  这块儿的过程较复杂,梳理起来也比较麻烦,当前的描述和一个系统的流程相比,我想还是有不少差距的。但是相信大家在看到每种情况的时候,或多或少的肯定有过类似的开发经历,每种情况都是和我们日常的开发工作息息相关的。如果这些介绍起不到一个教程或者引导的作用,那就权当是一次抛砖引玉,或者一次学习交流。

  最终还是希望大家能从系统的设计、重构、解决问题的角度去引出设计模式,然后才能真正理解设计模式。

 

9      总结

  从5.12开始写,到今天6.4,磕磕绊绊的总算写完了初稿。虽然不到一个月,但是坚持下来也很不容易。而且这只是一个开始,我想再在这个基础上继续写第二版、第三版,当然还需要再去看更多的书、博客以及结合实际的开发经验和例证。

  且先不说应用,即便是真正理解设计模式,也不是易事,没有开发经验、没有一个有效的方法,学起来也是事倍功半。甚至会像我之前一样,学一次忘一次。我觉得我现在提出的思路有一定效果,至少对于我是有效的。大家如果有其他建议或者思路,欢迎和我交流 wangfupeng1988$163.com($->@)