模型-视图-呈现器(MVP)模式及其在 ASP.NET 中的实现





5.00/5 (1投票)
如何在 ASP.NET 中实现 MVP 模式?
引言
自动化测试已成为现代应用程序中的关键,开发人员越来越意识到其在整个软件生命周期中的重要性。然而,并非所有应用程序的方面都能得到彻底测试,UI 组件和页面经常需要手动创建。这个过程既耗时又容易出错。为了解决这个挑战,在 20 世纪 90 年代初出现了一些设计模式,旨在减少这些困难并提供替代解决方案。
在这个简短的系列中,我们将探讨这些关键设计模式之一:模型-视图-呈现器(MVP)。我们将研究这种模式如何将大部分业务逻辑转移到特定的对象(称为呈现器),同时将 UI(视图)视为一个简单的、被动的元素。我们的目标是通过一个简单的 Blazor 应用程序来演示这些概念。
以下教科书对这个主题很有用。
本文最初发布于: 模型-视图-呈现器及其在 ASP.NET 中的实现
为什么测试在软件应用程序中至关重要?
我们不会详细探讨这个问题,因为自动化测试的重要性已被广泛认可。测试有助于确保非回归,使我们能够更频繁、更有信心地发布新版本。
例如,考虑一个基本的 **Payment** 类的要求,我们需要为给定价格计算增值税。相应的 C# 代码可能如下所示。
public class Payment
{
public decimal Price { get; private set; }
public Payment(decimal price)
{
Price = price;
}
public decimal PriceVATIncluded => Price * 1.2;
}
相应的测试类应类似于以下内容(使用 NUnit)。
public class PaymentTests
{
[Test]
public void CheckVATIsCorrectlyCalculated()
{
// Arrange
var payment = new Payment(100.0);
// Act
var priceWithVAT = payment.PriceVATIncluded;
// Assert
Assert.AreEqual(120.0, priceWithVAT);
}
}
这段代码非常简单,本身并不特别有趣,但它是我们在软件生命周期的某个时刻不可避免会遇到的。在这个例子中,**可以看出领域对象的测试可以相对容易地维护**。
为什么 UI 如此难以测试?
我们刚刚看到,测试领域类通常很简单(但不一定容易)。相比之下,测试 UI 类经常非常困难。
-
用户界面通常涉及元素之间的复杂交互,例如按钮、表单和动态内容。
-
由于设计更新,UI 可能会频繁更改,这可能会破坏现有测试或需要对测试脚本进行持续调整。
-
测试 UI 需要管理和验证应用程序的不同状态,包括用户输入、错误消息和加载条件。
-
自动化测试通常难以进行视觉验证,例如确保元素正确放置、样式正确以及具有响应性。
例如,考虑一个简单的登录页面,用户尝试进行身份验证。代码可能如下所示。
public class LoginView
{
// ...
public Task btnLoginButton_Submit(EventArgs e)
{
var username = txtUserName.Text;
var password = txtPassword.Text;
if (_userRepository.Authenticate(userName, password))
{
txtWelcome = "Welcome, you are logged in !";
btnSeeMoreFeatures.Enabled = true;
}
else
{
txtWelcome = "Sorry !";
}
}
}
要正确测试此代码,我们需要模拟一个提交事件(这可能很复杂),然后验证各种组件是否已正确更新。即使我们有一个可以直接从 HTML 中提取此数据的工具,我们仍然会面临竞态条件,因为过早检查新值可能导致不准确的结果。
此外,我们没有进行视觉验证。文本框是否正确渲染?设计是否具有响应性?这些问题需要仔细考虑。如何解决这些问题?
模型-视图-呈现器(MVP)来帮忙
在前面描述的 UI 测试中,认识到有两种测试很重要:一种纯粹关注业务逻辑(例如,当我单击“提交”按钮并且身份验证成功时,会显示欢迎消息;否则,会显示错误消息),另一种处理视觉效果(例如,按钮应为 200 像素宽并放置在文本框旁边)。
第二种(处理视觉效果)更难处理,通常需要 Selenium 或 Katalon 等专用工具(有关更多详细信息,请参阅我们的文章 这里)。然而,第一种,关注业务逻辑的,可以封装在专用的类中,以实现可维护性和可测试性。MVP 模式就在这里发挥作用。
定义
MVP 设计模式涉及将通常属于 UI 组件(视图)的业务逻辑委托给一个名为呈现器的专用类。该呈现器与模型交互以验证规则或调用更复杂的服务,然后根据需要更新 UI。因此,视图成为一个简单的实体,仅在必要时与呈现器通信:它是一个朴素对象。
朴素对象 (Humble Object) 是一种以经济高效的方式使这些难以实例化的对象的逻辑可测试的方法。
Meszaros xUnit Test Patterns
什么是模型?
这个主题无需详述:模型只是编程语言中域的表示。它包含了业务规则以及不同类之间存在的复杂逻辑。
重要
在微服务架构中,每个有界上下文(微服务)通常都有自己的模型。因此,UI 组件可能需要显示来自多个模型的数据。
模型通常很容易测试。
什么是视图?
如果模型未被其他客户端显示或使用,那么它的作用很小。在这种情况下,视图的职责是允许用户通过各种视觉元素(如文本框、按钮和表单)与模型进行交互。这些组件共同构成了 UI。

视图可以包含复杂的逻辑:即使在我们简单的例子中,“提交”按钮也必须从两个文本框获取值,验证身份验证,并据此显示消息。这种业务逻辑与管理视觉效果的代码交织在一起。

我们可以在这里结束并继续这种方法:对于小型应用程序或概念验证,仅拥有模型和与之交互的视图可能就足够了。然而,需要注意的是,这种方法可能会带来一些问题,如前所述。其他关注点也属于这个主题:例如,视图经常需要处理多线程。这些因素使得 UI 变得高度不可预测,因此非常难以测试。
什么是呈现器?
呈现器的作用是专门将业务逻辑卸载到单独的类中,从而使测试更加顺畅。视觉布局保留在视图中,并且仍然应该使用传统的 UI 测试工具进行测试。
需要记住的是
呈现器使视图能够卸载使之复杂的业务逻辑,使其只专注于视觉效果。

呈现器充当模型和视图之间的中介。此外,呈现器可以根据需要更新视图,无论是基于其自身决策还是模型的变化。重要的是,呈现器可以在测试类中实例化,从而促进测试并确保业务逻辑得到正确验证。
重要
视图不再直接与模型交互,也不再了解模型。取而代之的是,呈现器处理与模型的通信并提取必要的信息。
在简要概述了 MVP 模式后,现在让我们更详细地研究它并将其付诸实践。
我们将演示如何实现 MVP 模式,并遵循分步方法,提供一个彻底且全面的概述。
此 MVP 模式将使用 Blazor 应用程序实现,但它自然也可以应用于任何其他编程语言。
建立环境
我们将在 Visual Studio 2022 IDE 中配置一个标准的 Blazor 环境。这个基本应用程序将作为我们逐步阐明底层概念的基础。
- 例如,创建一个名为 _EOCS.ModelViewPresenter_ 的新解决方案,并在其中创建一个名为 _EOCS.ModelViewPresenter.UI_ 的新 Blazor Web App 项目。添加信息时,请确保选择“无”作为身份验证类型,“服务器”作为交互式渲染模式,并选中“包含示例页面”。

- 运行程序并验证可以访问所有路由。

信息
在本系列中,我们使用 .NET 8 版本的 .NET 框架。
微软提供的默认示例非常适合演示 MVP 模式如何有效地实现以增强可维护性和可测试性。有两个 URL 特别令人感兴趣,我们将在此基础上重构代码。
计数器页面
计数器页面非常简单:它有一个按钮,每次单击时都会增加计数器。

代码也非常简单。
@page "/counter"
@rendermode InteractiveServer
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
这段代码本身并没有什么问题,只是它将通用布局(带有位于左侧的按钮和下方显示消息的标签)与业务逻辑(具体来说,单击按钮时,标签必须显示增量)混合在一起。**因此**,独立测试此业务逻辑并确保在代码被另一位开发人员修改时避免潜在的回归变得具有挑战性。
天气页面
天气页面更复杂,但仍然相对简单。这里的关键补充是从数据存储检索数据(Web 应用程序中的常见操作)。然而,与计数器页面一样,业务逻辑仍然与通用布局交织在一起。

我们将从重构计数器页面的代码开始,而天气页面的重构将在下一篇文章中介绍。
添加视图
-
复制现有的 Counter.razor 文件,并将副本重命名为 CounterModified.razor。
-
添加一个新类并将其命名为 CounterModified.razor.cs。该文件应直接位于 Visual Studio 中相应类的下方。
-
在 CounterModified.razor.cs 文件中,添加以下代码。
public partial class CounterModifiedView : ComponentBase
{
public int CurrentCount { get; set; }
protected override async Task OnInitializedAsync()
{
}
public void IncrementCount()
{
}
}
这段代码将作为视图,正如我们所见,它非常简洁。
提示
我们正在利用 Visual Studio 的功能来组织我们的项目,同时还利用特定的 ASP.NET 页面生命周期(OnInitializedAsync 方法)来增强我们的开发过程。这段代码应该针对每种编程语言进行定制;然而,潜在的理念保持一致。
- 修改 CounterModified.razor 文件。
@page "/countermodified"
@rendermode InteractiveServer
@inherits CounterModifiedView
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @CurrentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
<p>@WarningMessage</p>
信息
在我们的实现中,视图最终由两个文件组成:一个关注通用布局,包括样式和 JavaScript(CounterModified.razor),而另一个则专门用于业务逻辑(CounterModified.razor.cs)。
现在是时候深入核心问题,探讨呈现器如何构建业务逻辑。
添加呈现器
目前,我们的代码无效,因为单击按钮不会触发任何操作。
- 在 CounterModified.razor.cs 文件中添加一个名为 CounterModifiedPresenter 的新类。
public class CounterModifiedPresenter
{
private CounterModifiedView _view;
private readonly object _lock = new object();
public CounterModifiedPresenter(CounterModifiedView view)
{
_view = view;
}
public int CurrentCount { get; private set; } = 0;
public void IncrementCount()
{
lock (_lock)
{
CurrentCount++;
_view.SetCurrentCount(CurrentCount);
}
}
}
正如我们在上一篇文章中所述,呈现器负责组织业务逻辑。具体来说,在我们的场景中,它必须确保在单击按钮时正确地增加计数器。这项任务由 _IncrementCount_ 方法处理。在这种情况下,逻辑非常简单:_IncrementCount_ 方法只需要在 _CurrentCount_ 属性上加一。
此外,我们可以看到,在呈现器增加计数器后,它还会更新视图。因此,呈现器必须拥有视图的引用,该引用通过构造函数传递。因此,必须修改视图的代码以正确初始化呈现器,并确保在需要更新时通知它。
public partial class CounterModifiedView : ComponentBase
{
protected CounterModifiedPresenter _presenter;
public int CurrentCount { get; set; }
protected override async Task OnInitializedAsync()
{
_presenter = new CounterModifiedPresenter(this);
}
public void IncrementCount()
{
_presenter.IncrementCount();
}
public void SetCurrentCount(int currentCount)
{
CurrentCount = currentCount;
}
}
信息
在这个例子中,我们可以看到视图如何将所有业务逻辑委托给第三方,仅专注于显示组件。
- 运行程序。
正如预期的那样,行为与之前相同。然而,我们现在获得了一个重要的新功能,我们将在下一节中进行演示。
添加测试
检查现有功能是否按预期工作
现在是努力的成果:我们可以测试页面的业务逻辑,而无需依赖 Selenium 等 UI 工具。相反,我们可以使用我们传统上使用的熟悉的测试工具(在本例中为 NUnit)。
-
添加一个新的 NUnit 测试项目,例如命名为 _EOCS.ModelViewPresenter.UI.Tests_。
-
添加对 EOCS.ModelViewPresenter.UI 项目的引用。
-
添加一个名为 CounterModifiedPresenterTests.cs 的新类。
-
在此类中添加以下测试。
public class CounterModifiedPresenterTests
{
[Test]
public void Check_CounterIsIncremented_WhenIncrementButtonIsClicked()
{
// Arrange
var view = new CounterModifiedView();
var presenter = new CounterModifiedPresenter(view);
// Act
presenter.IncrementCount();
// Assert
Assert.AreEqual(1, presenter.CurrentCount);
Assert.AreEqual(1, view.CurrentCount);
}
}
**此测试使我们能够验证 IncrementCount 方法是否按预期工作。** 同时,我们检查视图是否正确显示更新后的值。
编写新测试
我们现在可以采用更具测试驱动性的开发方法,在实现功能之前编写测试。例如,考虑一个业务规则,要求在单击按钮两次时显示警告消息。

- 在 CounterModifiedPresenterTests.cs 文件中添加以下测试。
[Test]
public void Check_WarningMessageIsShown_WhenIncrementButtonIsClickedTwoTimes()
{
// Arrange
var view = new CounterModifiedView();
var presenter = new CounterModifiedPresenter(view);
// Act
presenter.IncrementCount();
presenter.IncrementCount();
// Assert
Assert.AreEqual(2, presenter.CurrentCount);
Assert.AreEqual(2, view.CurrentCount);
Assert.AreEqual("Warning", view.WarningMessage);
}
这段代码只是将我们刚刚用英语概述的内容翻译成 C#。当然,由于此时某些方法不存在,此测试将会失败。
- 修改 CounterModifedView 类。
public partial class CounterModifiedView : ComponentBase
{
protected CounterModifiedPresenter _presenter;
public int CurrentCount { get; set; }
public string WarningMessage { get; set; }
protected override async Task OnInitializedAsync()
{
_presenter = new CounterModifiedPresenter(this);
}
public void IncrementCount()
{
_presenter.IncrementCount();
}
public void SetCurrentCount(int currentCount)
{
CurrentCount = currentCount;
}
public void DisplayWarningMessage(string message)
{
WarningMessage = message;
}
}
请注意,在视图中,我们只添加了一些简单的 getter 和 setter,仅此而已。本质上,视图变成了一个贫血对象。
- 修改 CounterModified.razor 文件。
@page "/countermodified"
@rendermode InteractiveServer
@inherits CounterModifiedView
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @CurrentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
<p>@WarningMessage</p>
- 修改 CounterModifiedPresenter 类。
public class CounterModifiedPresenter
{
private CounterModifiedView _view;
private readonly object _lock = new object();
public CounterModifiedPresenter(CounterModifiedView view)
{
_view = view;
}
public int CurrentCount { get; private set; } = 0;
public void IncrementCount()
{
lock (_lock)
{
CurrentCount++;
_view.SetCurrentCount(CurrentCount);
if (CurrentCount >= 2)
{
_view.DisplayWarningMessage("Warning");
}
}
}
}
我们可以看到,由呈现器负责执行业务规则;业务逻辑最终是在呈现器中实现的。
所有测试现在都通过了。

在用一个简单的例子演示了 MVP 模式后,我们现在将探讨如何将其应用于天气页面上更复杂但仍然简单的场景。为了避免使本文过长,有兴趣了解此实现方式的读者可以在 这里 找到后续内容。