Monday, November 12, 2007

GWT-RPC with Spring 2.x

Had some time recently to play with the Google Web Toolkit (GWT). One of my goals was to better understand its AJAX RPC model.

Being a long-time, hardcore Spring user, I was unpleasantly surprised to see that GWT-RPC's server-side model requires subclassing the supplied RemoteServiceServlet and mapping this in web.xml... ugh. Thankfully, the flexibility of Spring led to a solution that allows GWT-RPC services to be instantiated as with any other bean.

The solution has two facets - the infrastructure components that are wired up once, and the service components that you write as requirements demand. The emphasis was on simplifying the service components such that the RPC plumbing didn't get in the way of business logic.

The result:

package digitalascent.web.support.gwt;

import org.springframework.context.support.ApplicationObjectSupport;
import org.springframework.stereotype.Controller;

@Controller
@GwtRpcEndPoint
public class ServerServiceImpl extends ApplicationObjectSupport implements ServerStatusService {

public String getStatusData() {
return "Your random number: " + Math.random();
}
}


...nice and simple! The differences from a conventional GWT-RPC service:
  • No requirement to inherit from RemoteServiceServlet (or any other class for that matter);
  • Addition of the @GwtRpcEndPoint annotation to indicate that this bean is an end point for GWT-RPC;
  • Automagic mapping of URLs (more on this below);
  • Ability to use all of Spring's DI, AOP and other services on the bean (its no different than any other bean);
  • I've chosen to use @Controller so that Spring 2.5's component scanning finds and registers this bean; alternately, @Controller could be omitted and the bean simply registered via XML.
So where does the magic happen?
  1. @GwtRpcEndPoint; this annotation is applied to the service implementation class to indicate that it is the end point for GWT-RPC calls. Alternately, the selection logic could have simply looked for the RemoteService interface, however, that would prevent exclusion of some RemoteServices when necessary;
  2. GwtAnnotationHandlerMapping; this is a Spring HandlerMapping that introspects all registered beans looking for ones annotated with @GwtRpcEndPoint. When a GWT-RPC end point is found, its url is registered (more on URL mapping below);
  3. GwtRpcEndPointHandlerAdapter; this is a Spring HandlerAdapter that performs all the GWT-RPC goodies - decoding the request, invoking the method on the service bean, and encoding the response.
How are GWT-RPC urls built? I'm a *huge* fan of convention-over-configuration - it enforces consistency and scales well. By default, GWT-RPC urls are mapped by taking the fully-qualified class name of the interface that extends the RemoteService interface - in this case, digitalascent.web.support.gwt.ServiceStatusService and turning it into a URL by prepending a prefix, converting '.'s to '/'s and appending a suffix (the prefix and suffix are configurable properties of the bean). So we get /gwt-rpc/digitalascent/web/support/gwt/ServiceStatusService.do (where the prefix is '/gwt-rpc/' and the suffix is '.do'). A nice side effect - this is impervious to refactoring, as both the client and server URLs are derived from the same single fully-qualifed interface name.

This of course requires that the GWT client side code match up - a simple convenience method helps with this:

public static void setRpcEndPointUrl(Object proxy) {
ServiceDefTarget target = (ServiceDefTarget) proxy;

StringBuffer sb = new StringBuffer( );
sb.append( GWT.getTypeName(proxy) );
String endPointName = sb.substring( 0, sb.indexOf("_Proxy") );
endPointName = endPointName.replace('.','/');

target.setServiceEntryPoint("/gwt-rpc/" + endPointName + ".do");
}

...and, for those special cases, you can specify a specific URL in the @GwtRpcEndPoint annotation.

Here's the rest of the code:

GwtRpcEndPoint.java

package digitalascent.web.support.gwt;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target( { ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface GwtRpcEndPoint {
String value() default "";
}

GwtAnnotationHandlerMapping .java

package digitalascent.web.support.gwt;

import org.springframework.util.StringUtils;
import org.springframework.web.servlet.handler.AbstractDetectingUrlHandlerMapping;

import com.google.gwt.user.client.rpc.RemoteService;

/**
* Spring HandlerMapping that detects beans annotated with @GwtRpcEndPoint and registers their
* URLs.
*
* @author Chris Lee
*
*/
public class GwtAnnotationHandlerMapping extends AbstractDetectingUrlHandlerMapping {

private String prefix = "";

private String suffix = "";

protected String[] buildUrls(Class handlerType, String beanName) {

String remoteServiceName = null;
Class[] interfaces = handlerType.getInterfaces();
for (Class itf : interfaces) {
// find the interface that extends RemoteService
if (RemoteService.class.isAssignableFrom(itf)) {
remoteServiceName = itf.getName();
}
}

if (remoteServiceName == null) {
throw new IllegalArgumentException("Unable to generate name for " + handlerType.getName()
+ "; cannot locate interface that is a subclass of RemoteService");
}
String classPath = StringUtils.replace(remoteServiceName, ".", "/");

StringBuilder sb = new StringBuilder();

sb.append(prefix);

sb.append(classPath);

sb.append(suffix);
return new String[] { sb.toString() };
}

@Override
protected final String[] determineUrlsForHandler(String beanName) {
String[] urls = new String[0];
Class handlerType = getApplicationContext().getType(beanName);
if (handlerType.isAnnotationPresent(GwtRpcEndPoint.class)) {
GwtRpcEndPoint endPointAnnotation = handlerType.getAnnotation(GwtRpcEndPoint.class);
if (StringUtils.hasText(endPointAnnotation.value())) {
urls = new String[] { endPointAnnotation.value() };
} else {
urls = buildUrls(handlerType, beanName);
}
}

return urls;
}

public final void setPrefix(String prefix) {
this.prefix = prefix;
}

public final void setSuffix(String suffix) {
this.suffix = suffix;
}
}

GwtRcpEndPointHandlerAdapter.java

package digitalascent.web.support.gwt;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.web.servlet.HandlerAdapter;
import org.springframework.web.servlet.ModelAndView;

import com.google.gwt.user.client.rpc.RemoteService;
import com.google.gwt.user.client.rpc.SerializationException;
import com.google.gwt.user.server.rpc.RPC;
import com.google.gwt.user.server.rpc.RPCRequest;
import com.google.gwt.user.server.rpc.RemoteServiceServlet;

/**
* Spring HandlerAdapter to dispatch GWT-RPC requests. Relies on handlers registered by GwtAnnotationHandlerMapper
*
* @author Chris Lee
*
*/
public class GwtRcpEndPointHandlerAdapter extends RemoteServiceServlet implements HandlerAdapter {

private static ThreadLocal handlerHolder = new ThreadLocal();

private static final long serialVersionUID = -7421136737990135393L;

public long getLastModified(HttpServletRequest request, Object handler) {
return -1;
}

public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {

try {
// store the handler for retrieval in processCall()
handlerHolder.set(handler);
doPost(request, response);
} finally {
// clear out thread local to avoid resource leak
handlerHolder.set(null);
}

return null;
}

protected Object getCurrentHandler() {
return handlerHolder.get();
}

public boolean supports(Object handler) {
return handler instanceof RemoteService && handler.getClass().isAnnotationPresent(GwtRpcEndPoint.class);
}

@Override
public String processCall(String payload) throws SerializationException {
/*
* The code below is borrowed from RemoteServiceServet.processCall, with the following changes:
*
* 1) Changed object for decoding and invocation to be the handler (versus the original 'this')
*/

try {
RPCRequest rpcRequest = RPC.decodeRequest(payload, getCurrentHandler().getClass());

String retVal = RPC.invokeAndEncodeResponse(getCurrentHandler(), rpcRequest.getMethod(), rpcRequest
.getParameters());

return retVal;

} catch (Throwable t) {
return RPC.encodeResponseForFailure(null, t);
}
}
}

9 comments:

Scott said...

Looks interesting Chris. Is there any way to get access to the request or session objects when using this approach?

Chris Lee said...

I haven't wired in direct access for the request or session objects yet - that will come shortly, either in the form of a static utility class or a GwtRequestAware interface.

My preference is to inject Spring request or session scoped beans (http://static.springframework.org/spring/docs/2.5.x/reference/beans.html#beans-factory-scopes-other)

Solomon said...

@Chris. This is a pretty slick example of the new Spring 2.5 features.

@Scott. The Spring DispatcherServlet puts the request into a ThreadLocal accessible indirectly via the RequestContextHolder.getRequestAttributes()

Martin said...

hi chris

using GWT 1.4.60 and Tomcat 6.0.13

GwtRcpEndPointHandlerAdapter.getServletContext() causes NullPointerException because servlet's config is never initialized. How come that you didn't meet the same problem?

Martin said...

I fixed my issue for 1.4.60, but if you have a better solution, please let me know.


package com.mwaysolutions.gofer2.gwt;

import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.HandlerAdapter;
import org.springframework.web.servlet.ModelAndView;

import com.google.gwt.user.client.rpc.IncompatibleRemoteServiceException;
import com.google.gwt.user.client.rpc.RemoteService;
import com.google.gwt.user.client.rpc.SerializationException;
import com.google.gwt.user.server.rpc.RPC;
import com.google.gwt.user.server.rpc.RPCRequest;
import com.google.gwt.user.server.rpc.RemoteServiceServlet;

/**
* Spring HandlerAdapter to dispatch GWT-RPC requests. Relies on handlers
* registered by GwtAnnotationHandlerMapper
*
* @author Chris Lee, Martin Zdila
*
*/
public class GwtRcpEndPointHandlerAdapter extends RemoteServiceServlet implements HandlerAdapter, ApplicationContextAware {

private static ThreadLocal< Object> handlerHolder = new ThreadLocal< Object>();
private static final long serialVersionUID = -7421136737990135393L;
private ApplicationContext applicationContext;


@Override
public long getLastModified(final HttpServletRequest request, final Object handler) {
return -1;
}


@Override
public ModelAndView handle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) throws Exception {
try {
// store the handler for retrieval in processCall()
handlerHolder.set(handler);
doPost(request, response);
} finally {
// clear out thread local to avoid resource leak
handlerHolder.set(null);
}

return null;
}


protected Object getCurrentHandler() {
return handlerHolder.get();
}


@Override
public boolean supports(final Object handler) {
return handler instanceof RemoteService /*&& handler.getClass().isAnnotationPresent(GwtRpcEndPoint.class)*/;
}


@Override
public String processCall(final String payload) throws SerializationException {
/*
* The code below is borrowed from RemoteServiceServet.processCall, with
* the following changes:
*
* 1) Changed object for decoding and invocation to be the handler
* (versus the original 'this')
*/

try {
final RPCRequest rpcRequest = RPC.decodeRequest(payload, getCurrentHandler().getClass() /* this.getClass() */, this);
return RPC.invokeAndEncodeResponse(getCurrentHandler() /* this */, rpcRequest.getMethod(), rpcRequest.getParameters(), rpcRequest.getSerializationPolicy());
} catch (final IncompatibleRemoteServiceException ex) {
getServletContext().log("An IncompatibleRemoteServiceException was thrown while processing this call.", ex);
return RPC.encodeResponseForFailure(null, ex);
}
}


@Override
public ServletContext getServletContext() {
return ((WebApplicationContext) applicationContext).getServletContext();
}


@Override
public void setApplicationContext(final ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}

}

Chris Lee said...

Hi Martin,

Good catch - because we subclass the RemoteServiceServlet but don't actually use it as a Servlet, it doesn't have a ServletContext. The original code didn't need/use the ServletContext and never encountered this problem.

An alternate solution would be to use ServletContextAware to inject the servlet context.

Papick G. Taboada said...

Really nice work.
All we need now is a good extension point for the web.xml that gets re-generated.

I am adding my config into the tomcat/conf/web.xml - this one does not get re-generated (GWiT 1.4).

How do you setup your projects?

eggsy84 said...

Really like this implementation!

I've been posting on my blog a Spring and GWT tutorial for basic beginning steps, further on your guide will prove very useful!

RemoteServiceServlet...gone :)

Wouter said...

Interestingly, I came up with a similar solution while experimenting with GWT and Spring. I think the cleanest way to integrate Spring and GWT is indeed by using a Handler and HandlerAdapter pair...
I posted my solution @ http://forum.springframework.org/showthread.php?t=52805
Also, it seems GWT is going to pull some of the functions out of RemoteServiceServlet. That should enable you to remove the dependency on RemoteServiceServlet in your HandlerAdapter.