Когда я писал топик об использовании шаблона 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% приведу скриншот приложения, которое у меня в итоге получилось.
Да, я понимаю, что приложение ничего полезного не делает, но на его основе постараюсь показать, как разделить архитектуру на отдельные независимые части (mail, contacts и tasks, кстати, взятые просто так, с потолка), организовать переключение и связь между ними с помощью встроенных в GWT 2.1 механизмов. Помимо этого не будет рассмотрена M-составляющая MVP-паттерна, т.е. модель, поскольку в приложении в иллюстративных целях нет никакой привязки к данным.
Команда GWT предложила следующие ключевые составляющие для построения приложения с использованием MVP:
Рассмотрим теперь более подробно все эти компоненты в отдельности на примере реального кода. Кстати, код приложения доступен для cвободного доступа. Так что в него можно подглядывать по мере чтения.
Прежде всего стоит сказать, что в MVP принято “обмениваться” между презентером и видом только интерфейсами. Поэтому для каждого вида в приложении есть соответствующий интерфейс
В интерфейсе вида описан соответствующий интерфейс презентера, который будет выполнять всю полезную нагрузку (посылка запросов на получение данных, обработка событий из шины событий и т.д.).
Реализация этого интерфейса будет простой и не должна вызвать каких-то особых вопросов.
Связанный ui.xml-файл содержит всего один виджет, Label с простым текстом. Приводить его код нет смысла, его можно посмотреть на сайте проекта.
Это все, что касается View-части. Перейдем теперь к более интересному, к Activity.
В демо-приложении слева есть навигационная панель с ссылками. При кликах на этих ссылка выполняется переключение между видами и устанавливается CSS-стиль для текущей ссылки. Это действие я вынес в абстрактный родительский класс AbstractMainActivity, который является наследником встроенного класса AbstractActivity
И конкретная реализация конкретной Activity
Как это работает: ActivityManager при получении события изменения URL от PlaceHistoryManager создает с помощью ActivityMapper-а нужный инстанс Activity и запускает ее на выполнение с помощью метода start(). В этот метод одним из параметров передается контейнер, в который будет подставлен виджет вида. Вид мы получаем из ClientFactory, о которой будет немного ниже. В полученный инстанс вида мы инжектим презентер и выполняем отображение вида как виджета. Да, еще устанавливается CSS-правило для ссылки, которая ведет на текущий вид. Но это чисто визуальное оформление.
ClientFactory это простая фабрика, которая создает нужные объекты. Ее интерфейс описан следующим образом
Его реализация не отличается “умом и сообразительностью”
Инстанциирование объекта ClientFactory будет выполняться с помощью Deffered binding’а по правилу, которое описывается в файле описания GWT-модуля. Но об этом опять же позже в разделе, где будет рассмотрено конфигурирование всего MVP-хозяйства в единую рабочую систему. Тут стоит отметить, что в реальных проектах для задач, которые решает ClientFactory лучше воспользоваться Google GIN’ом. Достоинства DI-инструмента смысла описывать нет, они и так понятны.
Последним из ключевых элементов встроенного MVP является объект, который отвечает за состояние UI и выполняет манипуляции с history-токенами.
Как было упомянуто выше объект Place отвечает за текущее состояние UI. Состояние передается с помощью URL посредством history-токенов. По сути в этом объекте можно хранить параметры, которые передаются с хэш-URL’ой. Состояние URL кодируется/декодируется с помощью объекта Tokenizer’а. При работе с параметрами запроса, которые передаются в хэш-URL’е очень важно выдерживать следующее правило: все параметры, которые нам “заходят” из URL после обработки должны в таком же виде кодироваться назад в URL. Именно эта логика и реализуется в методах класса Tokenizer.
По соглашению, которое принято командой GWT и описывается в официальном руководстве класс токенайзера принято описывать как внутренний статический класс объекта Place. Это упрощает код для сохранения в переменных объекта Place параметров запроса. Хотя можно применять подход с отдельными классами для токенайзеров.
Чтобы не быть голословным рассмотрим код класса MailPlace
Этот класс отнаследован от встроенного класса 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.
При поступлении новой URL и успешной инстанциации объекта Place менеджер ActivityManager с помощью ActivityMapper’а принимает решение о том, какой объект презентера нужно запустить. Определение это реализовано просто и банально
Регистрация обработчиков хэш-URL’ов, т.е. токенайзеров выполняется в интерфейсе PlaceHistoryMapper
Все что нужно на данном этапе это просто перечислить в аннотации @WithTokenizers классы токенайзеров приложения.
Весь код по инициализации и запуску механизма MVP-фреймворка собран в onModuleLoad()-методе EntryPoint’а
Думаю, по коду пояснения будут лишними, все просто и понятно. Следует отметить, что вызов History.newItem(“mail:”) может является лишним. MailActivity и так запуститься, потому что в качестве умолчательного Place’а указан MailPlace. Другое дело что при старте мы не увидим в адресной строке браузера хэш-URL’у #mail:. Если отображение стартовой хеш-URL для проекта не критично то вызов History.newItem() можно убрать.
Чтобы встроенный MVP-фреймворк заработал нужно подключить соответствующие GWT-модули в файле описания GWT-модуля (gwt.xml-файл)
Здесь же указывается правило deferred binding’а для создания инстанса ClientFactory.
Вот вроде и все. Файл с корневым лэйаутом приложения AppLayout я не привожу, его можно посмотреть в исходниках. В этом файле есть ссылки, в атрибутах href которых указаны хэш-URL’ы для перехода к подсистемам приложения. Также открыть ту или иную подсистему можно просто набрав в адресной строке браузера корректную URL. Автоматически будет запущен процесс преобразования состояния из URL в соответствующий place с запуском соответствующей activity, которая и отобразит нужный нам вид.
Замечу дополнительно, что в заметке и демо-проекте не были рассматрены такие важные в жизни момена, как использование шины событий (eventBus), обработка параметров хэш-URL’ов и многое другое.
Работающий demo-проект, исходный код Google Code. Осторожно, Mercurial!
Жду отзывов и комментариев.
P.S. Сорри за многобукв. Надеюсь, что эта вводная заметка оказалась кому-то полезна (хоть и не полностью освещает всю мощь встроенного MVP), я не даром писал ее и она послужит отправной точкой при реализации действительно клёвых GWT-приложений
В данной заметке я постараюсь осветить основные моменты встроенного GWT MVP-фреймворка. Как пример будет использовано небольшое приложение, построенное с применением этого подхода.
Данная заметка является вольным трактатом (GWT MVP Development) и на уникальность подхода никоим образом не претендует. Если кого-то заинтересовало такое немаленькое введение
Скажу сразу, что я не буду заострять особого внимания на самом шаблоне проектирования MVP. Ознакомиться с его схемой можно, к примеру на (MVP на Wiki). Для того, чтобы заинтересовать %username% приведу скриншот приложения, которое у меня в итоге получилось.
Да, я понимаю, что приложение ничего полезного не делает, но на его основе постараюсь показать, как разделить архитектуру на отдельные независимые части (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-приложений