понедельник, 25 июля 2011 г.

Введение в MVP GWT 2.1

http://habrahabr.ru/blogs/gwt/113121/
Когда я писал топик об использовании шаблона Command для организации
RPC-вызовов в GWT то упоминал об MVP-паттерне для построения
архитектуры GWT-проектов. Сразу после выступления товарища Ray Rayan'а
с докладом о проектировании сложных приложений на GWT трудящиеся по
всему миру принялись реализовывать озвученные идеи в виде библиотек и
фреймворков. Результатом этих трудов стали средства, позволяющие
применять как некоторые моменты MVP-подхода (GWT-Presenter) так и его
целиком (Mvp4G). Это все замечательно, но мне лично (уверен, что и
остальным GWT-разработчикам) хотелось бы иметь стандартизированный
(если можно так сказать) фреймворк/подход для организации
GWT-приложений по MVP-схеме. И вот команда, которая отвечает в Google
за GWT, наконец-то в версии 2.1 наряду с остальными вкусными плюшками
предложила встроенный MVP-фреймворк.
В данной заметке я постараюсь осветить основные моменты встроенного
GWT MVP-фреймворка. Как пример будет использовано небольшое
приложение, построенное с применением этого подхода.
Данная заметка является вольным трактатом (GWT MVP Development) и на
уникальность подхода никоим образом не претендует. Если кого-то
заинтересовало такое немаленькое введение

Скажу сразу, что я не буду заострять особого внимания на самом шаблоне
проектирования MVP. Ознакомиться с его схемой можно, к примеру на (MVP
на Wiki). Для того, чтобы заинтересовать %username% приведу скриншот
приложения, которое у меня в итоге получилось.
Demo Project
Да, я понимаю, что приложение ничего полезного не делает, но на его
основе постараюсь показать, как разделить архитектуру на отдельные
независимые части (mail, contacts и tasks, кстати, взятые просто так,
с потолка), организовать переключение и связь между ними с помощью
встроенных в GWT 2.1 механизмов. Помимо этого не будет рассмотрена
M-составляющая MVP-паттерна, т.е. модель, поскольку в приложении в
иллюстративных целях нет никакой привязки к данным.

Основные составляющие встроенного MVP

Команда GWT предложила следующие ключевые составляющие для построения
приложения с использованием MVP:

* Activity -- в классическом подходе это Presenter. Отвечает за
логику открытого в данный момент вида. Не содержит в себе никаких
GWT-виджетов или связанного с UI кода. Но в свою очередь имеет
связанный объект вида (view). Запускается и останавливается
автоматически с помощью ActivityManager-а
* ActivityManager -- встроенный объект, который управляет жизненным
циклом Activities, которые в нем зарегистрированы
* Place -- отвечает за состояние текущего вида. В основном
состояние вида передается с использование URL-ов (например, открыть
контакт с ID=<таким-то> на редактирование) или по другому,
history-токенов. Благодаря объекту PlaceHistoryHandler, который
"слушает" изменения адресной строки браузера может быть воссоздано
нужное состояние объекта Place. При воссоздании или сохранении
состояния объекта Place используется PlaceTokenizer-объект, методы
которого вызываются при воссоздании и сохранении состояния
описываемого объекта
* PlaceHistoryMapper, ActivityMapper -- классы-мапперы, которые по
сути отвечают за регистрацию всех Place'ов и Activity'ей приложения.
ActivityMapper также на основании переданного объекта Place (который в
свою очередь был воссоздан из history-токена) принимает решение какой
объект Activity будет связан с соответствующим состоянием URL
* View -- простые Composite-виджеты, которые могут состоять из
других вложенных виджетов. Содержат в себе инстанс связанного
презентера (activity). Не содержат в себе логики, кроме как логики,
нужной для UI-нужд, например, переключение стилей и т.д. Выполнение
всей полезной логики вид делегирует связанному презентеру путем вызова
его методов.


Рассмотрим теперь более подробно все эти компоненты в отдельности на
примере реального кода. Кстати, код приложения доступен для cвободного
доступа. Так что в него можно подглядывать по мере чтения.

Визуальная составляющая или View

Прежде всего стоит сказать, что в MVP принято "обмениваться" между
презентером и видом только интерфейсами. Поэтому для каждого вида в
приложении есть соответствующий интерфейс

package com.gshocklab.mvp.client.mvp.view;

import com.google.gwt.user.client.ui.IsWidget;

public interface IMailView extends IsWidget {
public void setPresenter(IMailPresenter presenter);

public interface IMailPresenter { }
}


В интерфейсе вида описан соответствующий интерфейс презентера, который
будет выполнять всю полезную нагрузку (посылка запросов на получение
данных, обработка событий из шины событий и т.д.).
Реализация этого интерфейса будет простой и не должна вызвать каких-то
особых вопросов.

public class MailView extends Composite implements IMailView {
interface MailViewUiBinder extends UiBinder<Widget, MailView> { }
private static MailViewUiBinder uiBinder =
GWT.create(MailViewUiBinder.class);

private IMailPresenter presenter;

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

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


Связанный ui.xml-файл содержит всего один виджет, Label с простым
текстом. Приводить его код нет смысла, его можно посмотреть на сайте
проекта.
Это все, что касается View-части. Перейдем теперь к более интересному,
к Activity.

Логика страниц (видов) или Activity

В демо-приложении слева есть навигационная панель с ссылками. При
кликах на этих ссылка выполняется переключение между видами и
устанавливается CSS-стиль для текущей ссылки. Это действие я вынес в
абстрактный родительский класс AbstractMainActivity, который является
наследником встроенного класса AbstractActivity

package com.gshocklab.mvp.client.mvp.activity;

public abstract class AbstractMainActivity extends AbstractActivity {
private static Map<String, Element> navLinks = new
LinkedHashMap<String, Element>();
static {
navLinks.put(AppConstants.MAIL_LINK_ID,
DOM.getElementById(AppConstants.MAIL_LINK_ID));
navLinks.put(AppConstants.CONTACTS_LINK_ID,
DOM.getElementById(AppConstants.CONTACTS_LINK_ID));
navLinks.put(AppConstants.TASKS_LINK_ID,
DOM.getElementById(AppConstants.TASKS_LINK_ID));
}

public void applyCurrentLinkStyle(String viewId) {
for (String linkId : navLinks.keySet()) {
final Element link = navLinks.get(linkId);
if (link == null) continue;
if (linkId.equals(viewId)) {
link.addClassName("b-current");
} else {
link.removeClassName("b-current");
}
}
}
}


И конкретная реализация конкретной Activity

package com.gshocklab.mvp.client.mvp.activity;

public class MailActivity extends AbstractMainActivity implements
IMailView.IMailPresenter {
private ClientFactory clientFactory;

public MailActivity(ClientFactory clientFactory) {
this.clientFactory = clientFactory;
}

@Override
public void start(AcceptsOneWidget container, EventBus eventBus) {
applyCurrentLinkStyle(AppConstants.MAIL_LINK_ID);

final IMailView view = clientFactory.getMailView();
view.setPresenter(this);
container.setWidget(view.asWidget());
}
}


Как это работает: ActivityManager при получении события изменения URL
от PlaceHistoryManager создает с помощью ActivityMapper-а нужный
инстанс Activity и запускает ее на выполнение с помощью метода
start(). В этот метод одним из параметров передается контейнер, в
который будет подставлен виджет вида. Вид мы получаем из
ClientFactory, о которой будет немного ниже. В полученный инстанс вида
мы инжектим презентер и выполняем отображение вида как виджета. Да,
еще устанавливается CSS-правило для ссылки, которая ведет на текущий
вид. Но это чисто визуальное оформление.
ClientFactory это простая фабрика, которая создает нужные объекты. Ее
интерфейс описан следующим образом

public interface ClientFactory {
public EventBus getEventBus();
public PlaceController getPlaceController();

public IMailView getMailView();
public IContactsView getContactsView();
public ITasksView getTasksView();
}


Его реализация не отличается "умом и сообразительностью"

public class ClientFactoryImpl implements ClientFactory {
private final EventBus eventBus = new SimpleEventBus();
private final PlaceController placeController = new
PlaceController(eventBus);

private final IMailView mailView = new MailView();
private final IContactsView contactsView = new ContactsView();
private final ITasksView tasksView = new TasksView();

@Override public EventBus getEventBus() { return eventBus; }
@Override public PlaceController getPlaceController() { return
placeController; }
@Override public IMailView getMailView() { return mailView; }
@Override public IContactsView getContactsView() { return contactsView; }
@Override public ITasksView getTasksView() { return tasksView;}
}


Инстанциирование объекта ClientFactory будет выполняться с помощью
Deffered binding'а по правилу, которое описывается в файле описания
GWT-модуля. Но об этом опять же позже в разделе, где будет рассмотрено
конфигурирование всего MVP-хозяйства в единую рабочую систему. Тут
стоит отметить, что в реальных проектах для задач, которые решает
ClientFactory лучше воспользоваться Google GIN'ом. Достоинства
DI-инструмента смысла описывать нет, они и так понятны.
Последним из ключевых элементов встроенного MVP является объект,
который отвечает за состояние UI и выполняет манипуляции с
history-токенами.

Place или хеш-URL'ы и их обработка

Как было упомянуто выше объект Place отвечает за текущее состояние UI.
Состояние передается с помощью URL посредством history-токенов. По
сути в этом объекте можно хранить параметры, которые передаются с
хэш-URL'ой. Состояние URL кодируется/декодируется с помощью объекта
Tokenizer'а. При работе с параметрами запроса, которые передаются в
хэш-URL'е очень важно выдерживать следующее правило: все параметры,
которые нам "заходят" из URL после обработки должны в таком же виде
кодироваться назад в URL. Именно эта логика и реализуется в методах
класса Tokenizer.
По соглашению, которое принято командой GWT и описывается в
официальном руководстве класс токенайзера принято описывать как
внутренний статический класс объекта Place. Это упрощает код для
сохранения в переменных объекта Place параметров запроса. Хотя можно
применять подход с отдельными классами для токенайзеров.
Чтобы не быть голословным рассмотрим код класса MailPlace

package com.gshocklab.mvp.client.mvp.place;

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

public class MailPlace extends Place {
private static final String VIEW_HISTORY_TOKEN = "mail";

public MailPlace() { }

@Prefix(value = VIEW_HISTORY_TOKEN)
public static class Tokenizer implements PlaceTokenizer<MailPlace> {
@Override
public MailPlace getPlace(String token) {
return new MailPlace();
}

@Override
public String getToken(MailPlace place) {
return "";
}
}
}


Этот класс отнаследован от встроенного класса Place. В нем константой
объявлена часть хэш-URL, которая будет однозначно идентифицировать
состояние. В данном случае это "mail". За воссоздание состояния и его
сохранение через history-токены отвечает класс Tokenizer. Привязка
конкретной хэш-URL к токенайзеру осуществляется с помощью аннотации
@Prefix.
Обработка истории вообще интересная тема и заслуживает отдельной
статьи. Здесь ограничимся тем, что у нас с каждым объектом Place будет
связана своя хэш-URL'а. Эта URL обязательно должна заканчиваться на
":". После этого двоеточия можно указывать дополнительные параметры,
например, можно формировать URL вида #mail:inbox, #contacts:new и т.д.
и эти токены будут обработаны в методе getPlace(). По сути, первая
часть хэш-URL'ы является определителем подсистемы (mail, tasks etc.),
все, что следует после двоеточия можно расценивать как action'ы
подсистемы.
В демо-проекте дополнительные токены (или actions) не используются,
поэтому метод getToken() во всех токенайзерах возвращает пустую
строку, а метод getPlace() возвращает созданный объект Place.

Определение нужной Activity и регистрация обработчиков

При поступлении новой URL и успешной инстанциации объекта Place
менеджер ActivityManager с помощью ActivityMapper'а принимает решение
о том, какой объект презентера нужно запустить. Определение это
реализовано просто и банально

public class DemoActivityMapper implements ActivityMapper {
private ClientFactory clientFactory;

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

@Override
public Activity getActivity(Place place) {
if (place instanceof MailPlace) {
return new MailActivity(clientFactory);
} else if (place instanceof ContactsPlace) {
return new ContactsActivity(clientFactory);
} else if (place instanceof TasksPlace) {
return new TasksActivity(clientFactory);
}
return null;
}
}


Регистрация обработчиков хэш-URL'ов, т.е. токенайзеров выполняется в
интерфейсе PlaceHistoryMapper

package com.gshocklab.mvp.client.mvp;

import com.google.gwt.place.shared.PlaceHistoryMapper;
import com.google.gwt.place.shared.WithTokenizers;
import com.gshocklab.mvp.client.mvp.place.ContactsPlace;
import com.gshocklab.mvp.client.mvp.place.MailPlace;
import com.gshocklab.mvp.client.mvp.place.TasksPlace;

@WithTokenizers({MailPlace.Tokenizer.class,
ContactsPlace.Tokenizer.class, TasksPlace.Tokenizer.class})
public interface DemoPlaceHistoryMapper extends PlaceHistoryMapper { }


Все что нужно на данном этапе это просто перечислить в аннотации
@WithTokenizers классы токенайзеров приложения.

Собирая все вместе

Весь код по инициализации и запуску механизма MVP-фреймворка собран в
onModuleLoad()-методе EntryPoint'а

package com.gshocklab.mvp.client;

import com.google.gwt.activity.shared.ActivityManager;
import com.google.gwt.activity.shared.ActivityMapper;
import com.google.gwt.core.client.EntryPoint;
import com.google.gwt.core.client.GWT;
import com.google.gwt.event.shared.EventBus;
import com.google.gwt.place.shared.PlaceController;
import com.google.gwt.place.shared.PlaceHistoryHandler;
import com.google.gwt.user.client.History;
import com.google.gwt.user.client.ui.RootLayoutPanel;
import com.google.gwt.user.client.ui.SimplePanel;
import com.gshocklab.mvp.client.layout.AppLayout;
import com.gshocklab.mvp.client.mvp.DemoActivityMapper;
import com.gshocklab.mvp.client.mvp.DemoPlaceHistoryMapper;
import com.gshocklab.mvp.client.mvp.place.MailPlace;

public class MvpInActionEntryPoint implements EntryPoint {
private SimplePanel containerWidget;
private MailPlace defaultPlace = new MailPlace();

@Override
public void onModuleLoad() {
final AppLayout mainLayout = new AppLayout();
containerWidget = mainLayout.getAppContentHolder();

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

// activate activity manager and init display
ActivityMapper activityMapper = new DemoActivityMapper(clientFactory);
ActivityManager activityManager = new
ActivityManager(activityMapper, eventBus);
activityManager.setDisplay(containerWidget);

// display default view with activated history processing
DemoPlaceHistoryMapper historyMapper =
GWT.create(DemoPlaceHistoryMapper.class);
PlaceHistoryHandler historyHandler = new
PlaceHistoryHandler(historyMapper);
historyHandler.register(placeController, eventBus, defaultPlace);

RootLayoutPanel.get().add(mainLayout);

History.newItem("mail:");
}
}


Думаю, по коду пояснения будут лишними, все просто и понятно. Следует
отметить, что вызов History.newItem("mail:") может является лишним.
MailActivity и так запуститься, потому что в качестве умолчательного
Place'а указан MailPlace. Другое дело что при старте мы не увидим в
адресной строке браузера хэш-URL'у #mail:. Если отображение стартовой
хеш-URL для проекта не критично то вызов History.newItem() можно
убрать.
Чтобы встроенный MVP-фреймворк заработал нужно подключить
соответствующие GWT-модули в файле описания GWT-модуля (gwt.xml-файл)

<?xml version="1.0" encoding="UTF-8"?>
<module rename-to='mvpinaction'>
<inherits name='com.google.gwt.user.User' />
<inherits name="com.google.gwt.activity.Activity"/>
<inherits name="com.google.gwt.place.Place"/>

<entry-point class='com.gshocklab.mvp.client.MvpInActionEntryPoint' />

<replace-with class="com.gshocklab.mvp.client.ClientFactoryImpl">
<when-type-is class="com.gshocklab.mvp.client.ClientFactory" />
</replace-with>

<source path='client' />
</module>


Здесь же указывается правило deferred binding'а для создания инстанса
ClientFactory.

Вместо заключения

Вот вроде и все. Файл с корневым лэйаутом приложения AppLayout я не
привожу, его можно посмотреть в исходниках. В этом файле есть ссылки,
в атрибутах href которых указаны хэш-URL'ы для перехода к подсистемам
приложения. Также открыть ту или иную подсистему можно просто набрав в
адресной строке браузера корректную URL. Автоматически будет запущен
процесс преобразования состояния из URL в соответствующий place с
запуском соответствующей activity, которая и отобразит нужный нам вид.
Замечу дополнительно, что в заметке и демо-проекте не были рассматрены
такие важные в жизни момена, как использование шины событий
(eventBus), обработка параметров хэш-URL'ов и многое другое.

Работающий demo-проект, исходный код Google Code. Осторожно, Mercurial!
Жду отзывов и комментариев.

P.S. Сорри за многобукв. Надеюсь, что эта вводная заметка оказалась
кому-то полезна (хоть и не полностью освещает всю мощь встроенного
MVP), я не даром писал ее и она послужит отправной точкой при
реализации действительно клёвых GWT-приложений

* GWT
* , GWT2.1
* , Google Web Toolkit
* , MVP

Комментариев нет:

Отправить комментарий