MVP 模式實例解析


引言

可能有的朋友已經看過我翻譯的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), 67.5M );
       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進行初始化。

感謝閱讀,希望這篇文章能給你帶來幫助!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM