适配器模式与桥接模式
适配器模式和桥接模式一直以来都容易让人感到困惑。本文将探讨它们的定义、区别以及相似之处。
🔌 适配器图案
适配器模式试图通过使用实现预定义接口的中间类来解决使两个(或多个)不兼容的类兼容的问题。
等等,什么?我们再试一次!
问题
假设有一个页面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' => '...']
}
}
我们不希望让信息源了解不同的实现方式,因为将来我们可能需要添加其他信息源,这意味着要向信息源添加更多代码。因此,我们将采用适配器模式。
解决方案
适配器模式由以下 4 个要素组成:
- 🙋客户:这是想要连接多个数据源的类。
Feed在我们的示例中就是这种情况。 - 📚被适配者:客户端想要连接的源。在我们的示例中,我们有两个:
RedditApi&HackerNewsApi。 - 🎯目标:定义客户端将连接的单个 API 的接口或合约。
- 🔌适配器:一个实现了Target接口并委托给Adaptee源并格式化其输出的类。
首先,我们确定一个Target接口;我们称之为 Target TopicAdapterInterface,它将有一个getTopics()方法返回一个iterable主题数组,其中每个主题都是一个包含title、date和 的数组url。因此,它可以是一个数组的数组,或者是一个数组的生成器/迭代器。
如果您还不熟悉生成器或迭代器,请查看我关于数组生成器的文章。
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这意味着我们只差两个适配器了。我们先创建这两个适配器,然后再看看它们是如何工作的。
为了更方便地使用迭代器,我将使用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(),
);
}
}
这里可以看到我们的两个适配器:`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`]
}
适配器模式的优势
- 🔄 您可以在稍后插入额外的适配器,而无需更改客户端实现。
- 🖖 只有适配器需要知道被适配器,这实现了关注点分离。
- 🔬客户端代码易于测试,因为它只依赖于目标接口。
- 📦 使用 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());
}
}
在某个时候,我们需要一个 Markdown 编辑器和一个能够读取和存储 FTP 服务器上文件的 WYSIWYG 编辑器。我们可以创建一个新的编辑器,继承自 `Markdown` 或 `WYSIWYG`MarkdownEditor并WysiwygEditor重写 `Markdown` 和read()`WYSIWYG`store()方法。然而,这很可能会导致两个编辑器之间出现大量重复代码。因此,我们将使用桥接模式。
解决方案
桥式图案也由 4 个要素组成:
- 🎨抽象:一个抽象基类,它将一些预定义的函数委托给实现者。在我们的示例中,这将是一个
AbstractEditor. - 🧑🎨精细化抽象:抽象的具体实现。在我们的示例中,这将是
MarkdownEditor和WysiwygEditor。 - 🖌️实现者:抽象层用于委托的接口。在我们的示例中,这将是一个
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.
}
}
在这次重构中,我们创建了一个抽象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());
}
}
这就是“桥梁” 。它是抽象层和实现层之间的连接。它将一个编辑器连接到一个文件系统。但现在两者可以独立运行。我们可以添加多个编辑器,每个编辑器都有自己的格式,例如 `<script>`、`<script>`yaml或json`<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.
}
}
通过使用桥接模式,我们实现了 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')
如果我们再增加一个AbstractEditor,再增加一个,FileSystem就会有 9 种可能的组合,而实际上只增加了 2 个班级🤯!
桥式模式的优势
正如我们所见,使用桥式模式有一些好处:
- 💧 通过提取抽象层,代码更加符合 DRY(不要重复自己)原则。
- 🧱 通过创建两个可以独立变化的独立抽象,它具有更强的可扩展性。
- 🔬 各个班级规模较小,因此更容易测试和理解。
与适配器模式的相似之处
有些人难以理解桥接模式和适配器模式之间的区别的另一个原因是,“桥接”的连接部分实际上看起来像一个适配器。
- 客户端可以被视为抽象层,因为它也委托给接口。
- 目标可以被视为实现者,因为它也定义了一个需要遵循的接口。
- 适配器可以被视为精细化的实现者,因为它实现了接口并满足了要求。
最后一点可能最容易让人困惑;因为精化实现者实际上可以是依赖项或被适配者的适配器,但这并非必要条件。精化实现者通常是一个独立的类,而适配器则总是会进行委托。但两者确实并非互斥。
感谢阅读
希望您喜欢这篇文章!如果喜欢,请点个赞❤️或🦄,并考虑订阅!我几乎每周都会发布关于 PHP 的文章。您也可以在Twitter上关注我,获取更多内容和一些小技巧。
文章来源:https://dev.to/doekenorg/adapter-pattern-vs-bridge-pattern-11nd
