Design-Pattern-CoR
总算到了行为模式。回顾前文,在创建型模式中,关注如何创建某类实例,在结构型模式中,关注将不同类和对象组装到一起,以扩展或适配功能。而在行为模式中,关注对象间的交互和职责划分。
首先介绍责任链模式(Chain of Responsibility, CoR
)。责任链模式允许将请求沿着处理链传递,直到有一个处理器能够处理它。每个处理器决定是否处理请求或将其传递给链中的下一个处理器。
场景
在业务代码执行前,通常会进行多种校验,例如权限校验、参数校验、状态校验、缓存校验等。这些校验可能在不同业务中以不同组合,不同顺序的形式存在。如果在每个业务类中都复制粘贴这些校验并调整顺序,会导致代码重复率大。相对简洁一点的做法是把这些校验抽取成方法,这样在每个业务中只是调用这些校验方法。校验方法通常有个特点,前面的校验不通过,后面的校验则没必要继续了。随着业务复杂,校验方法越来越多。方法之间互相耦合,修改某个部分可能会影响到其他部分。
责任链模式
如果把校验过程进行抽象,可以提取出处理器(handler
)。处理器的功能是对请求进行操作,并根据操作结果决定是由下一个处理器继续处理,还是直接终止后续的所有处理。处理器通过成员变量持有下一个处理器的引用,多个处理器通过链表的形式组合在一起。链表上的顺序决定了处理器执行校验的顺序。
如此一来,每个校验都可以重构成一个处理器。而要使得处理器能通过链表一样串起来,这需要所有处理器是同一个类型,例如实现了同一个接口,或继承自同一个基类。这样的结构在装饰模式中也已经见过了。在java
中,优先会使用接口来表示多个类的共同行为,因此定义处理器接口handler
。而多个处理器实现类中,有很多共有部分,例如都需要持有下一个处理器的成员变量,成员变量的设置方法,以及链式调用通用逻辑。因此会使用一个抽象基类实现处理器接口,把这些共同的部分定义在基类中,这个抽象基类称为基础处理器(base handler
)。而具体处理器(Concrete Handlers
),通过继承基础处理器,实现抽象方法,来添加特有的具体处理逻辑。
基于上述设计思路,可以给出如下代码:
1 |
|
classDiagram
direction RL
class Client
class Handler {
<< interface >>
+ Handler()
+ setNext()
}
Client --> Handler
class ConcreteHandler {
+Handler()
}
ConcreteHandler ..|> Handler
class BaseHandler {
<<abstract>>
- Handler handler
+ Handler()*
+ setNext()
}
BaseHandler ..|> Handler
BaseHandler ..o Handler
ConcreteHandler --|> BaseHandler
责任链变体
谈到责任链的结构,总感觉和实际使用情况不符。对比上述关于校验流程的例子和示例代码,会发现也是匹配不上的。这是因为责任链模式存在很多变体,实际使用的是各种变体。在此简单介绍最常见的变体。
- 中断式责任链:拿校验的例子来说,校验过程通常是一个接着一个执行,若任意一个校验不通过,则立即终止后续的校验。上述示例代码显然不满足这个要求。因为需要根据校验结果判断是否继续校验,那么校验方法需要提供能表示是否校验通过的标识,例如返回一个
boolean
类型,true
表示通过校验。如此可以对handle
方法改造。示例代码如下,调整了基础处理器handle
的方法,并定义了实际执行校验逻辑的validate
抽象方法。任意一个处理器校验失败,就会直接return
,结束链式调用。
1 |
|
- 全传播责任链:与中断式责任链相反,全传播责任链会将请求一直传递,必须遍历所有处理器。请求可能被多个处理器处理,也可能没有处理器处理。
- 纯责任链:请求必须被某个处理器处理,且每个处理器只能选择处理或传递给下一节点(不可既处理又传递)。
- 不纯责任链:允许处理器部分处理请求后继续传递(如拦截器模式),或所有处理器均尝试处理(如中间件管道)。
- 动态责任链:链的节点可在运行时动态增删或重新排序。
这些变体不全是互斥的,例如spring mcv
的拦截器,在上述变体中属于不纯的、可中断、动态责任链。
与装饰模式的区别
对比装饰模式与责任链模式的结构,会发现两者一模一样,概念也几乎一一对应,并且两者都依赖递归组合将需要执行的操作传递给一系列对象。
有些文章提到装饰模式无法中断请求传递。但这不是明显差别,因为全传播责任链会要求请求遍历所有处理器,不会在调用链中间中断。也有文章提到从语义上说,装饰模式强调给对象动态添加功能,责任链强调众多处理器之一对对象进行处理。但不纯责任链允许多个处理器处理请求。我们也可以将这多个处理器,理解为是对功能的动态添加。例如spring mvc
中每添加一个拦截器,都是对拦截器整体功能的扩展。客户端使用的处理器,其实是所有处理器功能的结合。使用装饰模式的语义看待责任链一点也不违和。另外处理器判断某个请求是否是其职责,是否该处理,这判断本身也是一种处理。也就是说在这种理解下,就算处理器把处理委派给了下一个处理器,其本身也完成了一部分处理,不能很严谨地说只进行传递。装饰模式也能符合责任链的语义,例如数据加密的装饰器,可以判断数据是否已经加密,如果已经加密则不处理,并委派给下一个装饰器。在这种场景下,也并不是所有的装饰器都会对请求进行处理,装饰器也可以选择处理或传递中的一个。
有些文章认为责任链是装饰器的一种特殊形式,笔者赞同这个观点。如果硬是要比较两者的差别,笔者认为主要区别在于责任链模式的每个处理器,是相对独立的,彼此间互相不知道。而装饰模式中的装饰器之间,耦合程度会更高。例如装饰器之间可能存在依赖,必须按照特定顺序添加装饰器。 并且装饰模式中会有一个专门位于链尾部的具体部件,而在责任链模式中,每个处理器并不知道自己是否位于尾部。
一些想法
回到实际,我们似乎很少在代码中通过责任链去执行校验,上文把校验过程改造为责任链的例子并不好。这是因为不同的校验所需要的入参/上下文不同。例如校验方法的入参可能是日期,可能是金额,亦或各种字段可能的组合。这意味着不同的校验方法,应对的不是同一个请求,也就无法抽取出公共的处理抽象方法。这当然也有解决方法,例如新建一个自定义类,各种校验需要的入参都被包含在其中,类名就叫ValidationContext
。但这也加重了客户端的负担,需要收集各个字段,负责这个实例的创建和初始化,这可能得不偿失。
因此,实际适合责任链模式的场景,都需要多个处理器处理的是同一个请求。常被拿来举例子的权限审批,不同职级是对同一个请求表单进行审查。spring mcv
中,不同拦截器是对同一个HTTP请求和响应进行处理和拦截。所以在考虑把一连串相似的处理重构成责任链时,还需要考虑这些处理应对的是否为同一个请求,或者需要衡量改造成同一个请求的成本。
此外,多个处理器是否有必要通过链表串在一起,也是个值得思考的问题。备选方案是可以建一个List
包含所有处理器,客户端通过for
循环,按顺序依次调用所有处理器。在for
循环中也可以通过if
和break
关键字来跳出循环,模拟责任链中任意处理器中断请求传递的过程。对于客户端来说,只知道请求由一个处理器集合负责处理,不必关心其中的顺序,具体由谁来执行。List
也支持动态添加、删除元素的操作。整体看使用List
和责任链的行为和特征是一致的。
当处理器的功能不复杂,这么做确实可行。但当处理器需要在所有剩下处理器处理完后再执行某些操作,简单的List
循环就不再能满足需求。责任链的递归调用模式可以产生栈的效果,每个处理器可以在入栈和出栈时执行操作,也就是预处理和后处理。此外,在一些场景中,处理器天然地会以树的形式组织在一起。常被拿来举例的是UI
亦或是DOM
。我们点击了其中某个元素,产生的点击事件会沿着元素一直追溯到树的根,沿途的节点所代表的处理器都可能会处理这个事件,也可能传递给下一个节点。在这种场景下,责任链的结构天然地比List
更合适。
总结
责任链模式以链表结构串起一系列类似的处理器。请求会沿着链表传递,每个处理器都可以决定自己是否处理,是否交由下一个处理器处理,是否中断处理传递。
责任链模式的角色包括处理器接口、基础处理器、具体处理器。
责任链模式存在不同变体,主要的大类包括中断/全传播责任链,纯/不纯责任链,动态责任链等。
责任链模式和装饰模式几乎一模一样。笔者认为责任链模式是装饰模式的一种特例。
责任链模式适合的场景需要多个处理器处理的是同一个请求。
处理器以树结构组织时天然地适合责任链模式。有时也可以考虑使用List
储存处理器和循环调用作为责任链模式的替代方案。