Alternatywa dla GWT-RPC: RequestFactory

Prawdopodobnie znasz już dobrze mechanizm GWT-RPC, który dostarcza podstawowy sposób komunikacji klient-serwer w projekcie wykorzystującym GWT. Mechanizm ten z powodzeniem działa już od pierwszych wersji GWT i mając z początku alternatywę jedynie w postaci ręcznych zapytań do serwletów http, wygrywa prawie każde starcie. Nie ustrzegł się jednak kilku dość istotnych wad (wynikających z ogólnej architektury aplikacji GWT), które nie tyle co coś uniemożliwiają, ale bardziej uprzykrzają życie programiście. O ile GWT-RPC w wywoływaniu prostych akcji sprawdza się świetnie, to implementacja dobrej warstwy pobierania danych jest już dość trudna. Problem leży w tym, że GWT nie potrafi zserializować obiektów encji wybieranych wprost z bazy danych poprzez np. Hibernate’a, które zawierają dowiązania do innych obiektów encji będących z nimi w relacji. W praktyce sprowadza się to do tego, że nie można stosować mapowań relacji typu „lazy”, gdyż informacje o relacjach przechowywane w obiektach encji nie mogą być zserializowane i przesłane do strony klienckiej. Relacje można więc sobie odpuścić (co jeszcze sprawdzi się w małej aplikacji), bądź przesyłać przez RPC obiekty specjalnych klas DTO (Data-Transfer Object), które będą zawierały jedynie niezbędne dla widoku informacje – a tu czeka nas ręczne i wolne przepakowywanie atrybutów obiektów oraz mozolne tworzenie klas DTO. Da się z tym żyć, jednak da się zrobić lepiej!

Rozwiązań jest kilka i zostały one całkiem dobrze opisane na Google Code w artykułach na temat GWT. Jednym z nich jest zastosowanie projektu Dozer, który umożliwia automatyczną generację klas DTO na podstawie określonych plików XML – trochę lepiej, jednak zamiast klas Java musimy utrzymywać większą ilość plików konfiguracyjnych. Odmienne podejście reprezentuje projekt Gilead, pełniący rolę pewnego rodzaju adaptera encji. Dzięki niemu obiekty encji mogą być serializowane do innych środowisk, a po powrocie są ponownie uzupełniane o utracone w momencie serializacji informacje. Całość jakoś działa, jednak nie powala wydajnością – nie mniej projekt jest godny uwagi. Od GWT w wersji 2.1 mamy dostępne jeszcze jedno rozwiązanie: RequestFactory, będące alternatywą dla mechanizmu GWT-RPC, nie próbujące go jednak zastąpić.

RequestFactory jest mechanizmem zorientowanym na dane (data-oriented), w przeciwieństwie do GWT-RPC, które celuje w dostarczenie pewnego rodzaju usług (service-oriented). Zostało zaprojektowane do jak najwygodniejszego użycia przy dostępie do danych za pomocą narzędzi obiektowo-relacyjnych jak Hibernate czy specyfikacji JPA. Wymaga nieco większej konfiguracji początkowej, jednak postaram się pokazać, że pewne rzeczy wystarczy napisać tylko raz, aby korzystać z nich w całej aplikacji. Ogromną zaletą RequestFactory jest minimalizacja ilości przesyłanych danych, gdyż między obiektami proxy po stronie klienta, a właściwymi obiektami po stronie serwera transferowane są jedynie zmiany. Wspierane są też relacje między encjami, a poprawność encji może być sprawdzana zgodnie z normą JSR 303 (np. Hibernate Validator).

Temat jest dość spory, zdecydowałem więc, że na początek zaprezentuje jedynie część możliwości RequestFactory. Dowiesz się dzisiaj, jak skonfigurować projekt do poprawnego działania omawianego mechanizmu, jak poprawnie utworzyć interfejs EntityProxy oraz zaimplementować interfejsy Locator i ServiceLocator, na koniec jak skorzystać z osobnej klasy DAO do przeprowadzenia operacji CRUD (C – Tworzenie, R – Odczyt, U – Aktualizacja, D – Usuwanie). Na przyszłość zaś pozostawię temat relacji między encjami oraz walidacji encji.

Konfiguracja projektu

Punktem wyjścia do bieżącego projektu będzie projekt zapoczątkowany podczas próby połączenia JTA z GWT i kontynuowany niedawno w temacie szyfrowania danych za pomocą Jasypt. Konfiguracja projektu w pliku pom.xml nie zmieniła się, doszły jednak dwie wymagane przez RequestFactory zależności:

  • javax.validation:validation-api
  • org.json:json

Stosowne wpisy znalazły się w sekcji dependencies. Warto zaznaczyć, że jeśli nie używamy maven’a do zarządzania zależnościami projektu, należy ręcznie dołączyć do projektu bibliotekę gwt-servlet-deps.jar zawierającą dokładnie te same klasy co powyższe zależności.

Sample.gwt.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE module PUBLIC "-//Google Inc.//DTD Google Web Toolkit 1.6.4//EN" "http://google-web-toolkit.googlecode.com/svn/tags/1.6.4/distro-source/core/src/gwt-module.dtd">
<module rename-to="sample">

  <!-- Inherit the core Web Toolkit stuff.                  -->
  <inherits name="com.google.gwt.user.User"/>
  <inherits name="com.google.gwt.logging.Logging"/>
  <inherits name='com.google.web.bindery.requestfactory.RequestFactory' />

  <set-property name="gwt.logging.popupHandler" value="DISABLED"/>
  <set-property name="gwt.logging.enabled" value="TRUE"/> 

  <!-- Specify the app entry point class.                   -->
  <entry-point class="pl.avd.samples.gwt.requestfactory.client.SampleModule"/>

  <!-- Specify the paths for translatable code              -->
  <source path="client"/>
  <source path="shared"/>
</module>

Plik deskryptora modułu GWT zawiera tylko jeden dodatkowy wpis dołączający moduł RequestFactory. Od GWT w wersji 2.3 zmienił się pakiet modułu z com.google.gwt.requestfactory na com.google.web.bindery.requestfactory, należy więc o tym pamiętać, jeśli w projekcie używamy starszej wersji GWT. Zalecam jednak jak najszybszą aktualizację, gdyż starsze wersje mogą nie działać prawidłowo.

web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">

  <display-name>SampleGWTRequestFactory</display-name>
  <description>Sample GWT: RequestFactory</description>

  <welcome-file-list>
    <welcome-file>index.html</welcome-file>
  </welcome-file-list>

  <servlet>
    <servlet-name>requestFactoryServlet</servlet-name>
    <servlet-class>com.google.web.bindery.requestfactory.server.RequestFactoryServlet</servlet-class>
  </servlet>

  <servlet-mapping>
    <servlet-name>requestFactoryServlet</servlet-name>
    <url-pattern>/gwtRequest</url-pattern>
  </servlet-mapping>

  <filter>
    <filter-name>TransactionFilter</filter-name>
    <filter-class>pl.avd.samples.gwt.requestfactory.server.TransactionFilter</filter-class>
  </filter>

  <filter-mapping>
    <filter-name>TransactionFilter</filter-name>
    <url-pattern>/gwtRequest</url-pattern>
  </filter-mapping>
</web-app>

W deskryptorze wdrożenia aplikacji webowej znalazła się konfiguracja serwletu RequestFactory, do którego będą przychodziły wszystkie żądania operacji na danych. Serwlet taki jest jeden na całą aplikację, a jego implementacja dostarczona jest przez GWT. Jego wywołanie opakujemy dodatkowo znanym już z wcześniejszych projektów filtrem rozpoczynającym i kończącym transakcje.

Implementacja strony serwerowej

Część serwerową zaczniemy od stworzenia klasy Car, która będzie pełniła rolę encji. W stosunku do poprzednich projektów klasa ta została uzupełniona o atrybut version – wymagany do poprawnego działania RequestFactory w celu wysyłania zdarzeń o zmianach. Dodatkowo klasa Car będzie implementowała interfejs DatabaseEntity stworzony na potrzeby generycznej obsługi encji w projekcie.

DatabaseEntity.java
public interface DatabaseEntity<I> extends Serializable {

  I getId();

  Integer getVersion();
}

Interfejs ten zawiera dwie metody: getId() oraz getVersion(), które każda encja powinna zawierać.

Zgodnie z zaleceniami aktualizacja numeru wersji encji powinna zostać wydelegowana do bazy danych lub warstwy dostępu do danych, czyli np. Hibernate’a. Posłużymy się więc interceptorem:

VersionInterceptor.java
public class VersionInterceptor extends EmptyInterceptor {

  public static final String PROPERTY_VERSION = "version";

  @Override
  public boolean onFlushDirty(Object entity, Serializable id, Object[] currentState,
      Object[] previousState, String[] propertyNames, Type[] types) {
    if (entity instanceof DatabaseEntity) {
      for (int i = 0; i < propertyNames.length; i++) {
        if (PROPERTY_VERSION.equals(propertyNames[i])) {
          Integer ver = (Integer) currentState[i];
          currentState[i] = ver + 1;
          return true;
        }
      }
    }

    return false;
  }

  @Override
  public boolean onSave(Object entity, Serializable id, Object[] state, String[] propertyNames, Type[] types) {
    if (entity instanceof DatabaseEntity) {
      for (int i = 0; i < propertyNames.length; i++) {
        if (PROPERTY_VERSION.equals(propertyNames[i])) {
          state[i] = 0;
          return true;
        }
      }
    }

    return false;
  }
}

Interceptor ustawia wersję na 0 za każdym razem, gdy zapisywany do bazy jest nowy obiekt implementujący interfejs DatabaseEntity oraz zwiększa numer wersji, gdy obiekt jest aktualizowany.

Kolejnym krokiem jest zaimplementowanie kilku metod odczytu i modyfikacji danych w bazie. RequestFactory pozwala na umieszczenie ich w dwóch miejscach: klasie encji lub osobnym serwisie. Pierwszy sposób jest prostrzy, jednak jako że powoduje szybkie zaśmiecenie samej klasy encji, potrudzimy się o napisanie osobnej klasy DAO.

CarDao.java
public class CarDao {

  @SuppressWarnings("unchecked")
  public List list() {
    return HibernateUtil.getCurrentSession().createCriteria(Car.class).list();
  }

  public Car create(Car car) {
    HibernateUtil.getCurrentSession().save(car);
    return car;
  }

  public void update(Car car) {
    HibernateUtil.getCurrentSession().update(car);
  }

  public void remove(Car car) {
    HibernateUtil.getCurrentSession().delete(car);
  }
}

Operacje tu wykonywane są na tyle proste, że nie wymagają omówienia. Wartym do zapamiętania jest jednak to, że pomimo braku interfejsu z definicjami metod, ich nazwy muszą pokrywać się z interfejsem, który zdefiniujemy później po stronie klienckiej – do tego wrócimy jednak jeszcze później.

Aby RequestFactory mogło współpracować z osobną klasą DAO, trzeba zaimplementować jeszcze interfejsy Locator i ServiceLocator.

EntityLocator.java
public abstract class EntityLocator<I extends Serializable> extends Locator<DatabaseEntity<I>, I> {

  @Override
  public DatabaseEntity<I> create(Class<? extends DatabaseEntity<I>> clazz) {
    try {
      return clazz.newInstance();
    } catch (InstantiationException e) {
      throw new RuntimeException(e);
    } catch (IllegalAccessException e) {
      throw new RuntimeException(e);
    }
  }

  @SuppressWarnings("unchecked")
  @Override
  public DatabaseEntity<I> find(Class<? extends DatabaseEntity<I>> clazz, I id) {
    return (DatabaseEntity<I>) HibernateUtil.getCurrentSession().get(clazz, id);
  }

  @Override
  public Class<DatabaseEntity<I>> getDomainType() {
    return null;
  }

  @Override
  public I getId(DatabaseEntity<I> entity) {
    return entity.getId();
  }

  @Override
  public Object getVersion(DatabaseEntity<I> entity) {
    return entity.getVersion();
  }
}

Generyczna implementacja interfejsu Locator dostarcza szereg metod, które będą automatycznie wywoływane przez GWT. Niestety nie udało mi się napisać na tyle uniwersalnej implementacji, aby dało się ją zastosować w każdym przypadku.

EntityLongLocator.java
public class EntityLongLocator extends EntityLocator<Long> {

  @Override
  public Class<Long> getIdType() {
    return Long.class;
  }
}

Rozwiązaniem problemu było stworzenie osobnej klasy dla każdego typu danych klucza encji, która implementuje ostatnią już metodę interfejsu Locator.

EntityServiceLocator.java
public class EntityServiceLocator implements ServiceLocator {

  public Object getInstance(Class<?> clazz) {
    try {
      return clazz.newInstance();
    } catch (InstantiationException e) {
      throw new RuntimeException(e);
    } catch (IllegalAccessException e) {
      throw new RuntimeException(e);
    }
  }
}

Implementacja ServiceLocator’a używana jest przez RequestFactory do powoływania nowych obiektów proxy po stronie klienta, do których niezbędne są instancje właściwych klas serwerowych. Implementacja ta jest uniwersalna i może być stosowana do wielu innych encji.

Implementacja strony klienckiej

Dla każdej encji należy stworzyć osobny interfejs proxy, który będzie stosowany po stronie klienckiej i będzie udostępniał jedynie te dane, które potrzebujemy.

CarProxy.java
@ProxyFor(value = Car.class, locator = EntityLongLocator.class)
public interface CarProxy extends EntityProxy {

  Long getId();

  String getBrand();

  void setBrand(String brand);

  String getModel();

  void setModel(String model);

  String getColor();

  void setColor(String color);
}

Metody interfejsu muszą pokrywać się z metodami odpowiadającej mu klasy encji, nie ma jednak potrzeby definiowania ich wszystkich. Jeśli np. nie chcemy, aby identyfikator encji był modyfikowalny, wystawiamy jedynie stosownego gettera, co uniemożliwia zmianę jego wartości. Podobnie mogą być ukrywane inne atrybuty encji.
Za pomocą adnotacji wskazujemy, jakiej encji interfejs proxy dotyczy oraz jaka klasa będzie pełniła rolę Locator’a.

CarRequest.java
@Service(value = CarDao.class, locator = EntityServiceLocator.class)
public interface CarRequest extends RequestContext {

  Request<List<CarProxy>> list();

  Request<CarProxy> create(CarProxy car);

  Request<Void> update(CarProxy car);

  Request<Void> remove(CarProxy car);
}

Rozszerzenie interfejsu RequestContext zawiera metody, których wywołania są delegowane do zdefiniowanej za pomocą adnotacji klasy DAO. Jak wspomniałem wcześniej, metody te muszą mieć takie same nazwy, ich typem zwracanym jest zawsze Request a zamiast właściwej klasy encji jako atrybuty przyjmuje interfejs proxy. RequestFactory umożliwia dodatkowo definiowanie metod powiązanych z konkretną instancją obiektu, które zwracają obiekt klasy InstanceRequest, jednak nie da się ich zastosować wraz z osobną klasą DAO. O zastosowaniu i sposobie użycia tych metod opowiem innym razem, podczas kontynuacji bieżącego projektu.

DataRequestFactory.java
public interface DataRequestFactory extends RequestFactory {

  CarRequest carRequest();
}

Ostatni już interfejs, który będzie powoływany za pomocą GWT Deferred Binding, dzięki czemu kompilator podłoży pod niego stosowną implementację. Interfejs ten skupia wszystkie konteksty (rozszerzenia RequestContext).

Na koniec zostawiłem główną klasę modułu GWT, która zawiera cały interfejs użytkownika, inicjuje cały mechanizm RequestFactory oraz implementuje wywoływane za jego pomocą akcje.

SampleModule.java
public class SampleModule implements EntryPoint {

  public abstract class CarFieldUpdater<C> implements FieldUpdater<CarProxy, C> {

    public void update(int index, CarProxy object, C value) {
      CarRequest request = requestFactory.carRequest();
      CarProxy car = request.edit(object);
      doUpdate(index, car, value);
      request.update(car).fire();
    }

    protected abstract void doUpdate(int index, CarProxy car, C value);
  }

  public class CarRemoverDelegate implements ActionCell.Delegate<CarProxy> {

    public void execute(CarProxy object) {
      CarRequest request = requestFactory.carRequest();
      request.remove(object).fire();
      provider.getList().remove(object);
    }
  }

  public abstract class CarsListReadClickHancler implements ClickHandler {

    public void onClick(ClickEvent event) {
      requestFactory.carRequest().list().fire(new Receiver<List<CarProxy>>() {

        @Override
        public void onSuccess(List<CarProxy> result) {
          onRead(result);
        }
      });
    }

    protected abstract void onRead(List<CarProxy> result);
  }

  public abstract class CarCreatorClickHandler implements ClickHandler {

    public void onClick(ClickEvent event) {
      CarRequest request = requestFactory.carRequest();
      CarProxy car = request.create(CarProxy.class);
      doPrepare(car);
      request.create(car).fire(new Receiver<CarProxy>() {

        @Override
        public void onSuccess(CarProxy result) {
          onCreate(result);
        }
      });
    }

    protected abstract void doPrepare(CarProxy car);

    protected abstract void onCreate(CarProxy result);
  }

  private Logger LOG = Logger.getLogger("SAMPLE");

  private DataRequestFactory requestFactory;

  private ListDataProvider<CarProxy> provider;
  private FlowPanel layout;

  private TextBox brandField;
  private TextBox modelField;
  private TextBox colorField;

  public void onModuleLoad() {
    GWT.setUncaughtExceptionHandler(new GWT.UncaughtExceptionHandler() {

      public void onUncaughtException(Throwable caught) {
        LOG.log(Level.SEVERE, "uncaught exception", caught);
      }
    });

    Scheduler.get().scheduleDeferred(new Scheduler.ScheduledCommand() {

      public void execute() {
        init();
      }
    });
  }

  private void init() {
    EventBus eventBus = new SimpleEventBus();
    requestFactory = GWT.create(DataRequestFactory.class);
    requestFactory.initialize(eventBus);

    layout = new FlowPanel();
    provider = new ListDataProvider<CarProxy>();

    initInfo();
    initForm();
    initTable();

    RootPanel.get().add(layout);
  }

  private void initInfo() {
    layout.add(new Label("Witaj!"));
  }

  private void initForm() {
    FlexTable form = new FlexTable();

    brandField = new TextBox();
    modelField = new TextBox();
    colorField = new TextBox();

    form.setHTML(0, 0, "Marka:");
    form.setWidget(0, 1, brandField);
    form.setHTML(1, 0, "Model:");
    form.setWidget(1, 1, modelField);
    form.setHTML(2, 0, "Kolor:");
    form.setWidget(2, 1, colorField);

    layout.add(form);

    Button saveBtn = new Button("Zapisz");
    saveBtn.addClickHandler(new CarCreatorClickHandler() {

      @Override
      protected void doPrepare(CarProxy car) {
        car.setBrand(brandField.getValue());
        car.setModel(modelField.getValue());
        car.setColor(colorField.getValue());
      }

      @Override
      protected void onCreate(CarProxy result) {
        brandField.setValue(null);
        modelField.setValue(null);
        colorField.setValue(null);

        provider.getList().add(result);
      }
    });
    layout.add(saveBtn);
  }

  private void initTable() {
    CellTable<CarProxy> table = new CellTable<CarProxy>();
    table.setWidth("500px");

    provider.addDataDisplay(table);

    Column<CarProxy, Number> idCol = new Column<CarProxy, Number>(new NumberCell()) {

      @Override
      public Number getValue(CarProxy object) {
        return object.getId();
      }
    };
    table.addColumn(idCol, "ID");

    Column<CarProxy, String> column = new Column<CarProxy, String>(new EditTextCell()) {

      @Override
      public String getValue(CarProxy object) {
        return object.getBrand();
      }
    };
    column.setFieldUpdater(new CarFieldUpdater<String>() {

      @Override
      protected void doUpdate(int index, CarProxy car, String value) {
        car.setBrand(value);
      }
    });
    table.addColumn(column, "Marka");

    column = new Column<CarProxy, String>(new EditTextCell()) {

      @Override
      public String getValue(CarProxy object) {
        return object.getModel();
      }
    };
    column.setFieldUpdater(new CarFieldUpdater<String>() {

      @Override
      protected void doUpdate(int index, CarProxy car, String value) {
        car.setModel(value);
      }
    });
    table.addColumn(column, "Model");

    column = new Column<CarProxy, String>(new EditTextCell()) {

      @Override
      public String getValue(CarProxy object) {
        return object.getColor();
      }
    };
    column.setFieldUpdater(new CarFieldUpdater<String>() {

      @Override
      protected void doUpdate(int index, CarProxy car, String value) {
        car.setColor(value);
      }
    });
    table.addColumn(column, "Kolor");

    Column<CarProxy, CarProxy> actionColumn = new Column<CarProxy, CarProxy>(new ActionCell<CarProxy>("Usuń", new CarRemoverDelegate())) {

      @Override
      public CarProxy getValue(CarProxy object) {
        return object;
      }
    };
    table.addColumn(actionColumn, "Akcje");

    layout.add(table);

    final Button refreshBtn = new Button("Odśwież");
    refreshBtn.addClickHandler(new CarsListReadClickHancler() {

      @Override
      protected void onRead(List<CarProxy> result) {
        provider.getList().clear();

        for (CarProxy car : result) {
          provider.getList().add(car);
        }
      }
    });
    refreshBtn.addAttachHandler(new AttachEvent.Handler() {

      public void onAttachOrDetach(AttachEvent event) {
        refreshBtn.click();
      }
    });
    layout.add(refreshBtn);
  }
}

Pomimo tego że klasa ta zawiera tak wiele rzeczy, starałem się uprościć ją maksymalnie, dzięki czemu zdecydowałem się na umieszczenie jej w całości. Analizując kod zobaczysz, w jaki sposób powołany został interfejs RequestFactory oraz w jaki sposób jest używany.

Osobno należy powiedzieć kilka słów na temat operacji aktualizacji danych, która musi być wykonywana w bieżącym kontekście (RequestContext), a każdy zmieniany obiekt musi zostać przygotowany poprzez metodę kontekstu edit(…). RequestFactory na tyle sprytnie operuje danymi, że jeśli nie zauważy jakiejkolwiek zmiany atrybutów obiektu, nie będzie wywoływał operacji po stronie serwera.

Projekt standardowo można uruchomić poleceniem mvn gwt:run, do czego gorąco zachęcam. Polecam też wykonanie wszystkich dostępnych przez aplikację operacji na danych i obserwację logów na konsoli, gdzie standardowo będą pojawiały się komendy SQL generowane przez Hibernate’a.

Jeśli chcesz pobrać źródła projektu, możesz skorzystać z repozytorium lub pobrać plik z tej lokalizacji. Przypominam też, że jest to pierwszy artykuł na temat RequestFactory i w najbliższym czasie możesz spodziewać się kolejnych. Jeśli masz jakieś pytania, bądź problemy z implementacją, podziel się tym w komentarzach.

Skomentuj

1 Komentarze.

  1. +1, bardzo mi pomógł Twój opis wielkie dzięki :) Czekam na kolejne artykuły na temat RF!

Skomentuj


UWAGA - Możesz używać HTML tags and attributes:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

CommentLuv badge