VCMBox,一个 MVC - FuseBox 混合框架






4.54/5 (8投票s)
一个集 MVC 与 FuseBox 混合模式于一身的 .NET 框架,使用 C# 实现,并基于 Northwind 数据库。
引言
在我当前项目的背景下,我需要审查、模板化并重构(如果需要,而且确实需要)一个设计模式,以便所有编程团队都能在其上工作,并且该模式能与我们的 GUI 设计协同工作。所演变出的模式源自文档充分且广受讨论的 MVC 框架,以及在较小程度上源自 Cold Fusion 社区的 FuseBox 方法论。在此需要说明一点:我不会在这里过多讨论 MVC 或 FuseBox 的背景或理论,假设读者已经具备了相关背景知识,但以防万一,这里提供了一些链接,希望在继续阅读之前对某些人有所帮助。
本文档讨论并用代码示例强调了如何实现该框架,但我强烈建议下载代码本身以及代码的 Ndoc 文档以进一步研究。教程解决方案中的代码量实在太大,无法在此一一讨论。
问题与从 MVC 衍生模式
如上所述,问题的一部分包括需要以一种高度组件化的方式设计应用程序,允许开发团队独立工作(遵循模式),然后提交他们的组件进行集成。不花太多时间讨论集成问题,只想说的是,每个开发人员都会被分配应用程序的特定高级功能部分,简而言之,他们负责构建其指定功能区域的视口、控制器、模型和数据访问层。每隔几周,有时是几天,所有开发人员都会提交他们的组件进行集成,然后进行完整的构建。
从一开始就引起我注意的是模型中可能存在的冗余。通常,模型会包含建模解决方案所代表的业务或“域”的类对象。然而,由于数据绑定和诸如 `DataSet` 和 `DataTable` 等数据建模对象的使用所带来的效率和优势,我决定在模型中取消使用用户定义类来表示“域”数据。因此,“模型”的概念得到了调整,强类型 `DataSet` 取代了过去建模域数据所需的任何用户定义类。如果我坚持使用用户定义类来表示域数据,那么仍然需要使用 `DataSet`、`DataTable` 和 `DataReader` 等对象作为检索数据的临时容器。模型仍然需要支持一组操作(方法),因此需要一个公开所需行为和模式的类,这就是这个“衍生”模式将要要求的。模型的演进最终以一个封装了强类型 `DataSet`、用于辅助数据检索的数据访问层,并公开了 MVC 实现中通常发现的操作行为和计算的用户定义类的形式完成。图 1.0 展示了公开可见的 `Domain` 对象,“dal”(数据访问层)以及一个名为 `GetSomeDataById()` 的行为。
图 1.0
这张图描绘了构成该模式的每个主要工作单元之间的基本关系。`ViewPort` 是一个派生自 `UserControl` 的类,代表应用程序中的多个屏幕或视口之一。`Controller` 接受消息或服务请求,然后将这些请求转达给 `Model`。`Model` 类封装了一个强类型的 `DataSet`(建模域数据)和一个数据访问层(DAL)的实例,该 DAL 是 `SqlClient` Managed Provider 命名空间中对象的容器。所有 DAL 都可以轻松地包含在 `Model` 中,但以这种方式解耦,可以更轻松地在视觉上管理大量的 `SqlClient` 对象(在 VS.NET 中),并且还可能在不依赖 `Model` 的情况下重用数据访问层。
图 2.0
MVC 是“一成不变”的吗?
请牢记,这个模式**不**严格遵循 MVC 或 FuseBox。它经过了调整和调整,以更好地适应这个特定的项目,并消除了许多对于这类 .NET WinForms 桌面应用程序而言本可省略的冗余或冗长。多年来,MVC 框架已有无数种实现方式,随着新编程语言的出现,也出现了大量的衍生品。如上图(图 2)所示,控制器通常会接受用户的事件输入,然后指示模型和视口采取适当的数据相关操作。我们衍生的模式消除了 MVC 中视图和控制器之间的双向通信,并且还取消了控制器直接处理“键盘”或“鼠标”消息的责任,这主要是因为通过 Windows Forms、用户控件以及 `System.Windows.Forms` 命名空间中常见控件的观察者模式的使用,视图会不断地收到这些事件的通知。MVC 与“衍生”模式之间的区别在图 2.0 和图 3.0 之间得到了很好的体现。
图 3.0
从图 3.0 可以看出,我们的衍生模式在很大程度上遵循了 MVC 的基本原则。
- MVC 原则:模型不应该知道可能观察它的视图。衍生模式规定,从视图请求添加或更改模型中的域数据的通信通过控制器进行,任何对此域数据更改的通知都通过事件通知传播回订阅模型中这些事件的任何视图,此外,视图可以随时请求域(模型数据)的状态。
- MVC 原则:视口知道它所观察的模型的性质。
- MVC 原则:视口知道控制器。(我们的衍生模式只使用控制器的一个方法——`ControlHub()`,这是一个接受请求消息的静态方法)。
- MVC 原则:控制器知道模型和视口。这就是我们的衍生与经典 MVC 的不同之处。衍生模式的控制器根本不了解视口,这进一步增强了控制器的可解耦性,表明其易于替换的潜力。
图 4.0(“衍生模式”的顺序图)
那么这一切在代码中是什么样的呢?
好的,我能听到一些声音说“让我们看看用代码示例实现是什么样子的”。那么,废话不多说,我们开始吧。接下来代码列表和这里可供下载的源代码中包含了一个可工作的“衍生模式”示例,该示例使用了无处不在的 Northwind 数据库(SQL Server 2000 版本——MSDE 即可),因此我们将使用 SqlClient Managed provider。
下载的项目演示了该模式的完整设计应用,该设计已应用于 Northwind Traders Domain 的一个有限解决方案。我还包含了使用 Ndoc 生成的解决方案的完整文档,本文档作为“概述”页面嵌入其中。构成该模式的代码包括五个独立的程序集,每个程序集都实现了解决方案的一部分。这五个程序集如下,我们将逐一讨论:
- Harness 程序集。
- ViewPorts 程序集。
- Controller 程序集。
- Model 程序集。
- Data Access Layer 程序集。
Harness 程序集可以被认为是另一个 ViewPort 程序集,它基本上包含并渲染了 ViewPorts 程序集中包含的视口。在本教程中,Harness 程序集由一个 MDI 父窗体和一个子窗体组成,后者将包含来自 ViewPorts 程序集的视口。
现在,我们将通过一些代码来演示如何实现此模式的一个示例,并且我将遵循图 4.0 中包含的顺序图所描绘的工作流程。
Harness
应注意,并非所有 Harness 窗体都必须遵循此设计。
图 5.0
Harness 程序集中的主要关注点在于 MDI 子窗体的性质。在此实现中,MDI 子窗体包含 Model、Controller 和所有可在 Workspace 面板中显示的视口的实例。MDI 子窗体的构造函数初始化 Model、Controller 和 ViewPorts,因此每个 MDI 窗体及其视口都处理相同的 Model 和 Controller,这使得我们的应用程序能够包含 (n) 个 MDI 子窗体,每个子窗体都有不同的数据上下文。
列表 1.0
public frmNwindMdiChild()
{
InitializeComponent();
//create a new instance of the model
NwindModel = new Model();
//let the controller use the same model by
//passing through a reference to the model used
//by each instance of the MDI child form Viewport...
NwindController = new Controller(NwindModel);
//a handler in the MDI child is subscribed to
//the Models TreeLoaded Event
NwindModel.TreeLoaded += new DataFetchHandler(NwindModel_TreeLoaded);
//subscribe to the treeview afterselect event
this.tvwNwindEmployees.AfterSelect +=
new TreeViewEventHandler(tvwNwindEmployees_AfterSelect);
//create the new viewports to work in the workspace
eHeaderPort = new NorthWindHeader();
eDatailsPort = new EmployeeDetails(NwindController, NwindModel);
eOrderDetailsPort = new OrderDetails(NwindController, NwindModel);
}
从上面可以看出,Model 的 `TreeLoaded` 事件已通过处理程序方法 `NwindModel_TreeLoaded` 进行了订阅,当我们的应用程序请求创建新的 MDI 子窗体作为其 `Load` 方法的结果时,将引发该事件。
private void frmNwindMdiChild_Load(object sender, System.EventArgs e)
{
NwindController.ControlHub (
Mvc.DerivedPattern.NorthWind.Tutorial.Controllers.
Controller.NorthWindControlMessages.LoadTreeData);
}
因此,Controller 将向 Model 请求服务,Model 将引发事件,该事件将通知观察到的 MDI 子窗体 TreeView 的数据已加载。因此,将执行我们上面定义的处理程序。
private void NwindModel_TreeLoaded()
{
//The Model is informing the Viewport that
//data has possibly been found
//And if there are any employees then create
//the root node and let the tree begin to enumerate.
if(NwindModel.Employee.tblLoadAllEmployees.Rows.Count > 0)
{
//This will trigger a recursive building of the TreeView Nodes
EmployeesNode root = new EmployeesNode(this.tvwNwindEmployees);
}
}
如何显示视口
我们 MDI 子窗体上的 `TreeView` 应包含三种可能的(强类型)树节点,它们都已从 `TreeNode` 进行了子类化,因此我们的处理程序(参见上面的列表 1.0)将评估已选择的 `TreeNode` 类型,以确定显示哪个视口。
列表 2.0
private void tvwNwindEmployees_AfterSelect(object sender,
TreeViewEventArgs e)
{
switch(e.Node.GetType().FullName)
{
case "Mvc.DerivedPattern.NorthWind.Tutorial." +
"Harness.frmNwindMdiChild+EmployeesNode" :
this.pnlEmployeeWorkspace.Controls.Clear();
this.pnlEmployeeWorkspace.Controls.Add(eHeaderPort);
eHeaderPort.Show();
break;
case "Mvc.DerivedPattern.NorthWind.Tutorial." +
"Harness.frmNwindMdiChild+EmployeeNode" :
this.pnlEmployeeWorkspace.Controls.Clear();
this.pnlEmployeeWorkspace.Controls.Add(eDatailsPort);
eDatailsPort.Show(((EmployeeNode)e.Node).Id);
break;
case "Mvc.DerivedPattern.NorthWind.Tutorial." +
"Harness.frmNwindMdiChild+OrderNode" :
this.pnlEmployeeWorkspace.Controls.Clear();
this.pnlEmployeeWorkspace.Controls.Add(eOrderDetailsPort);
eOrderDetailsPort.Show(((OrderNode)e.Node).Id);
break;
default : //shouldnt happen so do nothing
break;
}
}
一旦评估了正确的节点类型,就会调用相应视口的 `Show()` 方法。请注意,`Show` 方法是 `UserControl` 类(每个视口都派生自该类)的继承方法的重写版本。我们稍后将详细讨论 `Show` 方法。
视口
除了构成 Harness 的窗体之外,视口还包括 `UserControl` 类的子类。
图 6.0
上面描绘的视口由一个 `GroupBox` 和一组 `Label` 控件、`TextBox` 控件、`Button` 控件和一个自定义的 `DateTimePicker` 子类组成,所有这些都将绑定到 Model 中的 `DataTable`。每个视口(包括上面这个)都需要实现 `IViewPort` 接口,其定义如下:
列表 3.0
interface IViewPort
{
void PopulateViewPort();
void Show(int id);
void SetUpBindings();
}
`Show()` 方法是 `UserControl` 类的一个固有方法的新的实现,其唯一偏差在于它期望的参数类型为 int
。`Show()` 方法的实现如下:
public void Show(int id)
{
LoadEmployeeDetails(id);
base.Show();
}
在 `Show` 方法实现的主体中调用了一个 Controller 调用 Worker 方法。
private void LoadEmployeeDetails(int id)
{
//set the parameter expected in the dal
NwindModel.EmployeeDal.sqlCmdSelectEmployeeDetails.
Parameters["@EmployeeID"].Value = id;
//Call the Controller with a request message
this.NwindController.ControlHub(
Mvc.DerivedPattern.NorthWind.Tutorial.Controllers.
Controller.NorthWindControlMessages.LoadEmployeeDetails);
}
`Show()` 方法调用一个私有的 Worker 方法(`LoadEmployeeDetails`),该方法将 NorthWind 员工 ID 分配给一个参数,然后 Worker 方法调用 Controller 并发送一条消息以检索具有匹配 ID 的员工的域数据。最后,调用基类 `Show` 方法以渲染视口。
`PopluateViewPort()` 包含通过绑定到 Model 中的数据来填充视口所需的代码,并且必须遵守 Model 中定义的委托签名,由
public delegate void DataFetchHandler();
在此实现中,Model 的所有事件都属于 `DataFetchHandler` 类型。
`PopulateViewPort` 被订阅为 Model 中在请求数据并找到数据后引发的事件的处理程序,随后,视口的 constituent 控件被绑定到 Model 中的 `DataTable` 之一。`PopulateViewPort` 的典型实现如下:
void IViewPort.PopulateViewPort()
{
foreach(Control ctl in this.grpEmployeeDetails.Controls)
{
ctl.DataBindings.Clear();
}
boundRow = (DataRowView)
this.BindingContext[NwindModel.Employee.tblSelectEmployee].Current;
((IViewPort)this).SetUpBindings();
}
`PopulateViewPort` 首先清除 `GroupBox` 的 `Controls` 集合中每个 constituent 控件的绑定,通过视口的 `BindingContext` 获取 Model 中的当前数据行,然后调用 `SetUpBindings()`。
`SetUpBindings` 方法简单地将视口上的控件绑定到 Model 中的数据。
void IViewPort.SetUpBindings()
{
this.txtId.DataBindings.Add("Text", boundRow, "EmployeeID");
this.txtTitle.DataBindings.Add("Text", boundRow, "TitleOfCourtesy");
this.txtFirstName.DataBindings.Add("Text", boundRow, "FirstName");
this.txtLastName.DataBindings.Add("Text", boundRow, "LastName");
this.txtPosition.DataBindings.Add("Text", boundRow, "Title");
this.dtpBirthDate.DataBindings.Add("Value", boundRow, "BirthDate");
this.dtpHireDate.DataBindings.Add("Value", boundRow, "HireDate");
this.txtAddress.DataBindings.Add("Text", boundRow, "Address");
this.txtCity.DataBindings.Add("Text", boundRow, "City");
this.txtRegion.DataBindings.Add("Text", boundRow, "Region");
this.txtPostCode.DataBindings.Add("Text", boundRow, "PostalCode");
this.txtCountry.DataBindings.Add("Text", boundRow, "Country");
this.txtExt.DataBindings.Add("Text", boundRow, "Extension");
this.txtNotes.DataBindings.Add("Text", boundRow, "Notes");
}
控制器
衍生模式的 Controller 是视口在数据检索和更改请求方面的单一通信点。由于视口与 Controller 之间是一对一的关系,消息类型也在 Controller 中以枚举类型的形式定义,并且唯一的方法或行为是 `ControlHub`,它评估消息并指示 Model 执行所请求的服务。控制器上的这种特定倾斜与那些控制器直接响应事件的实现有些不同,它更接近于 Cold Fusion 或 ASP 的 FuseBox 实现,后者会评估 FuseActions。关于 FuseBox 的更多信息,请参阅 此 PPT 演示文稿 或 fusebox.org。
列表 4.0(控件消息枚举与 ControlHub)
public enum NorthWindControlMessages : int
{
LoadTreeData = 0,
LoadEmployeeDetails = 1,
LoadOrderDetails = 2,
UpdateEmployeeDetails = 3,
UpdateOrder = 4
}
`ControlHub` 的主体根据消息进行 switch 操作,然后对 Model 发起行为(方法)请求。
public void ControlHub(NorthWindControlMessages msg)
{
switch(msg)
{
case NorthWindControlMessages.LoadTreeData :
this.EmployeeModel.LoadTreeData();
break;
case NorthWindControlMessages.LoadEmployeeDetails :
this.EmployeeModel.LoadEmployeeDetails();
break;
case NorthWindControlMessages.LoadOrderDetails :
this.EmployeeModel.LoadOrderDetails();
break;
case NorthWindControlMessages.UpdateEmployeeDetails :
this.EmployeeModel.UpdateEmployeeDetails();
break;
case NorthWindControlMessages.UpdateOrder :
this.EmployeeModel.UpdateOrder();
break;
}
}
Model 和数据访问层
如前所述,Model 类充当表示域模型的强类型 `DataSet` 的包装器,封装了提供数据操作机制的数据访问层,最后包含利用这两者的行为。
图 7.0
对于那些习惯使用 Visual Studio .NET 的用户来说,图 7.0 展示了使用强类型 `DataSet` 来表示域模型,在本例中,它包含五个 `DataTable`。如果您回顾列表 3.0 中的代码,并特别关注 `PopulateViewPort` 方法的实现,您会注意到视口直接与 Model 交互以检索 `DataRowView` 对象,然后使用该对象进行数据绑定。然而,Model 的行为通过填充数据或在内容发生更改时修改数据库来操作域模型中的 `DataTable`,作为由视口发出的请求。为了与迄今为止呈现的代码流程保持一致,我们将检查 Model 内部的一些行为操作。
列表 5.0(Model 行为)
public void LoadEmployeeDetails()
{
Employee.tblSelectEmployee.Clear();
Employee.tblSelectEmployee.AcceptChanges();
employeeDal.sqlConNwind.Open();
//set up the Select Command
employeeDal.sqlDadMultiUse.SelectCommand =
employeeDal.sqlCmdSelectEmployeeDetails;
//set up the Update Command
employeeDal.sqlDadMultiUse.UpdateCommand =
employeeDal.sqlCmdUpdateEmployee;
//Update Commands parameters
employeeDal.sqlCmdUpdateEmployee.Parameters.Clear();
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@EmployeeID",
SqlDbType.Int, 4, ParameterDirection.Input,
false, new byte(), new byte(),
Employee.tblSelectEmployee.Columns["EmployeeID"].
ColumnName, DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@FirstName",
SqlDbType.NVarChar, 10, ParameterDirection.Input, false ,
new byte(), new byte(), Employee.tblSelectEmployee.
Columns["FirstName"].ColumnName,
DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@LastName",
SqlDbType.NVarChar, 20, ParameterDirection.Input,
false, new byte(), new byte(),
Employee.tblSelectEmployee.Columns["LastName"].
ColumnName, DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@TitleOfCourtesy",
SqlDbType.NVarChar, 25, ParameterDirection.Input,
true, new byte(), new byte(),
Employee.tblSelectEmployee.Columns["TitleOfCourtesy"].
ColumnName, DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@Title",
SqlDbType.NVarChar, 30, ParameterDirection.Input,
true, new byte(), new byte(),
Employee.tblSelectEmployee.Columns["Title"].ColumnName,
DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@BirthDate",
SqlDbType.DateTime, 16, ParameterDirection.Input,
true, new byte(), new byte(),
Employee.tblSelectEmployee.Columns["BirthDate"].
ColumnName, DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@HireDate",
SqlDbType.DateTime, 16, ParameterDirection.Input, true,
new byte(), new byte(), Employee.tblSelectEmployee.
Columns["HireDate"].ColumnName, DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@Address",
SqlDbType.NVarChar, 60, ParameterDirection.Input,
true, new byte(), new byte(), Employee.tblSelectEmployee.
Columns["Address"].ColumnName, DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@City",
SqlDbType.NVarChar, 15, ParameterDirection.Input,
true, new byte(), new byte(), Employee.tblSelectEmployee.
Columns["City"].ColumnName, DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@Region",
SqlDbType.NVarChar, 15, ParameterDirection.Input,
true, new byte(), new byte(), Employee.tblSelectEmployee.
Columns["Region"].ColumnName, DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@PostalCode",
SqlDbType.NVarChar, 10, ParameterDirection.Input,
true, new byte(), new byte(), Employee.tblSelectEmployee.
Columns["PostalCode"].ColumnName, DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@Country",
SqlDbType.NVarChar, 15, ParameterDirection.Input,
true, new byte(), new byte(), Employee.tblSelectEmployee.
Columns["Country"].ColumnName, DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@HomePhone",
SqlDbType.NVarChar, 24, ParameterDirection.Input,
true, new byte(), new byte(), Employee.tblSelectEmployee.
Columns["HomePhone"].ColumnName, DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@Extension",
SqlDbType.NVarChar, 4, ParameterDirection.Input,
true, new byte(), new byte(), Employee.tblSelectEmployee.
Columns["Extension"].ColumnName, DataRowVersion.Current, null));
employeeDal.sqlCmdUpdateEmployee.Parameters.Add(
new System.Data.SqlClient.SqlParameter("@Notes",
SqlDbType.NText, 1073741823, ParameterDirection.Input,
true, new byte(), new byte(), Employee.tblSelectEmployee.
Columns["Notes"].ColumnName, DataRowVersion.Current, null));
employeeDal.sqlDadMultiUse.Fill(Employee,
Employee.tblSelectEmployee.TableName);
CloseDalConnection();
//The Model now may have a new employee selected in the viewport
//so raise the event for Viewport that consumes it.
if(EmployeeDetailSelected != null)
{
EmployeeDetailSelected();
}
}
请注意使用了数据访问层实例 `employeeDal` 和域模型实例 `Employee`。
数据访问层
数据访问层类由 `SqlClient` 命名空间中的对象组成,即 `SqlConnection`、`SqlDataAdapter` 和 `SqlCommand`。正如我所提到的,使用组件作为 DAL 中的容器所提供的视觉辅助很有用,而松耦合则允许更大的重用。
图 8.0
如果您往上看一点到列表 5.0,您会注意到 Model 方法正在使用数据访问层对象中的 `sqlCmdSelectEmployeeDetails` 和 `sqlCmdUpdateEmployee`,并填充域模型对象 `Employee`,特别是 `DataTable` `tblSelectEmployee`。
注意
- 本文的**目的仅在于概述**“衍生模式”,并未考虑异常处理和验证等一般编程任务,希望实现该模式的人员应自行考虑这些作为设计问题。
- 我为该框架起的临时名称是**VCMbox**,但我非常感谢任何不太“俗气”或“可预测”的建议,所以请不要害羞。
最后
在尝试使示例代码正常工作时需要考虑的一些事项:
- 该解决方案使用 Visual Studio 2003 创建,并且 SQL Server 2000 中的 NorthWind 数据库,乍一看,其架构与早期版本的 SQL Server 似乎有所不同。我还没有机会检查 7.0 及以下版本,因此,如果架构确实不同,则应用程序可能有效,也可能无效。因此,我建议读者在计划评估代码库的机器上安装 SQL Server 2000 评估版。
- 示例解决方案不适用于 NorthWind 的 Access 版本,仅提供了 SqlClient 受管提供程序。
- 如果遇到代码库与文档之间的任何不一致之处,请告知我,我会进行更新。
- 请记住将连接字符串更改为适当的设置。请参阅数据访问层项目,并在构造函数中进行设置。