.NET初学者架构设计指南(四)Model-View-Controller

Model-View-Controller简称为MVC,这是图形界面(GUI)应用程序的一种架构形式。Model是业务领域层,比如我们在前面两篇里面提到的Account、Entry、Bill、Invoice之类的对象,这些类构成了一个电信账务系统的业务领域层;View就是用户界面;Controller是指用户界面和业务对象之间的控制器,控制器的作用是从业务对象中获取数据显示到用户界面上,并且从界面上收集用户的输入和动作,然后调用业务对象完成业务功能。

大部分软件系统的工作可以总结成下面这样的流程:从存储数据的地方取得数据,把他们显示在用户界面上,然后用户在界面上修改这些数据,再把数据写回存储。数据在存储和界面之间来回流动。这种看似简单的分析方式经常让开发者有这样一种冲动:把界面和数据写在一起,这样可以少很多层次,少写很多代码,也可以减少运行过程的环节,似乎可以加快程序的运行效率。但是实际上,这种直来直去的烟囱式系统不能很好的隔离界面和业务代码,在开发和维护的过程中会带来很多麻烦。

把程序的界面和业务代码分离开会带来很多好处。一般的说来,界面比起业务逻辑来变化来的更加频繁一些,修改界面的时候,不应该对业务代码造成影响;开发这两个部分所使用的技巧也有很大的差别;在有些系统里面,需要为同一个功能开发多种界面,比如一个Windows窗体界面给后台管理人员使用,一个Web页界面提供给广大人民群众,还需要做一个适用于PDA浏览器的界面,如果界面和业务代码是混杂在一起的,多种界面的开发就需要做很多重复性的工作;把界面和业务代码分离开也使可以为自动化的单元测试提供很多方便,要对用户界面创建单元测试代码是十分繁杂的,而对业务代码做单元测试则是简单的,也是必要的。

于是有经验的开发者会在设计程序的时候创建一个业务领域层,在这个层次中有很多业务对象,他们直接体现了业务需求的核心。用户界面层不是自己实现业务功能,而是调用后台的业务对象。用户界面向用户展示业务对象的属性,并且捕获用户的输入,调用业务对象的方法实现各种功能需求。当业务逻辑层和用户界面层都具备了以后,剩下的一个问题就是:如何把这两个层次粘和起来——这就是控制器需要做的工作。

最简单的控制方式就是直接在界面中调用业务对象,这种方式称为Model-View模式。

Model-View模式在界面和业务模型之间建立了一种最简单的依赖关系,界面直接调用业务模型,模型通过消息这样的松耦合方式修改界面上的表示内容(比如上一篇里面使用C#语言中的事件实现告警在界面上的显示)。这样可以实现层次的分离,对改善软件系统的构架是有一定的帮助的。

使用过Microsoft Visual Studio各个版本的开发者一定对Model-View的控制方式非常熟悉,无是在VB、VC,还是后来的C#、ASP.NET中,我们把一个按钮控件拖放到窗体上,然后鼠标点击这个控件,就会自动生成一段事件响应代码。下面是用这种方式编写的一段程序,这是“非洲电信公司账务系统”的一个界面,营业员使用这个界面为用户缴费:

营业员在“号码”输入框里面填写用户的电话号码,在“金额”输入框里填写需要交的金额,然后点击“提交”按钮,就可以把钱交进账户。如果发生了异常情况,程序会出现提示。

按下“提交”按钮的时候,按钮发出Click事件,窗体可以捕获这个事件,采取响应行动。控制器就是用这样的机制实现的。

    private void button1_Click(object sender, System.EventArgs e)
    {
        
//按下缴费按钮,调用业务对象,实现缴费功能
        try
        {
            
string phone_no = textBox1.Text;
            
string money = textBox2.Text;

            
//根据电话号码得到用户对象
            User user = GetUserByPhoneNumber(phone_no);

            
//得到这个用户的账号
            Account account = user.Account;

            
//调用账号的Pay,接口给账号交钱
            
//Pay接口处理完费用之后要判断欠费,
            
//如果不再欠费向交换网络发指令,给用户开机
            account.Pay(float.Parse(money));
            MessageBox.Show(
"缴费成功了");
        }
        
catch (Exception e)
        {
            MessageBox.Show(e.Message);
        }
    }

这种控制器叫做“页面控制器”(Page Controller),控制器的功能是融合在界面中实现的。页面控制器简单实用,代码编写量很少,所涉及的技巧也并不高深,一个初学编程的人也很快就可以掌握。在解决一个简单的界面的时候,这样的控制器是非常适用的。但是,如果需要设计的程序具有更加复杂的功能,需要设计大量的界面和功能,这样的简单方式就会带来难以控制的复杂度。

下面的界面复杂一些,这是一种常见的图形界面模式:

窗体上方是一个菜单,排列着所有的功能点(比如用户缴费、资费变更),还有一些系统性的功能(比如重新登录、修改密码);菜单下面是一个工具条,工具条上列出了常用的功能;左侧是一个树视图,列出了所有的功能点;点击菜单、工具条和树视图上的功能图标的时候,窗体右侧会打开功能界面,用户在这个界面上进行操作;窗体最下面是一个状态栏,显示一些帮助信息和程序运行过程中出现的消息。

如果我们使用页面控制器的方式来实现这样的界面,就会在界面的代码里面出现大量的事件响应代码。这些代码相似,而又不同。他们调用着各种业务对象,实现复杂的功能,这使得界面的代码日益庞大。再加上一些权限、安全性方面的功能(这样的功能通常会涉及到所有的业务功能,我们把这种功能称作“横切面功能”),代码会复杂到难以控制。到最后一点简单的需求变更会在多处修改代码,造成大量的重复工作。

下面我们就来看看怎样使用MVC的构架方式实现这样的界面方式。我们将使用一种叫做“前端控制器”(Front Controller)的控制方式。这样的控制器采用一种集中控制的方法,做到了视图、业务模型和控制器的分离,软件形成了真正的MVC架构:

前端控制器由两个部分组成:第一个部分是一个任务分派机制,用户在界面上的每一个请求都是通过这个分派机制传递给后台的对象进行执行的;第二个部分是一个“Command模式”(命令模式)构成的请求处理方式,对分派来的任务进行处理。下面先补上一节设计模式课,简单的介绍一下命令模式。

下面的类图表示了一个最简单的命令模式:

Command类有一个Execute方法,SaveCommand和DeleteCommand都是Command的子类,他们都覆盖了Command类的Execute方法。

当我们想执行保存动作的时候,就这样做:

Command cmd = new SaveCommand(); 
cmd.Execute(); 

当我们想删除的时候,就调用DeleteCommand对象:

Command cmd = new DeleteCommand(); 
cmd.Execute(); 

执行不同的任务的时候,只有创建的命令对象类型是不一样的,执行过程都一样。如果用一个工厂封装创建Command对象的业务逻辑,就可以用完全相同的调用方式执行不同的任务。这个模式可以使我们在程序中扩充新的功能,而不对已经完成的功能造成影响。命令模式在很多系统里面广泛的使用,比如在Tomcat Web服务器中,当我们访问一个JSP页面的时候,Tomcat会把这个JSP编译成HttpJspBase类的一个子类,然后调用他的_jspService方法,执行我们自己编写的页面代码。这就是一个命令模式。

下面我们来看看刚才提到的图形界面是如何实现的,并且通过什么样的方式调用后台的业务逻辑层进行实际的工作。示例代码可以在这里下载:https://files.cnblogs.com/lane_cn/skii.zip。这是用Visual Studio 2003编写的一个项目。项目代码分为三个包。打开项目文件可以看见三个对应的文件夹:

1、AfricaTelecom:这个文件夹里是“非洲通信公司账务管理系统”的业务代码,业务逻辑层的对象都在这里面;

2、View:这里是视图。其中的BaseView是所有视图的基类。还有两个实际的视图PayMoney和ChangePrice,分别是用户缴费视图和资费变更视图。ViewFactory是建立视图实例的工厂;

3、Action:这里是程序中所有的行为,是控制器的重要部分。其中的BaseAction是所有行为的基类,ActionFactory是建立行为对象的工厂,其余的行为是分别用于用户缴费和资费变更等各项业务活动。

我们先来看看View(视图)。BaseView是一个用户控件,从他的定义可以看出来:  

public class BaseView : System.Windows.Forms.UserControl 

 

我们为BaseView定义了Id和ViewName两个属性,用来标记系统中的所有视图。BaseView向外界提供了一个“视图属性”接口,调用者可以使用这个接口把一些“名-值”对保存到视图中: 

private Hashtable attributes = new Hashtable();

/// <summary>
/// 视图属性
/// </summary>
public object GetAttribute(string key)
{
    
try 
    {
        
return this.attributes[key];
    }
    
catch
    {
        
return null;
    }
}

/// <summary>
/// 视图属性
/// </summary>
/// <param name="key"></param>
/// <param name="valuee"></param>
public void SetAttribute(string key, object valuee)
{
    
try 
    {
        
this.attributes.Remove(key);
    }
    
catch {}
    
this.attributes.Add(key, valuee);
}

视图提供了UpdateView和UpdateAttributes两个方法,用于把attributes容器里的“名-值”对显示到界面上,或者把界面上的文字保存到attributes容器中。这两个方法需要在子类中覆盖。

视图还向外界提供了两个事件:Change和MessageOut。Change向外界发出“视图已经被改变”的事件,这个事件可以用来实现这样的功能:在工具栏上有一个“保存”按钮,当视图打开时,这个“保存”按钮是不可用的,一旦视图发生变化,就会发出Change事件,于是工具栏可以捕捉到这个事件,将“保存”按钮设置为可用状态。MessageOut用于向外界发出消息,可以随时向外界通知某个任务的执行状态。

程序的主画面(MainWin)提供了一个LoadView方法,这个方法可以在窗体右侧区域加载一个视图,所加载的视图一定是BaseView的某个子类:  

/// <summary>
/// 打开视图
/// </summary>
/// <param name="view">视图</param>
public void LoadView(Skii.View.BaseView view) 
{
    
this.panel1.Controls.Clear();
    
    view.Dock 
= System.Windows.Forms.DockStyle.Fill;
    
this.panel1.Controls.Add(view);
    
this.SetActiveView(view);
    
    
this.statusBar1.Text = this.GetActiveView().GetName() + "就绪";
    
this.Text = "SkII - " + this.GetActiveView().GetName();
    
    
this.GetActiveView().MessageOut += 
        
new ViewMessageHandler(ActiveView_MessageOut);
    
this.GetActiveView().Change += 
        
new ViewChangeHandler(ActiveView_Change);
}

窗体右侧有一个面板控件:panel1,LoadView方法首先把面板上的控件全部去掉,然后把视图对象放到面板上。视图添加上去之后,更新主窗体的ActiveView属性,修改状态栏和窗体标题上的文字。最后再为MessageOut和Change事件设定响应函数。

MainWin向外界提供了一个静态的Instance属性,调用者可以通过这个属性得到主窗体的实例引用,然后调用LoadView方法。

下面我们再来介绍控制器。打开Action目录,BaseAction是所有Action的基类,他有Id和ActionName两个属性,用于标示系统中的行为。BaseAction有Enabled和IsVisible两个属性,分别表示行为是否可用、是否可见。

BaseAction中有几个Init方法,这几个方法分别对不同的窗体控件进行绑定,目前支持的控件有按钮、菜单、工具条、树节点。如果需要的话,还可以添加支持别的控件。控件和行为进行绑定时,需要参照行为的可用和可见情况,对控件的外观和行为进行设置。当控件被用户操作时,会触发响应函数,这些响应函数最后都会调用Execute方法。  

/// <summary> 
/// 执行行为 
/// </summary> 
public virtual void Execute() 

这样就为所有的窗体控件做到了统一的控制方式。比如我们可以把主窗体上的某个菜单项、工具栏按钮、树视图节点都绑定在同一个行为上,当这些控件发生动作的时候,都会触发这个行为的Execute方法。如果我们把这个行为设置为不可用,所有的相关控件同时都会成为灰色,不可点击。

在示例程序里,当主画面打开时,会建立三个Action,并且绑定相应的界面控件。请看MainWin的Form1_Load方法:  

        private void Form1_Load(object sender, System.EventArgs e)
        {
            
//退出应用程序
            BaseAction exitAction = ActionFactory.CreateAction(1000);
            exitAction.Init(menuItem7);

            
//打开用户缴费的界面
            BaseAction openPayMoneyAct = ActionFactory.CreateAction(1001);
            openPayMoneyAct.Init(toolBarButton1);
            openPayMoneyAct.Init(menuItem5);
            openPayMoneyAct.Init(
this.treeView1.Nodes[0].Nodes[0]);

            
//打开资费变更的界面
            BaseAction openChangePriceAct = ActionFactory.CreateAction(1002);
            openChangePriceAct.Init(toolBarButton2);
            openChangePriceAct.Init(menuItem6);
            openChangePriceAct.Init(treeView1.Nodes[
0].Nodes[1]);
            
//openChangePriceAct.IsVisible = false;
            
//openChangePriceAct.Enabled = false;
        }

在用户缴费和资费变更的界面上,也是用相似的方式实现了行为的控制。请看PayMoney和ChangePrice的构造函数,他们分别建立了自己需要的行为,并且和按钮进行绑定。

在Action的Execute方法中,收集视图上的输入,调用业务对象进行工作。然后再把工作的结果展现在视图上。下面是PayMoneyAction的Execute方法,他实现了缴费功能: 

public override void Execute()
{
    
//更新视图属性,把视图元素的值更新到属性里面
    BaseView view = MainWin.GetInstance().GetActiveView();
    view.UpdateAttributes();

    
//得到界面上的电话号码和缴费金额
    string phone_no = view.GetAttribute("phone_no").ToString();
    
string money_amount = view.GetAttribute("money_amount").ToString();

    
//调用业务对象进行缴费
    try
    {
        User user 
= User.GetUserByPhoneNo(phone_no);
        Account account 
= user.GetAccount();
        account.Pay(
float.Parse(money_amount));
    }
    
catch (Exception e)
    {
        MessageBox.Show(e.Message);
        
return;
    }
    
    
//向界面发出成功消息
    MessageBox.Show("缴费成功 :)");

    
//把界面清空
    view.SetAttribute("phone_no""");
    

    
//根据视图的属性,更新视图界面
    view.UpdateView();
}

现在我们实现了一个前端控制器的最简单的示例。令人不满意的是,其中有很多硬编码:在主窗体上的控件都是手工拖放上去的,行为也要手工定义,然后与控件进行绑定;并且在ActionFactory和ViewFactory中,行为和视图的建立也是用硬编码实现的。下面我们就来做一些事情,消除程序里的硬编码。

我们需要定义一个表,名叫ACTION_LIST,用来表示系统中所有的行为:

ActionFactory可以根据传入的ID编号查找到对应的类型,然后使用反射的方式建立需要的实例。如果我们需要对行为进行权限控制,可以再建立一个“访问控制表”(Access Control List,简称ACL),在这个访问控制表中记录用户和行为的访问限制关系。ActionFactory在建立Action实例之后,需要参照这个ACL对Action的Enabeld属性和IsVisible属性进行设置。只要维护一个ACL,就能实现最灵活的行为权限控制。

再建立一个类似的表:VIEW_LIST,用来定义所有的视图。ViewFactory可以根据这个表创建需要的视图实例:

ActionFactory和ViewFactory采用了反射的方式建立实例。尽管反射是一种比较消耗资源的方式,但是由于工厂把建立的实例缓存了起来,下次再调用的时候直接返回以前建立的实例,这样最大限度的避免了效率的下降。

下面,我们只要在主窗体中定义一个“控件行为表”,就可以消除所有的硬编码了:

当应用程序启动的时候,主窗体按照这个控件行为表的内容创建控件,并且绑定指定的行为。这样就消除了所有的硬编码,也大大加强了软件的灵活性。主窗体现在只是一个空空的框架,视图和业务模型也被很好的隔离。同时,在前端控制器中,应用软件的所有控制行为是集中的,这样,当我们希望在所有的控制器上统一加上某个功能的时候,会非常的简单。比如刚才看到的权限控制。再比如我们需要在所有的行为开始的时候建立一个数据库连接,在行为结束的时候再把连接销毁。使用这样框架,这种统一的行为将十分容易实现,不需要重复的复制粘贴代码。

采用这样的构架,视图和行为可以不断的实现增量开发,各个部件互相不会产生影响。加上.NET程序集的部署方式,可以将这些代码编译成多个动态链接库,实现增量部署,对程序的自动下载更新也是一个有利的条件。

MVC是一种十分重要的构架形式,他可以增强软件的灵活性,使得变更更加的容易。他的优势主要体现在图形界面软件上,主要也是用于增强这一类软件的灵活性,使得和界面相关的修改更加容易一些。比如刚才的主窗体,如果我们要把他改一个样子,如下:

右侧的面板换成了一个Tab标签页。用户切换视图之后,以前的视图不会直接关掉,而是保留在Tab页里,在一个新建的Tab页上显示新的视图。这样操作人员就可以暂时中断手中正在进行的工作,切换到别的视图上做事,做完了再切换回来。现在实现这个变更是十分简单的,只需要修改主窗体的LoadView代码,并且改变ActiveView属性的代码就可以了,对任何一个视图和业务行为都不会造成影响。再举一个例子:如果用户提出这样的需要:操作员可以把自己常用的功能拖放到工具栏上,根据自己的需要自由的定制工具栏的按钮。这种需要也可以很容易的实现。

但是MVC无法用来应对来自业务本身的变化。一旦这样的变化发生了,比如我们需要对变更产品资费的业务进行控制,某些号码段的用户将不允许选择某些资费,这样的控制以前是不存在的。这样的变更就只有通过业务领域层的修改才能解决了。要减轻业务变更带来的痛苦,最根本的方法还是要设计一个清晰合理的业务领域层。

posted on 2007-03-13 09:05  小陆  阅读(15458)  评论(34编辑  收藏  举报