※ [本文转录自 Translate-CS 看板 #1JRwBhdh ]
作者: PsMonkey (痞子军团团长) 看板: Translate-CS
标题: [翻译] GWT MVP part.1
时间: Mon May 12 00:18:48 2014
原文网址:http://www.gwtproject.org/articles/mvp-architecture.html
译文网址:http://blog.dontcareabout.us/2014/05/gwt-mvp-part1.html
BBS 版以 markdown 语法撰写
______________________________________________________________________
建立大型 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 测试。
[GWTTestCase]: http://www.gwtproject.org/javadoc/latest/
com/google/gwt/junit/client/GWTTestCase.html
这个 pattern 的核心是把功能分散到各个元件,这在逻辑上是有意义的。
但在 GWT 中还有一个明确的重点,是让 [View](#View) 的部份尽可能简单,
以减轻对 GWTTestCase 的依赖、降低整体的测试时间。
一旦你了解这个 design pattern 的原理,
那么建立以 MVP 为基础的 application 就会直觉又简单。
我们将用一个简单的通讯录系统为例子,协助你了解这些概念。
这个系统可以让使用者增加、编辑、检视存放在 server 上的联络人清单。
一开始,我们先把整个系统分成下面几个元件:
* [Model](#Model)
* [View](#View)
* [Presenter](#Presenter)
* [AppController](#Appcontroller)
在下面的章节中我们会看到这些元件之间如何互动:
* [结合 presenter 与 view](#binding)
* [event 与 event bus](#eventbus)
* [浏览纪录与 view 的转换](#history)
* [测试](#testing)
# 范例程式 #
这份教学文件中的范例程式可以在 [Tutorial-Contacts.zip] 里头找到。
![](http://www.gwtproject.org/images/mvp_diagram.png)
[Tutorial-Contacts.zip]: https://code.google.com/p/google-web-toolkit/
downloads/detail?name=Tutorial-Contacts.zip
# Model <a id="Model"></a>#
model 包含商业逻辑 object。在我们的通讯录系统中包含:
* `Contact`:联络人列表中的一个联络人。
这个 object 简化成只有姓氏、名字、电子邮件。
在更复杂的 application 中,这个 object 会有更多 field。
* `ContactDetails`:一个轻量版的 `Contact`,
只包含 unique id 跟显示名称。
这个“轻量”版的 `Contact` 会让取得联络人清单更有效率,
因为 serialize 跟传输的资料量会比较少。
跟 `Contact` 有大量 field 的复杂系统相比,
这个范例的最佳化效果不大。
一开始的 RPC 会回传 `ContactDetail` 的 list。
我们加上显示名称,让 `ContactsView`
可以先显示一些资料而不用作后续的 RPC。
# View <a id="View"></a>#
view 包含所有用来妆点系统的 UI 元件,
包含 table、label、button、textbox 等等。
view 的责任是 UI 元件的 layout,与 model 无关。
也就是说 view 不知道显示的是哪一个 `Contact`,
它只知道有 x 个 label、y 个 textbox、z 个 button、用垂直的方式排列。
在 view 之间切换则是由 [presenter](#Presenter) 层的
[浏览纪录管理](#history)处理。
在我们这个通讯录系统中的 view 有:
* `ContactsView`
* `EditContactView`
`EditContactView` 用来增加新的联络人、或著是修改既有的联络人。
# Presenter <a id="Presenter"></a>#
presenter 涵盖了通讯录系统所有的逻辑,
包含[浏览纪录管理](#history)、view 转换、
以及透过 RPC 与 server 同步资料。
一般来说,每个 view 都需要一个 presenter 来驱动、
处理 UI widget 发出的 [event](#eventbus)。
在范例程式中有这几个 presenter:
* `ContactsPresenter`
* `EditContactPresenter`
就如同 view 的部份,`EditContactPresenter` 可增加新的联络人、
以及编辑既有的联络人。
# AppController <a id="AppController"></a>#
要处理那些不归属于任何 presenter、而是系统层级的逻辑,
我们将导入 `AppController` 元件。
这个元件包含[浏览纪录管理](#history)以及 view 转换逻辑。
view 的交换会跟浏览纪录管理绑定在一起,后头会有更完整的讨论。
目前整个范例程式的结构会长的像这样:
![](http://www.gwtproject.org/images/contacts-project-hierarchy.png)
有了元件的结构,在我们开始跳进去写程式之前,
我们要先看一下整个启动流程。
下面这段程式码的一般流程会是:
1. GWT 的 bootstrap 会呼叫 `onModuleLoad()`
2. `onModuleLoad()` 会建立 RPC service、event bus 以及 `AppController`
3. `AppController` 会收到 `RootPanel` 的 instanse,然后接管
4. 之后 `AppController` 建立指定的 [presenter](#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 <a id="binding"></a>#
为了将 [presenter](#Presenter) 跟相关的 [view](#view) 连结在一起,
我们要在 presenter 当中定义 `Display` interface 并使用它。
用 `ContactsView` 来举例:
![](http://www.gwtproject.org/images/contact-list-view.png)
这个 view 有三个 widget:一个 table 跟两个 button。
要让系统能作一些有意义的事情,[presenter](#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](#Model) 的资料然后塞到 [view](#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](#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 <a id="eventbus"></a> #
> 译注:HandlerManager 后来就不建议拿来作为 event bus,
> 详情参见... 还没写的文章 [死]。此处依然保留原文的用法。
当 [presenter](#Presenter) 收到来自 view 上头的 widget 发出的 event,
你需要对这些 event 作一些动作。
因此,你需要用 GWT 的 [HandlerManager] 来建立 event bus。
event bus 是一种机制,来传递 event 以及注册某些 event 的 notify。
[HandlerManager]: http://www.gwtproject.org/javadoc/latest/
com/google/gwt/event/shared/HandlerManager.html
有一个重点要记住:不是所有的 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());
}
});
...
}
[HandlerManager.addHandler()]: http://www.gwtproject.org/javadoc/
latest/com/google/gwt/event/shared/HandlerManager.html
#addHandler(com.google.gwt.event.shared.GwtEvent.Type,%20H)
[GwtEvent.Type]: http://www.gwtproject.org/javadoc/latest/
com/google/gwt/event/shared/GwtEvent.Type.html
`AppController` 有一个叫做 `eventBus` 的 [HandlerManager] instance,
然后注册一个新的 `EditContactEventHandler`。
这个 handler 会抓到被编辑的联络人 id,
当 `EditContactEvent.getAssociatedType` 触发 event 的时候
会把 id 传给 `doEditContact()`。
多个元件可以监听单一 event,
所以用 [HandlerManager.fireEvent()] 触发 event 时,
HandlerManager 会对每一个有 handler 的元件
的 `EventHandler` interface 呼叫 `dispatch()`。
[HandlerManager.fireEvent()]: http://www.gwtproject.org/javadoc/
latest/com/google/gwt/event/shared/HandlerManager.html
#fireEvent(com.google.gwt.event.shared.GwtEvent)
我们来看一下 `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 的转换 <a id="history"></a> #
> 译注:前面遇到 history 都会直接翻成“浏览纪录”,
> 遇到指称 history 实际机制、如 history event、history stack 时则保留原文
所有 web application 都不可或缺的部份
就是处理 [history] event。
history event 是 token 字串,可以表示系统的新状态。
把它们当作你身处于系统何处的“标记”。
举个例子:使用者从“联络人列表”画面转换成“增加联络人”画面,
然后按下“返回”按钮。
这些动作的结果应该是让使用者回到“联络人列表”画面,
所以你应该将起始状态(联络人列表)push 进 history stack、
接着 push“增加联络人”画面。
如此一来,当使用者按下“返回”按钮时,
“增加联络人”的 token 会从 stack 中 pop 出来,
然后当下的 token 会是“联络人列表”。
[history]: http://www.gwtproject.org/doc/latest/
DevGuideCodingBasics.html#DevGuideHistory
现在我们已经有条理地清楚流程了,接着得决定该把程式码放在哪里?
考虑到 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();
...
}
}
[ValueChangeHandler]: http://www.gwtproject.org/javadoc/
latest/com/google/gwt/event/logical/shared/ValueChangeHandler.html
[onValueChange()]: http://www.gwtproject.org/javadoc/latest/
com/google/gwt/event/logical/shared/ValueChangeHandler.html
#onValueChange(com.google.gwt.event.logical.shared.ValueChangeEvent)
接着我们需要注册以接收 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()` 建立了画面转换的逻辑,
这就提供了一个集中控管、可重复使用的切换画面方法。
# 测试 <a id="testing"></a> #
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:
![](http://www.gwtproject.org/images/contacts-project-hierarchy-testing.png)
每个范例都会设定“增加 `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.