GWTでMVPアーキテクチャ + EventBus + History その2(HistoryもAppControllerもさようなら)

ちょうど昨日頑張ってHisotryとEventBus、AppControllerの話を書いたのに
今日気がついたらGWT2.1.0-RC1が来ていて、AppControllerもHistoryもEventBusもいらなくなったよ
オワタ \(^o^)/オワタ \(^o^)/オワタ \(^o^)/オワタ \(^o^)/オワタ \(^o^)/

はい、意味ナッシングです。

でもいらなくなったのは必要なくなったのではなく、作らなくてよくなっただけなので、
昨日出て速攻でGWT2.1.0-RC1版MVPフレームワーク対応を行ってみました。

だって男の子だもん

てことで昨日と同じ感じで前提から

私は英語力も日本語力もないので、
わかりにくい、解釈が間違っている個所は多分にあるかと思います。
そういった場合はやさしく見守ってください。

今回MVP Frameworkを作る上で参考にしたのはGWTの公式サイトです。
Googleさん今回は非常に分かりやすかったです。
サンプルのソースが間違っている個所が少々ありましたが。。。
で中身はほぼ写経しました(めんどいところはコピペしました)。

コードはこちらです。

全体像などなど

昨日の記事を見て下さい。
もう一回書くと↓のような画面遷移です。

https://cacoo.com/diagrams/UmEpII6zMLeG4bRm

よくある

「一覧」→「新作成入力」→「一覧」

「一覧」→「修正入力」→「一覧」

のような遷移のモジュールを作成しています。

今回はこれをGWTでMVP化するとどうなるかを書きます。

注意点

GWTのサイトだと書いていませんが、
Activityフレームワークを利用する場合は、
gwt.xml

を記述する必要があります。


登場人物

Activity

ActivityはMVPアーキテクチャで言うところのPresenterの役割を持ちます。
つまり責務は

です。

GWT MVPフレームワークでは
Activity(MVPのPresenter)となるクラスはAbstractActivityを継承します。
そしてstart()メソッドで、担当するViewに対して、データバインドを行います。

コンストラクタで出てくるPlaceは後で解説します。

ClientFactoryは単純なFactoryです。
各getHogeにてインスタンスを作成し、返却します。


ListPresenter(Activity)

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.ClientFactory;
import com.google.code.stk.client.service.TwitterServiceAsync;
import com.google.code.stk.client.ui.display.ListDisplay;
import com.google.code.stk.client.ui.place.EditPlace;
import com.google.code.stk.client.ui.place.ListPlace;
import com.google.code.stk.client.ui.place.NewPlace;
import com.google.code.stk.shared.model.AutoTweet;
import com.google.gwt.activity.shared.AbstractActivity;
import com.google.gwt.event.shared.EventBus;
import com.google.gwt.user.client.Window;
import com.google.gwt.user.client.rpc.AsyncCallback;
import com.google.gwt.user.client.ui.AcceptsOneWidget;

public class ListPresenter extends AbstractActivity implements ListDisplay.Presenter{

	private final TwitterServiceAsync service;
	private final ClientFactory clientFactory;
	private final ListPlace place;
	private ListDisplay display;

	public ListPresenter(ListPlace place , ClientFactory clientFactory) {
		super();
		this.place = place;
		this.clientFactory = clientFactory;
		this.service = this.clientFactory.getTwitterService();
	}


	@Override
	public void clickNewButton() {
		clientFactory.getPlaceController().goTo(new NewPlace());
	}

	@Override
	public void clickDeleteAnchor(final Key key) {
		service.delete(key, new AsyncCallback<Void>() {

			@Override
			public void onSuccess(Void arg0) {

				clientFactory.getPlaceController().goTo(new ListPlace("delete" , key.getId()));
			}

			@Override
			public void onFailure(Throwable throwable) {

			}
		});
	}

	@Override
	public void clickEditAnchor(Key key) {
		clientFactory.getPlaceController().goTo(new EditPlace(key.getId()));
	}

	@Override
	public void savePinCode(String value) {
		service.registAccessToken(value, new AsyncCallback<Void>() {

			@Override
			public void onFailure(Throwable arg0) {

			}

			@Override
			public void onSuccess(Void arg0) {
				display.getPinCodeInputPanel().setVisible(false);
				display.getSavePinCodeButton().setEnabled(true);
				Window.alert("ピンコードを保存しました。");
			}
		});
	}

	@Override
	public void start(final AcceptsOneWidget panel, EventBus eventBus) {
		display = clientFactory.getListDisplay();
		display.setPresenter(this);
		panel.setWidget(display);
		service.findAll(new AsyncCallback<List<AutoTweet>>() {

			@Override
			public void onSuccess(List<AutoTweet> arg0) {
				display.drowTable(arg0);
			}

			@Override
			public void onFailure(Throwable arg0) {
				Window.alert(arg0.getMessage());
			}
		});

	}

}

ClientFactory(Factory)

ClientFactoryはDeferred Bindingを利用しGWT.createにて作成します。
これによりごっそりViewなどをモックに変更することが出来ます。

Factory中のEventBusは昨日説明したEventBusとほぼ同じっぽいです。
つまり独自Eventを処理します。

PlaceControllerは後ほど説明します。

package com.google.code.stk.client;

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.gwt.core.client.GWT;
import com.google.gwt.event.shared.EventBus;
import com.google.gwt.event.shared.SimpleEventBus;
import com.google.gwt.place.shared.PlaceController;

public class ClientFactoryImpl implements ClientFactory {

	private ListDisplay listDisplay = null;

	private PlaceController placeController = null;

	private SimpleEventBus eventBus;

	private TwitterServiceAsync twitterService;

	@Override
	public AutoTweetDisplay getAutoTweetDisplay() {

		return new AutoTweetView();
	}

	@Override
	public EventBus getEventBus() {
		if(eventBus == null){
			eventBus = new SimpleEventBus();
		}
		return eventBus;
	}

	@Override
	public ListDisplay getListDisplay() {
		if(listDisplay == null){
			listDisplay = new ListView();
		}
		return listDisplay;
	}

	@Override
	public PlaceController getPlaceController() {
		if(placeController == null){
			placeController = new PlaceController(getEventBus());
		}
		return placeController;
	}

	@Override
	public TwitterServiceAsync getTwitterService() {
		if(twitterService == null){
			twitterService = GWT.create(TwitterService.class);
		}
		return twitterService;
	}

}
Display(IsWidget)

MVPのDisplayは単純なViewクラスなのでWidgetやUiBinderあたりで作れば問題ありませんが、
Activityで利用する場合はIsWidgetインターフェースを継承しておくと、よいです。

Displayインターフェースの中にPresenterインターフェースを持っています。
これはビジネスロジックをPresenterに委譲させるために、
Displayにとって処理してほしいことをPresenterに切り出し、
そのPresenterを継承しているクラスをsetPresenterにて登録させ、
すべてのビジネスロジックをPresenterにもたせます。
これにより、ビジネスロジックと、画面が切り離され、
デスクトップアプリでありがちな、BigViewを防ぎます。
またそれぞれインターフェースとして、宣言させ、
それぞれのインターフェースを継承させることによって、
PresenterとDisplayの依存度が下がりテストのしやすさがまします。

今回のListViewにとってのPresenterはListPresetnerです。
なのでListPresenterはListDislay.Presenterを継承し、
startメソッド内で、display.setPresenter(this)を行っています。

そしてListView内の各@UiHanderのついたメソッドでビジネスロジック(データ登録など)を
Presenterに委譲しています。
ちなみにMVPの場合は画面遷移もPresenter側の仕事なのでPresenterに任せています。
Viewが行うのは自身のもつWidgetの管理のみです。

ListDisplay

package com.google.code.stk.client.ui.display;

import java.util.List;

import com.google.appengine.api.datastore.Key;
import com.google.code.stk.shared.model.AutoTweet;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.FlowPanel;
import com.google.gwt.user.client.ui.IsWidget;

public interface ListDisplay extends IsWidget{

	void drowTable(List<AutoTweet> tweetList);

	public interface Presenter{

		void clickNewButton();

		void clickDeleteAnchor(Key key);

		void clickEditAnchor(Key key);

		void savePinCode(String value);
	}

	public void setPresenter(Presenter presenter);

	FlowPanel getPinCodeInputPanel();

	Button getSavePinCodeButton();

}

ListView

package com.google.code.stk.client.ui.display;

import java.util.List;

import com.google.code.stk.shared.model.AutoTweet;
import com.google.gwt.core.client.GWT;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.ClickHandler;
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.Anchor;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.Composite;
import com.google.gwt.user.client.ui.FlexTable;
import com.google.gwt.user.client.ui.FlowPanel;
import com.google.gwt.user.client.ui.InlineLabel;
import com.google.gwt.user.client.ui.TextBox;
import com.google.gwt.user.client.ui.Widget;

public class ListView extends Composite implements ListDisplay {

	@UiField
	Button newButton;

	@UiField
	InlineLabel sizeLabel;

	@UiField
	FlexTable table;

	@UiField
	Anchor pinCodeLink;

	@UiField
	FlowPanel pinCodeInputPanel;

	@UiField
	TextBox pinCode;

	@UiField
	Button savePinCode;

	private static ListViewUiBinder uiBinder = GWT
			.create(ListViewUiBinder.class);

	private Presenter presenter;

	interface ListViewUiBinder extends UiBinder<Widget, ListView>{
	}

	@Override
	public void drowTable(List<AutoTweet> tweetList) {
		table.removeAllRows();
		if (tweetList == null || tweetList.isEmpty()) {
			table.setText(0, 0, "tweetなし");
			return;
		}

		table.setText(0, 0, "ID");
		table.setText(0, 1, "対象");
		table.setText(0, 2, "内容");
		table.setText(0, 3, "時間");
		table.setText(0, 4, "間隔");
		table.setText(0, 5, "ブレ");
		table.setText(0, 6, "期間");
		table.setText(0, 7, "最終Tweet日");
		table.setText(0, 8, "修正");
		table.setText(0, 9, "削除");

		int row = 1;
		for (final AutoTweet autoTweet : tweetList) {
			table.getColumnFormatter().setWidth(1, "140em");
			table.getColumnFormatter().setWidth(3, "6em");
			table.getColumnFormatter().setWidth(4, "8em");
			table.setCellPadding(10);
			table.setCellSpacing(10);
			table.setText(row, 0, String.valueOf(autoTweet.getKey().getId()));
			table.setText(row, 1, autoTweet.getScreenName());
			table.setText(row, 2, autoTweet.getTweet());
			table.setText(row, 3, autoTweet.getTweetHour() + "時");
			table.setText(row, 4, autoTweet.getCycle().name());
			table.setText(row, 5, autoTweet.getBure().name());
			table.setText(row, 6, autoTweet.getStartMMdd() + " 〜 " + autoTweet.getEndMMdd());
			table.setText(row, 7, autoTweet.getLastTweetAt());
			Anchor syusei = new Anchor("修正");
			syusei.addClickHandler(new ClickHandler() {

				@Override
				public void onClick(ClickEvent arg0) {
					presenter.clickEditAnchor(autoTweet.getKey());
				}
			});

			table.setWidget(row, 8, syusei);
			Anchor sakujo = new Anchor("削除");

			sakujo.addClickHandler(new ClickHandler() {

				@Override
				public void onClick(ClickEvent arg0) {
					presenter.clickDeleteAnchor(autoTweet.getKey());
				}
			});

			table.setWidget(row, 9, sakujo);
			row++;
		}
	}

	@Override
	public Widget asWidget() {
		return this;
	}

	@Override
	public void setPresenter(Presenter presenter) {
		this.presenter = presenter;
	}

	@Override
	public FlowPanel getPinCodeInputPanel(){
		return pinCodeInputPanel;
	}

	@UiHandler("newButton")
	void onClickNewButton(ClickEvent e){
		presenter.clickNewButton();
	}

	@UiHandler("pinCodeLink")
	public void onClickPinCodeLink(ClickEvent e){
		pinCodeInputPanel.setVisible(true);
	}

	@UiHandler("savePinCode")
	public void onClickSavePinCode(ClickEvent e){
		savePinCode.setEnabled(false);
		presenter.savePinCode(pinCode.getValue());
	}

	public ListView() {
		initWidget(uiBinder.createAndBindUi(this));
	}

	@Override
	public Button getSavePinCodeButton() {
		return savePinCode;
	}
}
Place

Placeは画面や状態を表します。
昨日AppControllerとEventBus、Historyにてヒストリー中心の画面遷移を説明しました。
AppControllerとEventBus、Historyのやることをより、わかりやすく、そして責務を画面遷移、状態保持に
特化させたのがPlaceです。(タブン)

GWTでのPlaceフレームワークは画面や状態を表すPlaceクラスと、
それらを利用してHistoryイベントを発火するPlaceController、
PlaceとHistoryマッピングさせるPlaceHistoryMapper、
そしてPlaceとActivityをひもづける、ActivityMapperを利用して実現します。

まず画面や状態を表す、Placeクラスの子クラスから
ListPlace
ListPlaceは一覧画面を表すPlaceクラスの子クラスです。
idやstateを持っているのは削除処理からもどった、
修正処理から戻った、
新規作成から戻ったなどを管理するためです。

ListPlaceにはListPlaceの情報からTokenを導き出す、
Tokenizerを持っています。
TokenizerはListPlaceの作成と、ListPlaceからtokenの作成を行います。

package com.google.code.stk.client.ui.place;

import com.google.gwt.place.shared.Place;
import com.google.gwt.place.shared.PlaceTokenizer;

public class ListPlace extends Place {

	private final String state;
	private final Long id;

	public ListPlace(){
		this(null , null);
	}

	public ListPlace(String state, Long id) {
		this.state = state;
		this.id = id;
	}

	public ListPlace(String state) {
		this(state, null);
	}

	public Long getId() {
		return id;
	}

	public String getStringId(){
		return id == null?"":id.toString();
	}

	public String getState() {
		return state == null ? "" :state;
	}
	public static class Tokenizer implements PlaceTokenizer<ListPlace>{

		@Override
		public ListPlace getPlace(String token) {

			if(token.contains("delete")){
				return new ListPlace("delete" , Long.parseLong(token.replace("delete/", "")));
			}

			if(token.contains("edit")){
				return new ListPlace("edit" , Long.parseLong(token.replace("edit/", "")));
			}

			if(token.contains("new")){
				return new ListPlace("new");
			}

			return new ListPlace();
		}

		@Override
		public String getToken(ListPlace place) {
			return place.getState() + "/" + place.getStringId();
		}

	}
}
PlaceController

PlaceControllerはPlaceを利用して、PlaceHistoryChangeEventを発火し遷移を実現します。
たとえばListPresenterの下記メソッドは修正アンカー押下時に、
修正画面へ遷移させます。
ListPresenter

ListPresetner.java
	@Override
	public void clickEditAnchor(Key key) {
		clientFactory.getPlaceController().goTo(new EditPlace(key.getId()));
	}
PlaceHistoryMapper

PlaceHistoryMapperは各PlaceのTokenizerを利用して、HistoryTokenとPlaceのマッピングを行います。
@WithTokenizersアノテーションにTokenizerを指定するだけでマッピングしてくれます。
ちなみに発行されるHistoryTokenは {Place短縮クラス名}:{Tokenizer.getToken()}の形になります。。。
Place短縮クラス名がダサいので、治す方法が知りたい限りです。。。

package com.google.code.stk.client;

import com.google.code.stk.client.ui.place.EditPlace;
import com.google.code.stk.client.ui.place.ListPlace;
import com.google.code.stk.client.ui.place.NewPlace;
import com.google.gwt.place.shared.PlaceHistoryMapper;
import com.google.gwt.place.shared.WithTokenizers;

@WithTokenizers({ListPlace.Tokenizer.class , EditPlace.Tokenizer.class , NewPlace.Tokenizer.class})
public interface AppPlaceHistoryMapper extends PlaceHistoryMapper {

}
ActivityMapper

ActivityMapperはPlaceとActivityをひもづけます。
ひもづけは頑張ります。。。
instanceofが微妙なのですが、、、
どうにかしたいですね。。。

package com.google.code.stk.client;

import com.google.code.stk.client.ui.place.EditPlace;
import com.google.code.stk.client.ui.place.ListPlace;
import com.google.code.stk.client.ui.place.NewPlace;
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.activity.shared.Activity;
import com.google.gwt.activity.shared.ActivityMapper;
import com.google.gwt.place.shared.Place;

public class AppActivityMapper implements ActivityMapper {

	private ClientFactory clientFactory;

	public AppActivityMapper(ClientFactory clientFactory) {
		super();
		this.clientFactory = clientFactory;
	}

	@Override
	public Activity getActivity(Place place) {
		if(place instanceof ListPlace){
			return new ListPresenter((ListPlace)place, clientFactory);
		}

		if(place instanceof NewPlace){
			return new NewPresenter((NewPlace)place ,  clientFactory);
		}

		if(place instanceof EditPlace){
			return new EditPresenter((EditPlace)place, clientFactory);
		}

		return null;
	}

}
Activity、Placeまとめ

以上がActivity、Placeの関連クラスとその実装です。
画面遷移をHistory中心で行うことが出来、ブラウザBack、Ajaxにつよりアプリケーションが作成できます。
昨日書いた、AppControllerや、Historyフレームワークの中に隠れた状態になっています。
(または各MapperやPlaceに分散されている)
上記の各MapperクラスはEntryPointあたりで各Manegerクラスに登録しておきます

		ClientFactory clientFactory = GWT.create(ClientFactory.class);
		EventBus eventBus = clientFactory.getEventBus();
		PlaceController placeController = clientFactory.getPlaceController();

		// Start ActivityManager for the main widget with our ActivityMapper
		ActivityMapper activityMapper = new AppActivityMapper(clientFactory);
		ActivityManager activityManager = new ActivityManager(activityMapper,eventBus);
		activityManager.setDisplay(appWidget);

		// Start PlaceHistoryHandler with our PlaceHistoryMapper
		AppPlaceHistoryMapper historyMapper = GWT
				.create(AppPlaceHistoryMapper.class);
		PlaceHistoryHandler historyHandler = new PlaceHistoryHandler(historyMapper);
		historyHandler.register(placeController, eventBus, defaultPlace);

もうチョイ細かい動きはまた今度追います。

あとがき

今回のも前回のも全くMVPフレームワークのデザインパターンを説明してない。。。
なんでHogeDisplayがPresetner持ってるのとか説明しないとですね。。。
でもAcitivityとPlaceが出ちゃったんだもん。。。
うん、良かったと思ってるよ。。。
Data Presenterも書きたいですね。