Thursday, September 29, 2011

Atmosphere and JSF are good friends

Decision to write this long article was driven by PrimeFaces community. People have troubles to develop web applications with server push techniques. Even with Atmosphere - framework for building portable Comet and WebSocket based applications. Portable means it can run on Tomcat, JBoss, Jetty, GlassFish or any other application server which supports Servlet specification 2.5 or higher. If you want to be independent on underlying server and don't want to have headaches during implementation, use Atmosphere framework. It simplifies development and can be easy integrated into JSF or any other web frameworks (don't matter). I read Atmosphere doesn't play nice with JSF or similar. PrimeFaces had a special Athmosphere handler for JSF in 2.2.1. But that's all is not necessary. It doesn't matter what for a web framework you use - the intergration is the same. My intention is to show how to set up a bidirectional communication between client and server by using of Atmosphere. My open source project "Collaborative Online-Whiteboard" demonstrates that this approach works well for all three transport protocols - Long-Polling, Streaming and WebSocket. This is an academic web application built on top of the Raphaël / jQuery JavaScript libraries, Atmosphere framework and PrimeFaces. You can open two browser windows (browser should support WebSocket) and try to draw shapes, to input text, to paste image, icon, to remove, clone, move elements etc.


We need a little theory at first. For bidirectional communication we need to define a Topic at first (called sometimes "channel"). Users subscribe to the Topic and act as Subscribers to receive new data (called updates or messages as well). A subscriber acts at the same time as Publisher and can send data to all subscribers (broadcasting). Topic can be expressed by an unique URL - from the technical point of view. URL should be specified in such a manner that all bidirectional communication goes through AtmosphereServlet. JSF requests should go through FacesServlet. This is a very important decision and probably the reason why Atmosphere integration in various web frameworks like JSF and Struts sometimes fails. Bidirectional communication should be lightweight and doesn't run all JSF lifecycle phases! This can be achieved by proper servlet-mapping in web.xml. The picture below should demonstrate the architecture (example of my open source project).


For my use case I need messages in JSON format, but generally any data can be passed over bidirectional channel (XML, HTML). What is the Topic exactly? It depends on web application how you define the Topic. You can let user types it self (e.g. in chat applications) or make it predefined. In my application I equate Topic with Whiteboard-Id which is generated as UUID. Furthermore, I have introduced User-Id (= Publisher-Id) as well and called it Sender. Sender serves as identificator to filter Publisher out of all Subscribers. Publisher is normally not interested in self notification. I'm going to show using of Sender later. My Topic-URL follows this pattern
http://host:port/pubsub/{topic}/{sender}.topic
and looks like e.g. as
http://localhost:8080/pubsub/ea288b4c-f2d5-467f-8d6c-8d23d6ab5b11/e792f55e-2309-4770-9c48-de75354f395d.topic
I use Atmosphere with Jersey I think it's a better way than to write Atmosphere handlers or try to integrate MeteorServlet. Therefore, to start using of Atmosphere, you need a dependency to current Atmosphere and Jersey. Below is a dependency configuration for Maven users. Note: I cut logging off.
<dependency>
    <groupId>org.atmosphere</groupId>
    <artifactId>atmosphere-jersey</artifactId>
    <version>0.8-SNAPSHOT</version>
    <exclusions>
        <exclusion>
            <groupId>org.atmosphere</groupId>
            <artifactId>atmosphere-ping</artifactId>
        </exclusion>
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.apache.geronimo.specs</groupId>
    <artifactId>geronimo-servlet_3.0_spec</artifactId>
    <version>1.0</version>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.6.2</version>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-nop</artifactId>
    <version>1.6.2</version>
</dependency>
The next step is to add Atmosphere-jQuery plugin to the client side. This plugin can be downloaded from the Atmosphere's homepage. After placing jquery.atmosphere.js under webapp/resources/js you can include it with
<h:outputScript library="js" name="jquery.atmosphere.js" target="head"/>
Yes, I know that Atmosphere can load jquery.atmosphere.js from JAR file in classpath, but a manual include is better in JSF environment. Now we are able to subscribe to the Topic and communicate with the server on the client side. There are two calls: jQuery.atmosphere.subscribe() and jQuery.atmosphere.response.push(). To establish a bidirectional communication, you need to call subscribe(). You have to call this method just once when page has been loaded. After that the method push() is responsible for data publushing to all subsribers. In the mentioned above web application I have an JavaScript object WhiteboardDesigner which encapsulates the Atmosphere's subscribe() call in the method subscribePubSub().
// Subscribes to bidirectional channel.
// This method should be called once the web-application is ready to use.
this.subscribePubSub = function() {
  jQuery.atmosphere.subscribe(
    this.pubSubUrl,
    this.pubSubCallback,
    jQuery.atmosphere.request = {
      transport: this.pubSubTransport,
      maxRequest: 100000000
    });
    
    this.connectedEndpoint = jQuery.atmosphere.response;
}
The first parameter this.pubSubUrl is the shown above Topic-URL. The second parameter this.pubSubCallback is the callback function. The third parameter is the request configuration which keeps among other things transport protocol defined in this.pubSubTransport: "long-polling", "streaming" or "websocket". The last line assigns jQuery.atmosphere.response the variable this.connectedEndpoint which allows us data publishing via push(). So now when new data being received, the callback function this.pubSubUrl gets called. Inside of the callback we can extract received data from response object. You can get the idea how to handle broadcasted data from my example:
// Callback method defined in the subscribePubSub() method.
// This method is always called when new data (updates) are available on server side.
this.pubSubCallback = function(response) {
  if (response.transport != 'polling' &&
      response.state != 'connected' &&
      response.state != 'closed' && response.status == 200) {
      var data = response.responseBody;
      if (data.length > 0) {
        // convert to JavaScript object
        var jsData = JSON.parse(data);

       // get broadcasted data
        var action = jsData.action;
        var sentProps = (jsData.element != null ? jsData.element.properties : null);
        switch (action) {
          case "create" :
            ...
            break;
          case "update" :
            ...
            break;
          case "remove" :
            ...
            break;
          ... 
          default :
        }
        ...
      }
  }
}
To publish data over the Topic-URL you need to call this.connectedEndpoint.push(). this.connectedEndpoint is a defined above shortcut for jQuery.atmosphere.response. I call it in my example for created whiteboard elements as follows (just an example):
// Send changes to server when a new image was created.
this.sendChanges({
  "action": "create",
  "element": {
    "type": "Image",
    "properties": {
      "uuid": ...,
      "x": ...,
      "y": ...,
      "url": ...,
      "width": ...,
      "height": ...
    }
  }
});

// Send any changes on client side to the server.
this.sendChanges = function(jsObject) {
  // set timestamp
  var curDate = new Date();
  jsObject.timestamp = curDate.getTime() + curDate.getTimezoneOffset() * 60000;

  // set user
  jsObject.user = this.user;

  // set whiteboard Id
  jsObject.whiteboardId = this.whiteboardId;

  var outgoingMessage = JSON.stringify(jsObject);

  // send changes to all subscribed clients
  this.connectedEndpoint.push(this.pubSubUrl, null, jQuery.atmosphere.request = {data: 'message=' + outgoingMessage});
}
Passed JavaScript object jsObject is converted to JSON via JSON.stringify(jsObject) befor it's sending to the server.

What is necessary on the server side? Calls jQuery.atmosphere.subscribe() and jQuery.atmosphere.response.push() have to be caught on the server side. I use Jersey and its annotations to catch GET- / POST-requests for the certain Topic-URL in a declarative way. For that are responsible annotations @GET, @POST und @PATH. I have developed the class WhiteboardPubSub to catch the Topic-URL by means of @Path("/pubsub/{topic}/{sender}"). With @GET annotated method subscribe() catches the call jQuery.atmosphere.subscribe(). This method tells Atmosphere to suspend the current request until an event occurs. Topic (= Broadcaster) is also created in this method. Topic-String is extracted by the annotation @PathParam from the Topic-URL. You can imagine a Broadcaster as a queue. As soon as a new message lands in the queue all subscribers get notified (browsers which are connected with Topic get this message). With @POST annotated method publish() catches the call jQuery.atmosphere.response.push(). Client's data will be processed there and broadcasted to all subscribed connections. This occurs independent from the underlying transport protocol.
@Path("/pubsub/{topic}/{sender}")
@Produces("text/html;charset=ISO-8859-1")
public class WhiteboardPubSub
{
  private @PathParam("topic") Broadcaster topic;

  @GET
  public SuspendResponse<String> subscribe() {
    return new SuspendResponse.SuspendResponseBuilder<String>().
               broadcaster(topic).outputComments(true).build();
  }

  @POST
  @Broadcast
  public String publish(@FormParam("message") String message,
                      @PathParam("sender") String sender,
                      @Context AtmosphereResource resource) {
    // find current sender in all suspended resources and
    // remove it from the notification
    Collection<AtmosphereResource<?, ?>> ars = topic.getAtmosphereResources();
    if (ars == null) {
      return "";
    }

    Set<AtmosphereResource<?, ?>> arsSubset = new HashSet<AtmosphereResource<?, ?>>();
    HttpServletRequest curReq = null;
    for (AtmosphereResource ar : ars) {
      Object req = ar.getRequest();
      if (req instanceof HttpServletRequest) {
        String pathInfo = ((HttpServletRequest)req).getPathInfo();
        String resSender = pathInfo.substring(pathInfo.lastIndexOf('/') + 1);
        if (!sender.equals(resSender)) {
          arsSubset.add(ar);
        } else {
          curReq = (HttpServletRequest) req;
        }
      }
    }

    if (curReq == null) {
      curReq = (HttpServletRequest) resource.getRequest();
    }

    // process current message (JSON) and create a new one (JSON) for subscribed clients
    String newMessage = WhiteboardUtils.updateWhiteboardFromJson(curReq, message);

    // broadcast subscribed clients except sender
    topic.broadcast(newMessage, arsSubset);

    return "";
  }
}
In my case the method publish() looks for sender (= publisher of this message) among all subscribers and removes it from the notification. It looks a little bit complicated. In simple case you can write
@POST
@Broadcast
public Broadcastable publish(@FormParam("message") String message) {
  return new Broadcastable(message, "", topic);
}
and that's all! The last step is the mentioned above configuration in web.xml. *.jsf requests should be mapped to FacesServlet and *.topic requests to AtmosphereServlet. AtmosphereServlet can be configured comprehensively. Important configuration parameter is com.sun.jersey.config.property.packages. With this parameter you can tell Jersey where the annotated class is located (in my case WhiteboardPubSub). In my example Jersey scans the directory com.googlecode.whiteboard.pubsub.
<!-- Faces Servlet -->
<servlet>
  <servlet-name>Faces Servlet</servlet-name>
  <servlet-class>javax.faces.webapp.FacesServlet</servlet-class>
  <load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
  <servlet-name>Faces Servlet</servlet-name>
  <url-pattern>*.jsf</url-pattern>
</servlet-mapping>

<!-- Atmosphere Servlet -->
<servlet>
  <description>AtmosphereServlet</description>
  <servlet-name>AtmosphereServlet</servlet-name>
  <servlet-class>org.atmosphere.cpr.AtmosphereServlet</servlet-class>
  <init-param>
    <param-name>com.sun.jersey.config.property.resourceConfigClass</param-name>
    <param-value>com.sun.jersey.api.core.PackagesResourceConfig</param-value>
  </init-param>
  <init-param>
    <param-name>com.sun.jersey.config.property.packages</param-name>
    <param-value>com.googlecode.whiteboard.pubsub</param-value>
  </init-param>
  <init-param>
    <param-name>org.atmosphere.useWebSocket</param-name>
    <param-value>true</param-value>
  </init-param>
  <init-param>
    <param-name>org.atmosphere.useNative</param-name>
    <param-value>true</param-value>
  </init-param>
  <init-param>
    <param-name>org.atmosphere.cpr.WebSocketProcessor</param-name>
    <param-value>org.atmosphere.cpr.HttpServletRequestWebSocketProcessor</param-value>
  </init-param>
  <init-param>
    <param-name>org.atmosphere.cpr.broadcastFilterClasses</param-name>
    <param-value>org.atmosphere.client.JavascriptClientFilter</param-value>
  </init-param>
  <load-on-startup>2</load-on-startup>
</servlet>
<servlet-mapping>
  <servlet-name>AtmosphereServlet</servlet-name>
  <url-pattern>*.topic</url-pattern>
</servlet-mapping>
Please check Atmosphere documentation and showcases for more information.

Summary: We have seen that few things are needed to establish the bidirectional comminication: Topic-URL, callback function for data receiving, subsribe() und push() methods on client side, an Jersey class on server side and configuration in web.xml to bind all parts together. JSF- und Atmosphere-requests should be treated separately. You maybe ask "but what is about the case if I need to access JSF stuff in Atmosphere-request?" For a simple case you can use this technique to access managed beans. If you want to have a fully access to all JSF stuff you should use this technique which allows accessing FacesContext somewhere from the outside.

P.S. Good news. Atmosphere's lead Jeanfrancois Arcand announced his intention to pursue the work on Atmosphere. He can invest more time now in his great framework.

21 comments:

  1. hello oleg, how can i contact you. i am about to release a PF website and m having just one setback.

    ReplyDelete
  2. Thanks Oleg,
    very useful indeed. Where can I download your opensource "Collaborative Online-Whiteboard" code?

    ReplyDelete
  3. Hi, Oleg.

    Could you please tell, what you use as the UI console on the first screenshot?

    Thanks.

    ReplyDelete
  4. Hello Anton. I use Blackbird console http://www.gscottolson.com/blackbirdjs/

    ReplyDelete
  5. Source code and Homepage of the project is here http://code.google.com/p/online-whiteboard/

    ReplyDelete
  6. Nice Post Oleg!
    How can I change the app to get it work on Tomcat?
    or Glassfish?
    Thanks.

    ReplyDelete
  7. No changes are normally needed for Tomcat or GlassFish. Atmosphere is portable. Simple deploy your app on Tomcat 7 or GlassFish 3.1 (latest versions are better). Do you build from source?

    ReplyDelete
  8. Hallo Oleg,
    I delpoyed the war files. The push doesn't work in Streaming and long-polling on Tomcat (6 nor 7)!!
    Websocket works perfect on Both. I haven't tried Glassfish 3.1 yet.

    I'm developing a JSF web app for mobile and I'm thinking to use primemobile and for near real time stuff maybe atmosphere or IcePush..

    ReplyDelete
  9. I don't know which files did you deploy, but I guess that's a correct behavior :-) WebSocket is pre-defined (hard-coded) in CreateWhiteboard.java

    private String pubSubTransport = "websocket";

    You should change this to "long-polling" or "streaming" (depends on what you want).

    ReplyDelete
  10. I got the files from here:
    http://code.google.com/p/online-whiteboard/downloads/list

    ReplyDelete
  11. No, no. That are executable war files with embedded Jetty. You can execute them with
    java -jar . You should check out the source and build from source then with Maven (if you want).

    ReplyDelete
  12. I meant with java -jar whiteboard-long-polling.war
    for example.

    ReplyDelete
  13. Ok, thx Oleg.
    I'll give feedback as soon as possible.

    ReplyDelete
  14. I run the project under Netbeans 7 and added all the jar files from the executable war files. With Glassfish I got the following error:
    Warnung: StandardWrapperValve[AtmosphereServlet]: PWC1406: Servlet.service() for servlet AtmosphereServlet threw exception
    java.lang.IllegalStateException: Make sure you have enabled Comet or make sure the Thread invoking that method is the same as the Servlet.service() Thread.

    The app started with Tomcat 7 but the doesn't work and I get this:
    Warnung: Cannot serialize session attribute com.sun.faces.renderkit.ServerSideStateHelper.LogicalViewMap for session B34B556E351B5BE6F2AA52A6A20692D1
    java.io.NotSerializableException: com.googlecode.whiteboard.controller.WhiteboardsManager

    ReplyDelete
  15. I meant: The app started with Tomcat 7 but the push doesn't work and I get this:

    ReplyDelete
  16. Hello Oleg,
    any suggestions?

    ReplyDelete
  17. You probably should remove implement Serializable from com.googlecode.whiteboard.controller.WhiteboardsManager because it's in application scope (not session and doesn't need to be serialized). Sorry, but I don't maintain the code anymore, you should check out the sources http://code.google.com/p/online-whiteboard/source/checkout, change them and compile the WAR project with Maven.

    ReplyDelete
  18. Hi, Oleg,
    is there any possibility to find out on the server side, if a client disconnect (e.g. by just closing the browser or a failing internet connecting)?
    Unfortunately, I haven't found anything like that in the atmosphere sources...

    ReplyDelete
  19. Hmm... I don't know about this possibility. I think it's not possible from technical point of view, but you can ask this question in the Atmosphere forum.

    ReplyDelete
  20. Good post, it took me a week to clearly understand everything point to point. You code have really made me understand atmosphere framework little bit better.

    ReplyDelete

Note: Only a member of this blog may post a comment.