前面我们已经讲了22个设计模式。大部分设计模式的原理和实现都很简单,不过也有例外,比如今天要讲的最后一个设计模式--访问者模式。它可以算是 23 种经典设计模式中最难理解的几个之一。因为它难理解、难实现,应用它会导致代码的可读性、可维护性变差,所以,访问者模式在实际的软件开发中很少被用到,在没有特别必要的情况下,建议你不要使用访问者模式。

尽管如此,为了让你以后读到应用了访问者模式的代码的时候,能一眼就能看出代码的设计意图,同时为了整个专栏内容的完整性,我觉得还是有必要给你讲一讲这个模式。除此之外,为了最大化学习效果,我今天不只是单纯地讲解原理和实现,更重要的是,我会手把手带你还原访问者模式诞生的思维过程,让你切身感受到创造一种新的设计模式出来并不是件难事。

带你“发明”访问者模式

假设我们从网站上爬取了很多资源文件,它们的格式有三种:PDF、PPT、Word。我们现在要开发一个工具来处理这批资源文件。这个工具的其中一个功能是,把这些资源文件中的文本内容抽取出来放到 txt 文件中。如果让你来实现,你会怎么来做呢?

实现这个功能并不难,不同的人有不同的写法,我将其中一种代码实现方式贴在这里。其中,ResourceFile 是一个抽象类,包含一个抽象函数 extract2txt()。PdfFile、PPTFile、WordFile 都继承 ResourceFile 类,并且重写了 extract2txt() 方法。在 客户端中,我们可以利用多态特性,根据对象的实际类型,来决定执行哪个方法。
ResourceFile.php

<?php
namespace DesignPatterns\Visitor\v1;

abstract class ResourceFile
{
    protected $filePath;

    public function __construct($filePath)
    {
        $this->filePath = $filePath;
    }

    abstract public function extract2txt();
}

PdfFile.php

<?php
namespace DesignPatterns\Visitor\v1;


class PdfFile extends ResourceFile
{
    public function __construct($filePath)
    {
        parent::__construct($filePath);
    }

    public function extract2txt()
    {
        echo "Extract pdf.<br>";
    }
}

PPTFile.php

<?php
namespace DesignPatterns\Visitor\v1;

class PPTFile extends ResourceFile
{
    public function __construct($filePath)
    {
        parent::__construct($filePath);
    }

    //...省略一大坨从PPT中抽取文本的代码...
    //...将抽取出来的文本保存在跟filePath同名的.txt文件中...
    public function extract2txt()
    {
        echo "Extract ppt.<br>";
    }
}

WordFile.php

<?php
namespace DesignPatterns\Visitor\v1;


class WordFile extends ResourceFile
{
    public function __construct($filePath)
    {
        parent::__construct($filePath);
    }

    public function extract2txt()
    {
        echo "Extract word.<br>";
    }
}

Client.php

<?php
namespace DesignPatterns\Visitor\v1;

require dirname(__DIR__) . '/../vendor/autoload.php';
class Client
{
    private $resourceFiles = [];
    public function run()
    {
        $this->listAllResourceFiles();
        foreach ($this->resourceFiles as $value) {
            $value->extract2txt();
        }
    }

    public function listAllResourceFiles()
    {
        //...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
        $this->resourceFiles[] = new PdfFile('a.pdf');
        $this->resourceFiles[] = new PPTFile('b.ppt');
        $this->resourceFiles[] = new WordFile('c.word');

    }
}

$worker = new Client();
$worker->run();
// 运行结果是:
//Extract pdf.
//Extract ppt.
//Extract word.

如果工具的功能不停地扩展,不仅要能抽取文本内容,还要支持压缩、提取文件元信息(文件名、大小、更新时间等等)构建索引等一系列的功能,那如果我们继续按照上面的实现思路,就会存在这样几个问题:

  • 违背开闭原则,添加一个新的功能,所有类的代码都要修改;
  • 虽然功能增多,每个类的代码都不断膨胀,可读性和可维护性都变差了;
  • 把所有比较上层的业务逻辑都耦合到 PdfFile、PPTFile、WordFile 类中,导致这些类的职责不够单一,变成了大杂烩。

针对上面的问题,我们常用的解决方法就是拆分解耦,把业务操作跟具体的数据结构解耦,设计成独立的类。这里我们按照访问者模式的演进思路来对上面的代码进行重构。重构之后的代码如下所示。

abstract class ResourceFile
{
    protected $filePath;

    public function __construct($filePath)
    {
        $this->filePath = $filePath;
    }

    abstract public function acceptExtractor(Extractor $extractor);
}

class PdfFile extends ResourceFile
{
    public function __construct($filePath)
    {
        parent::__construct($filePath);
    }

    public function acceptExtractor(Extractor $extractor)
    {
        $extractor->extract2txtPdfFile($this);
    }
}

//...PPTFile、WordFile跟PdfFile类似,这里就省略了...
class Extractor
{
    public function extract2txtPPTFile(PPTFile $PPTFile)
    {
        echo "Extract ppt.<br>";
    }

    public function extract2txtPdfFile(PdfFile $pdfFile)
    {
        echo "Extract pdf.<br>";
    }

    public function extract2txtWordFile(WordFile $wordFile)
    {
        echo "Extract word.<br>";
    }

}

class Client
{
    private $resourceFiles = [];
    public function run()
    {
        $this->listAllResourceFiles();
        foreach ($this->resourceFiles as $value) {
            $value->acceptExtractor(new Extractor());
        }
    }

    public function listAllResourceFiles()
    {
        //...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
        $this->resourceFiles[] = new PdfFile('a.pdf');
        $this->resourceFiles[] = new PPTFile('b.ppt');
        $this->resourceFiles[] = new WordFile('c.word');

    }
}

$worker = new Client();
$worker->run();

//输出结果
//Extract pdf.
//Extract ppt.
//Extract word.

我们把抽取文本的操作抽取出来独立成一个Extract类 。在程序执行循环内时,根据多态特性,程序会调用实际类型的 acceptExtractor 方法,比如 PdfFile 的 acceptExtractor 方法,而PdfFile中的 this 类型是 PdfFile 的,在编译的时候就确定了,所以会调用 extractor 的 extract2txtPdfFile(PdfFile pdfFile) 这个方法。这个实现思路是不是很有技巧?这是理解访问者模式的关键所在,也是我之前所说的访问者模式不好理解的原因。

现在,如果要继续添加新的功能,比如前面提到的压缩功能,根据不同的文件类型,使用不同的压缩算法来压缩资源文件,那我们该如何实现呢?我们需要实现一个类似 Extractor 类的新类 Compressor 类,在其中定义三个同样的方法,实现对不同类型资源文件的压缩。除此之外,我们还要在每个资源文件类中定义新的 accept 方法。具体的代码如下所示:

abstract class ResourceFile
{
    protected $filePath;

    public function __construct($filePath)
    {
        $this->filePath = $filePath;
    }

    abstract public function acceptExtractor(Extractor $extractor);
    abstract public function acceptCompressor(Compressor $compressor);
}

class PdfFile extends ResourceFile
{
    public function __construct($filePath)
    {
        parent::__construct($filePath);
    }

    public function acceptExtractor(Extractor $extractor)
    {
        $extractor->extract2txtPdfFile($this);
    }
    public function acceptCompressor(Compressor $compressor)
    {
        $compressor->compressPdfFile($this);
    }
}
//...PPTFile、WordFile跟PdfFile类似,这里就省略了...
//...Extractor代码不变
class Compressor
{
    public function compressPPTFile(PPTFile $PPTFile)
    {
        echo  "compress  ppt.<br>";
    }
    public function compressPdfFile(PdfFile $pdfFile)
    {
        echo  "compress  pdf.<br>";
    }
    public function compressWordFile(WordFile $wordFile)
    {
        echo "compress word.<br>";
    }

}

class Client
{
    private $resourceFiles = [];
    public function run()
    {
        $this->listAllResourceFiles();
        foreach ($this->resourceFiles as $value) {
            $value->acceptExtractor(new Extractor());
        }
        foreach ($this->resourceFiles as $value) {
            $value->acceptCompressor(new Compressor());
        }
    }

    public function listAllResourceFiles()
    {
        //...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
        $this->resourceFiles[] = new PdfFile('a.pdf');
        $this->resourceFiles[] = new PPTFile('b.ppt');
        $this->resourceFiles[] = new WordFile('c.word');

    }
}

$worker = new Client();
$worker->run();
//运行结果
//Extract pdf.
//Extract ppt.
//Extract word.
//compress pdf.
//compress ppt.
//compress word.

上面代码还存在一些问题,添加一个新的业务,还是需要修改每个资源文件类,违反了开闭原则。针对这个问题,我们抽象出来一个 Visitor 接口,包含是三个命名非常通用的 visit() 方法,分别处理三种不同类型的资源文件。具体做什么业务处理,由实现这个 Visitor 接口的具体的类来决定,比如 Extractor 负责抽取文本内容,Compressor 负责压缩。当我们新添加一个业务功能的时候,资源文件类不需要做任何修改,只需要修改 客户端的代码就可以了。

按照这个思路我们可以对代码进行重构,重构之后的代码如下所示:

abstract class ResourceFile
{
    protected $filePath;

    public function __construct($filePath)
    {
        $this->filePath = $filePath;
    }

    abstract public function acceptExtractor(Visitor $visitor);
    abstract public function acceptCompressor(Visitor $visitor);
}


class PdfFile extends ResourceFile
{
    public function __construct($filePath)
    {
        parent::__construct($filePath);
    }

    public function acceptExtractor(Visitor $visitor)
    {
        $visitor->visitPdfFile($this);
    }
    public function acceptCompressor(Visitor $visitor)
    {
        $visitor->visitPdfFile($this);
    }
}

//...PPTFile、WordFile跟PdfFile类似,这里就省略了...

interface Visitor
{
    public function visitPPTFile(PPTFile $PPTFile);
    public function visitPdfFile(PdfFile $pdfFile);
    public function visitWordFile(WordFile $wordFile);
}

class Extractor implements Visitor
{

    public function visitPPTFile(PPTFile $PPTFile)
    {
        echo 'Extract ppt.<br>';
    }

    public function visitPdfFile(PdfFile $pdfFile)
    {
        echo 'Extract ppt.<br>';
    }

    public function visitWordFile(WordFile $wordFile)
    {
        echo 'Extract ppt.<br>';
    }
}

class Compressor implements Visitor
{
    public function visitPPTFile(PPTFile $PPTFile)
    {
        echo  "compress  ppt.<br>";
    }

    public function visitPdfFile(PdfFile $pdfFile)
    {
        echo  "compress  pdf.<br>";
    }

    public function visitWordFile(WordFile $wordFile)
    {
        echo "compress word.<br>";
    }

}

class Client
{
    private $resourceFiles = [];
    public function run()
    {
        $this->listAllResourceFiles();
        foreach ($this->resourceFiles as $value) {
            $value->acceptExtractor(new Extractor());
        }
        foreach ($this->resourceFiles as $value) {
            $value->acceptCompressor(new Compressor());
        }
    }

    public function listAllResourceFiles()
    {
        //...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
        $this->resourceFiles[] = new PdfFile('a.pdf');
        $this->resourceFiles[] = new PPTFile('b.ppt');
        $this->resourceFiles[] = new WordFile('c.word');

    }
}

$worker = new Client();
$worker->run();

重新来看访问者模式

刚刚我带你一步一步还原了访问者模式诞生的思维过程,现在,我们回过头来总结一下,这个模式的原理和代码实现。

访问者者模式的英文翻译是 Visitor Design Pattern。在 GoF 的《设计模式》一书中,它是这么定义的:

Allows for one or more operation to be applied to a set of objects at runtime, decoupling the operations from the object structure.

翻译成中文就是:允许一个或者多个操作应用到一组对象上,解耦操作和对象本身。

定义比较简单,结合前面的例子不难理解,我就不过多解释了。对于访问者模式的代码实现,实际上,在上面例子中,经过层层重构之后的最终代码,就是标准的访问者模式的实现代码。这里,我又总结了一张类图,贴在了下面,你可以对照着前面的例子代码一块儿来看一下。

最后,我们再来看下,访问者模式的应用场景。

一般来说,访问者模式针对的是一组类型不同的对象(PdfFile、PPTFile、WordFile)。不过,尽管这组对象的类型是不同的,但是,它们继承相同的父类(ResourceFile)或者实现相同的接口。在不同的应用场景下,我们需要对这组对象进行一系列不相关的业务操作(抽取文本、压缩等),但为了避免不断添加功能导致类(PdfFile、PPTFile、WordFile)不断膨胀,职责越来越不单一,以及避免频繁地添加功能导致的频繁代码修改,我们使用访问者模式,将对象与操作解耦,将这些业务操作抽离出来,定义在独立细分的访问者类(Extractor、Compressor)中。

总结

访问者模式允许一个或者多个操作应用到一组对象上,设计意图是解耦操作和对象本身,保持类职责单一、满足开闭原则以及应对代码的复杂性。

对于访问者模式,学习的主要难点在代码实现。而代码实现比较复杂的主要原因是,方法在大部分面向对象编程语言中是静态绑定的。也就是说,调用类的哪个方法,是在编译期间,由参数的声明类型决定的,而非运行时,根据参数的实际类型决定的。

正是因为代码实现难理解,所以,在项目中应用这种模式,会导致代码的可读性比较差。如果你的同事不了解这种设计模式,可能就会读不懂、维护不了你写的代码。所以,除非不得已,不要使用这种模式。

参考文章:设计模式之美- 访问者模式(上):手把手带你还原访问者模式诞生的思维过程

github示例:https://github.com/yangpanyao/design-patterns/tree/master/Visitor

Last modification:July 25th, 2020 at 05:04 pm