您可以捐助,支持我们的公益事业。

1元 10元 50元





认证码:  验证码,看不清楚?请点击刷新验证码 必填



  求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Model Center   Code  
会员   
   
 
     
   
 
 订阅
重新认识访问者模式:从实践到本质
 
作者:悬衡
  1160  次浏览      19 次
 2022-3-10
 
编辑推荐:
从开源项目以及应用系统中的实践出发,同时对比其他类似的设计模式,最后阐述其在编程范式中的本质,希望对您的学习有所帮助。
本文来自于阿里技术公众号 ,由火龙果软件Alice编辑、推荐。

简介:访问者模式在设计模式中的知名度虽然不如单例模式,但也是少数几个大家都能叫得上名字的设计模式了(另外几个可能就是“观察者模式”,“工厂模式” 了)。不过因为访问者模式的复杂性,人们很少在应用系统中使用,经过本文的探索,我们一定会产生新的认识,发现其更加灵活广泛的使用方式。

和一般介绍设计模式的文章不同,本文不会执着于死板的代码模板,而是直接从开源项目以及应用系统中的实践出发,同时对比其他类似的设计模式,最后阐述其在编程范式中的本质。

一 Calcite 中的访问者模式

开源类库常常利用访问者风格的 API 屏蔽内部的复杂性,从这些 API 入手学习,能够让我们先获得一个直观感受。

Calcite 是一个 Java 语言编写的数据库基础类库,诸如 Hive,Spark 等诸多知名开源项目都在使用。其中 SQL 解析模块提供了访问者模式的 API,我们利用它的 API 可以快速获取 SQL 中我们需要的信息,以获取 SQL 中使用的所有函数为例:

import org.apache.calcite.sql.SqlCall;
import org.apache.calcite.sql.SqlFunction;
import org.apache.calcite.sql.SqlNode;
import org.apache.calcite.sql.parser.SqlParseException;
import org.apache.calcite.sql.parser.SqlParser;
import org.apache.calcite.sql.util.SqlBasicVisitor;
import java.util.ArrayList;
import java.util.List;
public class CalciteTest {
public static void main(String[] args) throws SqlParseException {
String sql = "select concat('test-', upper(name)) from test limit 3";
SqlParser parser = SqlParser.create(sql);
SqlNode stmt = parser.parseStmt();
FunctionExtractor functionExtractor = new FunctionExtractor();
stmt.accept(functionExtractor);
// [CONCAT, UPPER]
System.out.println(functionExtractor.getFunctions());
}
private static class FunctionExtractor extends SqlBasicVisitor< Void> {
private final List< String> functions = new ArrayList<>();
@Override
public Void visit(SqlCall call) {
if (call.getOperator() instanceof SqlFunction) {
functions.add(call.getOperator().getName());
}
return super.visit(call);
}
public List< String> getFunctions() {
return functions;
}
}
}

代码中 FunctionExtractor 是 SqlBasicVisitor 的子类,并且重写了它的 visit(SqlCall) 方法,获取函数的名称并收集在了 functions 中。

除了 visit(SqlCall) 外,还可以通过 visit(SqlLiteral) (常量), visit(SqlIdentifier) (表名/列名)等等,实现更加复杂的分析。

有人会想,为什么 SqlParser 不直接提供类似于 getFunctions 等方法直接获取 SQL 中的所有函数呢?在上文的示例中, getFunctions 可能确实更加方便,但是 SQL 作为一个很复杂的结构, getFunctions 对于更加复杂的分析场景是不够灵活的,性能也是更差的。如果需要,完全可以很简单地实现一个如上文的 FunctionExtractor 来满足需求。

二 动手实现访问者模式

我们尝试实现一个简化版的 SqlVisitor 。

先定义一个简化版的 SQL 结构。

将 select upper(name) from test where age > 20 ; 拆解到这个结构上层级关系如图:

我们直接在 Java 代码中将上图的结构构造出来:

SqlNode sql = new SelectNode(
new FieldsNode(Arrays.asList(
new FunctionCallExpression("upper", Arrays.asList(
new IdExpression("name")
))
)),
Arrays.asList("test"),
new WhereNode(Arrays.asList(new OperatorExpression(
new IdExpression("age"),
">",
new LiteralExpression("20")
)))
);

这个类中都有一个相同的方法,就是 accept:

@Override
public < R> R accept(SqlVisitor< R> sqlVisitor) {
return sqlVisitor.visit(this);
}

这里会通过多态分发到 SqlVisitor 不同的 visit 方法上:

abstract class SqlVisitor< R> {
abstract R visit(SelectNode selectNode);
abstract R visit(FieldsNode fieldsNode);
abstract R visit(WhereNode whereNode);
abstract R visit(IdExpression idExpression);
abstract R visit(FunctionCallExpression functionCallExpression);
abstract R visit(OperatorExpression operatorExpression);
abstract R visit(LiteralExpression literalExpression);
}

SQL 结构相关的类如下:

abstract class SqlNode {
// 用来接收访问者的方法
public abstract < R> R accept(SqlVisitor< R> sqlVisitor);
}
class SelectNode extends SqlNode {
private final FieldsNode fields;
private final List< String> from;
private final WhereNode where;
SelectNode(FieldsNode fields, List< String> from, WhereNode where) {
this.fields = fields;
this.from = from;
this.where = where;
}
@Override
public < R> R accept(SqlVisitor< R> sqlVisitor) {
return sqlVisitor.visit(this);
}
//... get 方法省略
}
class FieldsNode extends SqlNode {
private final List< Expression> fields;
FieldsNode(List<Expression> fields) {
this.fields = fields;
}
@Override
public < R> R accept(SqlVisitor< R> sqlVisitor) {
return sqlVisitor.visit(this);
}
}
class WhereNode extends SqlNode {
private final List< Expression> conditions;
WhereNode(List< Expression> conditions) {
this.conditions = conditions;
}
@Override
public < R> R accept(SqlVisitor< R> sqlVisitor) {
return sqlVisitor.visit(this);
}
}
abstract class Expression extends SqlNode {
}
class IdExpression extends Expression {
private final String id;
protected IdExpression(String id) {
this.id = id;
}
@Override
public < R> R accept(SqlVisitor< R> sqlVisitor) {
return sqlVisitor.visit(this);
}
}
class FunctionCallExpression extends Expression {
private final String name;
private final List< Expression> arguments;
FunctionCallExpression(String name, List< Expression> arguments) {
this.name = name;
this.arguments = arguments;
}
@Override
public < R> R accept(SqlVisitor< R> sqlVisitor) {
return sqlVisitor.visit(this);
}
}
class LiteralExpression extends Expression {
private final String literal;
LiteralExpression(String literal) {
this.literal = literal;
}
@Override
public < R> R accept(SqlVisitor< R> sqlVisitor) {
return sqlVisitor.visit(this);
}
}
class OperatorExpression extends Expression {
private final Expression left;
private final String operator;
private final Expression right;
OperatorExpression(Expression left, String operator, Expression right) {
this.left = left;
this.operator = operator;
this.right = right;
}
@Override
public < R> R accept(SqlVisitor< R> sqlVisitor) {
return sqlVisitor.visit(this);
}
}

有的读者可能会注意到,每个类的 accept 方法的代码都是一样的,那为什么不直接写在父类 SqlNode 中呢?如果尝试一下就会发现根本无法通过编译,因为我们的 SqlVisitor 中根本就没有提供 visit(SqlNode) ,即使添加了 visit(SqlNode) ,通过了编译,程序的运行结果也是不符合预期的,因为此时所有的 visit 调用都会指向 visit(SqlNode) ,其他重载方法就形同虚设了。

导致这种现象的原因是,不同的 visit 方法互相之间只有参数不同,称为“重载”,而 Java 的 “重载” 又被称为 “编译期多态”,只会根据 visit(this) 中 this 在编译时的类型决定调用哪个方法,而它在编译时的类型就是 SqlNode,尽管它在运行时可能是不同的子类。

所以,我们可能经常会听说用动态语言写访问者模式会更加简单,特别是支持模式匹配的函数式程序设计语言(这在 Java 18 中已经有较好支持),后面我们再回过头来用模式匹配重新实现下本小节的内容,看看是不是简单了很多。

接下来我们像之前一样,是使用 SqlVisitor 尝试解析出 SQL中所有的函数调用。

先实现一个 SqlVisitor,这个 SqlVisitor 所作的就是根据当前节点的结构以此调用 accept,最后将结果组装起来,遇到 FunctionCallExpression 时将函数名称添加到集合中:

class FunctionExtractor extends SqlVisitor< List< String>> {
@Override
List< String> visit(SelectNode selectNode) {
List<String> res = new ArrayList<>();
res.addAll(selectNode.getFields().accept(this));
res.addAll(selectNode.getWhere().accept(this));
return res;
}
@Override
List< String> visit(FieldsNode fieldsNode) {
List< String> res = new ArrayList<>();
for (Expression field : fieldsNode.getFields()) {
res.addAll(field.accept(this));
}
return res;
}
@Override
List< String> visit(WhereNode whereNode) {
List< String> res = new ArrayList<>();
for (Expression condition : whereNode.getConditions()) {
res.addAll(condition.accept(this));
}
return res;
}
@Override
List< String> visit(IdExpression idExpression) {
return Collections.emptyList();
}
@Override
List< String> visit(FunctionCallExpression functionCallExpression) {
// 获得函数名称
List< String> res = new ArrayList<>();
res.add(functionCallExpression.getName());
for (Expression argument : functionCallExpression.getArguments()) {
res.addAll(argument.accept(this));
}
return res;
}
@Override
List< String> visit(OperatorExpression operatorExpression) {
List< String> res = new ArrayList<>();
res.addAll(operatorExpression.getLeft().accept(this));
res.addAll(operatorExpression.getRight().accept(this));
return res;
}
@Override
List< String> visit(LiteralExpression literalExpression) {
return Collections.emptyList();
}
}

main 中的代码如下:

public static void main(String[] args) {
// sql 定义
SqlNode sql = new SelectNode( //select
// concat("test-", upper(name))
new FieldsNode(Arrays.asList(
new FunctionCallExpression("concat", Arrays.asList(
new LiteralExpression("test-"),
new FunctionCallExpression(
"upper",
Arrays.asList(new IdExpression("name"))
)
))
)),
// from test
Arrays.asList("test"),
// where age > 20
new WhereNode(Arrays.asList(new OperatorExpression(
new IdExpression("age"),
">",
new LiteralExpression("20")
)))
);
// 使用 FunctionExtractor
FunctionExtractor functionExtractor = new FunctionExtractor();
List< String> functions = sql.accept(functionExtractor);
// [concat, upper]
System.out.println(functions);
}

以上就是标准的访问者模式的实现,直观感受上比之前 Calcite 的 SqlBasicVisitor 用起来麻烦多了,我们接下来就去实现 SqlBasicVisitor 。

三 访问者模式与观察者模式

在使用 Calcite 实现的 FunctionExtractor 中,每次 Calcite 解析到函数就会调用我们实现的 visit(SqlCall) ,称它为 listen(SqlCall) 似乎比 visit 更加合适。这也显示了访问者模式与观察者模式的紧密联系。

在我们自己实现的 FunctionExtractor 中,绝大多数代码都是在按照一定的顺序遍历各种结构,这是因为访问者模式给予了使用者足够的灵活性,可以让实现者自行决定遍历的顺序,或者对不需要遍历的部分进行剪枝。

但是我们的需求 “解析出 SQL 中所有的函数”,并不关心遍历的顺序,只要在经过“函数”时通知一下我们即可,对于这种简单需求,访问者模式有点过度设计,观察者模式会更加合适。

大多数使用访问者模式的开源项目会给“标准访问者”提供一个默认实现,比如 Calcite 的 SqlBasicVisitor ,默认实现会按照默认的顺序对 SQL 结构进行遍历,而实现者只需要重写它关心的部分就行了,这样就相当于在访问者模式的基础上又实现了观察者模式,即不丢失访问者模式的灵活性,也获得观察者模式使用上的便利性。

我们给自己的实现也来添加一个 SqlBasicVisitor 吧:

class SqlBasicVisitor< R> extends SqlVisitor< R> {
@Override
R visit(SelectNode selectNode) {
selectNode.getFields().accept(this);
selectNode.getWhere().accept(this);
return null;
}
@Override
R visit(FieldsNode fieldsNode) {
for (Expression field : fieldsNode.getFields()) {
field.accept(this);
}
return null;
}
@Override
R visit(WhereNode whereNode) {
for (Expression condition : whereNode.getConditions()) {
condition.accept(this);
}
return null;
}
@Override
R visit(IdExpression idExpression) {
return null;
}
@Override
R visit(FunctionCallExpression functionCallExpression) {
for (Expression argument : functionCallExpression.getArguments()) {
argument.accept(this);
}
return null;
}
@Override
R visit(OperatorExpression operatorExpression) {
operatorExpression.getLeft().accept(this);
operatorExpression.getRight().accept(this);
return null;
}
@Override
R visit(LiteralExpression literalExpression) {
return null;
}
}

SqlBasicVisitor 给每个结构都提供了一个默认的访问顺序,使用这个类我们来实现第二版的 FunctionExtractor :

class FunctionExtractor2 extends SqlBasicVisitor< Void> {
private final List< String> functions = new ArrayList<>();
@Override
Void visit(FunctionCallExpression functionCallExpression) {
functions.add(functionCallExpression.getName());
return super.visit(functionCallExpression);
}
public List< String> getFunctions() {
return functions;
}
}

它的使用如下:

class Main {
public static void main(String[] args) {
SqlNode sql = new SelectNode(
new FieldsNode(Arrays.asList(
new FunctionCallExpression("concat", Arrays.asList(
new LiteralExpression("test-"),
new FunctionCallExpression(
"upper",
Arrays.asList(new IdExpression("name"))
)
))
)),
Arrays.asList("test"),
new WhereNode(Arrays.asList(new OperatorExpression(
new IdExpression("age"),
">",
new LiteralExpression("20")
)))
);
FunctionExtractor2 functionExtractor = new FunctionExtractor2();
sql.accept(functionExtractor);
System.out.println(functionExtractor.getFunctions());
}
}

四 访问者模式与责任链模式

ASM 也是一个提供访问者模式 API 的类库,用来解析与生成 Java 类文件,能想到的所有 Java 知名开源项目都有他的身影,Java8 的 Lambda 表达式特性甚至都是通过它来实现的。如果只是能解析与生成 Java 类文件,ASM 或许还不会那么受欢迎,更重要的是它优秀的抽象,它将常用的功能抽象为一个个小的访问者工具类,让复杂的字节码操作变得像搭积木一样简单。

假设需要按照如下方式修改类文件:

  1. 删除 name 属性
  2. 给所有属性添加 @NonNull 注解

但是出于复用和模块化的角度考虑,我们想把两个步骤分别拆成独立的功能模块,而不是把代码写在一起。在 ASM 中,我们可以分别实现两个小访问者,然后串在一起,就变成能够实现我们需求的访问者了。

删除 name 属性的访问者:

class DeleteFieldVisitor extends ClassVisitor {
// 删除的属性名称, 对于我们的需求,它就是 "name"
private final String deleteFieldName;
public DeleteFieldVisitor(ClassVisitor classVisitor, String deleteFieldName) {
super(Opcodes.ASM9, classVisitor);
this.deleteFieldName = deleteFieldName;
}
@Override
public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
if (name.equals(deleteFieldName)) {
// 不再向下游传递该属性, 对于下游来说,就是被 "删除了"
return null;
}
// super.visitField 会去继续调用下游 Visitor 的 visitField 方法
return super.visitField(access, name, descriptor, signature, value);
}
}

给所有属性添加 @NonNull 注解的访问者:

class AddAnnotationVisitor extends ClassVisitor {
public AddAnnotationVisitor(ClassVisitor classVisitor) {
super(Opcodes.ASM9, classVisitor);
}
@Override
public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
FieldVisitor fieldVisitor = super.visitField(access, name, descriptor, signature, value);
// 向下游 Visitor 额外传递一个 @NonNull 注解
fieldVisitor.visitAnnotation("javax/annotation/Nonnull", false);
return fieldVisitor;
}
}

在 main 中我们将它们串起来使用:

public class AsmTest {
public static void main(String[] args) throws URISyntaxException, IOException {
Path clsPath = Paths.get(AsmTest.class.getResource("/visitordp/User.class").toURI());
byte[] clsBytes = Files.readAllBytes(clsPath);
// 串联 Visitor
// finalVisitor = DeleteFieldVisitor -> AddAnnotationVisitor -> ClassWriter
// ClassWriter 本身也是 ClassVisitor 的子类
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
ClassVisitor finalVisitor = new DeleteFieldVisitor(new AddAnnotationVisitor(cw),
"name");
// ClassReader 就是被访问的对象
ClassReader cr = new ClassReader(clsBytes);
cr.accept(finalVisitor, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
byte[] bytes = cw.toByteArray();
Files.write(clsPath, bytes);
}
}

通过访问者模式与责任链模式的结合,我们不再需要将所有的逻辑都写在一个访问者中,我们可以拆分出多个通用的访问者,通过组合他们实现更加多种多样的需求。

五 访问者模式与回调模式

“回调” 可以算是“设计模式的设计模式”了,大量设计模式中都有它的思想,诸如观察者模式中的“观察者”,“命令模式”中的“命令”,“状态模式” 中的 “状态” 本质上都可以看成一个回调函数。

访问者模式中的“访问者”显然也是一个回调,和其他回调模式最大的不同是,“访问者模式” 是一种带有“导航”的回调,我们通过传入的对象结构给实现者下一步回调的“导航”,实现者根据“导航”决定下一步回调的次序。

如果我想先访问 fieldA ,再访问 fieldB ,最后 fieldC ,对应到访问者的实现就是:

visit(SomeObject someObject) {
someObject.fieldA.accept(this);
someObject.fieldB.accept(this);
someObject.fieldC.accept(this);
}

这在实际中的应用就是 HATEOAS (Hypermedia as the Engine of Application State),HATEOAS 风格的 HTTP 接口除了会返回用户请求的数据外,还会包含用户下一步应该访问的 URL,如果将整个应用的 API 比作一个家的话,假如用户请求客厅的数据,那么接口除了返回客厅的数据外,还会返回与客厅相连的 "厨房",“卧室”与“卫生间”的 URL:

----请求----GET /sittingroomHOST home.example.comAccept: application/xml----服务端返回----HTTP/1.1 200 OKContent-Type: application/xml
<?xml version="1.0"?><sittingroom> <!-- 客厅相关数据 --> <television>海康</television> <sofa>宜家</sofa> <!-- 与客厅相连的其他房间, 方便用户下一步访问 --> <link rel="kitchen" href="https://home.example.com/sittingroom/kitchen"/> <link rel="bedroom" href="https://home.example.com/sittingroom/bedroom"/> <link rel="toilet" href="https://home.example.com/sittingroom/toilet"/></sittingroom>

这样做的好处是,可以无缝地升级与更换资源的 URL(因为这些 URL 都是服务端返回的),而且开发者在不需要文档的情况下顺着导航,可以摸索学会 API 的使用,解决了 API 组织混乱的问题。关于 HATEOAS 更实际的例子可以见 How to GET a Cup of Coffee[1]。

六 实际应用

之前举的例子可能都更偏向于开源基础类库的应用,那么在更加广泛的应用系统中,它要如何应用呢?

1 复杂的嵌套结构访问

现在的 toB 应用为了满足不同企业的稀奇古怪的定制需求,提供的配置功能越來越复杂,配置项之间不再是简单的正交独立的关系,而是相互嵌套递归,正是访问者模式发挥的场合。

钉钉审批的流程配置就是一个十分复杂的结构:

做过简化的审批流模型如下:

模型和流程配置的对应关系如下图:

RouteNode 除了像普通节点一样通过 next 连接下一个节点,其中包含的每个 condition 又是一个完整的流程配置(递归定义),由此可见审批节点模型是复杂的嵌套结构。

除了整体结构复杂外,每个节点的配置也相当复杂:

面对如此复杂的配置,最好能通过配置解析二方包(下文中都简称为 SDK)对应用层屏蔽配置的复杂性。如果 SDK 只是返回一个图结构给应用层的话,应用层就不得不感知节点之间的关联并且每次都需要编写容易出错的遍历算法,此时访问者模式就变成了我们的不二之选。

访问者模式的实现套路和之前一样的,就不多说了,我们举个应用层例子:

  • 流程仿真:让用户在不实际运行流程的情况下就能看到流程的执行分支,方便调试
class ProcessSimulator implements ProcessConfigVisitor {
private List< String> traces = new ArrayList<>();
@Override
public void visit(StartNode startNode) {
if (startNode.next != null) {
startNode.next.accept(this);
}
}
@Override
public void visit(RouteNode routeNode) {
// 计算出满足条件的分支
for (CondtionNode conditionNode : routeNode.conditions) {
if (evalCondition(conditionNode.condition)) {
conditionNode.accept(this);
}
}
if (routeNode.next != null) {
routeNode.next.accept(this);
}
}
@Override
public void visit(ConditionNode conditionNode) {
if (conditionNode.next != null) {
conditionNode.next.accept(this);
}
}
@Override
public void visit(ApproveNode approveNode) {
// 记录下在仿真中访问到的审批节点
traces.add(approveNode.id);
if (approveNode.next != null) {
approveNode.next.accept(this);
}
}
}

2 SDK 隔离外部调用

为了保证 SDK 的纯粹性,一般 SDK 中都不会去调用外部接口,但是为了实现一些需求又不得不这么做,此时我们可以将外部调用放在应用层访问者的实现中,然后传入 SDK 中执行相关逻辑。

在上面提到的流程仿真中过程,条件计算常会包括外部接口调用,比如通过连接器调用一个用户指定接口决定流程分支,为了保证流程配置解析 SDK 的纯粹性,不可能在 SDK 包中进行调用的,因此就在访问者中调用。

七 使用 Java18 实现访问者模式

回到最初的命题,用访问者模式获得 SQL 中所有的函数调用。前面说过,用函数式编程语言中常见的模式匹配可以更加方便地实现,而最新的 Java18 中已经对此有比较好的支持。

从 Java 14 开始,Java 支持了一种新的 Record 数据类型,示例如下:

// sealed 表示胶囊类型, 即 Expression 只允许是当前文件中 Num 和 Add
sealed interface Expression {
// record 关键字代替 class, 用于定义 Record 数据类型
record Num(int value) implements Expression {}
record Add(int left, int right) implements Expression {}
}

TestRecord 一旦实例化,字段就是不可变的,并且它的 equals 和 hashCode 方法会被自动重写,只要内部的字段都相等,它们就是相等的:

public static void main(String[] args) {
Num n1 = new Num(2);
// n1.value = 10; 这行代码会导致编译不过
Num n2 = new Num(2);
// true
System.out.println(n1.equals(n2));
}

更加方便的是,利用 Java 18 中最新的模式匹配功能,可以拆解出其中的属性:

public int eval(Expression e) {
return switch (e) {
case Num(int value) -> value;
case Add(int left, int right) -> left + right;
};
}

我们首先使用 Record 类型重新定义我们的 SQL 结构:

sealed interface SqlNode {
record SelectNode(FieldsNode fields, List< String> from, WhereNode where) implements SqlNode {}
record FieldsNode(List< Expression> fields) implements SqlNode {}
record WhereNode(List< Expression> conditions) implements SqlNode {}
sealed interface Expression extends SqlNode {
record IdExpression(String id) implements Expression {}
record FunctionCallExpression(String name, List< Expression> arguments) implements Expression {}
record LiteralExpression(String literal) implements Expression {}
record OperatorExpression(Expression left, String operator, Expression right) implements Expression {}
}
}

然后利用模式匹配,一个方法即可实现之前的访问,获得所有函数调用:

public List< String> extractFunctions(SqlNode sqlNode) {
return switch (sqlNode) {
case SelectNode(FieldsNode fields, List< String> from, WhereNode where) -> {
List< String> res = new ArrayList<>();
res.addAll(extractFunctions(fields));
res.addAll(extractFunctions(where));
return res;
}
case FieldsNode(List< Expression> fields) -> {
List< String> res = new ArrayList<>();
for (Expression field : fields) {
res.addAll(extractFunctions(field));
}
return res;
}
case WhereNode(List< Expression> conditions) -> {
List< String> res = new ArrayList<>();
for (Expression condition : conditions) {
res.addAll(extractFunctions(condition));
}
return res;
}
case IdExpression(String id) -> Collections.emptyList();
case FunctionCallExpression(String name, List< Expression> arguments) -> {
// 获得函数名称
List< String> res = new ArrayList<>();
res.add(name);
for (Expression argument : arguments) {
res.addAll(extractFunctions(argument));
}
return res;
}
case LiteralExpression(String literal) -> Collections.emptyList();
case OperatorExpression(Expression left, String operator, Expression right) -> {
List< String> res = new ArrayList<>();
res.addAll(extractFunctions(left));
res.addAll(extractFunctions(right));
return res;
}
}
}

对比一下第二小节的代码,最大的区别就是 sqlNode.accept(visitor) 被换成了对 extractFunctions 的递归调用。另外就是原本通过类来封装的行为,变成了更加轻量的函数。我们将在下一小节探讨其更加深入的含义。

八 重新认识访问者模式

在 GoF 的设计模式原著中,对访问者模式的描述如下:

表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

从这句话可以看出,访问者模式实现的所有功能本质上都可以通过给每个对象增加新的成员方法实现,利用面向对象多态的特性,父结构调用并且聚合子结构相应方法的返回结果,以之前的抽取 SQL 所有函数为例,这一次不用访问者实现,而是在每个类中增加一个 extractFunctions 成员方法:

class SelectNode extends SqlNode {
private final FieldsNode fields;
private final List<String> from;
private final WhereNode where;
SelectNode(FieldsNode fields, List<String> from, WhereNode where) { this.fields = fields; this.from = from; this.where = where; }
public FieldsNode getFields() { return fields; }
public List<String> getFrom() { return from; }
public WhereNode getWhere() { return where; }
public List<String> extractFunctions() { List<String> res = new ArrayList<>(); // 继续调用子结构的 extractFunctions res.addAll(fields.extractFunctions()); res.addAll(selectNode.extractFunctions()); return res; }}

访问者模式本质上就是将复杂的类层级结构中成员方法全部都抽象到一个类中去:

这两种编写方式有什么区别呢?Visitor 这个名字虽然看起来像名词,但是从前面的例子和论述来看,它的实现类全部是关于操作的抽象,从模式匹配的实现方式中就更能看出这一点,ASM 中甚至将 Visitor 作为一个个小操作的抽象进行排列组合,因此两种编写方式也对应两种世界观:

  • 面向对象:认为操作必须和数据绑定到一起,即作为每个类的成员方法存在,而不是单独抽取出来成为一个访问者
  • 函数式编程:将数据和操作分离,将基本操作进行排列组合成为更加复杂的操作,而一个访问者的实现就对应一个操作

这两个方式,在编写的时候看起来区别不大,只有当需要添加修改功能的时候才能显现出他们的天壤之别,假设现在我们要给每个类增加一个新操作:

  • 成员函数实现方式:需要给类层级结构的每个类增加一个实现,需要修改原来的代码,不符合开闭原则
  • 访问者实现方式:新建一个访问者即可,完全不影响原来的代码。占优。

这种场景看起来是增加访问者更加方便。那么再看下一个场景,假设现在要在类层级结构中增加一个新类:

  • 成员函数实现方式:新建一个类即可,完全不影响原来代码。占优。
  • 访问者实现方式:需要给每个访问者增加新类的代码实现,需要修改原来的代码,不符合开闭原则。

这两个场景对应了软件的两种拆分方式,一种是按照数据拆分,一种是按照功能点拆分,以阿里双十一的各个分会场与功能为例:盒马,饿了么和聚划算分别作为一个分会场参与了双十一的促销,他们都需要提供优惠券,订单和支付等功能。

虽然在用户看来 盒马,饿了么和聚划算是三个不同的应用,但是底层系统可以有两种划分方式:

  • 按应用划分:盒马,饿了么,聚划算这个三个系统完全独立,分别实现一遍三个功能点。虽然有重复造轮子的嫌疑,但是能够短平快地支撑创新业务,这可能就是所谓的“拆中台”。
  • 按功能划分:将系统分为 优惠券系统,订单系统和支付系统,然后三个应用都使用相同的功能系统,在功能系统内部通过配置或者拓展点的方式处理业务之间的不同。这其实就是所谓的 “中台”,虽然能最大程度上地重用已有技术成果,但是中台的种种限制也会遏制创新业务的发展。

任何一种划分方式都要承受该种方式带来的缺点。所有现实中的应用,不论是架构还是编码,都没有上面的例子那么极端,而是两种混用。比如 盒马,饿了么,聚划算 都可以在拥有自己系统的同时,复用优惠券这样的按功能划分的系统。对应到编码也是一样,软件工程没有银弹,我们也要根据特性和场景决定是采用面向对象的抽象,还是访问者的抽象。更多的时候需要两者混用,将部分核心方法作为对象成员,利用访问者模式实现应用层的那些琐碎杂乱的需求。

   
1160 次浏览       19
相关文章

领域知识与限界上下文
深度解析DDD中台和微服务设计
领域建模的体系化思维与6种方法论
迄今为止最完整的DDD实践
 
相关文档

设计模式-原型模式
设计模式.组合模式
设计模式的六大原则实例
设计模式和代码重构
相关课程

JavaEE架构、 设计模式及性能调优
高质量软件设计与设计模式
设计模式及最佳实践
J2EE设计模式指南

最新活动计划
MBSE(基于模型的系统工程)4-18[北京]
自然语言处理(NLP) 4-25[北京]
基于 UML 和EA进行分析设计 4-29[北京]
以用户为中心的软件界面设计 5-16[北京]
DoDAF规范、模型与实例 5-23[北京]
信息架构建模(基于UML+EA)5-29[北京]
 
 
最新文章
在EA中内嵌文档- Artifact
EA中模型视图
EA中的实体关系图
使用EA进行风险建模
EA中的项目词汇表
EA的模型导出或导入csv文件
自定义表格(Custom Table)在EA中的使用
Gap Analysis Matrix(差距分析矩阵)
更多...   
MBSE工具
MBSE平台
建模工具 EA
模型库-Model Center
需求管理-ReqManager
自动建模-Modeler
多级仿真-Sys Simulator
代码工程-Code Engineer
文档生成器-DocGenerator
更多...   
成功案例
广汽研究院 SysML+EA+软件分析设计
高合汽车研发部门 建模工具EA、WebEA、学习视频
国汽智联 建模工具EA、模型库、WebEA和iSpace
亿咖通 MBSE工程体系与工具链咨询
中航无人机 MBSE工具链
吉利汽车 购买EA工具
华科汽车零部件 购买EA工具
东风岚图汽车 购买EA工具 以及EA定制开发
更多...