Spring Bayeux GWT Chat Webapp

David Winterfeldt

2008


The chat example allows a client to send and receive messages published to a chat channel and also to see a list of the other chat clients. The example has GWT & Dojo Cometd integration on the client and Spring Bayeux integration on the server. The GWT part of the client handles display and client interaction, but calls out to external JavaScript to let Dojo Cometd handle Bayeux publish/subscribe on the client. The Spring by Example Web Module is used for it's Bayeux support to configure the Bayeux Chat Service.

To briefly explain Comet & Bayeux, Comet is server side push basically. Instead of a standard HTTP request that makes a request and immediately receives a response, Comet either has an open connection to stream data to the client or long polling is used. Long polling, which is what the current Bayeux implementation uses, makes a request and the server doesn't respond until an event occurs on the server. Bayeux is a protocol that clearly defines a number of things, but one of it's primary goals is providing low latency over HTTP. It provides a publish/subscribe API for handling events. Bayeux also has implementation in more than just Java and JavaScript.

The chat example is based on the Dojo Cometd chat example and a slightly modified version of the example is also available for comparison in the web application. The only changes made to the Dojo Cometd example was just enough to get it integrated into the Tiles page and changing the JavaScript since the URL for subscribing to the chat service is slightly different. The actual Bayeux Chat Service, which will be shown further in the example, has only minor changes compared to the original to integrate it with Spring.

The project uses Spring by Example Web Module's Spring Bayeux Integration for Comet on Jetty. This has support for creating and initializing the main Bayeux instance in Spring and then the Spring Bayeux servlet will retrieve it from the Spring context. Also, Bayeux Services can then have the Bayeux instance injected along with Hibernate, Web Services, etc. A message driven POJO could even then publish a message event to Bayeux clients.

[Note]Note

Currently this example only runs on Jetty since it uses the Bayeux implementation provided by Jetty. Tomcat also has Comet support and it should be easy to port a Bayeux implementation to it if one doens't already exist. There isn't currently a standard API to suspend and resume HTTP requests, but the Servlet 3.0 Draft Specification is standardizing this. Hopefully it will be finalized relatively soon and most major servlet engines will have a Servlet 3.0 implementation. Jetty already has a pre-release 7.0 version implementing the Servlet 3.0 specification as it currently is.

1. Server Configuration

Web Configuration

This is basically the same as a standard Spring MVC web configuration, but two things to note are the Spring JS ResourceServlet and the SpringContinuationCometdServlet handles Bayeux requests. The ResourceServlet is used for serving static files from jars. The Spring JS libraries contain Dojo, and are actually served from there. The Dojox JavaScript libraries are in the dojox-resources.jar. The SpringContinuationCometdServlet handles all Bayeux publish and subscribe requests and is mapped to '/cometd/*'.

/WEB-INF/web.xml
                    
<?xml version="1.0" encoding="UTF-8"?>
<web-app id="WebApp_ID" version="2.4" 
         xmlns="http://java.sun.com/xml/ns/j2ee" 
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
         xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee 
                             http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd">
    <display-name>chat</display-name>
    
    <listener>
        <listener-class>
            org.springframework.web.context.ContextLoaderListener
        </listener-class>
    </listener>

    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>
            /WEB-INF/web-application-context.xml
        </param-value>
    </context-param>

    <filter>
        <filter-name>encoding-filter</filter-name>
        <filter-class>
            org.springframework.web.filter.CharacterEncodingFilter
        </filter-class>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF-8</param-value>
        </init-param>
    </filter>

    <filter-mapping>
        <filter-name>encoding-filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <!-- Serves static resource content from .jar files such as spring-faces.jar -->
    <servlet>
        <servlet-name>resources</servlet-name>
        <servlet-class>org.springframework.js.resource.ResourceServlet</servlet-class>
        <load-on-startup>0</load-on-startup>
    </servlet>

    <servlet>
        <servlet-name>cometd</servlet-name>
        <servlet-class>org.springbyexample.cometd.continuation.SpringContinuationCometdServlet</servlet-class>
        <init-param>
            <param-name>asyncDeliver</param-name>
            <param-value>false</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
        
    <servlet>
        <servlet-name>chat</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
		<init-param>
			<param-name>contextConfigLocation</param-name>
			<param-value></param-value>
		</init-param>
		<load-on-startup>2</load-on-startup>
    </servlet>
    
    <!-- Map all /resources requests to the Resource Servlet for handling -->
    <servlet-mapping>
        <servlet-name>resources</servlet-name>
        <url-pattern>/resources/*</url-pattern>
    </servlet-mapping>

    <servlet-mapping>
        <servlet-name>cometd</servlet-name>
        <url-pattern>/cometd/*</url-pattern>
    </servlet-mapping>
    
    <servlet-mapping>
        <servlet-name>chat</servlet-name>
        <url-pattern>*.htm</url-pattern>
    </servlet-mapping>
    
    <welcome-file-list>
        <welcome-file>index.jsp</welcome-file>
    </welcome-file-list>

</web-app>
                    
                

Spring Configuration

The context:component-scan will register the Bayeux Chat Service. The bayeux bean class is SpringContinuationBayeux. This is the main instance that will control of Bayeux's publish and subscribe on the server. It will be used by all Bayeux services and by the Bayeux servlet. Basic values like timeout can be set (p-namespace is being used for setter injection, shorcut for using property element). The Bayeux implementation also supports filtering of any messages. This example has a filter defined to not allow any markup to be sent and also has two basic regular expression filters. One matches 'Spring by Example' (either a capital or lower case 's' for 'spring', 'b' for 'by', and 'e' for 'example') or 'sbe' (upper or lower case) and changes it to 'Spring by Example'. The other corrects typos for 'the' and 'spring'.

/WEB-INF/bayeux-context.xml
                    
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:p="http://www.springframework.org/schema/p"
    xmlns:context="http://www.springframework.org/schema/context"
    xsi:schemaLocation="http://www.springframework.org/schema/beans 
                        http://www.springframework.org/schema/beans/spring-beans.xsd
                        http://www.springframework.org/schema/context 
                        http://www.springframework.org/schema/context/spring-context.xsd">

    <context:component-scan base-package="org.springbyexample.web.cometd" />
    
    <bean id="bayeux" 
          class="org.springbyexample.cometd.continuation.SpringContinuationBayeux"
          p:timeout="300000"
          p:interval="0"
          p:maxInterval="10000"
          p:multiFrameInterval="2000"
          p:logLevel="0"
          p:directDeliver="true">
        <property name="filters">
            <value>
                <![CDATA[
[
  { 
    "channels": "/**",
    "filter"  : "org.mortbay.cometd.filter.NoMarkupFilter",
    "init"    : {}
  },

  { 
    "channels": "/chat/*",
    "filter"   : "org.mortbay.cometd.filter.RegexFilter",
    "init"    : [ 
                  [ "[Ss]pring [Bb]y [Ee]xample","Spring by Example" ],
                  [ "[Ss][Bb][Ee]","Spring by Example" ]
                ]
  },
  
  { 
    "channels": "/chat/**",
    "filter"   : "org.mortbay.cometd.filter.RegexFilter",
    "init"    : [ 
                  [ "teh ","the "],
                  [ "sring ","spring "] 
                ]
  }
  
  
]
                ]]>
            </value>
        </property>
    </bean>

</beans>
                    
                

Code Example

The ChatService is basically the same as the one from the Dojo Cometd example. The key differences are that the class is annotated with @Component and the constructor is annotated with @Autowired. So the service is picked up by the context:component-scan because of the @Component annotation and the Bayeux instance configured in the XML configuration is injected into the constructor because of the @Autowired annotation. At this point the class just calls super in it's constructor to give the Bayeux instance to it's parent and working on the service is the same. So it is very easy to modify any Bayeux service example to be Spring configured and gain the full advantage of IoC and DI.

Example 1. ChatService

src/main/java/org/springbyexample/web/cometd/chat/ChatService.java
                    
@Component
public class ChatService extends BayeuxService {
    
    final Logger logger = LoggerFactory.getLogger(ChatService.class);
    
    final ConcurrentMap<String, Set<String>> _members = new ConcurrentHashMap<String, Set<String>>();

    /**
     * Constructor
     */
    @Autowired
    public ChatService(Bayeux bayeux) {
        super(bayeux, "chat");
        subscribe("/chat/**", "trackMembers");
    }

    /**
     * Tracks chat clients.
     */
    public void trackMembers(Client joiner, String channel,
                             Map<String, Object> data, String id) {
        if (Boolean.TRUE.equals(data.get("join"))) {
            Set<String> m = _members.get(channel);

            if (m == null) {
                Set<String> new_list = new CopyOnWriteArraySet<String>();
                m = _members.putIfAbsent(channel, new_list);
                if (m == null) {
                    m = new_list;
                }
            }

            final Set<String> members = m;
            final String username = (String) data.get("user");

            members.add(username);

            joiner.addListener(new RemoveListener() {
                public void removed(String clientId, boolean timeout) {
                    members.remove(username);

                    logger.info("members: " + members);
                }
            });
            
            logger.info("Members: " + members);
            
            send(joiner, channel, members, id);
        }
    }
    
}