0%

责任链模式

亦称:职责链模式、命令链、CoR、Chain of Command、Chain of Responsibility

意图

责任链模式是一种行为设计模式,允许你将请求沿着处理者链进行发送。收到请求后,每个处理者均可对请求进行处理,或将其传递给链上的下个处理者。

责任链设计模式

问题

假如你正在开发一个在线订购系统。你希望对系统访问进行限制,只允许认证用户创建订单。此外,拥有管理权限的用户也拥有所有订单的完全访问权限。

简单规划后,你会意识到这些检查必须依次进行。只要接收到包含用户凭据的请求,应用程序就可尝试对进入系统的用户进行认证。但如果由于用户凭据不正确而导致认证失败,那就没有必要进行后续检查了。

责任链解决的问题

请求必须经过一系列检查后才能由订购系统来处理。

在接下来的几个月里,你实现了后续的几个检查步骤。

  • 一位同事认为直接将原始数据传递给订购系统存在安全隐患。因此你新增了额外的验证步骤来清理请求中的数据。

  • 过了一段时间,有人注意到系统无法抵御暴力密码破解方式的攻击。为了防范这种情况,你立刻添加了一个检查步骤来过滤来自同一 IP 地址的重复错误请求。

  • 又有人提议你可以对包含同样数据的重复请求返回缓存中的结果,从而提高系统响应速度。因此,你新增了一个检查步骤,确保只有没有满足条件的缓存结果时请求才能通过并被发送给系统。

每增加一个检查步骤,程序都变得更加臃肿、混乱和丑陋

代码变得越来越多,也越来越混乱。

检查代码本来就已经混乱不堪,而每次新增功能都会使其更加臃肿。修改某个检查步骤有时会影响其他的检查步骤。最糟糕的是,当你希望复用这些检查步骤来保护其他系统组件时,你只能复制部分代码,因为这些组件只需部分而非全部的检查步骤。

系统会变得让人非常费解,而且其维护成本也会激增。你在艰难地和这些代码共处一段时间后,有一天终于决定对整个系统进行重构。

解决方案

与许多其他行为设计模式一样,责任链会将特定行为转换为被称作处理者的独立对象。在上述示例中,每个检查步骤都可被抽取为仅有单个方法的类,并执行检查操作。请求及其数据则会被作为参数传递给该方法。

模式建议你将这些处理者连成一条链。链上的每个处理者都有一个成员变量来保存对于下一处理者的引用。除了处理请求外,处理者还负责沿着链传递请求。请求会在链上移动,直至所有处理者都有机会对其进行处理。

最重要的是:处理者可以决定不再沿着链传递请求,这可高效地取消所有后续处理步骤。

在我们的订购系统示例中,处理者会在进行请求处理工作后决定是否继续沿着链传递请求。如果请求中包含正确的数据,所有处理者都将执行自己的主要行为,无论该行为是身份验证还是数据缓存。

处理者依次排列,组成一条链

处理者依次排列,组成一条链。

不过还有一种稍微不同的方式(也是更经典一种),那就是处理者接收到请求后自行决定是否能够对其进行处理。如果自己能够处理,处理者就不再继续传递请求。因此在这种情况下,每个请求要么最多有一个处理者对其进行处理,要么没有任何处理者对其进行处理。在处理图形用户界面元素栈中的事件时,这种方式非常常见。

例如,当用户点击按钮时,按钮产生的事件将沿着 GUI 元素链进行传递,最开始是按钮的容器(如窗体或面板),直至应用程序主窗口。链上第一个能处理该事件的元素会对其进行处理。此外,该例还有另一个值得我们关注的地方:它表明我们总能从对象树中抽取出链来。

对象树的枝干可以组成一条链

对象树的枝干可以组成一条链。

所有处理者类均实现同一接口是关键所在。每个具体处理者仅关心下一个包含execute执行方法的处理者。这样一来,你就可以在运行时使用不同的处理者来创建链,而无需将相关代码与处理者的具体类进行耦合。

真实世界类比

与技术支持交谈可能不容易

给技术支持打电话时你可能得应对多名接听人员。

最近,你刚为自己的电脑购买并安装了一个新的硬件设备。身为一名极客,你显然在电脑上安装了多个操作系统,所以你会试着启动所有操作系统来确认其是否支持新的硬件设备。Windows 检测到了该硬件设备并对其进行了自动启用。但是你喜爱的 Linux 系统并不支持新硬件设备。抱着最后一点希望,你决定拨打包装盒上的技术支持电话。

首先你会听到自动回复器的机器合成语音,它提供了针对各种问题的九个常用解决方案,但其中没有一个与你遇到的问题相关。过了一会儿,机器人将你转接到人工接听人员处。

这位接听人员同样无法提供任何具体的解决方案。他不断地引用手册中冗长的内容,并不会仔细聆听你的回应。在第 10 次听到“你是否关闭计算机后重新启动呢?”这句话后,你要求与一位真正的工程师通话。

最后,接听人员将你的电话转接给了工程师,他或许正缩在某幢办公大楼的阴暗地下室中,坐在他所深爱的服务器机房里,焦躁不安地期待着同一名真人交流。工程师告诉了你新硬件设备驱动程序的下载网址,以及如何在 Linux 系统上进行安装。问题终于解决了!你挂断了电话,满心欢喜。

责任链模式结构

责任链设计模式的结构责任链设计模式的结构

  1. 处理者(Handler)声明了所有具体处理者的通用接口。该接口通常仅包含单个方法用于请求处理,但有时其还会包含一个设置链上下个处理者的方法。

  2. 基础处理者(Base Handler)是一个可选的类,你可以将所有处理者共用的样本代码放置在其中。

    通常情况下,该类中定义了一个保存对于下个处理者引用的成员变量。客户端可通过将处理者传递给上个处理者的构造函数或设定方法来创建链。该类还可以实现默认的处理行为:确定下个处理者存在后再将请求传递给它。

  3. 具体处理者(Concrete Handlers)包含处理请求的实际代码。每个处理者接收到请求后,都必须决定是否进行处理,以及是否沿着链传递请求。

    处理者通常是独立且不可变的,需要通过构造函数一次性地获得所有必要地数据。

  4. 客户端(Client)可根据程序逻辑一次性或者动态地生成链。值得注意的是,请求可发送给链上的任意一个处理者,而非必须是第一个处理者。

伪代码

在本例中,责任链模式负责为活动的 GUI 元素显示上下文帮助信息。

责任链结构的示例

GUI 类使用组合模式生成。每个元素都链接到自己的容器元素。你可随时构建从当前元素开始的、遍历其所有容器的元素链。

应用程序的 GUI 通常为对象树结构。例如,负责渲染程序主窗口的对话框类就是对象树的根节点。对话框包含面板,而面板可能包含其他面板,或是按钮文本框等下层元素。

只要给一个简单的组件指定帮助文本,它就可显示简短的上下文提示。但更复杂的组件可自定义上下文帮助文本的显示方式,例如显示手册摘录内容或在浏览器中打开一个网页。

责任链结构的示例

帮助请求如何在 GUI 对象中移动。

当用户将鼠标指针移动到某个元素并按下F1键时,程序检测到指针下的组件并对其发送帮助请求。该请求不断向上传递到该元素所有的容器,直至某个元素能够显示帮助信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 处理者接口声明了一个创建处理者链的方法。还声明了一个执行请求的方法。
interface ComponentWithContextualHelp is
method showHelp()


// 简单组件的基础类。
abstract class Component implements ComponentWithContextualHelp is
field tooltipText: string

// 组件容器在处理者链中作为“下一个”链接。
protected field container: Container

// 如果组件设定了帮助文字,那它将会显示提示信息。如果组件没有帮助文字
// 且其容器存在,那它会将调用传递给容器。
method showHelp() is
if (tooltipText != null)
// 显示提示信息。
else
container.showHelp()


// 容器可以将简单组件和其他容器作为其子项目。链关系将在这里建立。该类将从
// 其父类处继承 showHelp(显示帮助)的行为。
abstract class Container extends Component is
protected field children: array of Component

method add(child) is
children.add(child)
child.container = this


// 原始组件应该能够使用帮助操作的默认实现...
class Button extends Component is
// ...

// 但复杂组件可能会对默认实现进行重写。如果无法以新的方式来提供帮助文字,
// 那组件总是还能调用基础实现的(参见 Component 类)。
class Panel extends Container is
field modalHelpText: string

method showHelp() is
if (modalHelpText != null)
// 显示包含帮助文字的模态窗口。
else
super.showHelp()

// ...同上...
class Dialog extends Container is
field wikiPageURL: string

method showHelp() is
if (wikiPageURL != null)
// 打开百科帮助页面。
else
super.showHelp()


// 客户端代码。
class Application is
// 每个程序都能以不同方式对链进行配置。
method createUI() is
dialog = new Dialog("预算报告")
dialog.wikiPageURL = "http://..."
panel = new Panel(0, 0, 400, 800)
panel.modalHelpText = "本面板用于..."
ok = new Button(250, 760, 50, 20, "确认")
ok.tooltipText = "这是一个确认按钮..."
cancel = new Button(320, 760, 50, 20, "取消")
// ...
panel.add(ok)
panel.add(cancel)
dialog.add(panel)

// 想象这里会发生什么。
method onF1KeyPress() is
component = this.getComponentAtMouseCoords()
component.showHelp()

责任链模式适合应用场景

当程序需要使用不同方式处理不同种类请求,而且请求类型和顺序预先未知时,可以使用责任链模式。

该模式能将多个处理者连接成一条链。接收到请求后,它会“询问”每个处理者是否能够对其进行处理。这样所有处理者都有机会来处理请求。

当必须按顺序执行多个处理者时,可以使用该模式。

无论你以何种顺序将处理者连接成一条链,所有请求都会严格按照顺序通过链上的处理者。

如果所需处理者及其顺序必须在运行时进行改变,可以使用责任链模式。

如果在处理者类中有对引用成员变量的设定方法,你将能动态地插入和移除处理者,或者改变其顺序。

实现方式

  1. 声明处理者接口并描述请求处理方法的签名。

    确定客户端如何将请求数据传递给方法。最灵活的方式是将请求转换为对象,然后将其以参数的形式传递给处理函数。

  2. 为了在具体处理者中消除重复的样本代码,你可以根据处理者接口创建抽象处理者基类。

    该类需要有一个成员变量来存储指向链上下个处理者的引用。你可以将其设置为不可变类。但如果你打算在运行时对链进行改变,则需要定义一个设定方法来修改引用成员变量的值。

    为了使用方便,你还可以实现处理方法的默认行为。如果还有剩余对象,该方法会将请求传递给下个对象。具体处理者还能够通过调用父对象的方法来使用这一行为。

  3. 依次创建具体处理者子类并实现其处理方法。每个处理者在接收到请求后都必须做出两个决定:

    • 是否自行处理这个请求。
    • 是否将该请求沿着链进行传递。
  4. 客户端可以自行组装链,或者从其他对象处获得预先组装好的链。在后一种情况下,你必须实现工厂类以根据配置或环境设置来创建链。

  5. 客户端可以触发链中的任意处理者,而不仅仅是第一个。请求将通过链进行传递,直至某个处理者拒绝继续传递,或者请求到达链尾。

  6. 由于链的动态性,客户端需要准备好处理以下情况:

    • 链中可能只有单个链接。
    • 部分请求可能无法到达链尾。
    • 其他请求可能直到链尾都未被处理。

责任链模式优缺点

  • 你可以控制请求处理的顺序。

  • 单一职责原则。你可对发起操作和执行操作的类进行解耦。

  • 开闭原则。你可以在不更改现有代码的情况下在程序中新增处理者。

  • 部分请求可能未被处理。

与其他模式的关系

  • 责任链模式命令模式中介者模式观察者模式用于处理请求发送者和接收者之间的不同连接方式:

    • 责任链按照顺序将请求动态传递给一系列的潜在接收者,直至其中一名接收者对请求进行处理。
    • 命令在发送者和请求者之间建立单向连接。
    • 中介者清除了发送者和请求者之间的直接连接,强制它们通过一个中介对象进行间接沟通。
    • 观察者允许接收者动态地订阅或取消接收请求。
  • 责任链通常和组合模式结合使用。在这种情况下,叶组件接收到请求后,可以将请求沿包含全体父组件的链一直传递至对象树的底部。

  • 责任链的管理者可使用命令模式实现。在这种情况下,你可以对由请求代表的同一个上下文对象执行许多不同的操作。

    还有另外一种实现方式,那就是请求自身就是一个命令对象。在这种情况下,你可以对由一系列不同上下文连接而成的链执行相同的操作。

  • 责任链装饰模式的类结构非常相似。两者都依赖递归组合将需要执行的操作传递给一系列对象。但是,两者有几点重要的不同之处。

    责任链的管理者可以相互独立地执行一切操作,还可以随时停止传递请求。另一方面,各种装饰可以在遵循基本接口的情况下扩展对象的行为。此外,装饰无法中断请求的传递。

原型模式

亦称:克隆、Clone、Prototype

意图

原型模式是一种创建型设计模式,使你能够复制已有对象,而又无需使代码依赖它们所属的类。

原型设计模式

问题

如果你有一个对象,并希望生成与其完全相同的一个复制品,你该如何实现呢?首先,你必须新建一个属于相同类的对象。然后,你必须遍历原始对象的所有成员变量,并将成员变量值复制到新对象中。

不错!但有个小问题。并非所有对象都能通过这种方式进行复制,因为有些对象可能拥有私有成员变量,它们在对象本身以外是不可见的。

从外部复制对象会遇到什么问题?

“从外部”复制对象并非总是可行。

直接复制还有另外一个问题。因为你必须知道对象所属的类才能创建复制品,所以代码必须依赖该类。即使你可以接受额外的依赖性,那还有另外一个问题:有时你只知道对象所实现的接口,而不知道其所属的具体类,比如可向方法的某个参数传入实现了某个接口的任何对象。

解决方案

原型模式将克隆过程委派给被克隆的实际对象。模式为所有支持克隆的对象声明了一个通用接口,该接口让你能够克隆对象,同时又无需将代码和对象所属类耦合。通常情况下,这样的接口中仅包含一个克隆方法。

所有的类对克隆方法的实现都非常相似。该方法会创建一个当前类的对象,然后将原始对象所有的成员变量值复制到新建的类中。你甚至可以复制私有成员变量,因为绝大部分编程语言都允许对象访问其同类对象的私有成员变量。

支持克隆的对象即为原型。当你的对象有几十个成员变量和几百种类型时,对其进行克隆甚至可以代替子类的构造。

预生成原型

预生成原型可以代替子类的构造。

其运作方式如下:创建一系列不同类型的对象并不同的方式对其进行配置。如果所需对象与预先配置的对象相同,那么你只需克隆原型即可,无需新建一个对象。

真实世界类比

现实生活中,产品在得到大规模生产前会使用原型进行各种测试。但在这种情况下,原型只是一种被动的工具,不参与任何真正的生产活动。

细胞分裂

一个细胞的分裂。

由于工业原型并不是真正意义上的自我复制,因此细胞有丝分裂(还记得生物学知识吗?)或许是更恰当的类比。有丝分裂会产生一对完全相同的细胞。原始细胞就是一个原型,它在复制体的生成过程中起到了推动作用。

原型模式结构

基本实现

原型设计模式的结构原型设计模式的结构

  1. 原型(Prototype)接口将对克隆方法进行声明。在绝大多数情况下,其中只会有一个名为clone克隆的方法。

  2. 具体原型(Concrete Prototype)类将实现克隆方法。除了将原始对象的数据复制到克隆体中之外,该方法有时还需处理克隆过程中的极端情况,例如克隆关联对象和梳理递归依赖等等。

  3. 客户端(Client)可以复制实现了原型接口的任何对象。

原型注册表实现

原型注册表原型注册表

  1. 原型注册表(Prototype Registry)提供了一种访问常用原型的简单方法,其中存储了一系列可供随时复制的预生成对象。最简单的注册表原型是一个名称 → 原型的哈希表。但如果需要使用名称以外的条件进行搜索,你可以创建更加完善的注册表版本。

伪代码

在本例中,原型模式能让你生成完全相同的几何对象副本,同时无需代码与对象所属类耦合。

原型模式示例的结构

克隆一系列位于同一类层次结构中的对象。

所有形状类都遵循同一个提供克隆方法的接口。在复制自身成员变量值到结果对象前,子类可调用其父类的克隆方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// 基础原型。
abstract class Shape is
field X: int
field Y: int
field color: string

// 常规构造函数。
constructor Shape() is
// ...

// 原型构造函数。使用已有对象的数值来初始化一个新对象。
constructor Shape(source: Shape) is
this()
this.X = source.X
this.Y = source.Y
this.color = source.color

// clone(克隆)操作会返回一个形状子类。
abstract method clone():Shape


// 具体原型。克隆方法会创建一个新对象并将其传递给构造函数。直到构造函数运
// 行完成前,它都拥有指向新克隆对象的引用。因此,任何人都无法访问未完全生
// 成的克隆对象。这可以保持克隆结果的一致。
class Rectangle extends Shape is
field width: int
field height: int

constructor Rectangle(source: Rectangle) is
// 需要调用父构造函数来复制父类中定义的私有成员变量。
super(source)
this.width = source.width
this.height = source.height

method clone():Shape is
return new Rectangle(this)


class Circle extends Shape is
field radius: int

constructor Circle(source: Circle) is
super(source)
this.radius = source.radius

method clone():Shape is
return new Circle(this)


// 客户端代码中的某个位置。
class Application is
field shapes: array of Shape

constructor Application() is
Circle circle = new Circle()
circle.X = 10
circle.Y = 10
circle.radius = 20
shapes.add(circle)

Circle anotherCircle = circle.clone()
shapes.add(anotherCircle)
// 变量 `anotherCircle(另一个圆)`与 `circle(圆)`对象的内
// 容完全一样。

Rectangle rectangle = new Rectangle()
rectangle.width = 10
rectangle.height = 20
shapes.add(rectangle)

method businessLogic() is
// 原型是很强大的东西,因为它能在不知晓对象类型的情况下生成一个与
// 其完全相同的复制品。
Array shapesCopy = new Array of Shapes.

// 例如,我们不知晓形状数组中元素的具体类型,只知道它们都是形状。
// 但在多态机制的帮助下,当我们在某个形状上调用 `clone(克隆)`
// 方法时,程序会检查其所属的类并调用其中所定义的克隆方法。这样,
// 我们将获得一个正确的复制品,而不是一组简单的形状对象。
foreach (s in shapes) do
shapesCopy.add(s.clone())

// `shapesCopy(形状副本)`数组中包含 `shape(形状)`数组所有
// 子元素的复制品。

原型模式适合应用场景

如果你需要复制一些对象,同时又希望代码独立于这些对象所属的具体类,可以使用原型模式。

这一点考量通常出现在代码需要处理第三方代码通过接口传递过来的对象时。即使不考虑代码耦合的情况,你的代码也不能依赖这些对象所属的具体类,因为你不知道它们的具体信息。

原型模式为客户端代码提供一个通用接口,客户端代码可通过这一接口与所有实现了克隆的对象进行交互,它也使得客户端代码与其所克隆的对象具体类独立开来。

如果子类的区别仅在于其对象的初始化方式,那么你可以使用该模式来减少子类的数量。别人创建这些子类的目的可能是为了创建特定类型的对象。

在原型模式中,你可以使用一系列预生成的、各种类型的对象作为原型。

客户端不必根据需求对子类进行实例化,只需找到合适的原型并对其进行克隆即可。

实现方式

  1. 创建原型接口,并在其中声明克隆方法。如果你已有类层次结构,则只需在其所有类中添加该方法即可。

  2. 原型类必须另行定义一个以该类对象为参数的构造函数。构造函数必须复制参数对象中的所有成员变量值到新建实体中。如果你需要修改子类,则必须调用父类构造函数,让父类复制其私有成员变量值。

    如果编程语言不支持方法重载,那么你可能需要定义一个特殊方法来复制对象数据。在构造函数中进行此类处理比较方便,因为它在调用new运算符后会马上返回结果对象。

  3. 克隆方法通常只有一行代码:使用new运算符调用原型版本的构造函数。注意,每个类都必须显式重写克隆方法并使用自身类名调用new运算符。否则,克隆方法可能会生成父类的对象。

  4. 你还可以创建一个中心化原型注册表,用于存储常用原型。

    你可以新建一个工厂类来实现注册表,或者在原型基类中添加一个获取原型的静态方法。该方法必须能够根据客户端代码设定的条件进行搜索。搜索条件可以是简单的字符串,或者是一组复杂的搜索参数。找到合适的原型后,注册表应对原型进行克隆,并将复制生成的对象返回给客户端。

    最后还要将对子类构造函数的直接调用替换为对原型注册表工厂方法的调用。

原型模式优缺点

  • 你可以克隆对象,而无需与它们所属的具体类相耦合。

  • 你可以克隆预生成原型,避免反复运行初始化代码。

  • 你可以更方便地生成复杂对象。

  • 你可以用继承以外的方式来处理复杂对象的不同配置。

  • 克隆包含循环引用的复杂对象可能会非常麻烦。

与其他模式的关系