发布于 2026-01-06 1 阅读
0

适配器模式与桥接模式

适配器模式与桥接模式

适配器模式和桥接模式一直以来都容易让人感到困惑。本文将探讨它们的定义、区别以及相似之处。

🔌 适配器图案

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

等等,什么?我们再试一次!

问题

假设有一个页面Feed想要显示来自多个来源(例如 Reddit 和 Hacker News)的最新话题。对于这些来源,我们有两个 API 客户端:`Reddit` 和 `Hacker News`。RedditApi两者HackerNewsApi都返回话题列表,但它们的 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' => '...']
    }
}
Enter fullscreen mode Exit fullscreen mode

我们不希望让信息源了解不同的实现方式,因为将来我们可能需要添加其他信息源,这意味着要向信息源添加更多代码。因此,我们将采用适配器模式。

解决方案

适配器模式由以下 4 个要素组成:

  • 🙋客户:这是想要连接多个数据源的类。Feed在我们的示例中就是这种情况。
  • 📚被适配者:客户端想要连接的源。在我们的示例中,我们有两个: RedditApi& HackerNewsApi
  • 🎯目标:定义客户端将连接的单个 API 的接口或合约。
  • 🔌适配器:一个实现了Target接口并委托给Adaptee源并格式化其输出的类。

首先,我们确定一个Target接口;我们称之为 Target TopicAdapterInterface,它将有一个getTopics()方法返回一个iterable主题数组,其中每个主题都是一个包含titledate和 的数组url。因此,它可以是一个数组的数组,或者是一个数组的生成器/迭代器。

如果您还不熟悉生成器或迭代器,请查看我关于数组生成器的文章。

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

现在我们可以创建一个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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

所以我们现在有一个客户端 Feed、一个目标 TopicAdapterInterface和两个被适配者 RedditApiHackerNewsApi这意味着我们只差两个适配器了。我们先创建这两个适配器,然后再看看它们是如何工作的。

为了更方便地使用迭代器,我将使用iterator_map()我的doekenorg/iterator-functions包中的函数。

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(),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

这里可以看到我们的两个适配器:`Adapter`RedditTopicAdapter和 `Adapter` HackerNewsTopicAdapter。这两个类都实现了 `Adapter` 接口TopicAdapterInterface,并提供了所需的 `Adapter`getTopics()方法。它们各自注入了一个`Adaptee`作为依赖项,并使用该 `Adaptee` 来检索主题并将其格式化为所需的数组。

这意味着我们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`]
}
Enter fullscreen mode Exit fullscreen mode

适配器模式的优势

  • 🔄 您可以在稍后插入额外的适配器,而无需更改客户端实现。
  • 🖖 只有适配器需要知道被适配器,这实现了关注点分离。
  • 🔬客户端代码易于测试,因为它只依赖于目标接口。
  • 📦 使用 IoC 容器时,通常可以获取/标记所有具有特定接口的服务,从而很容易找到所有适配器并将其注入或自动装配到客户端中。

真实案例

适配器模式是最常用的模式之一,因为它具有很强的可扩展性。它甚至可以被其他包扩展,而无需修改原始包。以下是一些实际示例。

缓存适配器

大多数框架都有一个缓存系统,它使用统一的 API 进行操作,同时提供适配器来适配不同的实现,例如:Redis、Memcached 或文件系统缓存。Laravel 将这些适配器称为“存储” Store,你可以在代码仓库中找到它们illuminate/cache。它们在代码仓库中为这类存储提供了Target接口 。illuminate/contracts

文件系统适配器

另一个常见的操作是将数据写入文件。这些文件可能位于其他位置,例如:FTP 服务器、Dropbox 文件夹或 Google 云端硬盘。用于将数据写入文件的最常用软件包之一是 ` FilesystemAdapter` thephpleague/flysystem。该软件包提供了一个`FilesystemAdapter`接口,可以有特定的实现。由于有了这个`Target`接口,其他人可以构建提供其他文件系统的第三方软件包,例如spatie/flysystem-dropboxSpatie 开发的 `FilesystemAdapter`。

🔀 桥式模式

桥接模式经常与适配器模式混淆,这并非没有道理。让我们来看看桥接模式试图解决什么问题,以及它与适配器模式有何不同。

问题

假设我们有两个编辑器:AMarkdownEditor和 B。WysiwygEditor这两个编辑器都可以读取和格式化某个文件,并更新该文件的源代码。AMarkdownEditor显然返回 Markdown 文本,而 BWysiwygEditor返回 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());
    }
}
Enter fullscreen mode Exit fullscreen mode

在某个时候,我们需要一个 Markdown 编辑器和一个能够读取和存储 FTP 服务器上文件的 WYSIWYG 编辑器。我们可以创建一个新的编辑器,继承自 `Markdown` 或 `WYSIWYG`MarkdownEditorWysiwygEditor重写 `Markdown` 和read()`WYSIWYG`store()方法。然而,这很可能会导致两个编辑器之间出现大量重复代码。因此,我们将使用桥接模式。

解决方案

桥式图案也由 4 个要素组成:

  • 🎨抽象:一个抽象基类,它将一些预定义的函数委托给实现者。在我们的示例中,这将是一个AbstractEditor.
  • 🧑‍🎨精细化抽象:抽象的具体实现。在我们的示例中,这将是MarkdownEditorWysiwygEditor
  • 🖌️实现者:抽象层用于委托的接口。在我们的示例中,这将是一个FileSystemInterface
  • 🖼️具体实现者:实现者的具体实现,它实际执行工作。在我们的示例中,这将是LocalFileSystem一个FtpFileSystem……

我认为,正是这一点使得这种模式难以理解:

没有桥

与适配器模式中存在实际适配器不同,桥接模式中没有桥接。不过别担心,我们很快就会看到它作为桥接器的关键所在!

重构代码

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

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.
    }
}
Enter fullscreen mode Exit fullscreen mode

在这次重构中,我们创建了一个抽象AbstractEditor层,其中包含了编辑器之间所有重复的代码,并让编辑器扩展这个抽象层。这样,编辑器(或称“精化抽象层”)就可以专注于它们最擅长的领域:格式化文件的源代码。

但请记住,我们目前还没有实现者改进实现者,而我们确实希望使用多个文件系统。因此,让我们创建实现者,并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());
    }
}
Enter fullscreen mode Exit fullscreen mode

这就是“桥梁” 。它是抽象层实现层之间的连接。它将一个编辑器连接到一个文件系统。但现在两者可以独立运行。我们可以添加多个编辑器,每个编辑器都有自己的格式,例如 `<script>`、`<script>`yamljson`<script> csv`。所有这些编辑器都可以使用任何文件系统来读取和存储文件。

现在我们可以创建一个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.
    }
}
Enter fullscreen mode Exit fullscreen mode

通过使用桥接模式,我们实现了 4 种不同的实现组合:

// 1. A local markdown file editor
new MardownEditor(new LocalFileSystem(), 'local-file.md')
// 2. An FTP markdown file editor
new MardownEditor(new FtpFileSystem(), 'ftp-file.md')
// 3. A local WYSIWYG file editor
new WysiwygEditor(new LocalFileSystem(), 'local-file.html')
// 4. An FTP WYSIWYG file editor
new WysiwygEditor(new FtpFileSystem(), 'ftp-file.html')
Enter fullscreen mode Exit fullscreen mode

如果我们再增加一个AbstractEditor,再增加一个,FileSystem就会有 9 种可能的组合,而实际上只增加了 2 个班级🤯!

桥式模式的优势

正如我们所见,使用桥式模式有一些好处:

  • 💧 通过提取抽象层,代码更加符合 DRY(不要重复自己)原则
  • 🧱 通过创建两个可以独立变化的独立抽象,它具有更强的可扩展性。
  • 🔬 各个班级规模较小,因此更容易测试和理解。

与适配器模式的相似之处

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

  • 客户可以被视为抽象层,因为它也委托给接口。
  • 目标可以被视为实现者因为它也定义了一个需要遵循的接口。
  • 适配器可以被视为精细化的实现者因为它实现了接口并满足了要求。

最后一点可能最容易让人困惑;因为精化实现者实际上可以依赖项或被适配者的适配器但这并非必要条件。精化实现者通常是一个独立的类,而适配器则总是会进行委托。但两者确实并非互斥。

感谢阅读

希望您喜欢这篇文章!如果喜欢,请点个赞❤️或🦄,并考虑订阅!我几乎每周都会发布关于 PHP 的文章。您也可以在Twitter上关注我,获取更多内容和一些小技巧。

文章来源:https://dev.to/doekenorg/adapter-pattern-vs-bridge-pattern-11nd