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上に乗っけているTwitterBot(のデータ入力画面)を使います。

コード: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アーキテクチャを細かく書きます。
タブン、ソノウチ、イツカカキマス