说到自治视图,可能很多人会感到模式,但是我想很多人(尤其是.NET开发人员)可能经常在采用这种模式来设计我们的应用。Windows Forms和ASP.NET Web Forms虽然分别属于GUI和Web开发框架,但是它们都采用了事件驱动的开发方式。所有与UI相关的逻辑都可以定义在针对视图(Windows Form或者Web Form)的后台代码(Code Behind)中,并最终注册到视图本身或者视图元素(控件)的相应事件上。
一个典型的人机交互应用具有三个主要的关注点,即数据在可视化界面上的呈现、UI处理逻辑(用于处理用户交互式操作的逻辑)和业务逻辑。对于自治视图模式来说,它实际上这三种混合在一起,势必会带来如下一些问题:
首先,业务逻辑是与UI无关的,应该最大限度地被重用。由于业务逻辑定义在自治视图中,相当于完全与视图本身绑定在一起。如果我们能够将UI的行为抽象出来,基于抽象化UI的处理逻辑也是可以被共享的,定义在自治视图的UI处理逻辑完全丧失了重用的可能。
其次,业务逻辑具有最强的稳定性,UI处理逻辑次之,而可视化界面上的呈现最差,比如我们经常会为了更好的呈现效果来调整HTML。将具有不同稳定性的元素融为一体,具有最差稳定性的元素决定了整体的稳定性,这是“短板理论”在软件设计中的体现。
再次,任何涉及到UI的组件都不易测试。UI是呈现给人看的,并且用于与人进行交互,用机器来模拟活生生的人来对组件实施自动化测试不是一件容易的事,自治视图严重损害了组件的可测试性。
为了解决自治视图导致的这些问题,我们需要采用采用关注点分离(SoC, Seperation of Concerns)的方针将可视化界面呈现、UI处理逻辑和业务逻辑三者分离出来,并且采用采用合理的交互方式将它们之间的影响降到最低。由于将三者“分而治之”,自然也使UI逻辑和业务逻辑编程的容易被测试的组件,使测试驱动设计与开发变成了可能。这里用于进行关注点分离的模式就是MVC。
MVC的创建者是Trygve M. H. Reenskau,他是挪威的计算机专家,同时也是奥斯陆大学的名誉教授。MVC是他在1979年访问施乐帕克研究中心(Xerox PARC,Xerox Palo Alto Research Center)期间是提出一种主要针对GUI应用的软件架构模式。MVC最初用于SmallTalk,Trygve最初对MVC的描述记录在《Applications Programming in Smalltalk-80(TM):
How to use Model-View-Controller (MVC)》这篇论文中,有兴趣的读者可以通过地址http://st-www.cs.illinois.edu/users/smarch/st-docs/mvc.html阅读这篇论文。MVC体现了关注点分离这一基本的设计方针,它将构成一个人机交互应用涉及到的功能分为Model、Controller和View三部分,三者各自具有的基本职责或者功能范围如下:
下图揭示了MVC模式下Model、View和Controller之间的交互。对于传统的MVC模式,很多人认为Controller仅仅是View和Model之间的中介,实则不然,View和Model存在直接的联系。View可以直接调用Model查询其状态信息。当Model状态发生改变的时候,它也可以直接通知View。比如在一个提供股票实时价位的应用,维护股价信息的Model在股价变化的情况下可以直接通知相关的View改变其显示信息。
我看到很多人将MVC和所谓的“三层架构”进行比较,其实两者并没有什么可比性,MVC更不是分别对应着UI、业务逻辑和数据存取三个层次。不过两者也不能说完全没有关系,我们现在就来讨论这个问题。
Trygve M. H. Reenskau当时提出MVC的时候实际上将其作为构建整个GUI应用的架构模式,而Model维护着整个应用的状态并实现了所有的业务逻辑,它更多地体现为一个领域模型。而对于多层架构来说(比如我们经常提及的三层架构),MVC是被当成是UI呈现层(Presentation Layer)的设计模式,而Model则更多地体现为访问业务层的入口(Gateway)。如果采用面向服务的设计,将业务功能定义成相应服务并通过接口(契约)的形式暴露出来,这里的Model甚至还可以表示成进行服务调用的代理。
MVP是一种广泛使用的基于架构模式,使用与基于事件驱动的应用框架,比如ASP.NET Web Forms和Windows Forms应用。MVP中的M和V对应中MVC的Model和View,而P(Presenter)则自然代替了MVC中的Controller。但是MVP并非仅仅体现在从Controller到Presenter的转换,更对地体现在Model、View和Presenter之间的交互上。
MVC模式中元素之间混乱的交互只要体现在允许View和Model绕开Controller进行单独“交流”,这在MVP模式中得到了彻底地解决。如下图所示,能够与Model直接进行交互的仅限于Presenter,View只能间接地通过Presenter调用Model。Model的独立性在这里得到了真正的体现,它不仅仅与可视化元素的呈现无关(View)和与UI处理逻辑(Presenter)无关。使用MVP的应用是用户驱动的而非Model驱动的,所以Model不需要主动通知View以提醒状态发生了改变。
MVP不仅仅避免了View和Model之间的耦合,更进一步地降低Presenter对View的依赖。如图1-2所示,Presenter依赖的是一个抽象化的View,即View实现的接口IView。这带来的最直接的好处就是使定义在Presenter中的UI处理逻辑变得易于测试。由于Presenter对View的依赖行为定义在接口IView中,我们只需要Mock一个实现了该接口的View就能对Presenter进行测试。
构成MVP三要素之间的交互体现在两个方面,即View/Presenter和Presenter/Model。Presenter和Model之间的交互很清晰,仅仅体现在Presenter对Model的单向调用。而View和Presenter之间该采用怎样的交互方式是整个MVP的核心,MVP针对关注点分离的初衷能否体现在具体的应用中很大程度上取决于两者之间的交互方式是否正确。按照View和Presenter之间的交互方式以及View本身的职责范围,Martin Folwer将MVP可分为PV(Passive View)和SoC(Supervising Controller)两种模式。
解决View难以测试的最好的办法就是让它无须测试,如果View不需要测试,其先决条件就是让它尽可能不涉及到UI处理逻辑,而这就是PV模式目的所在。顾名思义,PV(Passive View)是一个被动的View,针对包含其中的UI元素(比如控件)的操作不是由View自身来操作,而交给Presenter来操控。
如果我们纯粹地采用PV模式来设计View,意味着我们需要将定义View中的UI元素通过属性的形式暴露出来。具体来说,当我们在为View定义接口的时候,需要定义基于UI元素的属性以使Presenter可以对View进行细粒度地操作,但这并不是意味着我们直接将View上的控件暴露出来。举个简单的例子,我们开发的HR系统 中具有如下图所示的Web页面用于根据部门获取员工列表。
现在通过ASP.NET Web Form应用来涉及这个页面,我们来讨论一下如果采用PV模式View的接口该如何定义。对于Presenter来说,View供它操作的控件有两个,一个是包含所有部门列表的DropDownList,另一个则是显示员工列表的GridView。在页面加载的时候,Presenter将部门列表绑定在DropDownList上,与此同时包含所有员工的列表被绑定到GridView。当用户选择某个部门并点击“查询”按钮后,View将包含筛选部门在内的查询请求转发给Presenter,后者筛选出相应的员工列表之后将其绑定到GridView。
如果我们为该View定义一个接口IEmployeeSearchView,我们不能像如下的代码所示将上述这两个控件直接以属性的形式暴露出来。针对数据绑定对控件类型的选择属于View的内部细节(比如说针对部门列表的显示,我们可以选择DropDownList也可以选择ListBox),不能体现在表示用于抽象View的接口中。在另一方面,理想情况下定义在Presenter中的UI处理逻辑应该是与具体的技术平台无关的,如果在接口中涉及到了控件类型,这无疑将Presenter也具体的技术平台绑定在了一起。
1: public interface IEmployeeSearchView
2: {
3: DropDownList Departments { get;}
4: GridView Employees { get; }
5: }
正确的接口和实现该接口的View(一个Web页面)应该采用如下的定义方式。Presenter通过属性Departments和Employees进行赋值进而实现对DropDownList和GridView进行绑定,通过属性SelectedDepartment得到用户选择的筛选部门。为了尽可能让接口只暴露必须的信息,我们特意将对属性的读写作了控制。
1: public interface IEmployeeSearchView
2: {
3: IEnumerable<string> Departments { set; }
4: string SelectedDepartment { get; }
5: IEnumerable<Employee> Employees { set; }
6: }
7:
8: public partial class EmployeeSearchView: Page, IEmployeeSearchView
9: {
10: //其他成员
11: public IEnumerable<string> Departments
12: {
13: set
14: {
15: this.DropDownListDepartments.DataSource = value;
16: this.DropDownListDepartments.DataBind();
17: }
18: }
19: public string SelectedDepartment
20: {
21: get { return this.DropDownListDepartments.SelectedValue;}
22: }
23: public IEnumerable<Employee> Employees
24: {
25: set
26: {
27: this.GridViewEmployees.DataSource = value;
28: this.GridViewEmployees.DataBind();
29: }
30: }
31: }
虽然从可测试性的角度来说PV模式是一种不错的选择,因为所有的UI处理逻辑全部定义在Presenter上,意味着所有的UI处理逻辑都可以被测试。但是我们需要将View可供操作的UI元素定义在对应的接口中,对于一些复杂的富客户端(Rich Client)View来说,接口成员将会变得很多,这无疑会提升编程所需的代码量。从另一方讲,由于Presenter需要在控件级别对View进行细粒度的控制,这无疑会提供Presenter本身的复杂度,往往会使原本简单的逻辑复杂化,在这种情况下我们往往采用SoC模式。
在SoC(Supervising Controller)模式下,为了降低Presenter的复杂度,我们将诸如数据绑定和格式化这样简单的UI处理逻辑逻辑转移到View中,这些处理逻辑会体现在View实现的接口中。尽管View从Presenter中接管了部分UI处理逻辑,但是Presenter依然是整个三角关系的驱动者,View被动的地位依然没有改变。对于用户作用在View上的交互操作,View本身并不进行响应,而是直接将交互请求转发给Presenter,后者在独立完成相应的处理流程(可能涉及到针对Model的调用)之后会驱动View或者创建新的View作为对用户交互操作的响应。
View和Presenter之间的交互是整个MVP的核心,能够正确地应用MVP模式来架构我们的应用极大地取决于能够正确地处理View和Presenter两者之间的关系。在由Model、View和Presenter组成的三角关系的核心不是View而是Presenter,Presenter不是View调用Model的中介,而是最终决定如何响应用户交互行为的决策者。
打个比方,View是Presenter委派到前端的客户代理,而作为客户的自然就是最终的用户。对于以鼠标/键盘操作体现的交互请求应该如何处理,作为代理的View并没有决策权,所以它会将请求汇报给委托人Presenter。View向Presenter发送用户交互请求应该采用这样的口吻:“我现在将用户交互请求发送给你,你看着办,需要我的时候我会协助你”,而不应该是这样:“我现在处理用户交互请求了,我知道该怎么办,但是我需要你的支持,因为实现业务逻辑的Model只信任你”。
对于Presenter处理用户交互请求的流程,如果中间环节需要涉及到Model,它会直接发起对Model的调用。如果需要View的参与(比如需要将Model最新的状态反应在View上),Presenter会驱动View完成相应的工作。
对于绑定到View上的数据,不应该是View从Presenter上“拉”回来的,应该是Presenter主动“推”给View的。从消息流(或者消息交换模式)的角度来讲,不论是View向Presenter完成针对用户交互请求的同志,还是Presenter在进行交互请求处理过程中驱动View完成相应的UI操作,都是单向(One-Way)的。反应在 应用编程接口的定义上就意味着不论是定义在Presenter中被View调用的方法,还是定义在IView接口中被Presenter调用的方法最好都是没有返回值得。如果不采用方法调用的形式,我们也可以通过事件注册的方式实现View和Presenter的交互,事件机制体现的消息流无疑是单向的。
View本身仅仅实现单纯的、独立的UI处理逻辑,它处理的数据应该是Presenter实时推送给它的,所以View尽可能不维护数据状态。定义在IView的接口最好只包含方法,而避免属性的定义,Presenter所需的关于View的状态应该在接收到View发送的用户交互请求的时候一次得到,而不需要通过View的属性去获取。
为了让读者对MVP模式,尤其是该模式下的View和Presenter之间的交互方式具有一个深刻的认识,我们现在来进行一个简单的实例演示。本实例采用上面提及的关于员工查询的场景,并且采用ASP.NET Web Form来建立这个简单的应用,最终呈现出来的效果如上图所示。前面我们已经演示了采用PV模式下的IView应该如何定义,现在我们来看看SoC模式下的IView有何不同。先来看看表示员工信息的数据类型如何定义,我们通过具有如下定义的数据类型Employee来表示一个员工。简单起见,我们仅仅定义了表示员工基本信息(ID、姓名、性别、出生日期和部门)的5个属性。
1: public class Employee
2: {
3: public string Id { get; private set; }
4: public string Name { get; private set; }
5: public string Gender { get; private set; }
6: public DateTime BirthDate { get; private set; }
7: public string Department { get; private set; }
8:
9: public Employee(string id, string name, string gender, DateTime birthDate, string department)
10: {
11: this.Id = id;
12: this.Name = name;
13: this.Gender = gender;
14: this.BirthDate = birthDate;
15: this.Department = department;
16: }
17: }
作为包含应用状态和状态操作行为的Model通过如下一个简单的EmployeeRepository类型还体现。如代码所示,表示所有员工列表的数据通过一个静态字段来维护,而GetEmployees返回指定部门的员工列表。如果没有指定筛选部门或者指定的部门字符为空,则直接返回所有的员工列表。
1: public class EmployeeRepository
2: {
3: private static IList<Employee> employees;
4: static EmployeeRepository()
5: {
6: employees = new List<Employee>();
7: employees.Add(new Employee("001", "张三", "男", new DateTime(1981, 8, 24), "销售部"));
8: employees.Add(new Employee("002", "李四", "女", new DateTime(1982, 7, 10), "人事部"));
9: employees.Add(new Employee("003", "王五", "男", new DateTime(1981, 9, 21), "人事部"));
10: }
11: public IEnumerable<Employee> GetEmployees(string department = "")
12: {
13: if (string.IsNullOrEmpty(department))
14: {
15: return employees;
16: }
17: return employees.Where(e => e.Department == department).ToArray();
18: }
19: }
接下来我们来看作为View接口的IEmployeeSearchView的定义。如下面的代码片断所示,该接口定义了BindEmployees和BindDepartments两个方法,分别用于绑定基于部门列表的DropDownList和基于员工列表的DataView。除此之外,IEmployeeSearchView接口还定义了一个事件DepartmentSelected,该事件会在用户选择了筛选部门后点击“查询”按钮时触发。DepartmentSelected事件参数类型为自定义的DepartmentSelectedEventArgs,属性Department表示用户选择部门。
1: public interface IEmployeeSearchView
2: {
3: void BindEmployees(IEnumerable<Employee> employees);
4: void BindDepartments(IEnumerable<string> departments);
5: event EventHandler<DepartmentSelectedEventArgs> DepartmentSelected;
6: }
7:
8: public class DepartmentSelectedEventArgs : EventArgs
9: {
10: public string Department { get; private set; }
11: public DepartmentSelectedEventArgs(string department)
12: {
13: Guard.ArgumentNotNullOrEmpty(department, "department");
14: this.Department = department;
15: }
16: }
作为MVP三角关系核心的Presenter通过具有如下定义的EmployeeSearchPresenter表示。如下面的代码片断所示,表示View的只读属性类型为IEmployeeSearchView接口,而另一个只读属性Repository则表示作为Model的EmployeeRepository对象,两个属性均在构造函数中初始化。
1: public class EmployeeSearchPresenter
2: {
3: public IEmployeeSearchView View { get; private set; }
4: public EmployeeRepository Repository { get; private set; }
5:
6: public EmployeeSearchPresenter(IEmployeeSearchView view)
7: {
8: this.View = view;
9: this.Repository = new EmployeeRepository();
10: this.View.DepartmentSelected += OnDepartmentSelected;
11: }
12: public void Initialize()
13: {
14: IEnumerable<Employee> employees = this.Repository.GetEmployees();
15: this.View.BindEmployees(employees);
16: string[] departments = new string[] { "销售部", "采购部", "人事部", "IT部" };
17: this.View.BindDepartments(departments);
18: }
19: protected void OnDepartmentSelected(object sender, DepartmentSelectedEventArgs args)
20: {
21: string department = args.Department;
22: var employees = this.Repository.GetEmployees(department);
23: this.View.BindEmployees(employees);
24: }
25: }
在构造函数中我们注册了View的DepartmentSelected事件,作为事件处理器的OnDepartmentSelected方法通过调用Repository(即Model)实现了针对所选部门的筛选,而返回的员工列表通过调用View的BindEmployees方法实现了在View上的数据绑定。在Initialize方法中,我们通过调用Repository获取了表示所有员工的列表,并通过View的BindEmployees方法显示在界面上;通过调用View的BindDepartments方法将作为筛选条件的部门列表绑定在View上。
最后我们来看看作为View的Web页面如何定义,如下所示的是作为页面主体部分的HTML,核心部分之包括一个用于绑定筛选部门列表的DropDownList和一个绑定员工列表的GridView。
1: <html xmlns="http://www.w3.org/1999/xhtml">
2: <head runat="server">
3: ...
4: </head>
5: <body>
6: <form id="form1" runat="server">
7: <div id="page">
8: <div class="top">
9: 选择查询部门:
10: <asp:DropDownList ID="DropDownListDepartments" runat="server" />
11: <asp:Button ID="ButtonSearch" runat="server" Text="查询" OnClick="ButtonSearch_Click" />
12: </div>
13: <asp:GridView ID="GridViewEmployees" runat="server" AutoGenerateColumns="false" Width="100%">
14: <Columns>
15: <asp:BoundField DataField="Name" HeaderText="姓名" />
16: <asp:BoundField DataField="Gender" HeaderText="性别" />
17: <asp:BoundField DataField="BirthDate" HeaderText="出生日期" DataFormatString="{0:dd/MM/yyyy}" />
18: <asp:BoundField DataField="Department" HeaderText="部门"/>
19: </Columns>
20: </asp:GridView>
21: </div>
22: </form>
23: </body>
24: </html>
如下所示的是该Web页面的后台代码的定义。它实现了定义在IEmployeeSearchView接口的两个方法(BindEmployees和BindDepartments)和一个事件(DepartmentSelected)。表示Presenter的同名属性在构造函数中被初始化。在页面加载的时候(Page_Load方法)Presenter的Initialize方法被调用,而在“查询”按钮被点击的时候(ButtonSearch_Click)事件DepartmentSelected被触发。
1: public partial class Default : Page, IEmployeeSearchView
2: {
3: public EmployeeSearchPresenter Presenter { get; private set; }
4: public event EventHandler<DepartmentSelectedEventArgs> DepartmentSelected;
5: public Default()
6: {
7: this.Presenter = new EmployeeSearchPresenter(this);
8: }
9: protected void Page_Load(object sender, EventArgs e)
10: {
11: if (!this.IsPostBack)
12: {
13: this.Presenter.Initialize();
14: }
15: }
16: public void BindEmployees(IEnumerable<Employee> employees)
17: {
18: this.GridViewEmployees.DataSource = employees;
19: this.GridViewEmployees.DataBind();
20: }
21: public void BindDepartments(IEnumerable<string> departments)
22: {
23: this.DropDownListDepartments.DataSource = departments;
24: this.DropDownListDepartments.DataBind();
25: }
26: protected void ButtonSearch_Click(object sender, EventArgs e)
27: {
28: string department = this.DropDownListDepartments.SelectedValue;
29: DepartmentSelectedEventArgs eventArgs = new DepartmentSelectedEventArgs(department);
30: if (null != DepartmentSelected)
31: {
32: DepartmentSelected(this, eventArgs);
33: }
34: }
35: }
Trygve M. H. Reenskau当初提出的MVC是作为基于GUI的桌面应用的架构模式,并不太适合Web本身的特性。虽然MVC/MVP也可以直接用于ASP.NET Web Form应用,但这是因为微软基于桌面应用的编程模式 来设计基于Web Form的ASP.NET应用框架的。Web应用不同于GUI桌面应用在于用户是通过浏览器与应用进行交互,交互请求和相应是通过HTTP请求和回复来完成的。
为了让MVC能够Web应用提供原生的支持,另一个被称为Model2 的MVC变体被提出来,Model2来源于基于Java的Web应用架构模式。Java Web应用具有两种基本的架构模式,分别被称为Model1和Model2。Model1类似于我们前面提及的自治试图模式,它将数据的可视化呈现和用户交互操作的处理逻辑合并在一起。Model1使用于那些比较简单的Web应用,对于相对复杂的应用应该采用Model 2。
为了让开发者采用相应的编程模式进行GUI桌面应用和Web应用的开发,微软通过ViewState和Postback对背后的HTTP请求和回复机制进行了封装,使我们能够像编写Windows Forms应用一样采用事件驱动的方式进行ASP.NET Web Forms应用的编程。而Model 2采用完全不同的设计,它让开发者直接面向Web,让他们关注HTTP的请求和回复流程,所以Model 2提供对Web应用原生的支持。
对于Web应用来说,和用户直接交互的UI界面由浏览器来提供,接下来我们详细讨论作为MVC的三要素是如何相互协作对从浏览器发出的用户交互请求的响应的,下图所示的序列图体现了整个流程的全过程。
Model 2种一个HTTP请求的目标是Controller中的某个Action,后者体现为定义在Controller类型中的某个方法,所以对请求的处理最终体现在对Controller对象的激活和对Action方法的执行。一般来说,Controller、Action以及作为Action方法的部分参数(针对HTTP-GET)可以直接通过请求的URL解析出来。
如上图所示,我们通过一个拦截器(Interceptor)对抵达Web服务器的HTTP请求进行拦截。一般的Web应用框架都提供了针对这样一种拦截机制,对于ASP.NET来说,我们可以以HttpModule的形式来定义这么一个拦截器。拦截器根据请求解析出目标Controller和对应的Action,Controller被激活之后Action方法被执行。对于需要传入Action方法的输入参数,则来源于请求地址或/和Post的数据。
在Controller的Action方法被执行过程中,它可以调用Model获取或者改变其状态。在Action方法执行的最后阶段会选择相应的View,绑定在View上的数据来源Model或者基于显示要求进行得简单逻辑计算,我们有时候它们成为VM(View Model),即基于View的Model(MVC中的Model是与UI无关的)。生成的View最终写入HTTP回复并最终呈现在用户的浏览器中。
和MVP一样,Model 2完全隔断了View和Model之间的联系。Controller作为支配者地位在Model 2体现尤为明显,用户交互请求不再由View报告给Controller(Presenter),而是由拦截器直接转发给Controller。Controller不仅仅决定着Model的调用,还决定了View的选择和生成。ASP.NET MVC就是基于Model 2模式设计的。
凭着读者对ASP.NET MVC的了解,通过上面对Model2模式的介绍,应该很清楚地认识到ASP.NET MVC就是根据Model2模式设计的。基于HTTP请求的拦截机制是通过一个自定义的HttpModule和一个自定义HttpHandler来实现的,在本章的最后我们会通过一个例子来模拟ASP.NET MVC的工作原理。
在上面我们多次强调MVC的Model是维持应用状态提供业务功能的领域模型,或者是多层架构中进入业务层的入口或者业务服务的代理,但是ASP.NET MVC中的Model还是这个Model吗?稍微了解ASP.NET MVC的读者都知道ASP.NET MVC的Model仅仅是绑定到View上的数据而已,它和MVC模式中的Model并不是一回事。由于ASP.NET MVC中的Model是基于View的,我们可以将其称为View Model。
由于ASP.NET MVC只有View Model,所以ASP.NET MVC应用框架本社实际上仅仅关于View和Controller,真正的Model以及Model和Controller之间的交互体现在我们如何来设计Controller。我个人觉得将用于构建ASP.NET MVC的MVC模式成为M(Model)-V(View)-VM(View Model)-C(Controller)也许更为准确。