外观模式:简化代码的入门指南





0/5 (0投票)
外观模式入门指南
在这篇文章中,我将介绍我最常用的设计模式之一,称为外观模式(或 Façade),并解释我为什么喜欢使用它。正如我分享所有信息一样,作为软件工程师,保持实用主义至关重要。话虽如此,本文并不是说服你只使用这种模式或没有其他替代方案。相反,我想用你虚拟的编程工具箱中的另一个设计模式工具来武装你。可用的工具越多,你就越能做好准备,构建出色的东西。
我们希望通过外观模式实现什么
当我开发软件时,无论是个人、专业,还是作为影响软件方向的人,我不会直接编码,我鼓励专注于软件的灵活性。我见过太多次这样的情况:代码库达到一个地步,人们只是说
“好吧,我们需要重写它,因为它永远不支持 X”
一位开发者
不幸的是,我知道我的职业生涯中还会遇到很多次这种情况。很多时候,这种情况的发生是因为代码库充满了对特定实现的紧密耦合。随着时间的推移,这种耦合变得如此强大,以至于当需要进行调整时,似乎不可能在不重新开始的情况下解决它。
现在,外观模式并不能神奇地为我们解决这个问题。它不是灵丹妙药。但外观模式是一个工具,我们可以利用它来帮助隐藏实现细节或其他复杂性,并提供一个我们乐于使用的友好 API。我们还可以达到一个拥有可扩展系统的地步,该系统可以添加功能或数据源,而无需修改核心代码来支持它。
听起来很酷?让我们深入了解一下。
一个配套视频!
示例起点
我想用一个例子来讲解代码如何演变到外观模式能够发挥作用的地步。对于这个例子,你可以在 GitHub 上找到我们使用外观模式之前的代码,如下所示
Console.WriteLine("Starting example 1...");
var data = new Data(
"Dev Leader Website",
"https://www.devleader.ca");
var originalEmailBasedDataPublisher = new OriginalEmailBasedDataPublisher();
await originalEmailBasedDataPublisher.PublishAsync(
"https://smtpserver",
"SomeUsername",
"SecretPassword123",
data);
Console.WriteLine("Example 1 complete.");
sealed record Data(
string Name,
string Value);
sealed class OriginalEmailBasedDataPublisher
{
public Task PublishAsync(
string smtpServer,
string smtpUsername,
string smtpPassword,
Data data)
{
// TODO: go actually send some email... this is just to demo
Console.WriteLine($"Sending email for '{data}'...");
return Task.CompletedTask;
}
}
在上面的示例代码中,我们有一个简单的数据发布器,它能够接收一段数据并将其发布出去。它没有实现(因为它与这个上下文无关紧要),但其想法是,这个特定的实现是基于电子邮件的发布,并且它需要一些特定于电子邮件的配置。
随着应用程序的增长,你发现现在需要将数据发布到两个位置。不仅如此,你在代码中发布数据的位置也从一个地方增加到多个地方。因此,为了实现下一个更改,你需要去代码库中的多个位置添加如下例所示的代码
Console.WriteLine("Starting example 2...");
var data = new Data(
"Dev Leader Website",
"https://www.devleader.ca");
var originalEmailBasedDataPublisher = new OriginalEmailBasedDataPublisher();
var originalSmsBasedDataPublisher = new OriginalSmsBasedDataPublisher();
// this is the code that gets dropped into multiple spots across your application:
await originalEmailBasedDataPublisher.PublishAsync(
"https://smtpserver",
"SomeUsername",
"SecretPassword123",
data);
await originalSmsBasedDataPublisher.PublishAsync(
new SmsConfiguration("Some Config Details"),
data);
Console.WriteLine("Example 2 complete.");
如果我们继续这种模式,假设随着时间的推移,我们的代码库越来越大,很可能发生两件事
- 我们不断添加更多发布者,它们有自己的发布标准
- 我们不断向代码库添加更多需要维护的地方,每当我们更改、添加或删除发布者时
那么我们能做什么呢?
用外观 API 进行重构
和大多数事情一样,有多种方法可以使这段代码更具可扩展性和灵活性。这与我们是否想在这里使用外观模式无关。即使我在这里建议转换为外观模式,你仍然可以用许多不同的方式来完成,并且仍然拥有一个像外观一样包裹着你实现的模式。
我喜欢采取的第一步是思考我希望外观 API 看起来是什么样子。当我编写自己的应用程序时,这通常会(相对)自然地出现,因为我经常使用外观模式。在这种情况下,我建议我们查看每个 PublishAsync
需要提供什么,而不是特定于任何一个实现。如果我们查看两个现有实现,看起来唯一存在的共同参数将是 Data
记录。此外,两个实现都是 async Task
,没有特定的返回值。
有时,如果我们没有看到足够的例子就尝试重构,我们可能会创建一个不够灵活的 API 来满足其他实现。我通常喜欢遵循“三次法则”来处理此类事情,这样在我尝试重构之前至少看到了三个实现。然而,在这种情况下,我们可以创建我们的 API,使其具有一个 Data
参数和通用的 async Task
返回类型。
这给我们留下了一个如下所示的 API
interface IDataPublisher
{
Task PublishAsync(Data data);
}
实现外观依赖
需要注意的是,我们之前定义的 API 将贯穿我们外观所实现的代码。电子邮件和短信的现有实现不必满足此 API,因为我们可以在代码中外观内部的一个位置调用它们的特定实现。但是,在此示例中,我们实际上可以将这些实现特定的参数移动到这些对象的构造函数参数中。这可能并非适用于所有情况,但对于我们的示例,这些配置可以安全地存在于我们拥有的对象实例的生命周期内。
我们的电子邮件实现可以切换到以下代码
sealed class EmailBasedDataPublisher : IDataPublisher
{
public EmailBasedDataPublisher(
string smtpServer,
string smtpUsername,
string smtpPassword)
{
// NOTE: we pass in implementation-specific
// configuration via constructor now
}
public Task PublishAsync(Data data)
{
// TODO: go actually send some email... this is just to demo
Console.WriteLine($"Sending email for '{data}'...");
return Task.CompletedTask;
}
}
我们的短信实现可以切换到以下代码
sealed class SmsBasedDataPublisher : IDataPublisher
{
public SmsBasedDataPublisher(SmsConfiguration configuration)
{
// NOTE: we pass in implementation-specific
// configuration via constructor now
}
public Task PublishAsync(Data data)
{
// TODO: go actually send some email... this is just to demo
Console.WriteLine($"Sending SMS for '{data}'...");
return Task.CompletedTask;
}
}
实现外观
我们实际上可以将外观编写成与电子邮件和短信依赖项无关,这是因为我们多做了一步。通过让实现共享一个通用 API,外观无需为它们中的任何一个做任何特殊的事情。然而,拥有外观的主要驱动因素之一是隐藏复杂性并将其放在一个公共位置。因此,如果你需要在外观内部明确调用每个实现,这仍然可能比原始代码有所改进。
当依赖项也与外观本身的 API 匹配时,我们的外观看起来像这样
sealed class PublisherFacade : IDataPublisher
{
private readonly IReadOnlyCollection<IDataPublisher> _publishers;
public PublisherFacade(IEnumerable<IDataPublisher> publishers)
{
_publishers = publishers.ToArray();
}
public async Task PublishAsync(Data data)
{
// NOTE: this could be implemented in many different ways...
var publishTasks = _publishers.Select(x => x.PublishAsync(data));
await Task.WhenAll(publishTasks);
}
}
如果我们查看外观上的 PublishAsync
方法,你会看到我留下了一个关于我们如何在实现中实现灵活性的注释。你希望外观串行调用每个依赖实现吗?并行调用吗?我们需要对我们正在调用的每个依赖项的执行进行排序吗?
这里有很多决定要做。如果你回想一下最初的例子,现在这些逻辑集中在一个地方,而不是分散在你的代码库中。
整合
让我们看看这个实例化并调用我们外观的代码示例
Console.WriteLine("Starting example 3...");
var emailPublisher = new EmailBasedDataPublisher(
"https://smtpserver",
"SomeUsername",
"SecretPassword123");
var smsPublisher = new SmsBasedDataPublisher(
new SmsConfiguration("Some Configuration"));
var publisher = new PublisherFacade(new IDataPublisher[]
{
emailPublisher,
smsPublisher
});
var data = new Data(
"Dev Leader Website",
"https://www.devleader.ca");
await publisher.PublishAsync(data);
Console.WriteLine("Example 3 complete.");
在上面的代码中,几乎每一行都只是设置我们的外观和依赖项。最后两行正在设置一个 Data
记录并实际调用我们外观的 PublishAsync
方法。如果我们将其与初始代码的方向进行对比
- 我们现在已将特定的实现依赖项隐藏在我们的外观中。它存在于一个位置,而不是我们想要发布数据的每个位置。
- 如果我们对每个发布者的执行顺序有特殊逻辑,那么现在它被包含在一个位置,而不是代码库中每个想要发布数据的位置。
- 如果我们想添加更多数据发布者,我们调用
PublishAsync
的任何代码都不需要更改。我们可以通过向外观提供额外的实现来扩展系统功能。
下一步是什么?
希望这个概述能给你一些关于如何利用外观模式的想法。我发现它们对于隐藏复杂性以及通过向外观注入新依赖项来扩展我的系统非常有价值。
话虽如此,你可能对研究一些使用 Autofac 和反射进行插件加载感兴趣!