GWTでMVPアーキテクチャ + EventBus + History その1(HistoryとAppController)
久しぶりに書きます。
最近はもっぱらGWTを扱ってます。
半年ぐらい触ってみて、やはりGWTではMVPアーキテクチャが大事になってくるなと思ったりしました。
僕も最初はまったく分かっていなかったのですが、id:k2juniorさんのこちらの日記をみて結構理解ができました。
そのあとGWTの大規模開発の説明文(?)においてある、
サンプルプロジェクトをみて何となく理解して、
自分なりに書けるようになってきたのでMVP + EventBusパターンについて書いてみます。
なお英語は読めない、聞けないなので、
Googlge IO 2009のGoogle Web Toolkit Architecture: Best Practices for Architecting Your GWT Appや
GWTの大規模開発の説明文(?)はなんとな〜くしかわかっていません。
なので解釈や、使い方が間違っている場所は多々あると思いますので、
ご指摘をお願いいたします。
ちなみにサンプルとしてはGAE上に乗っけているTwitterのBot(のデータ入力画面)を使います。
コード:http://goo.gl/7xPh (Google Codeです。)
作っているものの全体像(画面遷移)
今回つくっている画面遷移は以下のような感じです。
https://cacoo.com/diagrams/UmEpII6zMLeG4bRm
よくある
「一覧」→「新作成入力」→「一覧」
「一覧」→「修正入力」→「一覧」
のような遷移のモジュールを作成しています。
今回はこれをGWTでMVP化するとどうなるかを書きます。
登場人物
MVP + EventBusパターンでは以下のような登場人物がいます。
Model パッケージ:com.google.code.stk.share.model
MVCのModel
(モデルの解釈がいろいろあるので詳細は除きます。)
私の場合はappengineのEntityがそのままModelとしています。
package com.google.code.stk.shared.model; import java.io.Serializable; import com.google.appengine.api.datastore.Key; import com.google.code.stk.shared.Enums; import org.slim3.datastore.Attribute; import org.slim3.datastore.Model; @Model(schemaVersion = 1) public class AutoTweet implements Serializable { private static final long serialVersionUID = 1L; @Attribute(primaryKey = true) private Key key; @Attribute(version = true) private Long version; /** tweet */ @Attribute(unindexed = true) private String tweet; /** bure */ @Attribute(unindexed = true) private Enums.Bure bure; /** cycle */ @Attribute(unindexed = true) private Enums.Cycle cycle; /** startMMdd */ private String startMMdd; /** endMMdd */ private String endMMdd; /** tweetTime(hh) */ @Attribute private String tweetHour; @Attribute(unindexed = true) private String lastTweetAt; private String screenName; /** * keyを取得します。 * @return key */ public Key getKey() { return key; } /** * keyを設定します。 * @param key key */ public void setKey(Key key) { this.key = key; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((key == null) ? 0 : key.hashCode()); return result; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } AutoTweet other = (AutoTweet) obj; if (key == null) { if (other.key != null) { return false; } } else if (!key.equals(other.key)) { return false; } return true; } /** * versionを取得します。 * @return version */ public Long getVersion() { return version; } /** * versionを設定します。 * @param version version */ public void setVersion(Long version) { this.version = version; } /** * tweetを取得します。 * @return tweet */ public String getTweet() { return tweet; } /** * tweetを設定します。 * @param tweet tweet */ public void setTweet(String tweet) { this.tweet = tweet; } /** * cycleを取得します。 * @return cycle */ public Enums.Cycle getCycle() { return cycle; } /** * cycleを設定します。 * @param cycle cycle */ public void setCycle(Enums.Cycle cycle) { this.cycle = cycle; } /** * startMMddを取得します。 * @return startMMdd */ public String getStartMMdd() { return startMMdd; } /** * startMMddを設定します。 * @param startMMdd startMMdd */ public void setStartMMdd(String startMMdd) { this.startMMdd = startMMdd; } /** * endMMddを取得します。 * @return endMMdd */ public String getEndMMdd() { return endMMdd; } /** * endMMddを設定します。 * @param endMMdd endMMdd */ public void setEndMMdd(String endMMdd) { this.endMMdd = endMMdd; } /** * tweetTime(hhmm)を取得します。 * @return tweetTime(hhmm) */ public String getTweetHour() { return tweetHour; } /** * tweetTime(hhmm)を設定します。 * @param tweetTime tweetTime(hhmm) */ public void setTweetHour(String tweetTime) { this.tweetHour = tweetTime; } public void setBure(Enums.Bure bure) { this.bure = bure; } public Enums.Bure getBure() { return bure; } public String getLastTweetAt() { return lastTweetAt; } public void setLastTweetAt(String lastTweetAt) { this.lastTweetAt = lastTweetAt; } public String getScreenName() { return screenName; } public void setScreenName(String value) { this.screenName = value; } }
Display パッケージ:com.google.code.stk.client.ui.display
MVPのViewにあたるインターフェース。
実際のViewクラス(WidgetsやUiBinder)の各項目をメソッドに持ちます。
package com.google.code.stk.client.ui.display; import java.util.List; import com.google.appengine.api.datastore.Key; import com.google.gwt.user.client.ui.Button; import com.google.gwt.user.client.ui.HasValue; public interface AutoTweetDisplay extends Display { HasValue<String> getKeyId(); HasValue<String> getTweet(); HasValue<String> getBure(); HasValue<String> getCycle(); HasValue<String> getEndMMdd(); HasValue<String> getStartMMdd(); HasValue<String> getTweetHour(); void setPresenter(Presenter presenter); public interface Presenter{ public void regist(); } Button getRegistButton(); void setScreenNames(List<Key> arg0); HasValue<String> getScreenName(); }
DisplayはPresenterとViewをそ結合にし、
再利用性とテストをしやすくしています。
ほとんどのメソッドがHas〜インターフェースで宣言されているのは、
テスト時などにモックを入れやすくするためです。
View パッケージ:com.google.code.stk.client.ui.display
ViewクラスはUiBinderやWidgetsで作成された、「画面」を持つクラスです。
ViewはDisplayを実装し、画面を作成します。
package com.google.code.stk.client.ui.display; import java.util.List; import com.google.appengine.api.datastore.Key; import com.google.code.stk.client.ui.base.ValueListBox; import com.google.code.stk.shared.Enums.Bure; import com.google.code.stk.shared.Enums.Cycle; import com.google.gwt.core.client.GWT; import com.google.gwt.event.dom.client.ClickEvent; import com.google.gwt.uibinder.client.UiBinder; import com.google.gwt.uibinder.client.UiField; import com.google.gwt.uibinder.client.UiHandler; import com.google.gwt.user.client.ui.Button; import com.google.gwt.user.client.ui.Composite; import com.google.gwt.user.client.ui.HasValue; import com.google.gwt.user.client.ui.TextBox; import com.google.gwt.user.client.ui.Widget; public class AutoTweetView extends Composite implements AutoTweetDisplay{ @UiField TextBox tweet; @UiField TextBox startMMdd; @UiField TextBox endMMdd; @UiField ValueListBox cycle; @UiField ValueListBox tweetHour; @UiField ValueListBox bure; @UiField TextBox keyId; @UiField Button registButton; @UiField ValueListBox screenName; private static AutoTweetViewUiBinder uiBinder = GWT .create(AutoTweetViewUiBinder.class); private Presenter presenter; private List<Key> arg0; interface AutoTweetViewUiBinder extends UiBinder<Widget, AutoTweetView> { } public AutoTweetView() { initWidget(uiBinder.createAndBindUi(this)); initDisplay(); } private void initDisplay() { for (Bure b : Bure.values()) { bure.addItem(b.name()); } for(Cycle c : Cycle.values()){ cycle.addItem(c.name()); } for(int i = 0; i < 24 ; i++){ String hour = String.valueOf(i); if(hour.length() < 2){ hour = 0 + hour; } tweetHour.addItem(hour +"時" , hour); } } @UiHandler("registButton") public void onRegistButtonClick(ClickEvent e){ registButton.setEnabled(false); presenter.regist(); } @Override public Button getRegistButton(){ return registButton; } @Override public HasValue<String> getBure() { return bure; } @Override public HasValue<String> getCycle() { return cycle; } @Override public HasValue<String> getEndMMdd() { return endMMdd; } @Override public HasValue<String> getKeyId() { return keyId; } @Override public HasValue<String> getStartMMdd() { return startMMdd; } @Override public HasValue<String> getTweetHour() { return tweetHour; } @Override public Widget asWidget() { return this; } @Override public HasValue<String> getTweet() { return tweet; } @Override public void setPresenter(Presenter presenter) { this.presenter = presenter; } @Override public void setScreenNames(List<Key> arg0) { this.arg0 = arg0; for (Key key : this.arg0) { screenName.addItem(key.getName()); } } @Override public HasValue<String> getScreenName() { return screenName; } }
Presenter
MVPのPで「機能提供者」の役割を持ちます。
やることは
- イベントの処理
- データバインド(Viewへ渡す)
です。
package com.google.code.stk.client.ui.presenter; import java.util.List; import com.google.appengine.api.datastore.Key; import com.google.code.stk.client.event.NewTweetCreatedEvent; import com.google.code.stk.client.service.TwitterServiceAsync; import com.google.code.stk.client.ui.display.AutoTweetDisplay; import com.google.code.stk.client.ui.display.AutoTweetDisplay.Presenter; import com.google.code.stk.shared.Enums.Bure; import com.google.code.stk.shared.Enums.Cycle; import com.google.code.stk.shared.model.AutoTweet; import com.google.gwt.event.shared.HandlerManager; import com.google.gwt.user.client.rpc.AsyncCallback; public class NewPresenter extends AbstractPresenter<AutoTweetDisplay> implements Presenter { private final TwitterServiceAsync service; public NewPresenter(AutoTweetDisplay display, HandlerManager eventBus , TwitterServiceAsync service) { super(display, eventBus); this.service = service; display.setPresenter(this); } @Override public void regist() { display.getRegistButton().setEnabled(false); AutoTweet data = new AutoTweet(); data.setBure(Bure.valueOf(display.getBure().getValue())); data.setCycle(Cycle.valueOf(display.getCycle().getValue())); data.setStartMMdd(display.getStartMMdd().getValue()); data.setEndMMdd(display.getEndMMdd().getValue()); data.setTweet(display.getTweet().getValue()); data.setTweetHour(display.getTweetHour().getValue()); data.setScreenName(display.getScreenName().getValue()); service.regist(data, new AsyncCallback<Void>() { @Override public void onSuccess(Void arg0) { display.getRegistButton().setEnabled(true); eventBus.fireEvent(new NewTweetCreatedEvent()); } @Override public void onFailure(Throwable throwable) { } }); } @Override protected void initView() { container.clear(); service.findAllAccessToeknOnlyKey(new AsyncCallback<List<Key>>() { @Override public void onFailure(Throwable arg0) { } @Override public void onSuccess(List<Key> arg0) { display.setScreenNames(arg0); container.add(display.asWidget()); } }); } }
AppController
AppControllerは各PresenterとDisplayの管理、
独自Eventの処理を行います。
またValueChangeHandlerを実装することで、
PresenterとHistoryを管理することにより、
画面遷移をコントロールしています。
package com.google.code.stk.client; import com.google.code.stk.client.event.DeleteTweetClickEvent; import com.google.code.stk.client.event.DeleteTweetClickHandler; import com.google.code.stk.client.event.EditTweetClickEvent; import com.google.code.stk.client.event.EditTweetClickHandler; import com.google.code.stk.client.event.NewTweetClickEvent; import com.google.code.stk.client.event.NewTweetClickHandler; import com.google.code.stk.client.event.NewTweetCreatedEvent; import com.google.code.stk.client.event.NewTweetCreatedHandler; import com.google.code.stk.client.service.TwitterService; import com.google.code.stk.client.service.TwitterServiceAsync; import com.google.code.stk.client.ui.display.AutoTweetDisplay; import com.google.code.stk.client.ui.display.AutoTweetView; import com.google.code.stk.client.ui.display.ListDisplay; import com.google.code.stk.client.ui.display.ListView; import com.google.code.stk.client.ui.presenter.AbstractPresenter; import com.google.code.stk.client.ui.presenter.EditPresenter; import com.google.code.stk.client.ui.presenter.ListPresenter; import com.google.code.stk.client.ui.presenter.NewPresenter; import com.google.gwt.core.client.GWT; import com.google.gwt.event.logical.shared.ValueChangeEvent; import com.google.gwt.event.logical.shared.ValueChangeHandler; import com.google.gwt.event.shared.HandlerManager; import com.google.gwt.user.client.History; import com.google.gwt.user.client.ui.HasWidgets; public class AppController implements ValueChangeHandler<String> { private final HandlerManager eventBus; private HasWidgets container; private TwitterServiceAsync service = GWT.create(TwitterService.class); public AppController(HandlerManager eventBus) { super(); this.eventBus = eventBus; bind(); } public void go(HasWidgets container){ this.container = container; if(History.getToken() == null || History.getToken().equals("")){ History.newItem("list"); }else{ History.fireCurrentHistoryState(); } } private void bind() { History.addValueChangeHandler(this); eventBus.addHandler(NewTweetClickEvent.TYPE, new NewTweetClickHandler() { @Override public void onClick(NewTweetClickEvent e) { History.newItem("new"); } }); eventBus.addHandler(EditTweetClickEvent.TYPE, new EditTweetClickHandler() { @Override public void onClick(EditTweetClickEvent e) { History.newItem("edit/" + String.valueOf(e.getKey())); } }); eventBus.addHandler(DeleteTweetClickEvent.TYPE, new DeleteTweetClickHandler() { @Override public void onClick(DeleteTweetClickEvent e) { History.newItem("list/deleteComplete/" + String.valueOf(e.getKey())); } }); eventBus.addHandler(NewTweetCreatedEvent.TYPE, new NewTweetCreatedHandler() { @Override public void onCreated(NewTweetCreatedEvent e) { History.newItem("list/newComplete"); } }); } @Override public void onValueChange(ValueChangeEvent<String> arg0) { String token = History.getToken(); if(token == null || token.startsWith("list") || token.equals("")){ ListDisplay display = new ListView(); AbstractPresenter<ListDisplay> presenter = new ListPresenter(display , eventBus , service); presenter.go(container); return; } if(token.equals("new")){ AutoTweetView display = new AutoTweetView(); AbstractPresenter<AutoTweetDisplay> presenter = new NewPresenter(display, eventBus, service); presenter.go(container); return; } if(token.startsWith("edit/")){ long id = Long.parseLong(token.replace("edit/", "")); AutoTweetView display = new AutoTweetView(); AbstractPresenter<AutoTweetDisplay> presenter = new EditPresenter(display, eventBus, service, id); presenter.go(container); } } }
EventBus (HandlerManager)
EventBus(HandlerManager)は独自作成Eventの管理を行います。
このEventBusに独自Eventを登録し、Handlerの処理を定義することができます。
動きの流れ
① 始まり(EntryPoint → AppController)
まずEntryPointではEventBusの作成と、AppControllerの作成をおこないます。
またAppControllerに表示の起点(container)を通知します。
package com.google.code.stk.client; import com.google.gwt.core.client.EntryPoint; import com.google.gwt.event.shared.HandlerManager; import com.google.gwt.user.client.ui.RootPanel; public class Index implements EntryPoint { @Override public void onModuleLoad() { HandlerManager eventBus = new HandlerManager(null); AppController appController = new AppController(eventBus); appController.go(RootPanel.get("contents")); RootPanel.get("msg").setVisible(false); } }
AppControllerがnewされるタイミングでコンストラクタにて、EventBusを受け取り、
独自イベントの登録を行います。(bind()メソッド)
独自Eventの作成pointは画面遷移が行われる個所です。
また独自Eventの処理も画面遷移が行われたことを記録するため
Historyの登録のみおこないます。
またこの時、AppController自信をHistoryのValueChangeEventHandlerとして登録します。
@AppControllerのコンストラクタ
public AppController(HandlerManager eventBus) { super(); this.eventBus = eventBus; bind(); } private void bind() { History.addValueChangeHandler(this); eventBus.addHandler(NewTweetClickEvent.TYPE, new NewTweetClickHandler() { @Override public void onClick(NewTweetClickEvent e) { History.newItem("new"); } }); eventBus.addHandler(EditTweetClickEvent.TYPE, new EditTweetClickHandler() { @Override public void onClick(EditTweetClickEvent e) { History.newItem("edit/" + String.valueOf(e.getKey())); } }); eventBus.addHandler(DeleteTweetClickEvent.TYPE, new DeleteTweetClickHandler() { @Override public void onClick(DeleteTweetClickEvent e) { History.newItem("list/deleteComplete/" + String.valueOf(e.getKey())); } }); eventBus.addHandler(NewTweetCreatedEvent.TYPE, new NewTweetCreatedHandler() { @Override public void onCreated(NewTweetCreatedEvent e) { History.newItem("list/newComplete"); } }); }
EntryPointでAppControllerの作成が完了したら、AppController#goを呼び出し、
初期画面を表示します。
goメソッドでは、初期表示を行う親Widgetsを受け取り、保存します。
そして、Historyになにも積まれてなければ、初期画面を表示するためHistoryにtokenを保存します。
既に、Historyに何かあった場合(通常はF5などで画面を更新したなど)は現行のTokenの状態でValueChangeEventを発生させます。
public void go(HasWidgets container){ this.container = container; if(History.getToken() == null || History.getToken().equals("")){ History.newItem("list"); }else{ History.fireCurrentHistoryState(); } }
goメソッドにて、発行されたHistoryTokenにより、ValueChangeEventが発生します。
AppControllerのコンストラクタにて、AppController自信がHistoryのValueChangeHandlerとして
登録されているため、AppControllerのonValueChangeメソッドが呼び出されます。
onValueChangeメソッドではHistoryTokenをもとにどの画面を表示するか(DisplayとPresenterを使うか)を
判定します。
初期処理の場合は、tokenが"list"となっているので、ListDisplayとListPresenterが利用されます。
@Override public void onValueChange(ValueChangeEvent<String> arg0) { String token = History.getToken(); if(token == null || token.startsWith("list") || token.equals("")){ ListDisplay display = new ListView(); AbstractPresenter<ListDisplay> presenter = new ListPresenter(display , eventBus , service); presenter.go(container); return; } if(token.equals("new")){ AutoTweetView display = new AutoTweetView(); AbstractPresenter<AutoTweetDisplay> presenter = new NewPresenter(display, eventBus, service); presenter.go(container); return; } if(token.startsWith("edit/")){ long id = Long.parseLong(token.replace("edit/", "")); AutoTweetView display = new AutoTweetView(); AbstractPresenter<AutoTweetDisplay> presenter = new EditPresenter(display, eventBus, service, id); presenter.go(container); } }
以上にて、EntryPointからAppControllerまでの処理は完了です。
AppControllerがHistoryにより画面遷移を制御することで、
画面遷移=Historyへの登録という状態が出来ます。
これによりF5やブラウザバックに強いアプリケーションが作成できます。
たとえば上記にて一覧画面が表示された後、新規作成画面を表示する場合は、
ListPresenterにてNewTweetClickEventをEventBus経由で発火することにより、
再びAppControllerへ処理が戻り、Historyが"new"で積まれ、
AppControllerのonValueChangeメソッドが呼ばれ
NewPresetnerとNewDisplayが選ばれて画面が表示されます。
大規模な開発の場合はこのAppControllerを複数作成し、Event処理を分割することも可能です。
またonValueChangeごとで、コード分割(GWT.runAsync)を行って画面ごとにコードを分割することも可能です。
Historyを使った画面遷移は管理もしやすく、値画面遷移にパラメータを必要とする場合も、#hoge/valueなどとしてうまく取得することもできます。
※タブンGmailとかもそんな感じ?
AppControllerを利用することで、見やすい画面遷移の制御を作ることが出きます。
あとがき
文才がない。。。。
次回はMVPアーキテクチャを細かく書きます。
タブン、ソノウチ、イツカカキマス