引言
可能有的朋友已經看過我翻譯的Jean-Paul Boodhoo的 模型-視圖-提供器 模式 一文了(如果沒有,建議你先看下再看這篇文章,畢竟這兩篇是緊密聯系的)。在那篇文章中,作者為了說明 MVP 的優點之一,易測性,引入了單元測試和NMock框架。可能有的朋友對這部分不夠熟悉,也因為本人翻譯水平有限,導致看后感覺不夠明朗,所以我就補寫了這篇文章,對作者給出的范例程序作了些許簡化和整理,讓我們一步步地來實現一個符合MVP模式的Web頁面。
開始前的准備
在譯文中,作者使用了Northwind數據庫的Customer表來作為范例,這個表包含了太多的字段,而且字段類型缺乏變化,只有一個自定義的Country類型,其余均為String類型。這樣容易讓大家忽視掉MVP模式需要注意的一點,或者說是優勢之一:視圖部分,通常也就是一個Aspx頁面,向用戶顯示的數據類型只有一種可能,就是字符串。即便你想向用戶顯示一個數字,比如金額,在顯示之前,也會要么顯式、要么隱式地轉換為了字符串類型;而對象的字段類型卻可能是多種多樣的。所以,View的接口定義只包含String類型的Set屬性,而實際將各種類型向String類型轉換的工作,全部在提供器中完成。通過這樣的方式,頁面的CodeBehind將進一步簡潔,連格式轉換都移到了單獨的提供器類中了。如果上面的加粗的字體你一時不能領悟也不要緊,一點點看下去你自然會明白。
本文中,我們使用一個Book類作為我們的領域對象,它將包含 字符串、日期、數字三種類型,后面我們會看到它的代碼。本文的范例依然是以一個通過選擇Book列表的下拉框,來顯示Book的詳細信息 的Web窗體頁面來作說明。
現在創建一個新的空解決方案,起名為 MVP-Pattern,我們開始吧。
Model(Service)層的實現
大家可能對譯文的圖1和圖3有點混淆,實際上圖1的Service層和圖3的Model層是同一個事物,它們的工作都是一樣的:實際的從數據庫(或者存儲文件)中獲取數據、填充對象,然后返回給提供器。
MVP.DTO 項目
我們先在解決方案下創建類庫項目 MVP.DTO,DTO代表着Data Transfer Object(數據傳輸對象),這個項目和通常三層、四層構架的業務對象(Business Object)很類似,注意DTO項目實際上不應該屬於Model層,它不會引用任何項目,但是因為各個層的項目都會引用它,所以我們在這里先創建它:
這個項目包含這樣幾個類,首先是BookDTO,它代表着我們的Book對象,它的代碼如下:
public class BookDTO {
private int id; // 索引
private string title; // 標題
private DateTime pubDate; // 出版日期
private decimal price; // 價格
// 構造函數 及 Get屬性略...
}
接下來它還包含三個接口,這三個接口定義了 傳送 給頁面上下拉框(DropDownList)的數據,以及如何為下拉框 送數據。可能正是因為它們的目的是 數據傳送 ,而不僅僅是將數據庫表映射成業務對象,所以才會稱之為DTO,而非Business Object吧。我們一個個來看下:
首先,我們想一想DropDownList的每個列表項ListItem需要什么數據?當然是一個Text,一個Value了,所以定義第一個接口 ILookupDTO,它代表了ListItem所需的數據,只定義了這兩個屬性:
public interface ILookupDTO {
string Value { get; } // 獲取值
string Text { get; } // 獲取文本
}
接着,給出了一個它的簡單實現 SimpleLookupDTO :
public class SimpleLookupDTO : ILookupDTO {
private string value;
private string text;
public SimpleLookupDTO(string value, string text) {
this.value = value;
this.text = text;
}
public string Value {
get { return value; }
}
public string Text {
get { return text; }
}
}
NOTE:如果是我,我會將之命名為IListItemDTO,但是這篇文章和譯文聯系甚密,所以我盡量保持和譯文一樣的命名
接下來,我們還要要為頁面上的DropDownList傳送數據,所以再定義接口ILookupList:
public interface ILookupList{
void Add(ILookupDTO dto); // 添加項目
void Clear(); // 清除所有項目
ILookupDTO SelectedItem{get;} // 獲得選中項目
}
在 MVP.DTO 項目中只定義了這個接口,但沒有給出它的實現,因為它的實現顯然和UI層很靠近,所以它的實現我們將它放到后面的 MVP.WebControls 項目(UI層)中。
最后是ILookupCollection接口及其實現。這里,我不得不批判一下這個接口的命名,它很容易讓人困惑:因為List是一個集合,Collection也是一個集合,所以第一眼感覺就是ILookupCollection和 ILookupList應該是同一個事物,但是這里同時出現,讓人摸不着頭腦。實際上它們是完全不同的:
- ILookupList 更多的是描述了一個事物,即是頁面上的DropDownList,它定義的方法也是對其本身進行操作的。
- ILookupCollection 描述的是一個行為,它僅包含一個方法,BindTo(),方法接收的參數正是ILookupList,意為將ILookupCollection的數據綁定到 ILookupList上。而ILookupCollection包含的數據,是ILookupDTO的集合(IList<ILookupDTO>,由類型外部通過構造函數傳入)。
public interface ILookupCollection {
void BindTo(ILookupList list);
}
public class LookupCollection : ILookupCollection {
private IList<ILookupDTO> items;
public LookupCollection(IEnumerable<ILookupDTO> items) {
this.items = new List<ILookupDTO>(items); // 根據傳遞進來的items創建新的列表
}
public int Count {
get { return items.Count; } // 獲取項目數
}
// 將項目綁定到列表
public void BindTo(ILookupList list) {
list.Clear(); // 先清空列表
foreach (ILookupDTO dto in items) { // 遍歷集合,綁定到列表中
list.Add(dto);
}
}
}
到這里 MVP.DTO 項目就結束了,我們再來看一下大家都熟悉的數據訪問層,MVP.DataAccess。
MVP.DataAccess 項目
這一是和數據最接近的一層,用來獲取來自數據庫(或者其它存儲)的數據。因為本文的目的是講述MVP模式的構架,我們不需要把注意力集中在數據訪問上,所以這一層我直接HardCode了,而非從數據庫中獲取。
這一層定義了一個接口 IBookMapper:
public interface IBookMapper {
IList<BookDTO> GetAllBooks(); // 獲取所有Book
BookDTO FindById(int bookId); // 獲取某一Id的Book
}
以及一個實現了此接口的BookMapper類:
public class BookMapper :IBookMapper {
private readonly IList<BookDTO> list;
public BookMapper() {
list = new List<BookDTO>();
BookDTO book;
book = new BookDTO(1, "Head First Design Patterns", new DateTime(2007, 9, 12),
list.Add(book);
// 略... 共添加了若干個
}
public IList<BookDTO> GetAllBooks() {
return new List<BookDTO>(this.list);
}
public BookDTO FindById(int bookId) {
foreach (BookDTO book in list) {
if (book.Id == bookId)
return new BookDTO(book.Id, book.Title, book.PubDate, book.Price);
}
return null; // 沒有找到則返回Null
}
}
NOTE:這里有一個技巧,在GetAllBooks()和FindById()方法中,我沒有直接返回list列表,或者是list中的book項目,而是對它們進行了深度復制,返回了它們的副本。這樣是為了避免在類型外部通過引用類型變量訪問類型內部成員。更多內容可以參考我之前寫的 創建常量、原子性的值類型 一文(Effective C#的筆記)。
MVP.Task 項目
MVP.Task 項目是Model層的核心,之前創建的兩個項目都是為這個項目進行服務的。它包含一個接口 IBookTask,這個接口定義了Task的兩個主要工作:1、返回所有的Book列表(用於綁定DropDownList列表);2、根據某一個Book的Id返回該Book的詳細信息。
public interface IBookTask {
ILookupCollection GetBookList(); // 返回圖書列表
BookDTO GetDetailsForBook(int bookId); // 返回某一圖書
}
我覺得這個接口的定義是MVP模式的精華所在之一,GetDetailsForBook()方法很容易理解,我們幾乎現在就可以猜到它會把工作委托給MVP.DataAccess項目的BookMapper去處理,因為BookMapper已經包含了類似的方法FindById()。關鍵就在於 GetBookList()方法,注意它返回的是ILookupCollection,而非一個IList<BookDTO>。這樣我們在后面將介紹的提供器中,只需要在獲取到的ILookupCollection上調用BindTo方法,然后傳遞列表對象,就可以綁定列表了,實現了Web頁面和CodeBehind邏輯的分離(MVP模式的精要所在);而如果這里我們僅僅返回IList<Book>,那么綁定列表的工作勢必要移交給上一層去處理。
接下來我們面臨了一個問題:MVP.DataAccess 項目中的 BookMapper.GetAllBook()方法返回的是 IList<Book>,而這里需要的是一個ILookupCollection。回頭看一下ILookupCollection的實現,它內部維護的是一個IList<ILookupDTO>,ILookupDTO是業務無關的,它包含了Text和Value屬性用於向頁面上的DropDownList的列表項提供數據。在本例中,ILookupDTO的Text應該為書名,而Value應該為書的Id。這樣,我們最好能創建一個Converter類,能夠進行由BookDTO到ILookupDTO,進而由IList<BookDTO> 到 IList<ILookupDTO>的轉換。最后將轉換好的IList<ILookupDTO>作為參數傳遞給ILookupCollection的構造函數,從而得到一個ILookupCollection。
注意到ILookupDTO是業務無關的,所以我們定義接口名稱,為ObjectToLookupConverter,而非BookToLookupConverter。另外,以后我們可能創建其他的類型,比如Customer(客戶)也能轉換為LookupDTO,我們定義一個泛型接口(使得Converter類不限於BookDTO才能使用):
public interface IObjectToLookupConverter<T> {
// 將 T類型的對象obj 轉換為 ILookupDTO類型
ILookupDTO ConvertFrom(T obj);
// 將 IList<T> 類型的對象列表 轉換為 IList<ILookupDTO> 類型
IList<ILookupDTO> ConvertAllFrom(IList<T> obj);
}
再定義一個抽象基類實現這個接口,抽象類實現接口的ConvertAllFrom()方法,並將其中中實際的轉換工作委托給 ConvertFrom() 方法:
public abstract class ObjectToLookupConverter<T> : IObjectToLookupConverter<T> {
public abstract ILookupDTO ConvertFrom(T obj);
public IList<ILookupDTO> ConvertAllFrom(IList<T> objList) {
List<T> list = new List<T>(objList);
return list.ConvertAll<ILookupDTO>(delegate(T obj) {
return ConvertFrom(obj); // 將實際的轉換委托給 ConvertFrom()方法
});
}
}
最后,到了實際的將 Book 轉換為 LookupDTO 的部分了,非常的簡單:
public sealed class BookToLookupConverter : ObjectToLookupConverter<BookDTO> {
public override ILookupDTO ConvertFrom(BookDTO book) {
return new SimpleLookupDTO(book.Id.ToString(), book.Title);
}
}
好了,有了這些准備工作,我們實現 IBookTask接口就變得輕易的多了。現在,創建MVP.Task項目的最后一個類,BookTask。注意GetBookList()方法的實現過程,和我們上面的分析一模一樣:
public class BookTask : IBookTask {
private readonly IBookMapper bookMapper;
public BookTask()
: this(new BookMapper()) {
}
public BookTask(IBookMapper bookMapper) {
this.bookMapper = bookMapper;
}
// 獲取圖書列表
public ILookupCollection GetBookList() {
IList<BookDTO> bookList = bookMapper.GetAllBooks();// 獲取IList<BookDTO>
IList<ILookupDTO> list = // 轉換為 IList<ILookupDTO>
new BookToLookupConverter().ConvertAllFrom(bookList);
// 構建ILookupCollection
ILookupCollection collection = new LookupCollection(list);
return collection;
}
// 獲取某一圖書的詳細信息
public BookDTO GetDetailsForBook(int bookId) {
BookDTO book = bookMapper.FindById(bookId);
return book;
}
}
至此,Model層或者叫Service服務層的所有項目都已經結束了,我們接下來看MVP的V(View層)是如何構建的。
View 層的實現
Web 站點項目 和 MVP.WebControl 項目
你可能會奇怪為什么現在就講述View層,而不是Presenter提供器層?這是因為Presenter是View 和 Model的一個協調者,從下面幅圖就可以看出來。所以,我們需要先看下View層如何實現,進而才能去討論Pesenter層。
View層包含兩個項目,一個是站點項目,一個是MVP.WebControl項目,我們先看站點項目。它僅包含一個頁面:Default.aspx,內容也是簡單之極,我們先看頁面部分的HTML代碼:
<h1>MVP 模式范例</h1>
選擇圖書<asp:DropDownList runat="server" ID="ddlBook"></asp:DropDownList>
< br /><br />
< div style="line-height:140%;">
<strong>書名:</strong><asp:Literal ID="ltrTitle" runat="server"></asp:Literal><br />
<strong>出版日期:</strong><asp:Literal ID="ltrPubDate" runat="server"></asp:Literal><br />
<strong>價格:</strong><asp:Literal ID="ltrPrice" runat="server"></asp:Literal>
< /div>
非常的簡單,是吧?然后我們再看一下后置代碼,通常情況下,我們會在后置代碼中寫DropDownList的PostBack事件,並且設置根據得到的數據填充三個Literal控件的Text屬性。而在MVP模式中,這部分的工作將會交由提供器來完成,所以,我們只需要為這些控件建立Set訪問器,並且將頁面的引用傳給提供器就可以了(如何傳遞頁面引用給提供器后面會討論)。我們現在在頁面的后置代碼中添加一組Set屬性,分別去為頁面的三個Literal控件賦值:
public string Title {
set { ltrTitle.Text = value; }
}
public string Price {
set { ltrPrice.Text = value; }
}
public string PubDate {
set { ltrPubDate.Text = value; }
}
通常情況下DropDownList的填充也是在后置代碼中完成的,而為了能讓提供器對DropDownList的數據進行填充,我們需要讓這個DropDownList能夠與ILookupList聯系起來,並進一步通過調用來自MVP.Task中的 ILookupCollection的BindTo()方法,來對列表進行綁定。
記得到現在為止我們都沒有實現 ILookupList接口,現在是時候實現它了,新建一個項目MVP.WebControl,添加對MVP.DTO的引用,然后創建ILookupList接口的實現WebLookupList。在對ILookupList接口的實現中,對DropDownList進行包裝,為了更好的代碼重用,我們傳遞DropDownList的基類ListControl,而非DropDownList本身:
public class WebLookupList : ILookupList {
private ListControl underlyingList;
public WebLookupList(ListControl underlyingList) {
this.underlyingList = underlyingList;
}
public void Add(ILookupDTO dto) {
underlyingList.Items.Add(new ListItem(dto.Text, dto.Value));
}
public void Clear() {
underlyingList.Items.Clear();
}
public ILookupDTO SelectedItem {
get {
ListItem item = underlyingList.SelectedItem;
return new SimpleLookupDTO(item.Value, item.Text);
}
}
}
可以看到我們實際上將對這個接口實現的具體工作都委托給了 ListControl,這樣,當我們在ILookupList上調用Add()方法添加列表項時,便會添加到頁面的DropDownList上。
記住:我們期望能讓提供器送數據的所有Web頁面上的控件,都應該為提供器提供一個入口。在前面,我們為三個Literal空間提供的入口是Set屬性。這里我們一樣需要提供一個Get屬性,來讓提供器能夠獲得一個ILookupList。在Default頁面的后置代碼中添加下面代碼:
public ILookupList BookList {
get { return new WebLookupList(ddlBook); }
}
Presenter 層的實現
實現Presenter(提供器)之前我們先考慮它的作用是什么:從Task中獲取數據,然后送到View層(Aspx頁面)中。這就暗示 提供器必須包含 Task和 View層的引用。但是如果我們是無法讓提供器引用站點項目的,因為站點項目不會生成單獨的dll文件(基於每個頁面生成dll)。但是站點卻可以引用提供器,所以我們只要在提供器項目中定義一個接口,然后讓頁面去實現這個接口,我們通過這個接口去為頁面送數據(調用接口的Set訪問器)。
MVP.Presentation 項目
現在你可以將頁面上的三個Literal和一個DropDownList與這個View接口聯系起來了。創建MVP.Presentation項目,然后我們定義Default頁面需要實現的IViewBookView接口:
public interface IViewBookView {
ILookupList BookList { get; }
string Title { set; }
string PubDate { set; }
string Price { set; }
}
這個接口的定義完全是基於Web頁面的,你需要為頁面提供哪些數據,或者為哪個控件送數據,那么就定義哪些屬性。然后我們讓Web項目引用MVP.Presentation項目,在修改頁面的后置代碼文件Default.aspx.cs,讓它去實現這個接口(因為頁面已經包含了這個接口的所有定義,所以這里只是起到一個向提供器傳遞窗體的作用)。
public partial class _Default : System.Web.UI.Page, IViewBookView
下一步,我們要實現提供器,我們在項目中再添加一個文件 ViewBookPresenter.cs,添加下面代碼:
public class ViewBookPresenter {
private readonly IViewBookView view;
private readonly IBookTask task;
public ViewBookPresenter(IViewBookView view) : this(view, new BookTask()) { }
public ViewBookPresenter(IViewBookView view, IBookTask task) {
this.view = view;
this.task = task;
}
// 初始化方法,綁定列表
public void Initialize() {
ILookupCollection collection = task.GetBookList(); // 獲取圖書列表
collection.BindTo(view.BookList); // 綁定到列表
DisplayBookDetails(); // 顯示圖書信息
}
// 獲取選中的圖書的Id
private int? SelectedBookId {
get {
string selectedId = view.BookList.SelectedItem.Value;
if (String.IsNullOrEmpty(selectedId)) return null;
int? id = null;
try {
id = int.Parse(selectedId.Trim());
} catch (FormatException) { }
return id;
}
}
// 顯示特定圖書的詳細信息
public void DisplayBookDetails() {
int? bookId = SelectedBookId;
if (bookId.HasValue) {
BookDTO book = task.GetDetailsForBook(bookId.Value);
UpdateViewFrom(book);
}
}
// 更新頁面的信息,在這里進行格式化
private void UpdateViewFrom(BookDTO book) {
view.Price = book.Price.ToString("c");
view.PubDate = String.Format(new DateFomatter(), "{0}", book.PubDate);
view.Title = book.Title;
}
// 格式日期,作為示范,所有格式化工作都放到 Presenter中
private class DateFomatter : ICustomFormatter, IFormatProvider {
public string Format(string format, object arg, IFormatProvider formatProvider) {
DateTime date = (DateTime)arg;
return string.Format("{0}年{1}月{2}日", date.Year, date.Month, date.Day);
}
public object GetFormat(Type formatType) {
return this;
}
}
}
上面的代碼是很直白的,只有一個主題思想:從task中獲取數據,然后調用view接口的屬性,或者從view接口獲得DropDownList的引用(通過ILookupList),然后通過 BindTo()方法為列表填充數據。注意到Initialize()方法,它為列表填充數據,這個應該在頁面加載之前就被調用;還有DisplayBookDetails()方法,它應該在列表的SelectedIndexChanged事件被觸發時調用,所以我們還有最后一部沒有做,再次修改Default.aspx.cs文件,設置這些方法的觸發時機。
最后一步,再次修改Default.aspx.cs文件
在后置代碼類中添加如下代碼,完成上一小節說明的所有內容:
private ViewBookPresenter presenter;
protected override void OnInit(EventArgs e) {
base.OnInit(e);
presenter = new ViewBookPresenter(this); // 創建Presenter的實例
// 為DropDownList綁定事件處理方法
ddlBook.SelectedIndexChanged += delegate {
presenter.DisplayBookDetails();
};
}
protected void Page_Load(object sender, EventArgs e) {
if (!IsPostBack) {
presenter.Initialize(); // 綁定列表
}
}
這里值得注意的是 ViewBookPresenter 對象的創建,它通過this關鍵字,將頁面本身傳遞了進去,而頁面本身實現了IViewBookView接口,滿足構造函數的簽名,這樣提供器通過IViewBookView便可以訪問頁面上的屬性和列表,並為之提供數據。
總結
這篇文章是對 模型-視圖-提供器 模式 一文范例程序的一個刨析和說明。在本文中,我們創建了一個包含多個項目的完整的符合MVP模式的Web頁面。我們先創建了基礎項目 MVP.DTO,用於傳送數據、MVP.DataAccess,用於數據訪問;接着分別創建了 Model層、View層、Presenter層,並講述了它們之間的調用關系,以及使用的要點。通過這則范例,希望大家能對MVP模式有了一定的認識和了解。
不一定項目的每個頁面,都去采用MVP模式來構建。但如果運用的好的話,可以將多個頁面共同的的某一部分(或者叫功能)抽象出來,使用同一個提供器,可以很大程度上實現代碼重用。另外也可以一個Page實現多個IView,將頁面功能分離成多個部分,需要使用哪個功能,就實現哪個IView,並使用相應的IViewPresenter進行初始化。
感謝閱讀,希望這篇文章能給你帶來幫助!
- 原創文章
- 編著文章
- 翻譯文章
- 讀書筆記
- 轉載文章