重新认识访问者模式:从实践到本质

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 访问者模式在设计模式中的知名度虽然不如单例模式,但也是少数几个大家都能叫得上名字的设计模式了(另外几个可能就是“观察者模式”,“工厂模式” 了)。不过因为访问者模式的复杂性,人们很少在应用系统中使用,经过本文的探索,我们一定会产生新的认识,发现其更加灵活广泛的使用方式。

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

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

Calcite 中的访问者模式

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

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

importorg.apache.calcite.sql.SqlCall;
importorg.apache.calcite.sql.SqlFunction;
importorg.apache.calcite.sql.SqlNode;
importorg.apache.calcite.sql.parser.SqlParseException;
importorg.apache.calcite.sql.parser.SqlParser;
importorg.apache.calcite.sql.util.SqlBasicVisitor;
importjava.util.ArrayList;
importjava.util.List;
publicclassCalciteTest {
publicstaticvoidmain(String[] args) throwsSqlParseException {
Stringsql="select concat('test-', upper(name)) from test limit 3";
SqlParserparser=SqlParser.create(sql);
SqlNodestmt=parser.parseStmt();
FunctionExtractorfunctionExtractor=newFunctionExtractor();
stmt.accept(functionExtractor);
// [CONCAT, UPPER]System.out.println(functionExtractor.getFunctions());
    }
privatestaticclassFunctionExtractorextendsSqlBasicVisitor<Void> {
privatefinalList<String>functions=newArrayList<>();
@OverridepublicVoidvisit(SqlCallcall) {
if (call.getOperator() instanceofSqlFunction) {
functions.add(call.getOperator().getName());
            }
returnsuper.visit(call);
        }
publicList<String>getFunctions() {
returnfunctions;
        }
    }
}

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

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

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

动手实现访问者模式

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

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

image.png

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


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

SqlNodesql=newSelectNode(
newFieldsNode(Arrays.asList(
newFunctionCallExpression("upper", Arrays.asList(
newIdExpression("name")
                        ))
                )),
Arrays.asList("test"),
newWhereNode(Arrays.asList(newOperatorExpression(
newIdExpression("age"),
">",
newLiteralExpression("20")
                )))
        );

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

@Overridepublic<R>Raccept(SqlVisitor<R>sqlVisitor) {
returnsqlVisitor.visit(this);
    }

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

abstractclassSqlVisitor<R> {
abstractRvisit(SelectNodeselectNode);
abstractRvisit(FieldsNodefieldsNode);
abstractRvisit(WhereNodewhereNode);
abstractRvisit(IdExpressionidExpression);
abstractRvisit(FunctionCallExpressionfunctionCallExpression);
abstractRvisit(OperatorExpressionoperatorExpression);
abstractRvisit(LiteralExpressionliteralExpression);
}

SQL 结构相关的类如下:

abstractclassSqlNode {
// 用来接收访问者的方法publicabstract<R>Raccept(SqlVisitor<R>sqlVisitor);
}
classSelectNodeextendsSqlNode {
privatefinalFieldsNodefields;
privatefinalList<String>from;
privatefinalWhereNodewhere;
SelectNode(FieldsNodefields, List<String>from, WhereNodewhere) {
this.fields=fields;
this.from=from;
this.where=where;
    }
@Overridepublic<R>Raccept(SqlVisitor<R>sqlVisitor) {
returnsqlVisitor.visit(this);
    }
//... get 方法省略}
classFieldsNodeextendsSqlNode {
privatefinalList<Expression>fields;
FieldsNode(List<Expression>fields) {
this.fields=fields;
    }
@Overridepublic<R>Raccept(SqlVisitor<R>sqlVisitor) {
returnsqlVisitor.visit(this);
    }
}
classWhereNodeextendsSqlNode {
privatefinalList<Expression>conditions;
WhereNode(List<Expression>conditions) {
this.conditions=conditions;
    }
@Overridepublic<R>Raccept(SqlVisitor<R>sqlVisitor) {
returnsqlVisitor.visit(this);
    }
}
abstractclassExpressionextendsSqlNode {
}
classIdExpressionextendsExpression {
privatefinalStringid;
protectedIdExpression(Stringid) {
this.id=id;
    }
@Overridepublic<R>Raccept(SqlVisitor<R>sqlVisitor) {
returnsqlVisitor.visit(this);
    }
}
classFunctionCallExpressionextendsExpression {
privatefinalStringname;
privatefinalList<Expression>arguments;
FunctionCallExpression(Stringname, List<Expression>arguments) {
this.name=name;
this.arguments=arguments;
    }
@Overridepublic<R>Raccept(SqlVisitor<R>sqlVisitor) {
returnsqlVisitor.visit(this);
    }
}
classLiteralExpressionextendsExpression {
privatefinalStringliteral;
LiteralExpression(Stringliteral) {
this.literal=literal;
    }
@Overridepublic<R>Raccept(SqlVisitor<R>sqlVisitor) {
returnsqlVisitor.visit(this);
    }
}
classOperatorExpressionextendsExpression {
privatefinalExpressionleft;
privatefinalStringoperator;
privatefinalExpressionright;
OperatorExpression(Expressionleft, Stringoperator, Expressionright) {
this.left=left;
this.operator=operator;
this.right=right;
    }
@Overridepublic<R>Raccept(SqlVisitor<R>sqlVisitor) {
returnsqlVisitor.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 时将函数名称添加到集合中:

classFunctionExtractorextendsSqlVisitor<List<String>> {
@OverrideList<String>visit(SelectNodeselectNode) {
List<String>res=newArrayList<>();
res.addAll(selectNode.getFields().accept(this));
res.addAll(selectNode.getWhere().accept(this));
returnres;
    }
@OverrideList<String>visit(FieldsNodefieldsNode) {
List<String>res=newArrayList<>();
for (Expressionfield : fieldsNode.getFields()) {
res.addAll(field.accept(this));
        }
returnres;
    }
@OverrideList<String>visit(WhereNodewhereNode) {
List<String>res=newArrayList<>();
for (Expressioncondition : whereNode.getConditions()) {
res.addAll(condition.accept(this));
        }
returnres;
    }
@OverrideList<String>visit(IdExpressionidExpression) {
returnCollections.emptyList();
    }
@OverrideList<String>visit(FunctionCallExpressionfunctionCallExpression) {
// 获得函数名称List<String>res=newArrayList<>();
res.add(functionCallExpression.getName());
for (Expressionargument : functionCallExpression.getArguments()) {
res.addAll(argument.accept(this));
        }
returnres;
    }
@OverrideList<String>visit(OperatorExpressionoperatorExpression) {
List<String>res=newArrayList<>();
res.addAll(operatorExpression.getLeft().accept(this));
res.addAll(operatorExpression.getRight().accept(this));
returnres;
    }
@OverrideList<String>visit(LiteralExpressionliteralExpression) {
returnCollections.emptyList();
    }
}

main 中的代码如下:

publicstaticvoidmain(String[] args) {
// sql 定义SqlNodesql=newSelectNode( //select// concat("test-", upper(name))newFieldsNode(Arrays.asList(
newFunctionCallExpression("concat", Arrays.asList(
newLiteralExpression("test-"),
newFunctionCallExpression(
"upper",
Arrays.asList(newIdExpression("name"))
                                )
                        ))
                )),
// from testArrays.asList("test"),
// where age > 20newWhereNode(Arrays.asList(newOperatorExpression(
newIdExpression("age"),
">",
newLiteralExpression("20")
                )))
        );
// 使用 FunctionExtractorFunctionExtractorfunctionExtractor=newFunctionExtractor();
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 结构进行遍历,而实现者只需要重写它关心的部分就行了,这样就相当于在访问者模式的基础上又实现了观察者模式,即不丢失访问者模式的灵活性,也获得观察者模式使用上的便利性。

image.png

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

classSqlBasicVisitor<R>extendsSqlVisitor<R> {
@OverrideRvisit(SelectNodeselectNode) {
selectNode.getFields().accept(this);
selectNode.getWhere().accept(this);
returnnull;
    }
@OverrideRvisit(FieldsNodefieldsNode) {
for (Expressionfield : fieldsNode.getFields()) {
field.accept(this);
        }
returnnull;
    }
@OverrideRvisit(WhereNodewhereNode) {
for (Expressioncondition : whereNode.getConditions()) {
condition.accept(this);
        }
returnnull;
    }
@OverrideRvisit(IdExpressionidExpression) {
returnnull;
    }
@OverrideRvisit(FunctionCallExpressionfunctionCallExpression) {
for (Expressionargument : functionCallExpression.getArguments()) {
argument.accept(this);
        }
returnnull;
    }
@OverrideRvisit(OperatorExpressionoperatorExpression) {
operatorExpression.getLeft().accept(this);
operatorExpression.getRight().accept(this);
returnnull;
    }
@OverrideRvisit(LiteralExpressionliteralExpression) {
returnnull;
    }
}

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

classFunctionExtractor2extendsSqlBasicVisitor<Void> {
privatefinalList<String>functions=newArrayList<>();
@OverrideVoidvisit(FunctionCallExpressionfunctionCallExpression) {
functions.add(functionCallExpression.getName());
returnsuper.visit(functionCallExpression);
    }
publicList<String>getFunctions() {
returnfunctions;
    }
}

它的使用如下:

classMain {
publicstaticvoidmain(String[] args) {
SqlNodesql=newSelectNode(
newFieldsNode(Arrays.asList(
newFunctionCallExpression("concat", Arrays.asList(
newLiteralExpression("test-"),
newFunctionCallExpression(
"upper",
Arrays.asList(newIdExpression("name"))
                                )
                        ))
                )),
Arrays.asList("test"),
newWhereNode(Arrays.asList(newOperatorExpression(
newIdExpression("age"),
">",
newLiteralExpression("20")
                )))
        );
FunctionExtractor2functionExtractor=newFunctionExtractor2();
sql.accept(functionExtractor);
System.out.println(functionExtractor.getFunctions());
    }
}

访问者模式与责任链模式

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

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

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

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

删除 name 属性的访问者:

classDeleteFieldVisitorextendsClassVisitor {
// 删除的属性名称, 对于我们的需求,它就是 "name"privatefinalStringdeleteFieldName;
publicDeleteFieldVisitor(ClassVisitorclassVisitor, StringdeleteFieldName) {
super(Opcodes.ASM9, classVisitor);
this.deleteFieldName=deleteFieldName;
    }
@OverridepublicFieldVisitorvisitField(intaccess, Stringname, Stringdescriptor, Stringsignature, Objectvalue) {
if (name.equals(deleteFieldName)) {
// 不再向下游传递该属性, 对于下游来说,就是被 "删除了"returnnull;
        }
// super.visitField 会去继续调用下游 Visitor 的 visitField 方法returnsuper.visitField(access, name, descriptor, signature, value);
    }
}

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

classAddAnnotationVisitorextendsClassVisitor {
publicAddAnnotationVisitor(ClassVisitorclassVisitor) {
super(Opcodes.ASM9, classVisitor);
    }
@OverridepublicFieldVisitorvisitField(intaccess, Stringname, Stringdescriptor, Stringsignature, Objectvalue) {
FieldVisitorfieldVisitor=super.visitField(access, name, descriptor, signature, value);
// 向下游 Visitor 额外传递一个 @NonNull 注解fieldVisitor.visitAnnotation("javax/annotation/Nonnull", false);
returnfieldVisitor;
    }
}

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

publicclassAsmTest {
publicstaticvoidmain(String[] args) throwsURISyntaxException, IOException {
PathclsPath=Paths.get(AsmTest.class.getResource("/visitordp/User.class").toURI());
byte[] clsBytes=Files.readAllBytes(clsPath);
// 串联 Visitor// finalVisitor =  DeleteFieldVisitor -> AddAnnotationVisitor -> ClassWriter// ClassWriter 本身也是 ClassVisitor 的子类 ClassWritercw=newClassWriter(ClassWriter.COMPUTE_FRAMES);
ClassVisitorfinalVisitor=newDeleteFieldVisitor(newAddAnnotationVisitor(cw), 
"name");
// ClassReader 就是被访问的对象ClassReadercr=newClassReader(clsBytes);
cr.accept(finalVisitor, ClassReader.SKIP_DEBUG|ClassReader.SKIP_FRAMES);
byte[] bytes=cw.toByteArray();
Files.write(clsPath, bytes);
    }
}

image.png

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

访问者模式与回调模式

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

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

image.png

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

visit(SomeObjectsomeObject) {
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 /sittingroom
HOST home.example.com
Accept: application/xml
----服务端返回----
HTTP/1.1 200 OK
Content-Type: application/xml
<?xmlversion="1.0"?><sittingroom><!-- 客厅相关数据 --><television>海康</television><sofa>宜家</sofa><!-- 与客厅相连的其他房间, 方便用户下一步访问 --><linkrel="kitchen"href="https://home.example.com/sittingroom/kitchen"/><linkrel="bedroom"href="https://home.example.com/sittingroom/bedroom"/><linkrel="toilet"href="https://home.example.com/sittingroom/toilet"/></sittingroom>

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

实际应用

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

复杂的嵌套结构访问

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

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

image.png

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

image.png

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

image.png

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

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

image.png

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

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

  • 流程仿真:让用户在不实际运行流程的情况下就能看到流程的执行分支,方便调试
classProcessSimulatorimplementsProcessConfigVisitor {
privateList<String>traces=newArrayList<>();
@Overridepublicvoidvisit(StartNodestartNode) {
if (startNode.next!=null) {
startNode.next.accept(this);
        }
    }
@Overridepublicvoidvisit(RouteNoderouteNode) {
// 计算出满足条件的分支for (CondtionNodeconditionNode : routeNode.conditions) {
if (evalCondition(conditionNode.condition)) {
conditionNode.accept(this);
            }
        }
if (routeNode.next!=null) {
routeNode.next.accept(this);
        }
    }
@Overridepublicvoidvisit(ConditionNodeconditionNode) {
if (conditionNode.next!=null) {
conditionNode.next.accept(this);
        }
    }
@Overridepublicvoidvisit(ApproveNodeapproveNode) {
// 记录下在仿真中访问到的审批节点traces.add(approveNode.id);
if (approveNode.next!=null) {
approveNode.next.accept(this);
        }
    }
}

SDK 隔离外部调用

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

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

使用 Java18 实现访问者模式

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

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

// sealed 表示胶囊类型, 即 Expression 只允许是当前文件中 Num 和 AddsealedinterfaceExpression {
// record 关键字代替 class, 用于定义 Record 数据类型recordNum(intvalue) implementsExpression {}
recordAdd(intleft, intright) implementsExpression {}
}

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

publicstaticvoidmain(String[] args) {
Numn1=newNum(2);
// n1.value = 10; 这行代码会导致编译不过Numn2=newNum(2);
// trueSystem.out.println(n1.equals(n2));
}

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

publicinteval(Expressione) {
returnswitch (e) {
caseNum(intvalue) ->value;
caseAdd(intleft, intright) ->left+right;
    };
}

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

sealedinterfaceSqlNode {
recordSelectNode(FieldsNodefields, List<String>from, WhereNodewhere) implementsSqlNode {}
recordFieldsNode(List<Expression>fields) implementsSqlNode {}
recordWhereNode(List<Expression>conditions) implementsSqlNode {}
sealedinterfaceExpressionextendsSqlNode {
recordIdExpression(Stringid) implementsExpression {}
recordFunctionCallExpression(Stringname, List<Expression>arguments) implementsExpression {}
recordLiteralExpression(Stringliteral) implementsExpression {}
recordOperatorExpression(Expressionleft, Stringoperator, Expressionright) implementsExpression {}
    }
}

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

publicList<String>extractFunctions(SqlNodesqlNode) {
returnswitch (sqlNode) {
caseSelectNode(FieldsNodefields, List<String>from, WhereNodewhere) -> {
List<String>res=newArrayList<>();
res.addAll(extractFunctions(fields));
res.addAll(extractFunctions(where));
returnres;
        }
caseFieldsNode(List<Expression>fields) -> {
List<String>res=newArrayList<>();
for (Expressionfield : fields) {
res.addAll(extractFunctions(field));
            }
returnres;
        }
caseWhereNode(List<Expression>conditions) -> {
List<String>res=newArrayList<>();
for (Expressioncondition : conditions) {
res.addAll(extractFunctions(condition));
            }
returnres;
        }
caseIdExpression(Stringid) ->Collections.emptyList();
caseFunctionCallExpression(Stringname, List<Expression>arguments) -> {
// 获得函数名称List<String>res=newArrayList<>();
res.add(name);
for (Expressionargument : arguments) {
res.addAll(extractFunctions(argument));
            }
returnres;
        }
caseLiteralExpression(Stringliteral) ->Collections.emptyList();
caseOperatorExpression(Expressionleft, Stringoperator, Expressionright) -> {
List<String>res=newArrayList<>();
res.addAll(extractFunctions(left));
res.addAll(extractFunctions(right));
returnres;
        }
    }
}

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

重新认识访问者模式

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

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

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

classSelectNodeextendsSqlNode {
privatefinalFieldsNodefields;
privatefinalList<String>from;
privatefinalWhereNodewhere;
SelectNode(FieldsNodefields, List<String>from, WhereNodewhere) {
this.fields=fields;
this.from=from;
this.where=where;
    }
publicFieldsNodegetFields() {
returnfields;
    }
publicList<String>getFrom() {
returnfrom;
    }
publicWhereNodegetWhere() {
returnwhere;
    }
publicList<String>extractFunctions() {
List<String>res=newArrayList<>();
// 继续调用子结构的 extractFunctionsres.addAll(fields.extractFunctions());
res.addAll(selectNode.extractFunctions());
returnres;
    }
}

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

image.png

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

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

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

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

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

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

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

image.png

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

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

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


招聘信息:


笔者现任职于钉钉智能办公应用,团队的审批系统是国内目前最大规模的工作流系统,其灵活的流程搭建和表单搭建能力服务了上百万中小企业。

我们春季实习生招聘正在火热进行中,岗位有服务端开发/前端开发,Base 地可以是杭州或者北京,如果你是 23 届相关专业的毕业生,欢迎投简历到 qinyuan.dqy@alibaba-inc.com,邮件标题为 “姓名-院校-技术方向-来自阿里云开发者社区”。

相关文章
|
7月前
|
设计模式 算法 编译器
C++访问者模式:设计与应用之美
C++访问者模式:设计与应用之美
65 0
|
设计模式 关系型数据库
设计模式八大原则知多少
设计模式是一种通用的解决问题的经验,可以帮助我们设计出可重用、可维护和可扩展的软件。
|
4月前
|
算法 Java 程序员
在Java的编程世界里,多态不仅仅是一种代码层面的技术,它是思想的碰撞,是程序员对现实世界复杂性的抽象映射,是对软件设计哲学的深刻领悟。
在Java的编程世界里,多态不仅仅是一种代码层面的技术,它是思想的碰撞,是程序员对现实世界复杂性的抽象映射,是对软件设计哲学的深刻领悟。
77 9
|
7月前
|
Java
类与对象:Java面向对象编程的基石
类与对象:Java面向对象编程的基石
|
7月前
|
设计模式 算法 Java
揭开访问者模式的神秘面纱-轻松增强对象行为
访问者模式是一种重要的软件设计模式,其核心思想是将操作逻辑与数据结构分离,通过引入访问者类实现对数据结构中元素的灵活操作。这种模式特别适用于处理具有复杂行为和数据结构的对象,如编译器和图形编辑器等。访问者模式不仅可以提高系统的灵活性和可扩展性,还有助于增强代码的可读性和可维护性,降低模块间的耦合度。对于软件架构师和开发人员来说,熟练掌握访问者模式具有重要的实践意义,能够帮助他们更有效地设计和开发软件系统,实现更好的系统结构和代码组织。
88 0
揭开访问者模式的神秘面纱-轻松增强对象行为
|
Java 开发者
Java面向对象编程的4个主要概念
Java面向对象编程的4个主要概念
80 3
|
机器学习/深度学习 人工智能 算法
概念理解
概念理解
61 2
|
容器
谈谈对组合模式的看法
谈谈对组合模式的看法
63 0
|
设计模式 数据库 容器
领略设计模式的魅力,谈谈组合模式搭配访问者模式
组合模式(composite) 我们都知道文件和文件夹的概念,并且文件是可以存放在文件夹中,文件夹中也可以存放其他文件夹。需要设计一个简单的程序来实现文件夹和文件的关系。 实现思路 文件夹需要存放文件夹和文件,首先想到的是在文件夹中设计两个集合分别来存放文件夹和文件。 有展示文件路径需求时,不清楚在最里层是文件夹还是文件,所以需要把文件夹和文件当做一个对象来处理会更好,都是条目。所以需要创建一个他们共同的父类。 对文件夹的设计优化,创建一个集合容器,容器类型是父类,即解决了既要存放文件夹又要存放文件的问题。
|
SQL 设计模式 Java
重新认识访问者模式:从实践到本质
访问者模式在设计模式中的知名度虽然不如单例模式,但也是少数几个大家都能叫得上名字的设计模式了。不过因为访问者模式的复杂性,人们很少在应用系统中使用,经过本文的探索,我们一定会产生新的认识,发现其更加灵活广泛的使用方式。
重新认识访问者模式:从实践到本质