设计模式--访问者模式的思考

访问者模式是一个比较复杂的设计模式,他的复杂性不是由于自身,而是因为会与其他模式配合使用,两者复杂性叠加,导致逻辑很难看明白。本文主要目地是理清楚访问者模式的本质以及利弊,探究如何在业务中应用该模式的思想。

访问者模式

访问者模式的定义为:表示一个作用于某对象结构中的各元素的操作,它可以使你在不改变元素的类的前提下定义作用于这些元素的新操作。定义是比较拗口的,简单点来说,就是在面对复杂数据结构时,可以在对应结构不感知的情况下,为该结构增加一系列的功能,比如我们平常会定义Domain类,然后在Domain Service为Domain扩展一系列的方法,这其实也算是符合访问者模式的定义,Doamin类作为输入的复杂数据结构,DomainService在不改变Domain类的情况下,给Domain增加CRUD等方法。

博主举出的这种沾亲式的案例,其实也想表达本来没有设计模式,但大家把一种策略当成模板后,设计模式就自然而然的诞生了。设计模式是编程设计原则的体现,很多人使用往往都会生搬硬套,但博主认为设计模式需要了解模式背后要解决的问题是什么,了解本质目地后,那么各种约束规则便不再是设计束缚。

访问者模式的结构

image-20220702190601681

访问者模式就涉及两个关键的类,Element与Visitor,其中Element是复杂数据结构,Visitor是想要为Element增加的功能实现。

Visitor

上图中Visitor定义为一个访问者接口,其中含有visitor(ConcreteElement1),visitor(ConcreteElement2)两个方法, 该接口本身不具有明确意义,只是提供了针对具象元素的访问通道,实现上具体什么含义,取决于ConcreteVisitor1ConcreteVisitor2的实现逻辑。

Element

本身就是一个数据结构模型,可以是一个Model,也可以是多个Model组合而成的复杂结构。往往在不同的模型上有着差别的方法,并且需要很灵活的扩展。比如ConcreteElement1可能只需要分析(analysis)功能,ConcreteElement2则不需要分析,需要保存(save)功能。按照传统思路,要么直接在ConcreteElement1中增加analysis方法,要么就专为ConcreteElement1新建一个Service,这两种方法都存在扩展性不足的问题,因此visitor模式是为了对这两种方式进行改进而诞生的设计。

简单的访问者模式

简单的访问者模式是我自己起的名字,简化一些不必要的扩展,看看最简单的情况下访问者模式是什么样子,然后再由这种最简单的模式扩展到下面的复杂形式。这里的简化是将Element的多态给去除,假设Element就是一个实现类,那么此时每一个Visitor就相当于一个Element内部方法的迁移,接下来看具体案例。

如下图所示,假设当前Element是Dog,狗,然后我们想要给他增加健康评估(Health)和耐力预测(Endurance)技能。

image-20220702194526944

首先是定义Visitor接口以及Element实体类,并分别实现通道方法visitaccept

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
/**
* 访问者接口
*/
public interface Visitor {
/**
* 连接Element的通道方法
*/
void visit(Dog dog);
}

/**
* 定义实体类
*/
public class Dog {
private String name;
private String type;
private String ...;

/**
* 定义通道方法,将自身传递给访问者,让访问者能够访问自身属性
* 当然这里还可以用instance of感知到具体访问者,并由此做额外的功能
*/
public void accept(Visitor visitor) {
visitor.visit(this);
}
}

接下来是实现具体访问者的逻辑,访问者主要是获取Element的属性,然后按照自己的逻辑实现计算,变相的为Element增加对应的能力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 健康度计算
*/
public class HealthVisitor implements Visitor {
@Getter
private String score;
@Override
public void visit(Dog dog) {
// 模拟获取Dog的各种指标数据,然后计算健康度
this.score = 健康度(dog);
}
}
/**
* 耐力评估
*/
public class EnduranceVisitor implements Visitor {
@Getter
private String endurance;
@Override
public void visit(Dog dog) {
// 模拟获取Dog的各种指标数据,然后计算耐力
this.endurance = 耐力(dog);
}
}

那么定义以及实现都搞定后,想要使用什么功能,如下所示,直接初始化对应的访问者,然后用访问者调用主体类。

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
Dog dog = new Dog();
dog.setName("张三");
dog.setType("二哈");
// 初始化访问者,调用实体类
HealthVisitor visitor = new HealthVisitor();
visitor.visit(dog);

System.out.println(visitor.getScore());
}

看完上述实现,我们可以分析下这个简单案例。如果不使用访问者模式,那么可以新建一个DogDomainService,然后在Service中实现健康评估(Health)和耐力预测(Endurance)技能,这种方式也是可以的,当功能更加复杂后,将其用访问者模式分离开功能,每个复杂功能单独实现,像是插件一样,想要扩展时,也只需要新增加一个Visitor的实现策略,也是合理的。因此访问者模式的本质目地之一我们可以简单的认为就是将原本的属于Element的功能给拆散到Visitor中,便于后续灵活扩展。当然这样的简单案例发挥不出访问模式的优势,这种扩展一般策略模式就足以了,接下来看下多主体类下的访问者模式。

多主体类的访问者模式

与上述访问者模式不同的是,多主体模式下的Element是多继承结构,比如Animal下面分为了狗(Dog)以及猫(Cat)还有鹦鹉(Parrot)等等,每个不同的Element具有特殊的功能,比如猫(Cat)的攀爬(scramble)能力,鹦鹉(Parrot)的飞行(flight)能力,那么此时Visitor接口本身还是具有通用通道,访问者的具体实现类就根据自身需要,具有针对性的实现对应方法,一般依赖多态来分别实现区分不同的功能。

image-20220703114253495

如上图所示:此时Element被分为了Animal接口,以及三个实现类:Dog,Cat,Parrot。Visitor接口中分别增加了对三类主体的访问方法:visit(Dog),visit(Cat),visit(Parrot)。其中实现类HealthVisitor,EnduranceVisitor可以同时对三者进行健康评估和耐力评估,ScrambleVisitor只针对Cat做攀爬能力分析,FlightVisitor则只针对Parrot做飞行能力分析。

案例很好理解,就是上面的变种,因此就不展示具体代码了,这里我们分析下引入多主体类后,会产生哪些问题。

问题一:Visitor中对每个类都有个visitor方法,目地是什么?

这个问题想必是很多人的疑问,Visitor接口承担的是一个通道的作用,重载方法的定义是为了方法调用的一致性,只需要visit(xxx)以及accept(xxx)。当然也可以直接定义一个visit(Animal),但这样子类由需要instance of感知具体的Element是什么,才能进行单独的逻辑,反而增加复杂性,直观性也不足够,比如想找到所有给Dog增加的方法,就比较麻烦,不像有单独接口,可以直接定位到子类,得不偿失。

问题二:Visitor子类如何方便的选择自己针对的Element?

上述方式带来的弊端是子类感知到了所有的方法,像FlightVisitor这种实现类,只针对Parrot,他就不需要感知visit(Dog/Cat),此时一般会在Visitor接口下面增加一个VisitorAdapter抽象类,来实现所有的方法,只不过实现的逻辑都是Throw UnSupportException,然后子类再选择自己想要覆盖的实现。在或者将visit(Dog),visit(Cat),visit(Parrot)分别拆分到三个Visitor接口:DogVisitor,CatVisitor,ParrotVisitor,然后实现类利用多重继承,选择实现对应的接口,也是一种合理方式。

问题三:Visitor与Element之间的耦合关系是什么样子?

耦合关系决定了使用形式,从上述关系图来看,Element是不感知Visitor的,也就是具体有哪些Visitor,Element毫不关心,但反过来Visitor是强感知Element的,Visitor需要知道自己针对的是哪个Element,不针对哪些Element。这样的关系决定了我们在使用访问者模式时,是需要知道当下业务到底想要什么样子的Visitor。比如当前就需要对鹦鹉(Parrot)做飞行评估,那么就需要主动实例化出来FlightVisitor。

嵌套其他模式的访问者模式

这个是在实际开发过程中用到的最多的一种情况了,因此基于上面两个案例的铺垫,这里会直接使用实际案例来进行分析。

在SQL解析中,一般会经过词法分析,语法/语义分析(生成AST语法树),各种业务自定义逻辑(比如分库分表表名替换)这几个步骤,参考美团文章中的图,针对如下SQL会生成对应AST语法树:

1
select username, ismale from userinfo where age > 20 and level > 5 and 1 = 1

图2 语法树

这种树形结构,在应用中一般以组合模式形式构建,以Druid为例,解析后结构如下图所示,应用对外展示的则是最顶层的SQLStatement,其本质是SQLSelectStatement

1
2
3
4
5
String sql = "select username, ismale from userinfo where age > 20 and level > 5 and 1 = 1";
// 新建 MySQL Parser
SQLStatementParser parser = new MySqlStatementParser(sql);
// 使用Parser解析生成AST,这里SQLStatement就是AST
SQLStatement sqlStatement = parser.parseStatement();

image-20220703164319627

运用组合模式提供的嵌套能力,可以很轻松的将这个AST语法树给构建出来,但问题是怎么方便的访问?比如从上述语句中提取出来表名,就需要从顶层Select节点遍历到From节点,然后获取表名,如果再嵌套子查询,那么情况更加复杂。因此实际情况下,更多时候使用Visitor模式做组合对象的功能扩展,接下来我们使用Druid提供的Visitor接口,实现一个表名提取器。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 定义一个表名提取的visitor
public class TableNameVisitor implements SQLASTVisitor {
@Getter
List<String> tables = new LinkedList<>();
@Override
public boolean visit(SQLExprTableSource x) {
String tableName = x.getTableName();
if (null != tableName) {
tables.add(tableName);
}
return true;
}
}

该Visitor实现了SQLASTVisitor接口,这个是Druid预留的扩展,里面针对每一个组合中的实体类Element提供了Visitor通道,比如这里访问表名,只需要实现visit(SQLExprTableSource x)来访问表来源相关的语法节点即可。接下来使用该Visitor遍历语法树:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
String sql = "select username, ismale from userinfo where age > 20 and level > 5 and 1 = 1";
// 新建 MySQL Parser
SQLStatementParser parser = new MySqlStatementParser(sql);
// 使用Parser解析生成AST,这里SQLStatement就是AST
SQLStatement sqlStatement = parser.parseStatement();
// 使用访问者去遍历语法树
TableNameVisitor visitor = new TableNameVisitor();
sqlStatement.accept(visitor);
System.out.println("getTables:" + visitor.getTables());
}

遍历的过程只需要调用SQLStatement.accept(visitor),该节点会自动顺着语法树的顶层,一直遍历,直到每一个叶子节点。

模式总结

到这里,针对Visitor模式的本质基本上差不多了,Visitor模式的复杂性来源博主认为主要有两点:1)主体类Element本身是多重继承结构,或者是组合模式这种复合型结构,不符合人的直观思维,增加理解难度。2)Visitor的实现类是分散开的,且都是一个个独立的功能,不能很直观的展示一个对象究竟有哪些能力,也增加理解成本。

大多数时候,使用Visitor模式扩展必要性是不大的,策略模式就能满足了。但在最后一个案例中,如果没有Visitor模式,笔者还真的想象不到有什么好的方式能够解决组合模式的扩展性问题,这大概也是在实际开发中看到的Visitor模式都是和组合模式一起出现的原因。

参考

《研磨设计模式》- 访问者模式

SQL解析在美团的应用

读书笔记 -- 《Maven实战》
Linux -- Expect Script入门