重新认识访问者模式:从实践到本质
简介:访问者模式在设计模式中的知名度虽然不如单例模式,但也是少数几个大家都能叫得上名字的设计模式了。不过因为访问者模式的复杂性,人们很少在应用系统中使用,经过本文的探索,我们一定会产生新的认识,发现其更加灵活广泛的使用方式。
作者 | 悬衡
来源 | 阿里技术公众号
访问者模式在设计模式中的知名度虽然不如单例模式,但也是少数几个大家都能叫得上名字的设计模式了(另外几个可能就是“观察者模式”,“工厂模式” 了)。不过因为访问者模式的复杂性,人们很少在应用系统中使用,经过本文的探索,我们一定会产生新的认识,发现其更加灵活广泛的使用方式。
和一般介绍设计模式的文章不同,本文不会执着于死板的代码模板,而是直接从开源项目以及应用系统中的实践出发,同时对比其他类似的设计模式,最后阐述其在编程范式中的本质。
一 Calcite 中的访问者模式
开源类库常常利用访问者风格的 API 屏蔽内部的复杂性,从这些 API 入手学习,能够让我们先获得一个直观感受。
Calcite 是一个 Java 语言编写的数据库基础类库,诸如 Hive,Spark 等诸多知名开源项目都在使用。其中 SQL 解析模块提供了访问者模式的 API,我们利用它的 API 可以快速获取 SQL 中我们需要的信息,以获取 SQL 中使用的所有函数为例:
代码中 FunctionExtractor 是 SqlBasicVisitor 的子类,并且重写了它的 visit(SqlCall) 方法,获取函数的名称并收集在了 functions 中。
除了 visit(SqlCall) 外,还可以通过 visit(SqlLiteral)(常量),visit(SqlIdentifier)(表名/列名)等等,实现更加复杂的分析。
有人会想,为什么 SqlParser不直接提供类似于 getFunctions 等方法直接获取 SQL 中的所有函数呢?在上文的示例中,getFunctions 可能确实更加方便,但是 SQL 作为一个很复杂的结构,getFunctions 对于更加复杂的分析场景是不够灵活的,性能也是更差的。如果需要,完全可以很简单地实现一个如上文的 FunctionExtractor 来满足需求。
二 动手实现访问者模式
我们尝试实现一个简化版的 SqlVisitor。
先定义一个简化版的 SQL 结构。
这个类中都有一个相同的方法,就是 accept:
这里会通过多态分发到 SqlVisitor 不同的 visit 方法上:
SQL 结构相关的类如下:
导致这种现象的原因是,不同的 visit 方法互相之间只有参数不同,称为“重载”,而 Java 的 “重载” 又被称为 “编译期多态”,只会根据 visit(this) 中 this 在编译时的类型决定调用哪个方法,而它在编译时的类型就是 SqlNode,尽管它在运行时可能是不同的子类。
所以,我们可能经常会听说用动态语言写访问者模式会更加简单,特别是支持模式匹配的函数式程序设计语言(这在 Java 18 中已经有较好支持),后面我们再回过头来用模式匹配重新实现下本小节的内容,看看是不是简单了很多。
接下来我们像之前一样,是使用 SqlVisitor 尝试解析出 SQL中所有的函数调用。
先实现一个 SqlVisitor,这个 SqlVisitor 所作的就是根据当前节点的结构以此调用 accept,最后将结果组装起来,遇到 FunctionCallExpression 时将函数名称添加到集合中:
main 中的代码如下:
以上就是标准的访问者模式的实现,直观感受上比之前 Calcite 的 SqlBasicVisitor 用起来麻烦多了,我们接下来就去实现 SqlBasicVisitor。
三 访问者模式与观察者模式
在使用 Calcite 实现的 FunctionExtractor 中,每次 Calcite 解析到函数就会调用我们实现的 visit(SqlCall) ,称它为 listen(SqlCall) 似乎比 visit 更加合适。这也显示了访问者模式与观察者模式的紧密联系。
在我们自己实现的 FunctionExtractor 中,绝大多数代码都是在按照一定的顺序遍历各种结构,这是因为访问者模式给予了使用者足够的灵活性,可以让实现者自行决定遍历的顺序,或者对不需要遍历的部分进行剪枝。
但是我们的需求 “解析出 SQL 中所有的函数”,并不关心遍历的顺序,只要在经过“函数”时通知一下我们即可,对于这种简单需求,访问者模式有点过度设计,观察者模式会更加合适。
大多数使用访问者模式的开源项目会给“标准访问者”提供一个默认实现,比如 Calcite 的 SqlBasicVisitor,默认实现会按照默认的顺序对 SQL 结构进行遍历,而实现者只需要重写它关心的部分就行了,这样就相当于在访问者模式的基础上又实现了观察者模式,即不丢失访问者模式的灵活性,也获得观察者模式使用上的便利性。
SqlBasicVisitor 给每个结构都提供了一个默认的访问顺序,使用这个类我们来实现第二版的 FunctionExtractor:
它的使用如下:
四 访问者模式与责任链模式
ASM 也是一个提供访问者模式 API 的类库,用来解析与生成 Java 类文件,能想到的所有 Java 知名开源项目都有他的身影,Java8 的 Lambda 表达式特性甚至都是通过它来实现的。如果只是能解析与生成 Java 类文件,ASM 或许还不会那么受欢迎,更重要的是它优秀的抽象,它将常用的功能抽象为一个个小的访问者工具类,让复杂的字节码操作变得像搭积木一样简单。
假设需要按照如下方式修改类文件:
- 删除 name 属性
- 给所有属性添加 @NonNull 注解
但是出于复用和模块化的角度考虑,我们想把两个步骤分别拆成独立的功能模块,而不是把代码写在一起。在 ASM 中,我们可以分别实现两个小访问者,然后串在一起,就变成能够实现我们需求的访问者了。
删除 name 属性的访问者:
给所有属性添加 @NonNull 注解的访问者:
在 main 中我们将它们串起来使用:
五 访问者模式与回调模式
“回调” 可以算是“设计模式的设计模式”了,大量设计模式中都有它的思想,诸如观察者模式中的“观察者”,“命令模式”中的“命令”,“状态模式” 中的 “状态” 本质上都可以看成一个回调函数。
访问者模式中的“访问者”显然也是一个回调,和其他回调模式最大的不同是,“访问者模式” 是一种带有“导航”的回调,我们通过传入的对象结构给实现者下一步回调的“导航”,实现者根据“导航”决定下一步回调的次序。
这在实际中的应用就是 HATEOAS (Hypermedia as the Engine of Application State),HATEOAS 风格的 HTTP 接口除了会返回用户请求的数据外,还会包含用户下一步应该访问的 URL,如果将整个应用的 API 比作一个家的话,假如用户请求客厅的数据,那么接口除了返回客厅的数据外,还会返回与客厅相连的 "厨房",“卧室”与“卫生间”的 URL:
六 实际应用
之前举的例子可能都更偏向于开源基础类库的应用,那么在更加广泛的应用系统中,它要如何应用呢?
1 复杂的嵌套结构访问
现在的 toB 应用为了满足不同企业的稀奇古怪的定制需求,提供的配置功能越來越复杂,配置项之间不再是简单的正交独立的关系,而是相互嵌套递归,正是访问者模式发挥的场合。
钉钉审批的流程配置就是一个十分复杂的结构:
除了整体结构复杂外,每个节点的配置也相当复杂:
访问者模式的实现套路和之前一样的,就不多说了,我们举个应用层例子:
- 仿真:让用户在不实际运行流程的情况下就能看到流程的执行分支,方便调试
2 SDK 隔离外部调用
为了保证 SDK 的纯粹性,一般 SDK 中都不会去调用外部接口,但是为了实现一些需求又不得不这么做,此时我们可以将外部调用放在应用层访问者的实现中,然后传入 SDK 中执行相关逻辑。
在上面提到的流程仿真中过程,条件计算常会包括外部接口调用,比如通过连接器调用一个用户指定接口决定流程分支,为了保证流程配置解析 SDK 的纯粹性,不可能在 SDK 包中进行调用的,因此就在访问者中调用。
七 使用 Java18 实现访问者模式
回到最初的命题,用访问者模式获得 SQL 中所有的函数调用。前面说过,用函数式编程语言中常见的模式匹配可以更加方便地实现,而最新的 Java18 中已经对此有比较好的支持。
从 Java 14 开始,Java 支持了一种新的 Record 数据类型,示例如下:
TestRecord 一旦实例化,字段就是不可变的,并且它的 equals 和 hashCode 方法会被自动重写,只要内部的字段都相等,它们就是相等的:
更加方便的是,利用 Java 18 中最新的模式匹配功能,可以拆解出其中的属性:
我们首先使用 Record 类型重新定义我们的 SQL 结构:
然后利用模式匹配,一个方法即可实现之前的访问,获得所有函数调用:
八 重新认识访问者模式
在 GoF 的设计模式原著中,对访问者模式的描述如下:
表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。
从这句话可以看出,访问者模式实现的所有功能本质上都可以通过给每个对象增加新的成员方法实现,利用面向对象多态的特性,父结构调用并且聚合子结构相应方法的返回结果,以之前的抽取 SQL 所有函数为例,这一次不用访问者实现,而是在每个类中增加一个 extractFunctions 成员方法:
访问者模式本质上就是将复杂的类层级结构中成员方法全部都抽象到一个类中去:
- 面向对象:认为操作必须和数据绑定到一起,即作为每个类的成员方法存在,而不是单独抽取出来成为一个访问者
- 函数式编程:将数据和操作分离,将基本操作进行排列组合成为更加复杂的操作,而一个访问者的实现就对应一个操作
这两个方式,在编写的时候看起来区别不大,只有当需要添加修改功能的时候才能显现出他们的天壤之别,假设现在我们要给每个类增加一个新操作:
这种场景看起来是增加访问者更加方便。那么再看下一个场景,假设现在要在类层级结构中增加一个新类:
这两个场景对应了软件的两种拆分方式,一种是按照数据拆分,一种是按照功能点拆分,以阿里双十一的各个分会场与功能为例:盒马,饿了么和聚划算分别作为一个分会场参与了双十一的促销,他们都需要提供优惠券,订单和支付等功能。
任何一种划分方式都要承受该种方式带来的缺点。所有现实中的应用,不论是架构还是编码,都没有上面的例子那么极端,而是两种混用。比如 盒马,饿了么,聚划算 都可以在拥有自己系统的同时,复用优惠券这样的按功能划分的系统。对应到编码也是一样,软件工程没有银弹,我们也要根据特性和场景决定是采用面向对象的抽象,还是访问者的抽象。更多的时候需要两者混用,将部分核心方法作为对象成员,利用访问者模式实现应用层的那些琐碎杂乱的需求。
本文为阿里云原创内容,未经允许不得转载。
 
                    
                     
                    
                 
                    
                














 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号