编程

适配器模式 vs. 桥接模式

585 2024-03-04 23:34:00

适配器模式和桥接模式带来了许多混淆。本文中,我们将看看它们是什么,有何不同以及哪些相似之处。

🔌 适配器模式

适配器模式通过使用实现预定义接口的中间类,尝试解决两个(或以上)不兼容类的兼容问题。

问题描述

假设有个 Feed 用于从多个信息源显示最新主题,比如:Reddit & Hacker News。对于这两个源,它们有两个 API 客户端:RedditApiHackerNewsApi。两者都返回一个主题列表,不过它们的 API 并不相同。

class RedditApi {
    public function fetchTopicItems(): RedditFeedIterator {
        // Returns a `RedditFeedIterator` that provides `Topic` objects, that hold a `title`, `date` and `url`.
    }
}
 
class HackerNewsApi {
    public function getTopics(): array {
        // returns an array of ['topic_title' => '...', 'topic_date' => '...', 'topic_url' => '...']
    }
}

我们不想让我们的 Feed 知晓不同的实现,因为我们可能想在未来添加另一个源,这意味着要向 Feed 添加更多的代码。因此,我们将应用适配器模式。

解决方案

适配器模式由 4 个元素组成:

  • 🙋 客户端(Client): 这是要连接到多个源的类。在我们的例子中,这就是 Feed
  • 📚 适配者(Adaptee): Client 要连接的源。本例中由两个:RedditApi & HackerNewsApi
  • 🎯 目标(Target): 定义客户端(Client)将连接到的单个 API 的接口或契约。
  • 🔌 适配器(Adapter): 实现 Target 接口的类并委派给适配者信息源、格式化输出。

首先确定一个 Target 接口,我们称之为 TopicAdapterInterface,该接口有一个 getTopic() 方法,它返回 iterable 可迭代的主题,其中每个主题都是一个带有 titledateurl 字段。因此它是带有数组的数组或者 Generator/Iterator 数组。

interface TopicAdapterInterface
{
    /**
     * @return iterable Iterable of topic array ['title' => '...', 'date' => '...', 'url' => '...']
     */
    public function getTopics(): iterable;
}

现在我们可以创建使用这些适配器的 Feed 类。我们将遍历所有适配器,并 yield 它们的结果,因此我们获得作为 Generator 单个持续主题流。这当然没有考虑日期,但对于这个例子足够了。

class Feed
{
    /**
     * @param TopicAdapterInterface[] $adapters The adapters.
     */
    public function __construct(public array $adapters) {}
 
    public function getAllTopics(): iterable
    {
        foreach ($this->adapters as $adapter) {
            yield from $adapter->getTopics();
        }
    }
}

这样我们就有了一个客户端 Feed目标 TopicAdapterInterface 以及两个适配者 RedditApi & HackerNewsApi。这意味着我们只差两个适配器(Adapter)。我们先创建这些适配器,再看看如何使之工作。

要使之更易于与迭代器(Iterator)协作,我会使用 doekenorg/iterator-functions 包的 iterator_map() 函数。

class RedditTopicAdapter implements TopicAdapterInterface
{
    public function __construct(public RedditApi $reddit_api) {}
 
    public function getTopics(): iterable
    {
        return iterator_map(
            fn (Topic $topic) => [
                'title' => $topic->getTitle(),
                'date' => $topic->getDate('Y-m-d H:i:s'),
                'url' => $topic->getUrl(),
            ],
            $this->reddit_api->fetchTopicItems(),
        );
    }
}
 
class HackerNewsTopicAdapter implements TopicAdapterInterface
{
    public function __construct(public HackerNewsApi $hacker_news_api) {}
 
    public function getTopics(): iterable
    {
        return iterator_map(
            fn (array $topic) => [
                'title' => $topic['topic_title'],
                'date' => \DateTime::createFromFormat('H:i:s Y-m-d', $topic['topic_date'])->format('Y-m-d H:i:s'),
                'url' => $topic['topic_url'],
            ],
            $this->hacker_news_api->getTopics(),
        );
    }
}

此处你可以看到两个适配器:RedditTopicAdapterHackerNewsTopicAdapter。这两个类都实现了 TopicAdapterInterface 并提供了所需的 getTopics() 方法。它们获得各自的适配者将其作为依赖注入,并用来检索主题,同时格式化为所需的数组。

这意味着 Feed 现在通过将它们注入到构造函数使用这些适配器。要将这些连接在一起,它可能看起来有点像这样:

$hacker_news_adapter = new HackerNewsAdapter(new HackerNewsApi());
$reddit_adapter = new RedditTopicAdapter(new RedditApi());
$feed = new Feed([$hacker_news_adapter, $reddit_adapter]);
 
foreach ($feed->getAllTopics() as $topic) {
    var_dump($topic); // arrays of [`title`, `date` and `url`]
}

适配器模式的益处

  • 🔄 你可以此后插入另外的适配器,而不必修改客户端实现。
  • 🖖 只有适配器需要了解适配者,这就强制分离了关注点。
  • 🔬 客户端代码易于测试,因为它只依赖于 Target 接口。
  • 📦 当使用 IoC 容器时,通常可以使用特定的接口获取/标记所有服务,从而非常容易地找到并将所有适配器注入或自动连接到客户端。

真实世界案例

适配器模式是最常用的模式之一,因其具有可扩展性。它甚至可以由其他包扩展,而不必更改原始包。以下是一些现实世界中的例子。

缓存适配器

大部分框架都有一个缓存系统,该系统有一个单独的 API 来使用它,同时为不同的实现提供适配器,如 redis、memcache 或文件系统缓存。Laravel 称这些适配器为 Store,你可以在 illuminate/cache 中找到这些 store。它们在 illuminate/contracts 仓库为这些 store 提供 Target 接口。

文件系统适配器

通常我们将数据写入文件。文件可能位于某个位置,比如:FTP 服务器,Dropbox 目录或者 Google Drive。将数据写入到文件的有一个最常用包 thephpleague/flysystem。该包提供了一个可以有特定实现的 FilesystemAdapter 接口。同时因为 Target 接口,其他人可以创建第三方包提供另外一个文件系统,比如 spatie/flysystem-dropbox

🔀 桥接模式

桥接模式经常与适配器模式混为一谈。我们来看看该模式尝试解决什么问题,以及它与适配器模式的不同之处。

问题描述

假设我们有两个编辑器:MarkdownEditorWysiwygEditor。两个编辑器都可以读取并格式化文件,同时更新该文件。MarkdownEditor  返回 Markdown 文本,而 WysiwygEditor 返回 HTML。

class WysiwygEditor
{
    public function __construct(public string $file_path) {}
 
    protected function format(): string
    {
        return '<h1>Source</h1>'; // The formatted source.
    }
 
    public function read(): string
    {
        return file_get_contents($this->file_path);
    }
 
    public function store(): void
    {
        file_put_contents($this->file_path, $this->format());
    }
}
 
class MarkdownEditor
{
    public function __construct(public string $file_path) {}
 
    protected function format(): string
    {
        return '# Source'; // The formatted source.
    }
 
    public function read(): string
    {
        return file_get_contents($this->file_path);
    }
 
    public function store(): void
    {
        file_put_contents($this->file_path, $this->format());
    }
}

在某个时间点,我们要求 Markdown 编辑器和 WYSIWYG 编辑器可以读取和保存 FTP 服务器上的文件。我们可以创建一个新的编辑器,使之继承 MarkdownEditor 或  WysiwygEditor 并重写 read()store() 方法。不过这也可能在其中引入一些重复代码。所以,我们使用桥接模式。

方案

桥接模式也由 4 个元素组成:

  • 🎨 抽象(Abstraction): 一个抽象基类,它将一些预定义的函数委托给实现者(Implementor)。在我们的例子中,这将是一个 AbstractEditor
  • 🧑‍🎨 Refined Abstraction: 抽象(Abstraction)类的具体实现。也就是我们的例子中的 MarkdownEditorWysiwygEditor
  • 🖌️ 实现者(Implementor): 抽象类用以委托的接口。本例中的 FileSystemInterface
  • 🖼️ 具体实现者(Concrete Implementor): 实现者实际执行工作的特定实现。本例中的 LocalFileSystemFtpFileSystem

正是在这一点上,我认为使这种模式难以把握的一件事是:

与存在实际适配器的适配器模式不同;桥接模式并没有桥。但别担心,我们很快就会看到这座桥的原因!

重构代码

让我们通过实现桥接模式来重构我们的示例代码。我们将从两个编辑器中提取抽象类。

abstract class AbstractEditor {
    public function __construct(public string $file_path) {}
 
    abstract protected function format(): string;
 
    public function read(): string
    {
        return file_get_contents($this->file_path);
    }
 
    public function store(): void
    {
        file_put_contents($this->file_path, $this->format());
    }
}
 
class WysiwygEditor extends AbstractEditor
{
    protected function format(): string
    {
        return '<h1>Source</h1>'; // The formatted source.
    }
}
 
class MarkdownEditor extends AbstractEditor
{
    protected function format(): string
    {
        return '# Source'; // The formatted source.
    }
}

这个重构中,我们创建了一个 AbstractEditor,它现在包含了编辑器之间的所有重复代码,并使编辑器扩展了这种抽象。通过这种方式,编辑器,或精炼抽象(Refined Abstractions),只专注于他们最擅长的事情:格式化文件的源。

不过请记住,我们还没有实现者具体实现者,我们真正想使用文件系统。因此,我们来创建实现者及 LocalFileSystem 作为具体实现者。然后更新 AbstractEditor 使之使用实现者。

interface FilesystemInterface {
    public function read(string $file_path): string;
 
    public function store(string $file_path, string $file_contents): void;
}
 
class LocalFileSystem implements FilesystemInterface {
    public function read(string $file_path): string
    {
        return file_get_contents($file_path);
    }
 
    public function store(string $file_path, string $file_contents): void
    {
        file_put_contents($file_path, $file_contents);
    }
}
 
abstract class AbstractEditor {
    public function __construct(private FilesystemInterface $filesystem, private string $file_path) {}
 
    abstract protected function format(): string;
 
    public function read(): string
    {
        return $this->filesystem->read($this->file_path);
    }
 
    public function store(): void
    {
        $this->filesystem->store($this->file_path, $this->format());
    }
}

这就是“桥”。它是抽象类和实现者之间的连接。它将一个编辑器连接到文件系统。但现在两者可以独立变化。我们可以添加多个编辑器,它们都有自己的格式,比如 yamljsoncsv。并且所有这些编辑器可以使用任何文件系统来读取和保存这些文件。

因此我们可以创建 FtpFileSystem 用来读取及保存 FTP 上的格式化内容。

class FtpFileSystem implements FilesystemInterface {
    public function read(string $file_path): string
    {
        // Imagine the ultimate FTP file reading code here.
    }
    public function store(string $file_path, string $file_contents): void
    {
        // Imagine the ultimate FTP file writing code here.
    }
}

通过使用桥接模式,我们使之能够实现 4 中不同的实现组合:

class FtpFileSystem implements FilesystemInterface {
    public function read(string $file_path): string
    {
        // Imagine the ultimate FTP file reading code here.
    }
 
    public function store(string $file_path, string $file_contents): void
    {
        // Imagine the ultimate FTP file writing code here.
    }
}

如果我们添加另一个 AbstractEditor 和另一个 FileSystem,我们将有 9 种可能的组合,而只添加 2 个类 🤯!

桥接模式的好处

正如我们所看到的,使用桥接模式有一些好处:

  • 💧 通过提取除抽象类代码更加 DRY (Don't Repeat Yourself)。
  • 🧱 通过创建两个可以独立变化的独立抽象,它的可扩展性更强。
  • 🔬 单个类较小,因此更易于测试和理解。

与适配者模式是相似之处

有些人难以理解桥接模式和适配器模式之间的区别的另一个原因是,“桥接”的连接部分实际上看起来像适配器。

  • 客户端(Client)可以被视为抽象(Abstraction),因为它也委派给接口。
  • 目标(Target)类可以被视作实现者(Implementor)因值也定义了要遵循的接口。
  • 适配器(Adapter)可被视为具体实现者(Refined Implementor)因为它实现了接口并执行了需求。

最后一条可能最令人困惑,因为具体实现者(Refined Implementor)实际上可能是依赖项或者适配者的适配器,不过这不是必需的。具体实现者通常是一个独立的类,而适配器则始终是委托的。但这两者并不是相互排斥的。