适配器模式 vs. 桥接模式
适配器模式和桥接模式带来了许多混淆。本文中,我们将看看它们是什么,有何不同以及哪些相似之处。
🔌 适配器模式
适配器模式通过使用实现预定义接口的中间类,尝试解决两个(或以上)不兼容类的兼容问题。
问题描述
假设有个 Feed
用于从多个信息源显示最新主题,比如:Reddit & Hacker News。对于这两个源,它们有两个 API 客户端: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' => '...']
}
}
我们不想让我们的 Feed
知晓不同的实现,因为我们可能想在未来添加另一个源,这意味着要向 Feed
添加更多的代码。因此,我们将应用适配器模式。
解决方案
适配器模式由 4 个元素组成:
- 🙋 客户端(Client): 这是要连接到多个源的类。在我们的例子中,这就是
Feed
。 - 📚 适配者(Adaptee): Client 要连接的源。本例中由两个:
RedditApi
&HackerNewsApi
。 - 🎯 目标(Target): 定义客户端(Client)将连接到的单个 API 的接口或契约。
- 🔌 适配器(Adapter): 实现 Target 接口的类并委派给适配者信息源、格式化输出。
首先确定一个 Target 接口,我们称之为 TopicAdapterInterface
,该接口有一个 getTopic()
方法,它返回 iterable
可迭代的主题,其中每个主题都是一个带有 title
、date
和 url
字段。因此它是带有数组的数组或者 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(),
);
}
}
此处你可以看到两个适配器:RedditTopicAdapter
和 HackerNewsTopicAdapter
。这两个类都实现了 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
。
🔀 桥接模式
桥接模式经常与适配器模式混为一谈。我们来看看该模式尝试解决什么问题,以及它与适配器模式的不同之处。
问题描述
假设我们有两个编辑器:MarkdownEditor
和 WysiwygEditor
。两个编辑器都可以读取并格式化文件,同时更新该文件。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)类的具体实现。也就是我们的例子中的
MarkdownEditor
和WysiwygEditor
。 - 🖌️ 实现者(Implementor): 抽象类用以委托的接口。本例中的
FileSystemInterface
。 - 🖼️ 具体实现者(Concrete Implementor): 实现者实际执行工作的特定实现。本例中的
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.
}
}
这个重构中,我们创建了一个 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());
}
}
这就是“桥”。它是抽象类和实现者之间的连接。它将一个编辑器连接到文件系统。但现在两者可以独立变化。我们可以添加多个编辑器,它们都有自己的格式,比如 yaml
、json
或 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.
}
}
通过使用桥接模式,我们使之能够实现 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)实际上可能是依赖项或者适配者的适配器,不过这不是必需的。具体实现者通常是一个独立的类,而适配器则始终是委托的。但这两者并不是相互排斥的。