原文網址:http://www.gwtproject.org/articles/mvp-architecture.html
建立大型 application 都有其障礙,GWT application 也不例外。多個開發人員同時在一份程式碼上作業、維護既有功能,可能短時間內就會讓程式碼一團混亂。為了解決這個問題,我們導入 design pattern 來將 project 劃分出不同的責任區。
有很多 design pattern 可以選擇,例如 Presentation-Abstraction-Control、Model-View-Controller、Model-View-Presenter…… 等等。雖然每個 pattern 有其優點,不過我們發現 Model-View-Presenter(以下簡稱 MVP)架構在開發 GWT application 的效果最好。有兩個主要的原因:首先,就像其他 design pattern,MVP 會降低開發行為的耦合度,這讓多個開發人員可以同時工作。再者,MVP 會盡可能降低 GWTTestCase 的使用度。GWTTestCase 會需要 browser,但是大多數程式碼只要輕量、快速、不需要 browser 的 JRE 測試。
這個 pattern 的核心是把功能分散到各個元件,這在邏輯上是有意義的。但在 GWT 中還有一個明確的重點,是讓 View 的部份盡可能簡單,以減輕對 GWTTestCase 的依賴、降低整體的測試時間。
一旦你瞭解這個 design pattern 的原理,那麼建立以 MVP 為基礎的 application 就會直覺又簡單。我們將用一個簡單的通訊錄系統為例子,協助你聊解這些概念。這個系統可以讓使用者增加、編輯、檢視存放在 server 上的聯絡人清單。
一開始,我們先把整個系統分成下面幾個元件:
在下面的章節中我們會看到這些元件之間如何互動:
範例程式
這份教學文件中的範例程式可以在 Tutorial-Contacts.zip 裡頭找到。
Model
model 包含商業邏輯 object。在我們的通訊錄系統中包含:
Contact
:聯絡人列表中的一個聯絡人。這個 object 簡化成只有姓氏、名字、電子郵件。在更複雜的 application 中,這個 object 會有更多 field。ContactDetails
:一個輕量版的Contact
,只包含 unique id 跟顯示名稱。這個「輕量」版的Contact
會讓取得聯絡人清單更有效率,因為 serialize 跟傳輸的資料量會比較少。跟Contact
有大量 field 的複雜系統相比,這個範例的最佳化效果不大。一開始的 RPC 會回傳ContactDetail
的 list。我們加上顯示名稱,讓ContactsView
可以先顯示一些資料而不用作後續的 RPC。
View
view 包含所有用來妝點系統的 UI 元件,包含 table、label、button、textbox 等等。view 的責任是 UI 元件的 layout,與 model 無關。也就是說 view 不知道顯示的是哪一個 Contact
,它只知道有 x 個 label、y 個 textbox、z 個 button、用垂直的方式排列。在 view 之間切換則是由 presenter 層的瀏覽紀錄管理處理。
在我們這個通訊錄系統中的 view 有:
ContactsView
EditContactView
EditContactView
用來增加新的聯絡人、或著是修改既有的聯絡人。
Presenter
presenter 涵蓋了通訊錄系統所有的邏輯,包含瀏覽紀錄管理、view 轉換、以及透過 RPC 與 server 同步資料。一般來說,每個 view 都需要一個 presenter 來驅動、處理 UI widget 發出的 event。
在範例程式中有這幾個 presenter:
ContactsPresenter
EditContactPresenter
就如同 view 的部份,EditContactPresenter
可增加新的聯絡人、以及編輯既有的聯絡人。
AppController
要處理那些不歸屬於任何 presenter、而是系統層級的邏輯,我們將導入 AppController
元件。這個元件包含瀏覽紀錄管理以及 view 轉換邏輯。view 的交換會跟瀏覽紀錄管理綁定在一起,後頭會有更完整的討論。
目前整個範例程式的結構會長的像這樣:
有了元件的結構,在我們開始跳進去寫程式之前,我們要先看一下整個啟動流程。下面這段程式碼的一般流程會是:
- GWT 的 bootstrap 會呼叫
onModuleLoad()
onModuleLoad()
會建立 RPC service、event bus 以及AppController
AppController
會收到RootPanel
的 instanse,然後接管- 之後
AppController
建立指定的 presenter,然後提供 presenter 要驅動的 view
-
public class Contacts implements EntryPoint {
public void onModuleLoad() {
ContactsServiceAsync rpcService = GWT.create(ContactsService.class);
HandlerManager eventBus = new HandlerManager(null);
AppController appViewer = new AppController(rpcService, eventBus);
appViewer.go(RootPanel.get());
}
}
結合 presenter 與 view
為了將 presenter 跟相關的 view 連結在一起,我們要在 presenter 當中定義 Display
interface 並使用它。用 ContactsView
來舉例:
這個 view 有三個 widget:一個 table 跟兩個 button。要讓系統能作一些有意義的事情,presenter 需要作這些事情:
- button 點下去的反應
- 製作 table 的內容
- 當使用者點選一個聯絡人時的反應
- Query the view for selected contacts
在 ContactsPresenter
中,我們定義 Display
interface:
public class ContactsPresenter implements Presenter {
...
public interface Display extends HasValue<List<String>> {
HasClickHandlers getAddButton();
HasClickHandlers getDeleteButton();
HasClickHandlers getList();
void setData(List<String> data);
int getClickedRow(ClickEvent event);
List<Integer> getSelectedRows();
Widget asWidget();
}
}
如果 ContactsView
用 Button
跟 FlexTable
實做上面的 interface,那 ContactsPresenter
就沒啥作用可言。另外,如果我們想在 mobile browser 上頭執行這個程式,我們可以切換 view 而不用改變相關的程式碼。為了一目了然,在有了 getClickedRow()
、getSelectedRow()
這些 method,presenter 會假設 view 將會用 list 的方式呈現資料。也就是說,如果以一個夠宏觀的角度來看,view 可以換掉指定的 list 實做方式而沒有其他的副作用。setData()
這個 method 是一個簡單的作法去取得 model 的資料然後塞到 view 中,view 本身不需要瞭解 model。要顯示的資料與 model 的複雜度是直接相關的。更複雜的 model 會讓 view 在顯示的時候需要更多資料。用 setData()
的美妙之處在於:修改 model 的時候不用修改 view 的程式碼。
為了讓你知道這是怎麼辦到的,讓我們看看下面這段程式碼,這是從 server 收到 Contact
資料時的動作:
public class ContactsPresenter implements Presenter {
...
private void fetchContactDetails() {
rpcService.getContactDetails(new AsyncCallback<ArrayList<ContactDetails>>() {
public void onSuccess(ArrayList<ContactDetails> result) {
contacts = result;
List<String> data = new ArrayList<String>();
for (int i = 0; i < result.size(); ++i) {
data.add(contacts.get(i).getDisplayName());
}
display.setData(data);
}
public void onFailure(Throwable caught) {
...
}
});
}
}
要監聽 UI 的 event,我們必須:
public class ContactsPresenter implements Presenter {
...
public void bind() {
display.getAddButton().addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
eventBus.fireEvent(new AddContactEvent());
}
});
display.getDeleteButton().addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
deleteSelectedContacts();
}
});
display.getList().addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
int selectedRow = display.getClickedRow(event);
if (selectedRow >= 0) {
String id = contacts.get(selectedRow).getId();
eventBus.fireEvent(new EditContactEvent(id));
}
}
});
}
}
同樣的道理,為了享受 MVP 的好處,presenter 不用知道任何 widget 方面的程式碼。我們把 view 用 Display
interface 包起來,這樣就可以用 mock 來假造一個、JRE 不要去呼叫 asWidget()
一切就會很沒好。這就是為什麼你有吃又有得拿:minimize the GWT ties to allow a non-GWTTestCase to be useful,但仍舊有辦法將一個 Display
instance 塞到 panel 當中。
event 與 event bus
譯註:HandlerManager 後來就不建議拿來作為 event bus,詳情參見… 還沒寫的文章 [死]。此處依然保留原文的用法。
當 presenter 收到來自 view 上頭的 widget 發出的 event,你需要對這些 event 作一些動作。因此,你需要用 GWT 的 HandlerManager 來建立 event bus。event bus 是一種機制,來傳遞 event 以及註冊某些 event 的 notify。
有一個重點要記住:不是所有的 event 都要往 event bus 丟。盲目地把系統中所有 event 往 event bus 裡頭倒,可能會讓一個繁忙的系統被 event 處理給拖慢。再者,你會發現你得寫一堆照本宣科的程式碼來定義、產生、處理這些 event。
系統層級的 event 是唯一得丟進 event bus 的東西。系統對「使用者點擊」、「要作 RPC」這類的 event 沒啥興趣。相反的(至少在這個範例程式當中)我們把聯絡人更新、使用者切換到編輯畫面、server 回傳使用者刪除動作的 RPC 已經完成…… 這類的 event 丟進 event bus。
下面這些是我們定義的 event 列表:
AddContactEvent
ContactDeletedEvent
ContactUpdatedEvent
EditContactCancelledEvent
EditContactEvent
這些 event 都繼承 GwtEvent
然後 override dispatch()
跟 getAssociatedType()
。dispatch()
接收一個資料型態為 EventHandler
的參數,在我們的程式中有定義每個 event 的 handler interface:
AddContactEventHandler
ContactDeletedEventHandler
ContactUpdatedEventHandler
EditContactCancelledEventHandler
EditContactEventHandler
為了展示這些東西如何一起運作,讓我們看一下當使用者編輯聯絡人的時候會發生什麼事。首先,我們需要 AppController
註冊 EditContactEvent
。所以我們呼叫 HandlerManager.addHandler(),當 event 觸發的時候就會出叫它然後傳 GwtEvent.Type 給它。下面這段程式碼就是 AppController
如何註冊、收到 EditContactEvent
:
public class AppController implements ValueChangeHandler {
...
eventBus.addHandler(EditContactEvent.TYPE, new EditContactEventHandler() {
public void onEditContact(EditContactEvent event) {
doEditContact(event.getId());
}
});
...
}
AppController
有一個叫做 eventBus
的 HandlerManager instance,然後註冊一個新的 EditContactEventHandler
。這個 handler 會抓到被編輯的聯絡人 id,當 EditContactEvent.getAssociatedType
觸發 event 的時候會把 id 傳給 doEditContact()
。多個元件可以監聽單一 event,所以用 HandlerManager.fireEvent() 觸發 event 時,HandlerManager 會對每一個有 handler 的元件的 EventHandler
interface 呼叫 dispatch()
。
我們來看一下 EditContactEvent
,瞭解 event 是怎麼觸發的。如前面提到,我們已經把 ListContactView
的清單加上 click 的 handler。現在當使用者點聯絡人列表,我們會呼叫 HandlerManager.fireEvent()
、傳給它一個用聯絡人 id 作初始化的 EditContactEvent
,通知整個系統發生了這個行為。
public class ContactsPresenter {
...
display.getList().addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
int selectedRow = display.getClickedRow(event);
if (selectedRow >= 0) {
String id = contactDetails.get(selectedRow).getId();
eventBus.fireEvent(new EditContactEvent(id));
}
}
});
...
}
view 的轉換是一個主動的 event 流程,event 的來源會讓整個系統知道「畫面要轉換了,如果你在最後有一些清除工作要作,我建議你馬上作。」對 RPC 來說,這有一點不同。event 是在 RPC 完成之後觸發、而不是在 RPC 之前。原因是系統只關心狀態的改變(例如修改或是刪除聯絡人),這是 RPC 完成之後才發生的。
下面的例子是當更新聯絡人完畢時觸發 event:
public class EditContactPresenter {
...
private void doSave() {
contact.setFirstName(display.getFirstName().getValue());
contact.setLastName(display.getLastName().getValue());
contact.setEmailAddress(display.getEmailAddress().getValue());
rpcService.updateContact(contact, new AsyncCallback<Contact>() {
public void onSuccess(Contact result) {
eventBus.fireEvent(new ContactUpdatedEvent(result));
}
public void onFailure(Throwable caught) {
...
}
});
}
...
}
瀏覽紀錄與 view 的轉換
譯註:前面遇到 history 都會直接翻成「瀏覽紀錄」,遇到指稱 history 實際機制、如 history event、history stack 時則保留原文
所有 web application 都不可或缺的部份就是處理 history event。history event 是 token 字串,可以表示系統的新狀態。把它們當作你身處於系統何處的「標記」。舉個例子:使用者從「聯絡人列表」畫面轉換成「增加聯絡人」畫面,然後按下「返回」按鈕。這些動作的結果應該是讓使用者回到「聯絡人列表」畫面,所以你應該將起始狀態(聯絡人列表)push 進 history stack、接著 push「增加聯絡人」畫面。如此一來,當使用者按下「返回」按鈕時,「增加聯絡人」的 token 會從 stack 中 pop 出來,然後當下的 token 會是「聯絡人列表」。
現在我們已經有條理地清楚流程了,接著得決定該把程式碼放在哪裡?考慮到 history 不是歸屬於某個特定的 view,所以把它加到 AppController
當中是很合理的。
首先,我們得讓 AppController
去 implement ValueChangeHandler,並且宣告它自己的 onValueChange()。interface 跟參數的資料型態是字串,因為 history event 被簡化成 push 進 stack 的 token。
public class AppController implements ValueChangeHandler<String> {
...
public void onValueChange(ValueChangeEvent<String> event) {
String token = event.getValue();
...
}
}
接著我們需要註冊以接收 history event,這就像我們為了對付 event bus 的 event 所作的事情。
public class AppController implements ValueChangeHandler<String> {
...
private void bind() {
History.addValueChangeHandler(this);
...
}
}
前面的例子裡,當使用者從「聯絡人列表」畫面轉換到「增加聯絡人」畫面,我們提過要設定成初始的狀態。這十分重要,因為它不只是給了我們一個起始點、也是一段會去檢查既有 history token 的程式碼(例如使用者 bookmark 系統的某個狀態)並且導向到適當的 view。AppController
的 go()
就是所有東西都連接好之後會呼叫的 method,我們要在這裡加上:
public class AppController implements ValueChangeHandler<String> {
...
public void go(final HasWidgets container) {
this.container = container;
if ("".equals(History.getToken())) {
History.newItem("list");
} else {
History.fireCurrentHistoryState();
}
}
}
我們需要在 onValueChange()
作一些有意義的事情,這個 method 在使用者按下「上一頁」或「下一頁」時會被呼叫到。利用 event 的 getValue()
,我們可以決定接下來要顯示什麼 view:
public class AppController implements ValueChangeHandler<String> {
...
public void onValueChange(ValueChangeEvent<String> event) {
String token = event.getValue();
if (token != null) {
Presenter presenter = null;
if (token.equals("list")) {
presenter = new ContactsPresenter(rpcService, eventBus, new ContactView());
} else if (token.equals("add")) {
presenter = new EditContactPresenter(rpcService, eventBus, new EditContactView());
} else if (token.equals("edit")) {
presenter = new EditContactPresenter(rpcService, eventBus, new EditContactView());
}
if (presenter != null) {
presenter.go(container);
}
}
}
}
現在,當使用者在「增加聯絡人」畫面按下「返回」按鈕,GWT 的 history 機制會用前一個 token 的值來呼叫 onValueChange()
。在這個例子裡頭,前一個 view 是「聯絡人列表」、前一個 history token(在 go()
裡頭設定的)是「list」。
以這種方式處理 history event 並不限於「上一頁」、「下一頁」的處理,它們可以用在所有 view 的轉換上。再回頭看一下 AppController
的 go()
,你會發現如果目前的 history token 不是 null 的話,會呼叫 fireCurrentHistoryState()
。如此一來,假若使用者指定 http://myapp.com/contact.html#add
,一開始的 history token 就會是「add」、fireCurrentHistoryState()
會用這個 token 呼叫 onValueChange()
。這不單純只是系統設定起始畫面,其他導致畫面切換的使用者行為可以呼叫 History.newItem()
,這會 push 一個新的 history token 到 stack,然後就會引發呼叫 onValueChange()
。
你可以把 ContactsPresenter
的「增加聯絡人」button 掛上 handler,在收到 click event 時轉換到「增加聯絡人」畫面,如下:
public class ContactsPresenter implements Presenter {
...
public void bind() {
display.getAddButton().addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
eventBus.fireEvent(new AddContactEvent());
}
});
}
}
public class AppController implements ValueChangeHandler<String> {
...
private void bind() {
...
eventBus.addHandler(AddContactEvent.TYPE,
new AddContactEventHandler() {
public void onAddContact(AddContactEvent event) {
doAddNewContact();
}
});
}
private void doAddNewContact() {
History.newItem("add");
}
}
由於 onValueChange()
建立了畫面轉換的邏輯,這就提供了一個集中控管、可重複使用的切換畫面方法。
測試
MVP 減輕了對 GWT application 作 unit test 的痛苦。這不是說沒了 MVP 你就不能寫 unit test。實際上是沒問題的,但是往往會比一般用 JRE 為基礎的 JUnit 測試來得慢。為什麼?簡單地說,沒有用 MVP 的 application 的 test case 需要顯示用的 DOM、JS engine 等等的測試元件。基本上這些 test case 必須得在 browser 中運作。
GWT 的 GWTTestCase 就做得到,因為它會執行一個「headless」的 browser,然後跑每一個測試。啟動 browser 的時間加上實際執行 test case 的時間,這就是為什麼會比標準 JRE 測試來的久。用 MVP 之後,我們努力生出一個 view,裡頭跟 DOM、JS engine 相關的程式碼越少越簡單越好。程式碼越少、測試也就越少,測試越少、測試需要的時間也就越少。如果系統中的程式碼大多是 presenter,因為 presenter 是純粹的 JRE-base 元件,所以絕大多數的測試可以建立在快速、普通的 JUnit 上。
為了展示使用 MVP 驅動 JRE-base unit test(而不是用 GWTTestCase)所帶來的好處,我們對「通訊錄系統」加了下面幾個 test:
每個範例都會設定「增加 ContactDetail
清單」、「排序 ContactDetail
」然後「檢查排序結果是否正確」的測試。ExampleJRETest
裡頭會長這樣:
public class ExampleJRETest extends TestCase {
private ContactsPresenter contactsPresenter;
private ContactsServiceAsync mockRpcService;
private HandlerManager eventBus;
private ContactsPresenter.Display mockDisplay;
protected void setUp() {
mockRpcService = createStrictMock(ContactsServiceAsync.class);
eventBus = new HandlerManager(null);
mockDisplay = createStrictMock(ContactsPresenter.Display.class);
contactsPresenter = new ContactsPresenter(mockRpcService, eventBus, mockDisplay);
}
public void testContactSort(){
List<ContactDetails> contactDetails = new ArrayList<ContactDetails>();
contactDetails.add(new ContactDetails("0", "c_contact"));
contactDetails.add(new ContactDetails("1", "b_contact"));
contactDetails.add(new ContactDetails("2", "a_contact"));
contactsPresenter.setContactDetails(contactDetails);
contactsPresenter.sortContactDetails();
assertTrue(contactsPresenter.getContactDetail(0).getDisplayName().equals("a_contact"));
assertTrue(contactsPresenter.getContactDetail(1).getDisplayName().equals("b_contact"));
assertTrue(contactsPresenter.getContactDetail(2).getDisplayName().equals("c_contact"));
}
}
因為我們 view 的結構底下有一個 Display
interface,所以我們可以用 mock 的方法(在這裡是用 EasyMock)作 view,移除存取 browser 資源(DOM、JS engine… 等等)的需要,並避免用 GWTTestCase 作測試。
不過,我們還是用 GWTTestCase 作相同的測試:
public class ExampleGWTTest extends GWTTestCase {
private ContactsPresenter contactsPresenter;
private ContactsServiceAsync rpcService;
private HandlerManager eventBus;
private ContactsPresenter.Display display;
public String getModuleName() {
return "com.google.gwt.sample.contacts.Contacts";
}
public void gwtSetUp() {
rpcService = GWT.create(ContactsService.class);
eventBus = new HandlerManager(null);
display = new ContactsView();
contactsPresenter = new ContactsPresenter(rpcService, eventBus, display);
}
public void testContactSort(){
List<ContactDetails> contactDetails = new ArrayList<ContactDetails>();
contactDetails.add(new ContactDetails("0", "c_contact"));
contactDetails.add(new ContactDetails("1", "b_contact"));
contactDetails.add(new ContactDetails("2", "a_contact"));
contactsPresenter.setContactDetails(contactDetails);
contactsPresenter.sortContactDetails();
assertTrue(contactsPresenter.getContactDetail(0).getDisplayName().equals("a_contact"));
assertTrue(contactsPresenter.getContactDetail(1).getDisplayName().equals("b_contact"));
assertTrue(contactsPresenter.getContactDetail(2).getDisplayName().equals("c_contact"));
}
}
因為我們的系統已經在用 MVP 架構來設計了,這樣子建立測試並沒有實際上的意義。不過這不是重點,重點是 ExampleGWTTest
執行花了 15.23 秒,而輕量化的 ExampleJRETest
只用了 0.1 秒。如果你能設法把系統邏輯從 widget-base 的程式碼分離出來,你的 unit test 會更有效率。Imagine these members applied across the board to the hundreds of automated tests that are run on each build.
沒有留言:
張貼留言