大白话聊访问者模式:从入门到实践

Posted by 陈树义 on 2021-02-18

文章首发于个人博客 shuyi.tech,欢迎访问更多有趣有价值的文章。

访问者模式,重点在于访问者二字。说到访问,我们脑海中必定会想起新闻访谈,两个人面对面坐在一起。从字面上的意思理解:其实就相当于被访问者(某个公众人物)把访问者(记者)当成了外人,不想你随便动。你想要什么,我弄好之后给你(调用你的方法)。

01 什么是访问者模式?

访问者模式的定义如下所示,说的是在不改变数据结构的提前下,定义新操作。

封装一些作用于某种数据结构中的各元素的操作,它可以在不改变数据结构的前提下定义作用于这些元素的新的操作。

但在实际的应用中,我发现有些例子并不是如此。有些例子中并没有稳定的数据结构,而是稳定的算法。在树义看来,访问者模式是:把不变的固定起来,变化的开放出去。

我们举生活中一个例子来聊聊:某科学家接受记着访谈。我们都知道科学家接受访问,肯定是有流程上的限制的,不可能让你随便问。我们假设这个过程是:先问科学家的学校经历,再聊你的工作经历,最后聊你的科研成果。那么在这个过程中,固定的是什么东西呢?固定的是接受采访的流程。变化的是什么呢?变化的是不同的急着,针对学校经历,可能会提不同的问题。

根据我们之前的理解,访问者模式其实就是要把不变的东西固定起来,变化的开放出去。那么对于科学家接受访谈这个事情,我们可以这么将其抽象化。

首先,我们需要有一个 Visitor 类,这里定义了一些外部(记者)可以做的事情(提学校经历、工作经历、科研成就的问题)。

public interface Visitor {
    public void askSchoolExperience(String name);
    public void askWorkExperience(String name);
    public void askScienceAchievement(String name);
}

接着声明一个 XinhuaVisitor 类去实现 Visitor 类,这表示是新华社的一个记者(访问者)想去访问科学家。

public class XinhuaVisitor implements Visitor{
    @Override
    public void askSchoolExperience(String name) {
        System.out.printf("请问%s:在学校取得的最大成就是什么?\n", name);
    }

    @Override
    public void askWorkExperience(String name) {
        System.out.printf("请问%s:工作上最难忘的事情是什么?\n", name);
    }

    @Override
    public void askScienceAchievement(String name) {
        System.out.printf("请问%s:最大的科研成果是什么?", name);
    }
}

接着声明一个 Scientist 类,表明是一个科学家。科学家通过一个 accept() 方法接收记者(访问者)的访问申请,将其存储起来。科学家定义了一个 interview 方法,将访问的流程固定死了,只有教你问什么的时候,我才会让你(记者)提问。

public class Scientist {

    private Visitor visitor;

    private String name;

    private Scientist(){}

    public Scientist(String name) {
        this.name = name;
    }

    public void accept(Visitor visitor) {
        this.visitor = visitor;
    }

    public void interview(){
        System.out.println("------------访问开始------------");
        System.out.println("---开始聊学校经历---");
        visitor.askSchoolExperience(name);
        System.out.println("---开始聊工作经历---");
        visitor.askWorkExperience(name);
        System.out.println("---开始聊科研成果---");
        visitor.askScienceAchievement(name);
    }
}

最后我们声明一个场景类 Client,来模拟访谈这一过程。

public class Client {
    public static void main(String[] args) {
        Scientist yang = new Scientist("杨振宁");
        yang.accept(new XinhuaVisitor());
        yang.interview();
    }
}

运行的结果为:

------------访问开始------------
---开始聊学校经历---
请问杨振宁:在学校取得的最大成就是什么?
---开始聊工作经历---
请问杨振宁:工作上最难忘的意见事情是什么?
---开始聊科研成果---
请问杨振宁:最大的科研成果是什么?

看到这里,大家对于访问者模式的本质有了更感性的认识(把不变的固定起来,变化的开放出去)。在这个例子中,不变的固定的就是访谈流程,变化的就是你可以提不同的问题。

一般来说,访问者模式的类结构如下图所示:

  • Visitor 访问者接口。访问者接口定义了访问者可以做的事情。这个需要你去分析哪些是可变的,将这些可变的内容抽象成访问者接口的方法,开放出去。而被访问者的信息,其实就是通过访问者的参数传递过去。
  • ConcreteVisitor 具体访问者。具体访问者定义了具体某一类访问者的实现。对于新华社记者来说,他们更关心杨振宁科学成果方面的事情,于是他们提问的时候更倾向于挖掘成果。但对于青年报记者来说,他们的读者是青少年,他们更关心杨振宁在学习、工作中的那种精神。
  • Element 具体元素。这里指的是具体被访问的类,在我们这个例子中指的是 Scientist 类。一般情况下,我们会提供一个 accept() 方法,接收访问者参数,将相当于接受其范文申请。但这个方法也不是必须的,只要你能够拿到 visitor 对象,你怎么定义这个参数传递都可以。

对于访问者模式来说,最重要的莫过于 Visitor、ConcreteVisitor、Element 这三个类了。Visitor、ConcreteVisitor 定义访问者具体能做的事情,被访问者的参数通过参数传递给访问者。Element 则通过各种方法拿到被访问者对象,常用的是通过 accept() 方法,但这并不是绝对的。

需要注意的是,我们学习设计模式重点是理解类与类之间的关系,以及他们传递的信息。至于是通过什么方式传递的,是通过 accept() 方法,还是通过构造函数,都不是重点。

02 访问者模式的实际应用

前面我们用一个生活的例子帮助大家理解访问者模式,相信大家对访问者模式应该有了个感性的理解了。为了回归编程实践本身,让大家对访问者模式能有更好的实践理解。下面我们将从软件编程上讲讲访问者模式在开源框架中的应用。

文件树遍历

JDK 中有文件操作,我们自然是清楚的。有文件操作,那自然就会有文件夹的遍历操作,即访问某个文件夹下面的所有文件或文件夹。试想一下,如果我们想要打印出某个文件夹下所有文件及文件夹的名字,我们需要怎么做?

很简单的做法,其实就是直接做一个树的遍历,然后将名字打印出来呀!

没错,这确实是正确答案!

那么如果我希望统计一下所有文件及文件夹的个数呢?

那就再遍历一次,然后用一个计数器去一直加一呗!

没错,这也是正确答案!

但你是否发现了这两个过程中,我们有一个相同的操作:遍历文件树。无论是打印文件名,还是计算文件树,我们都需要去遍历文件树。而无论哪一个过程,我们最终要的其实就是访问文件。

还记得我们说过设计模式的本质是什么吗?设计模式的本质是找出不变的东西,再找出变化的东西,然后找到合适的数据结构(设计模式)去承载这种变化。

在这个例子里,不变的东西是文件树的遍历,变化的是对于文件的不同访问操作。很显然,访问者模式是比较适合承载这种变化的。我们可以把这种不变的东西(文件树的遍历)固定起来,把变化的东西(文件的具体操作)开放出去。JDK 对于文件树的遍历,其实就是使用访问者模式实现的。

JDK 中声明了一个 FileVisitor 接口,定义了遍历者可以做的操作。

public interface FileVisitor<T> {
    FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs);
    FileVisitResult visitFile(T file, BasicFileAttributes attrs)
        throws IOException;
    FileVisitResult visitFileFailed(T file, IOException exc)
        throws IOException;
    FileVisitResult postVisitDirectory(T dir, IOException exc)
        throws IOException;
}

FileVisitor 中定义的 visitFile() 方法,其实就是对于文件的访问。被访问者(文件)的信息通过第一个参数 file 传递过来。这样遍历者就可以访问文件的内容了。

SimpleFileVisitor 则是对于 FileVisitor 接口的实现,该类中仅仅是做了简单的参数校验,并没有太过的逻辑。

public class SimpleFileVisitor<T> implements FileVisitor<T> {
    @Override
    public FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs)
        throws IOException
    {
        Objects.requireNonNull(dir);
        Objects.requireNonNull(attrs);
        return FileVisitResult.CONTINUE;
    }
    
    @Override
    public FileVisitResult visitFile(T file, BasicFileAttributes attrs)
        throws IOException
    {
        Objects.requireNonNull(file);
        Objects.requireNonNull(attrs);
        return FileVisitResult.CONTINUE;
    }
    //....其他省略
}

FileVisitor 类和 SimpleFileVisitor 类对应的就是 UML 类图中的 Visitor 和 ConcreteVisitor 类。而 Element 元素,对应的其实是 JDK 中的 Files 类。

Files 文件中遍历文件树是通过 walkFileTree() 方法实现的。在 walkFileTree() 方法中实现了树的遍历,在遍历到文件的时候会通过 visitor 类的 visitFile 方法调用遍历者的方法,将遍历到的文件传递给遍历者,从而达到分离变化的目的。

ASM修改字节码

ASM 是 Java 的字节码增强技术,这里面就用到了访问者模式,主要是用来进行字节码的修改。在 ASM 中于此相关的三个类分别是:ClassReader、ClassVisitor、ClassWriter。

ClassReader 类相当于访问者模式中的 Element 元素。它将字节数组或 class 文件读入内存中,并以树的数据结构表示。该类定义了一个 accept 方法用来和 visitor 交互。

ClassVisitor 相当于抽象访问者接口。ClassReader 对象创建之后,需要调用 accept() 方法,传入一个 ClassVisitor 对象。在 ClassReader 的不同时期会调用 ClassVisitor 对象中不同的 visit() 方法,从而实现对字节码的修改。

ClassWriter 是 ClassVisitor 的是实现类,它负责将修改后的字节码输出为字节数组。

对于 ASM 这种场景而言,字节码规范是非常严格且稳定的,如果随便更改可能出问题。但我们又需要对字节码进行动态修改,从而达到某些目的。在这种情况下,ASM 的设计者采用了访问者模式将变化的部分隔离开来,将不变的部分固定下来,从而达到了灵活扩展的目的。

03 我们该如何使用?

从上面几个例子,我们大致可以明白访问者模式的使用场景:某些较为稳定的东西(数据结构或算法),不想直接被改变但又想扩展功能,这时候适合用访问者模式。

说到对于访问者模式使用场景的定义,我们会觉得模板方法模式与这个使用场景的定义很像。但它们还是有些许差别的。访问者模式的变化与非变化(即访问者与被访问者)之间,它们只是简单的包含关系,而模板方法模式的变化与非变化则是继承关系。 但它们也确实有类似的地方,即都是封装了固定不变的东西,开放了变动的东西。

访问者模式的优点很明显,即隔离了变化的东西,固定了不变的东西,使得整体的可维护性更强、具有更强的扩展性。但它也带来了设计模式通用的一些缺点,例如:

  • 类结构变得复杂。之前我们可是简单的调用关系,现在则是多个类之间的继承和组合关系。从一定程度上,提高了对开发人员的要求,提高了研发成本。
  • 被访问者的变更变得更加困难。例如我们上面科学家访谈的例子,如果科学家访谈希望新增一个环节,那么 Scientist 类需要修改,Visitor 类、XinhuaVisitor 类都需要修改。

有这些多优点,但也有这么多缺点,那实际工作中我们应该怎么判断是否用访问者模式呢?总的原则就是扬长避短,即当场景完全利用了访问者模式的优点,规避了访问者模式的缺点的时候,就是使用访问者模式的最佳时机。

虽然使用访问者模式会让被访问者的变更变得更加困难,但如果被访问者很稳定,基本不会变更,那这个缺点不就去除了么。例如在 ASM 的例子中,元素是 ClassReader,其存储了字节码的结构。而字节码结构完全不会轻易改变,所以在这个「被访问者的变更变得更加困难」的缺点也就不存在了。

而「类结构变得复杂」这个缺点,则是需要根据当时业务的复杂程度来看的。如果当时业务很简单,而且变化也不大,那么使用设计模式完全是多余的。但是如果当时业务很复杂了,我们还是在一个类里做修改,那么很大可能性会出大问题。这时候就需要用设计模式来承载复杂的业务结构了。

04 参考资料