Implementacja Comet’a w GWT, czyli jak poradzić sobie z Atmosphere’m

W dobie dzisiejszych aplikacji webowych często wykorzystywanym rozwiązaniem jest technika „Server Push„, występująca również pod nazwą „Comet„. Jest to technika pozwalająca na sprawne przesyłanie komunikatów w kierunku serwer -> klient (przeglądarka). Jak zapewne wiesz, protokół HTTP nie umożliwia przesyłania danych w tą stronę bez zainicjowania połączenia ze strony klienta. Na jakiej zasadzie działa więc Comet? Istnieje kilka popularnych rozwiązań tego problemu.

Najprostszą i najmniej wydajną implementacją jest po prostu cykliczne odpytywanie serwera w celu sprawdzenia, czy istnieją na serwerze nowe komunikaty, jeśli tak to są one zwracane wraz z odpowiedzią zapytania. Jak można się domyślić, odpytań będzie dużo, tym więcej im więcej użytkowników połączy się z aplikacją. Można nieco usprawnić ten mechanizm wprowadzając po stronie serwera pewien czas oczekiwania na nowe komunikaty, jednak nadal dużym narzutem będą same nagłówki HTTP odpytań serwera.

Można też skorzystać z technologii typu Adobe Flash, czy Aplety Java. Za ich pomocą można w łatwy sposób zbudować komunikację serwer -> klient, jednak trzeba się liczyć z tym, że przeglądarka użytkownika nie będzie ich obsługiwała. O ile Flash’a jeszcze da się przeżyć, to aplet Javy może skutecznie zniechęcić użytkownika do dalszego oglądania naszej strony. Często jednak wymagania projektu wykluczają też użycie Flasha, czy innych technologii nie obsługiwanych natywnie przez przeglądarkę, musimy skorzystać więc z innego rozwiązania.

Zwana „long polling” technika polega na przesyłaniu wielu komunikatów jednym otwartym kanałem między serwerem a przeglądarką użytkownika. Otwarcie kanału inicjowane jest ze strony klienta, jednak taki kanał nie jest zamykany po pierwszym odesłanym komunikacie. Trwa do tej pory, aż wystąpi timeout (zazwyczaj czas ten jest zależny od ustawień przeglądarki), po czym inicjowany jest na nowo. Komunikaty przesyłane są strumieniowo, nie ma więc zbędnego narzutu na ciągle powtarzające się nagłówki HTTP.

Najnowszym i chyba najwygodniejszym rozwiązaniem są WebSocket’y – dwukierunkowe kanały komunikacji przy użyciu jednego gniazda TCP. Niestety do ich obsługi wymagane są bardzo nowe (często testowe) wersje przeglądarek, w praktyce więc użycie ich w biznesie staje się niemożliwe.

Każde z powyższych rozwiązań wymaga mniejszej lub większej ilości linijek kodu, w zależności od stopnia skomplikowania. Niektóre z nich wymagają też specyficznej implementacji wykorzystującej natywne mechanizmy serwerów aplikacji, jeśli więc chcemy stworzyć prawdziwie uniwersalną aplikację, kodu robi się coraz więcej. Z pomocą przychodzą więc wszelkiego rodzaju szkielety pozwalające na szybkie wdrożenie Comet’a w naszej aplikacji, a jednym z nich jest Atmosphere. Jest on pewnego rodzaju fasadą ukrywającą rzeczywistą technologię wykorzystywaną w danej chwili. Co najważniejsze, Atmosphere potrafi sam wybrać implementację w zależności od środowiska, w którym został uruchomiony. Wspiera zarówno WebSockety jak i „long pooling”, przy czym potrafi wykorzystać specyfikację Servlet’ów 3.0, bądź natywne mechanizmy:

  • WebLogic AbstractAsyncServlet,
  • Tomcat CometProcessor,
  • GlassFish CometHandler,
  • Jetty Continuation,
  • JBoss HttpEvent,
  • Grizzly CometHandler,
  • Google App Engine restricted environment.

Jak widać, lista jest imponująca, a dodatkowo Atmosphere dostarcza własne API pozwalające na uruchomienie Comet’a w środowisku, które nie dostarcza żadnej obsługi asynchronicznej komunikacji. Dla nas ważne też jest to, że niedawno ukazała się wersja 0.7 zapewniająca natywne wsparcie dla GWT. Czego więc chcieć więcej?

Niestety jedną z wad Atmosphere’a jest słaba dokumentacja. W internecie również jest mało informacji na temat ciekawszych rozwiązań, a przykłady znajdujące się na stronie Atmosphere’a nie wyczerpują jego możliwości. Podstawową funkcjonalnością wydaje się przesyłanie komunikatów do wszystkich, bądź do konkretnych użytkowników, na tym postaram się więc skupić. Wykorzystam tutaj część kodu powstałego w artykułach o uwierzytelnianiu i autoryzacji za pomocą JAAS. Dzięki temu będzie można jednoznacznie identyfikować zalogowanych użytkowników i właśnie do nich przesyłać komunikaty.

Aplikacja będzie zawierała prostego czata, który udostępni możliwość wysyłania wiadomości publicznych oraz prywatnych. Dodatkowo dostępna będzie lista zalogowanych do aplikacji użytkowników. Jako że artykuł jest wstępem do Atmosphere’a, nie zobaczysz tu rozbudowanego komunikatora. Ma być prosto i czytelnie. Nauczysz się, jak przesyłać wiadomości między użytkownikami (obiekty inicjowane po stronie klienta) oraz jak inicjować komunikaty bezpośrednio w części serwerowej. Zobaczysz, jak wysyła się komunikaty globalne (do wszystkich użytkowników) oraz jak kierować wiadomości do konkretnych osób. Zaczynamy!

 

Konfiguracja Atmosphere’a znajduje się w kilku miejscach. Główny jego serwlet definiowany jest w pliku web.xml:

web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app version="3.0" 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_3_0.xsd">

  (...)

  <servlet>
    <description>AtmosphereServlet</description>
    <servlet-name>AtmosphereServlet</servlet-name>
    <servlet-class>org.atmosphere.cpr.AtmosphereServlet</servlet-class>
    <init-param>
      <!-- prevent deadlocks -->
      <param-name>org.atmosphere.disableOnStateEvent</param-name>
      <param-value>true</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
    <!--Uncomment if you want to use Servlet 3.0 Async Support-->
    <async-supported>true</async-supported>
  </servlet>

  <servlet-mapping>
    <servlet-name>AtmosphereServlet</servlet-name>
    <url-pattern>/sample/gwtComet</url-pattern>
  </servlet-mapping>

  (...)
</web-app>

Użyjemy tu implementacji opartej o Servlet’y 3.0, które są już wspierane przez praktycznie wszystkie nowe wersje kontenerów, również przez używane przez nas Jetty. Jeśli potrzebujesz uruchomić aplikację na serwerze nieobsługującym specyfikacji serwletów w wersji 3.0, sięgnij do dokumentacji/przykładów Atmosphere’a.

atmosphere.xml
<?xml version="1.0" encoding="windows-1252"?>

<atmosphere-handlers>
  <atmosphere-handler context-root="/sample/gwtComet"
      class-name="pl.avd.samples.gwt.comet.server.AtmosphereHandler">
    <property name="heartbeat" value="20000"/>
  </atmosphere-handler>
</atmosphere-handlers>

Plik atmosphere.xml zawiera definicje handlerów, które będą obsługiwały nasze komunikaty. Można tu zdefiniować kilka handlerów, wybierając różne implementacje. My dostarczymy tu własną klasę, która będzie rozszerzała klasę standardowego handler’a Atmosphere’a. Dodatkowo definiujemy parametr heartbeat – czas, po którym przez nieużywany kanał będzie przechodził komunikat kontrolny. Plik umieszczamy w katalogu META-INF.

context.xml
<?xml version="1.0" encoding="UTF-8"?>
<Context>
    <Loader delegate="true"/>
</Context>

Atmosphere zaleca też dodatkową konfigurację dla ładowarki klas. Ustawienie opcji delegate na „true” powoduje, że najpierw ładowane będą klasy rodzica (kontenera), a na koniec klasy aplikacji webowej. Na Jetty działa też bez tego pliku. Profilaktycznie plik umieszczamy w katalogach WEB-INF oraz META-INF (niektóre serwery wczytują ten plik z jednego katalogu, inne z drugiego).

To tyle, jeśli chodzi o konfigurację części serwerowej aplikacji. Aby Atmosphere działał nam w GWT, należy dodać odpowiedni wpis do desktryptora modułu GWT:

Sample.gwt.xml
  <!-- Inherit the Atmosphere Comet support.                -->
  <inherits name="org.atmosphere.gwt.Client" />

Definiujemy również klasę serializera, dla którego za pomocą adnotacji deklarujemy klasy, które będą przesyłane kanałem Comet’a.

CometSerializer.java
@SerialTypes(value = {Message.class, RefreshUsersEvent.class})
public abstract class CometSerializer extends AtmosphereGWTSerializer {
}

Podałem tutaj dwie klasy: Message, która będzie przenosiła wiadomości czatu oraz RefreshUsersEvent, która będzie informowała o zmianie w listach użytkowników. Dla uproszczenia klasa ta będzie zawierała całą listę aktualnie zalogowanych użytkowników, nie trzeba będzie więc odwoływać się dodatkowo do serwera.

CometListener.java
public abstract class CometListener implements AtmosphereListener {

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

  private AtmosphereClient client;

  @Override
  public void onConnected(int heartbeat, int connectionID) {
    LOG.info("comet.connected [" + heartbeat + ", " + connectionID + "]");
  }

  @Override
  public void onBeforeDisconnected() {
    LOG.info("comet.beforeDisconnected");
  }

  @Override
  public void onDisconnected() {
    LOG.info("comet.disconnected");
  }

  @Override
  public void onError(Throwable exception, boolean connected) {
    int statuscode = -1;
    if (exception instanceof StatusCodeException) {
      statuscode = ((StatusCodeException) exception).getStatusCode();
    }
    LOG.log(Level.SEVERE, "comet.error [connected=" + connected + "] (" + statuscode + ")", exception);
  }

  @Override
  public void onHeartbeat() {
    LOG.info("comet.heartbeat [" + client.getConnectionID() + "]");
  }

  @Override
  public void onRefresh() {
    LOG.info("comet.refresh [" + client.getConnectionID() + "]");
  }

  public void setClient(AtmosphereClient client) {
    this.client = client;
  }

  public AtmosphereClient getClient() {
    return client;
  }
}

Klasa listenera nasłuchuje na pewne zdarzenia związane z komunikacją Comet’a. Dostarczamy tu implementację zapisującą do logów poszczególne zdarzenia oraz zostawiamy obsługę najważniejszego z nich dla klasy powołującej listenera.

SampleModule.java
  private void initComet() {
    CometListener cometListener = new CometListener() {

      @Override
      public void onMessage(List&lt; ? extends Serializable&gt; messages) {
        LOG.info("comet.recive_messages: " + messages.size());
        for (Serializable mes : messages) {
          if (mes instanceof Message) {
            messageProvider.getList().add((Message) mes);
          } else if (mes instanceof RefreshUsersEvent) {
            usersList.clear();
            for (String principal : ((RefreshUsersEvent) mes).getPrincipals()) {
              usersList.addItem(principal);
            }
          }
        }
      }
    };

    AtmosphereGWTSerializer serializer = GWT.create(CometSerializer.class);
    client = new AtmosphereClient(GWT.getModuleBaseURL() + "gwtComet", serializer, cometListener);
    cometListener.setClient(client);
    client.start();
  }

Fragmentem głównej klasy modułu jest metoda uruchamiająca komunikację Comet’a. Dla listenera określamy obsługę przychodzących komunikatów.

  private boolean sendMessage(String mess) {
    if (mess == null || mess.length() == 0) {
      return false;
    }

    Message message = new Message();
    message.setRecipient(getMessageRecipient(mess));
    message.setText(getMessageText(mess));

    client.broadcast(message);
    return true;
  }

Dodajemy też metodę powołującą oraz wysyłającą nowe wiadomości na podstawie wpisanego w pole tekstowe tekstu. Wiadomości zawierają w sobie treść oraz odbiorcę (jeśli odbiorca nie został zdefiniowany, wiadomość zostanie wysłana do wszystkich). Jedyną czynnością, którą trzeba zrobić aby wysłać taki komunikat, jest wywołanie metody AtmosphereClient.broadcast(…).
Reszta klasy, to standardowe elementy interfejsu użytkownika, nie wymagają więc szczegółowego opisu.

Została nam jeszcze implementacja stosownej obsługi komunikatów po stronie serwera. Zaczniemy od klasy pomocniczej, która będzie gromadziła zasoby Atmosphere’a i przypisywała je do zalogowanych użytkowników. Zasobem można tutaj nazwać ustanowiony kanał, do którego będziemy mogli przesyłać komunikaty.

PrincipalConnectionsStorage.java
public class PrincipalConnectionsStorage {

  private static PrincipalConnectionsStorage instance = new PrincipalConnectionsStorage();	

  private Logger LOG = LoggerFactory.getLogger(PrincipalConnectionsStorage.class);

  private Map>String, Set>GwtAtmosphereResource<< principalGwtResources;

  private Timer timer;

  public static PrincipalConnectionsStorage get() {
    return instance;
  }

  private PrincipalConnectionsStorage() {
    principalGwtResources = new HashMap>String, Set>GwtAtmosphereResource<<();
    timer = new Timer();
  }

  public void addPrincipalGwtResource(Principal principal, GwtAtmosphereResource resource) {
    LOG.info("Add principal gwt resource [" + principal.getName() + ", " + resource.getConnectionID() + "]");
    Set>GwtAtmosphereResource< resources;

    if (!principalGwtResources.containsKey(principal.getName())) {
      resources = new HashSet>GwtAtmosphereResource<();
      principalGwtResources.put(principal.getName(), resources);
    } else {
      resources = principalGwtResources.get(principal.getName());
    }

    resources.add(resource);

    timer.schedule(new BroadcastUsersListTask(resource), 100);
  }

  public void removePrincipalGwtResource(Principal principal, GwtAtmosphereResource resource) {
    LOG.info("Remove principal gwt resource [" + principal.getName() + ", " + resource.getConnectionID() + "]");
    if (principalGwtResources.containsKey(principal.getName())) {
      principalGwtResources.get(principal.getName()).remove(resource);
    }

    timer.schedule(new BroadcastUsersListTask(resource), 100);
  }

  public Set>AtmosphereResource>?, ?<< getResourcesForRecipient(HasRecipient hasRecipient) {
    Set>AtmosphereResource>?, ?<< resources = new HashSet>AtmosphereResource>?, ?<<();
    String recipient = hasRecipient.getRecipient();

    if (principalGwtResources.containsKey(recipient)) {
      for (GwtAtmosphereResource res : principalGwtResources.get(recipient)) {
        resources.add(res.getAtmosphereResource());
      }
    }

    return resources;
  }

  public Collection>String< getPrincipalsNames() {
    List>String< principals = new ArrayList>String<();

    for (Entry>String, Set>GwtAtmosphereResource<< entry : principalGwtResources.entrySet()) {
      if (entry.getValue().size() < 0) {
        principals.add(entry.getKey());
      }
    }

    return principals;
  }
}

Dla uproszczenia kodu założyłem, że tylko zalogowani użytkownicy będą mogli przesyłać komunikaty. Zasoby Atmosphere’a są więc przechowywane w mapie, gdzie kluczem jest nazwa zalogowanego użytkownika. W wywołaniach metod addPrincipalGwtResource i removePrincipalGwtResource uruchamiany jest TimerTask, który wysyła dla wszystkich komunikat z nową listą użytkowników:

BroadcastUsersListTask.java
public class BroadcastUsersListTask extends TimerTask {

  private GwtAtmosphereResource resource;

  public BroadcastUsersListTask(GwtAtmosphereResource resource) {
    this.resource = resource;
  }

  @Override
  public void run() {
    RefreshUsersEvent ev = new RefreshUsersEvent();
    ev.setPrincipals(PrincipalConnectionsStorage.get().getPrincipalsNames());
    resource.getBroadcaster().broadcast(ev);
  }
}

Na koniec została jeszcze implementacja handlera komunikatów, którego już na początku zdefiniowaliśmy w pliku atmosphere.xml.

AtmosphereHandler.java
public class AtmosphereHandler extends AtmosphereGwtHandler {

  private Logger LOG = LoggerFactory.getLogger(AtmosphereHandler.class);

  @Override
  public int doComet(GwtAtmosphereResource resource) throws ServletException, IOException {
    HttpServletRequest req = resource.getAtmosphereResource().getRequest();
    Principal principal = req.getUserPrincipal();

    LOG.info("Comet initializing [" + principal.getName() + ", " + resource.getConnectionID() + "]");
    resource.getBroadcaster().setID("GWT_COMET");

    PrincipalConnectionsStorage.get().addPrincipalGwtResource(principal, resource);

    return NO_TIMEOUT;
  }

  @Override
  public void disconnect(GwtAtmosphereResource resource) {
    HttpServletRequest req = resource.getAtmosphereResource().getRequest();
    Principal principal = req.getUserPrincipal();

    LOG.info("Comet disconnected [" + principal.getName() + ", " + resource.getConnectionID() + "]");

    PrincipalConnectionsStorage.get().removePrincipalGwtResource(principal, resource);

    super.disconnect(resource);
  }

  @Override
  public void broadcast(Serializable message, GwtAtmosphereResource resource) {
    LOG.info("Comet broadcast message: " + message);
    if (message instanceof InjectSender) {
      HttpServletRequest req = resource.getAtmosphereResource().getRequest();
      Principal principal = req.getUserPrincipal();

      ((InjectSender) message).setSender(principal.getName());
    }

    if (message instanceof InjectSendDate) {
      ((InjectSendDate) message).setSendDate(new Date());
    }

    if (message instanceof HasRecipient) {
      HasRecipient hasRecipient = (HasRecipient) message;
      if (hasRecipient.hasRecipient()) {
        Set>AtmosphereResource>?, ?<< resources = PrincipalConnectionsStorage.get().getResourcesForRecipient(hasRecipient);
        getBroadcaster(resource).broadcast(message, resources);
        return;
      }
    }

    super.broadcast(message, resource);
  }
}

Metoda doComet wywoływana jest w momencie ustanawiania połączenia z nowym użytkownikiem. Metoda disconnect wykonuje się w momencie rozłączenia użytkownika z danym zasobem. W obu tych metodach dodajemy lub usuwamy przypisania zasobów do zalogowanych użytkowników przy pomocy napisanej wcześniej klasy. Ostatnia metoda, którą trzeba nadpisać, to broadcast. W metodzie tej wystarczy sprawdzić, czy w zawartości komunikatu został zdefiniowany jakiś odbiorca, jeśli tak, pobieramy wszystkie zasoby związane z danym odbiorcą i rozsyłamy do nich komunikat. W przeciwnym przypadku przekazujemy komunikat dalej do standardowej obsługi komunikatów (komunikat trafi do wszystkich).

To tylko zalążek możliwości Atmosphere’a, ale jak widać, podstawową funkcjonalność udało się osiągnąć w kilku niewielkich krokach. Standardowo już gotowy projekt można pobrać z niniejszej strony oraz uruchomić poleceniem „mvn gwt:run”. Po zalogowaniu się (użytkownicy: test1/pass1 lub test2/pass2) ukaże się okno wiadomości, lista zalogowanych użytkowników oraz pole tekstowe do wpisania nowej wiadomości. Jeśli nową wiadomość poprzedzimy wyrażeniem „@nazwa_uzytkownika:”, komunikat zostanie wysłany tylko do tej osoby.

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