为了解决以上两个问题,笔者和相关人员商量后,决定引入既有成熟模式,重新设计表示层的架构方式,并重构既有代码。
提到表示层(Presentation Layer)的模式,我想大家脑海中第一个闪过的很可能是经典的MVC(Model-View-Controller)。我最初也准备使用MVC,但经过 分析和实验后,我发现MVC并不适合目前的情况,因为MVC的结构相对复杂,Model和View之间要实现一个Observer模式,并实现双向通信。 这样重构起来Services层也必须修改。我并不想修改Services层,而且我想将View和Model彻底隔离,因为我个人并不喜欢View和 Model直接通信的架构方式。最终,我选择了MVP(Model-View-Presenter)模式。
经过两天的重构和验证,目前已经将MVP正式引入项目的表示层,并且解决了上文提到的两个问题。在这期间,积累了少许关于在.NET平台上实践MVP的经验,在这里汇集成此文,和朋友们共享。
UI与P Logic
首先,我想先明确一下UI和P Logic的概念。
表示层可以拆分为两个部分:User Interface(简称UI)和Presentation Logic(简称P Logic)。
UI是系统与用户交互的界面性概念,它的职责有两个——接受用户的输入和向用户展示输出。UI应该是一个纯静态的概念,本身不应包含任何逻辑,而单纯是一个接受输入和展示输出的“外壳”。例如,一个不包含逻辑的Windows Form,一张不包含逻辑的页面,一个不包含逻辑的Flex界面,都属于UI。
P Logic是表示层应有的逻辑性内容。例如,某个文本内容不能为空,当某个事件发生时获取界面上哪些内容,这都属于P Logic。应该指出,P Logic应该是抽象于具体UI的,它的本质是逻辑,可以复用到任何与此逻辑相符的UI。
UI与P Logic之间的联系是事件,UI可以根据用户的动作触发各种事件,P Logic响应事件并执行相应的逻辑。P Logic对UI存在约束作用,P Logic规定一套UI契约,UI要根据契约实现,才能被相应的P Logic调用。
下图展示了UI与P Logic的结构及交互原理。
图1、UI与P Logic
Model-View-Presenter模式
MVP模式最早由Taligent的Mike Potel在《MVP: Model-View-Presenter The Taligent Programming Model for C++ and Java》(点击这里下载)一文中提出。MVP的提出主要是为了解决MVC模式中结构过于复杂和模型-视图耦合性过高的问题。MVP的核心思想是将UI分离成View,将P Logic分离成Presenter,而业务逻辑和领域相关逻辑都分离到Model中。View和Model完全解除耦合,不再像MVC中实现一个Observer模式,两者的通信则依靠Presenter进行。Presenter响应View接获的用户动作,并调用Model中的业务逻辑,最后将用户需要的信息返回给View。
下图直观表示了MVP模式:
图2、MVP模式
图2清楚地展示了MVP模式的几个特点:
1、View和Model完全解耦,两者不发生直接关联,通过Presenter进行通信。
2、Presenter并不是与具体的View耦合,而是和一个抽象的View Interface耦合,View Interface相当于一个契约,抽象出了对应View应实现的方法。只要实现了这个接口,任何View都可以与指定Presenter兼容,从而实现 了P Logic的复用性和视图的无缝替换。
3、View在MVP里应该是一个“极瘦”的概念,最多也只能包含维护自身状态的逻辑,而其它逻辑都应实现在Presenter中。
总的来说,使用MVP模式可以得到以下两个收益:
1、将UI和P Logic两个关注点分离,得到更干净和单一的代码结构。
2、实现了P Logic的复用以及View的无缝替换。
在.NET平台上实现MVP模式
这一节通过一个示例程序展示在.NET平台上实现MVP的一种实践方法。本来想通过我目前负责的实际项目中的代码片段作为Demo,但这样做存在两个问 题:一是这样做可能会违反学校的保密守则,二是这个项目应用了许多其他框架和模式,如通过Unity实现依赖注入,通过PostSharp实现AOP来负 责异常处理和事务管理等,通过NHibernate实现的ORM等等,这样如果读者不了解系统整体架构就很难完全读懂代码片段,MVP模式不够突出。因 此,我专门为这篇文章实现了一个Demo,其中的MVP实践方式与实际项目中是一致的,而且Demo规模小,排除了其他干扰,使得读者更容易理解其中的 MVP实现方式。
这个简单的Demo运行效果如下:
图3、Demo界面
这个Demo的功能如下:这是一个简单的点餐软件。系统中存有餐厅所有菜品的信息,客户只需在界面右侧输入菜品名称和数量,单击“添加”按钮,菜品就会被 添加到左侧点餐列表,并显示此菜品详细信息。如果所点菜品不存在则软件会给出提示。另外,在左侧已点餐品列表中右键单击某个条目,在弹出菜单中点击“删 除”,则可将此菜品从列表删除。
下面分步骤介绍应用了MVP模式的实现方式。
第一步,解决方法及工程结构
这个Demo共有三个工程,MVPSimple.Model为Mock方式实现的Services,作为 Model;MVPSimple.Presenters为Presenter工程,其中包括Presenter和View Interface;MVPSimple.WinUI为View的Windows Forms实现。
第二步,构建Mock方式的Services
因为重点在于表示层,所以这里的Services使用了Mock方式,并没有包含真正的业务领域逻辑。其中MVPSimple.Model工程里两个文件的代码如下:
FoodDto.cs:
01 |
using System; |
02 |
03 |
namespace MVPSimple.Model |
04 |
{ |
05 |
/// <summary> |
06 |
/// 表示菜品类别的枚举类型 |
07 |
/// </summary> |
08 |
public enum FoodType |
09 |
{ |
10 |
主菜 = 1, |
11 |
汤 = 2, |
12 |
甜品 = 3, |
13 |
} |
14 |
15 |
/// <summary> |
16 |
/// 菜品的Data Transfer Object |
17 |
/// </summary> |
18 |
public class FoodDto |
19 |
{ |
20 |
/// <summary> |
21 |
/// ID,标识字段 |
22 |
/// </summary> |
23 |
public Int32 ID { get ; set ; } |
24 |
25 |
/// <summary> |
26 |
/// 菜品名称 |
27 |
/// </summary> |
28 |
public String Name { get ; set ; } |
29 |
|
30 |
/// <summary> |
31 |
/// 菜品类型 |
32 |
/// </summary> |
33 |
public FoodType Type { get ; set ; } |
34 |
35 |
/// <summary> |
36 |
/// 菜品价格 |
37 |
/// </summary> |
38 |
public Double Price { get ; set ; } |
39 |
40 |
/// <summary> |
41 |
/// 点菜数量 |
42 |
/// </summary> |
43 |
public Int32 Amount { get ; set ; } |
44 |
} |
45 |
} |
FoodServices.cs:
01 |
using System; |
02 |
using System.Collections.Generic; |
03 |
04 |
namespace MVPSimple.Model |
05 |
{ |
06 |
/// <summary> |
07 |
/// 菜品Services的Mock实现 |
08 |
/// </summary> |
09 |
public class FoodServices |
10 |
{ |
11 |
private IList<FoodDto> foodList = new List<FoodDto>(); |
12 |
13 |
/// <summary> |
14 |
/// 默认构造函数,初始化各个菜品 |
15 |
/// </summary> |
16 |
public FoodServices() |
17 |
{ |
18 |
this .foodList.Add( |
19 |
new FoodDto() |
20 |
{ |
21 |
ID = 1, |
22 |
Name = "牛排" , |
23 |
Price = 60.00, |
24 |
Type = FoodType.主菜, |
25 |
} |
26 |
); |
27 |
28 |
this .foodList.Add( |
29 |
new FoodDto() |
30 |
{ |
31 |
ID = 2, |
32 |
Name = "法式蜗牛" , |
33 |
Price = 120.00, |
34 |
Type = FoodType.主菜, |
35 |
} |
36 |
); |
37 |
38 |
this .foodList.Add( |
39 |
new FoodDto() |
40 |
{ |
41 |
ID = 3, |
42 |
Name = "水果沙拉" , |
43 |
Price = 58.00, |
44 |
Type = FoodType.甜品, |
45 |
} |
46 |
); |
47 |
48 |
this .foodList.Add( |
49 |
new FoodDto() |
50 |
{ |
51 |
ID = 4, |
52 |
Name = "奶油红菜汤" , |
53 |
Price = 15.00, |
54 |
Type = FoodType.汤, |
55 |
} |
56 |
); |
57 |
58 |
this .foodList.Add( |
59 |
new FoodDto() |
60 |
{ |
61 |
ID = 5, |
62 |
Name = "杂拌汤" , |
63 |
Price = 20.00, |
64 |
Type = FoodType.汤, |
65 |
} |
66 |
); |
67 |
} |
68 |
69 |
/// <summary> |
70 |
/// 按照菜品名称获取菜品详细信息 |
71 |
/// </summary> |
72 |
/// <param name="foodName">菜品名称</param> |
73 |
/// <returns>含有指定菜品信息的DTO</returns> |
74 |
public FoodDto GetFoodDetailByName(String foodName) |
75 |
{ |
76 |
foreach (FoodDto f in this .foodList) |
77 |
{ |
78 |
if (f.Name.Equals(foodName)) |
79 |
{ |
80 |
return f; |
81 |
} |
82 |
} |
83 |
84 |
return new FoodDto() { ID = 0 }; |
85 |
} |
86 |
} |
87 |
} |
第三步,通过View Interface规定View契约
如果想实现Presenter和View的交互和无缝替换,必须在它们之间规定一个契约。一般来说,每一张界面(注意是界面不是视图)都应该对应一个View接口,不过由于Demo只有一个页面,所以也只有一个View接口。
这里需要特别强调,View接口必须抽象于任何具体视图而服务于Presenter,所以,View接口中绝不能出现任何与具体视图相关的元素。例如,我 们的Demo中是使用Windows Forms作为视图实现,但View接口中绝不可出现与Windows Forms相耦合的元素,如返回一个Winform的TextBox。因为如果这样做的话,使用其他技术实现的View就无法实现这个接口了,如使用 Web Forms实现,而Web Forms是不可能返回一个Winform的TextBox的。
下面给出视图接口的代码。
IMainView.cs:
01 |
using System; |
02 |
using System.Collections.Generic; |
03 |
using MVPSimple.Model; |
04 |
05 |
namespace MVPSimple.Presenters |
06 |
{ |
07 |
/// <summary> |
08 |
/// MainView的接口,所有MainView必须实现此接口,此接口暴露给Presenter |
09 |
/// </summary> |
10 |
public interface IMainView |
11 |
{ |
12 |
/// <summary> |
13 |
/// View上的菜品名称 |
14 |
/// </summary> |
15 |
String foodName { get ; set ; } |
16 |
17 |
/// <summary> |
18 |
/// View上点菜数量 |
19 |
/// </summary> |
20 |
Int32 Amount { get ; set ; } |
21 |
22 |
/// <summary> |
23 |
/// 判断某一菜品是否已经存在于点菜列表中 |
24 |
/// </summary> |
25 |
/// <param name="foodName">菜品名称</param> |
26 |
/// <returns>结果</returns> |
27 |
bool IsExistInList(String foodName); |
28 |
29 |
/// <summary> |
30 |
/// 将某一菜品加入点菜列表 |
31 |
/// </summary> |
32 |
/// <param name="food">菜品DTO</param> |
33 |
void AddFoodToList(FoodDto food); |
34 |
|
35 |
/// <summary> |
36 |
/// 将某一已点菜品从列表中移除 |
37 |
/// </summary> |
38 |
/// <param name="foodName">欲移除的菜品名称</param> |
39 |
void RemoveFoodFromList(String foodName); |
40 |
41 |
/// <summary> |
42 |
/// View显示提示信息给用户 |
43 |
/// </summary> |
44 |
/// <param name="message">信息内容</param> |
45 |
void ShowMessage(String message); |
46 |
47 |
/// <summary> |
48 |
/// View显示确认信息并返回结果 |
49 |
/// </summary> |
50 |
/// <param name="message">信息内容</param> |
51 |
/// <returns>用户回答是确定还是取消。True - 确定,False - 取消</returns> |
52 |
bool ShowConfirm(String message); |
53 |
} |
54 |
} |
可以看到,IMainView抽象了如图3所示的界面,但又不包含任何与Windows Forms相耦合的元素,因此如果需要,以后完全可以使用Web Forms、WPF或SL等技术实现这个接口。
第四步,实现Presenter
上文说过,一个界面应该对应一个Presenter,这个Demo里只有一个界面,所以只有一个Presenter。Presenter仅于视图接口耦合,而并不和具体视图耦合,最好证据就是Presenter工程根本没有引用WinUI工程!代码如下:
MainPresenter.cs:
01 |
using System; |
02 |
using System.Collections.Generic; |
03 |
using MVPSimple.Model; |
04 |
05 |
namespace MVPSimple.Presenters |
06 |
{ |
07 |
/// <summary> |
08 |
/// MainView的Presenter |
09 |
/// </summary> |
10 |
public class MainPresenter |
11 |
{ |
12 |
/// <summary> |
13 |
/// 当前关联View |
14 |
/// </summary> |
15 |
public IMainView View { get ; set ; } |
16 |
17 |
/// <summary> |
18 |
/// 默认构造函数,初始化View |
19 |
/// </summary> |
20 |
/// <param name="view">MainView对象</param> |
21 |
public MainPresenter(IMainView view) |
22 |
{ |
23 |
View = view; |
24 |
} |
25 |
26 |
#region Acitons |
27 |
28 |
/// <summary> |
29 |
/// Action:将所点菜品增加到点菜列表 |
30 |
/// </summary> |
31 |
public void AddFoodAction() |
32 |
{ |
33 |
if (String.IsNullOrEmpty(View.foodName)) |
34 |
{ |
35 |
View.ShowMessage( "请选输入菜品名称" ); |
36 |
return ; |
37 |
} |
38 |
if (View.Amount <= 0) |
39 |
{ |
40 |
View.ShowMessage( "点菜的份数至少要是一份" ); |
41 |
return ; |
42 |
} |
43 |
if (View.IsExistInList(View.foodName)) |
44 |
{ |
45 |
View.ShowMessage(String.Format( "菜品【{0}】已经在您的菜单中" , View.foodName)); |
46 |
return ; |
47 |
} |
48 |
49 |
FoodServices foodServ = new FoodServices(); |
50 |
FoodDto food = foodServ.GetFoodDetailByName(View.foodName); |
51 |
if (food.ID == 0) |
52 |
{ |
53 |
View.ShowMessage(String.Format( "抱歉,本餐厅没有菜品【{0}】" ,View.foodName)); |
54 |
return ; |
55 |
} |
56 |
57 |
View.AddFoodToList(food); |
58 |
} |
59 |
60 |
/// <summary> |
61 |
/// Action:从点菜列表移除某一菜品 |
62 |
/// </summary> |
63 |
/// <param name="foodName">被移除菜品的名称</param> |
64 |
public void RemoveFoodAction(String foodName) |
65 |
{ |
66 |
if (View.ShowConfirm( "确定要删除吗?" )) |
67 |
{ |
68 |
View.RemoveFoodFromList(foodName); |
69 |
} |
70 |
} |
71 |
72 |
#endregion |
73 |
} |
74 |
} |
第五步,实现View
这里我们使用Windows Forms实现View。如果朋友们有兴趣,完全可以自己试着用Web或WPF实现以下视图,同时可以验证P Logic的可复用性和视图无缝替换,亲身体验一下MVP模式的威力。Winform的View代码如下。
frmMain.cs:
001 |
using System; |
002 |
using System.Windows.Forms; |
003 |
using MVPSimple.Model; |
004 |
using MVPSimple.Presenters; |
005 |
006 |
namespace MVPSimple.WinUI |
007 |
{ |
008 |
/// <summary> |
009 |
/// MainView的Windows Forms实现 |
010 |
/// </summary> |
011 |
public partial class frmMain : Form, IMainView |
012 |
{ |
013 |
/// <summary> |
014 |
/// 相关联的Presenter |
015 |
/// </summary> |
016 |
private MainPresenter presenter; |
017 |
018 |
/// <summary> |
019 |
/// 默认构造函数,初始化Presenter |
020 |
/// </summary> |
021 |
public frmMain() |
022 |
{ |
023 |
InitializeComponent(); |
024 |
this .presenter = new MainPresenter( this ); |
025 |
} |
026 |
027 |
#region IMainView Members |
028 |
029 |
/// <summary> |
030 |
/// View上的菜品名称 |
031 |
/// </summary> |
032 |
public String foodName |
033 |
{ |
034 |
get { return this .tbFoodName.Text; } |
035 |
set { this .tbFoodName.Text = value; } |
036 |
} |
037 |
038 |
/// <summary> |
039 |
/// View上点菜数量 |
040 |
/// </summary> |
041 |
public Int32 Amount |
042 |
{ |
043 |
get { return (Int32) this .tbAmount.Value; } |
044 |
set { this .tbAmount.Value = (Decimal)value; } |
045 |
} |
046 |
047 |
/// <summary> |
048 |
/// 判断某一菜品是否已经存在于点菜列表中 |
049 |
/// </summary> |
050 |
/// <param name="foodName">菜品名称</param> |
051 |
/// <returns>结果</returns> |
052 |
public bool IsExistInList(String foodName) |
053 |
{ |
054 |
foreach (ListViewItem i in this .lvFoods.Items) |
055 |
{ |
056 |
if (i.Text == foodName) |
057 |
{ |
058 |
return true ; |
059 |
} |
060 |
} |
061 |
062 |
return false ; |
063 |
} |
064 |
065 |
/// <summary> |
066 |
/// 将某一菜品加入点菜列表 |
067 |
/// </summary> |
068 |
/// <param name="food">菜品DTO</param> |
069 |
public void AddFoodToList(FoodDto food) |
070 |
{ |
071 |
ListViewItem item = new ListViewItem(); |
072 |
Double price = food.Price * (Double) this .tbAmount.Value; |
073 |
074 |
item.Text = food.Name; |
075 |
item.SubItems.Add(food.Type.ToString()); |
076 |
item.SubItems.Add( this .tbAmount.Value.ToString()); |
077 |
item.SubItems.Add(price.ToString()); |
078 |
this .lvFoods.Items.Add(item); |
079 |
} |
080 |
081 |
/// <summary> |
082 |
/// 将某一已点菜品从列表中移除 |
083 |
/// </summary> |
084 |
/// <param name="foodName">欲移除的菜品名称</param> |
085 |
public void RemoveFoodFromList(String foodName) |
086 |
{ |
087 |
foreach (ListViewItem i in this .lvFoods.Items) |
088 |
{ |
089 |
if (i.Text == foodName) |
090 |
{ |
091 |
this .lvFoods.Items.Remove(i); |
092 |
} |
093 |
} |
094 |
} |
095 |
096 |
/// <summary> |