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);
}
}
}