Friday, April 24, 2009

GWT RPC over arbitrary transports: Uber RPC

A frequent request that pops up in the GWT groups is how to run GWT RPC over non-XHR transport mechanisms, like script-tag injection, cross-domain POSTs, or OpenSocial Gadget's makeRequest().

There are a few ways to do this, including patching GWT itself, but the path of least resistance would be a module that you could inherit which would provide this functionality, without compromising or changing your client code in any way.

Step 1: Overwrite GWT ServiceInterfaceProxyGenerator


In order to handle alternate transport mechanisms, we need to take over generation of the RPC client stub that is created by the builtin generators, to do this, we add the following entries to a module file:

class="com.google.gwt.user.rebind.rpc.UberServiceInterfaceProxyGenerator">
class="com.google.gwt.user.client.rpc.RemoteService"/>


This tells GWT to invoke our UberServiceIntefaceProxyGenerator whenever someone calls GWT.create(RemoteService.class). There are some package-protected methods we need access to, so we arrange for our class to be in the com.google.gwt.user.rebind.rpc package. Next, we want to modify the generator to allow substitution of arbitrary client stub superclasses.

<define-property name="gwt.rpc.proxySuperclass"
values="org_timepedia_uberrpc_client_RpcServiceProxy"/>
<set-property name="gwt.rpc.proxySuperclass"
value="org_timepedia_uberrpc_client_RpcServiceProxy"/>

This construct is used to pass a compile time parameter to the generator as to which class will be used as a client stub, org.timepedia.uberrpc.client.RpcServiceProxy. Here is our proxy generator source, which exists merely to redirect to UberProxyCreator

public class UberServiceInterfaceProxyGenerator extends Generator {

@Override
public String generate(TreeLogger logger, GeneratorContext ctx,
String requestedClass) throws UnableToCompleteException {

logger.log(TreeLogger.WARN, "Running UberProxyCreator", null);

TypeOracle typeOracle = ctx.getTypeOracle();
assert (typeOracle != null);

JClassType remoteService = typeOracle.findType(requestedClass);
if (remoteService == null) {
logger.log(TreeLogger.ERROR, "Unable to find metadata for type '"
+ requestedClass + "'", null);
throw new UnableToCompleteException();
}

if (remoteService.isInterface() == null) {
logger.log(TreeLogger.ERROR, remoteService.getQualifiedSourceName()
+ " is not an interface", null);
throw new UnableToCompleteException();
}

UberProxyCreator proxyCreator = new UberProxyCreator(remoteService);

TreeLogger proxyLogger = logger.branch(TreeLogger.DEBUG,
"Generating client proxy for remote service interface '"
+ remoteService.getQualifiedSourceName() + "'", null);

return proxyCreator.create(proxyLogger, ctx);
}
}

Step 2; Override the superclass of the generated client proxy stub


This source was mostly copied unchanged from the original, except for the line which calls UberProxyCreator. The bulk of the work is done there. Again, I copied the source, but made just a few changes to the routine which creates the SourceWriter

private SourceWriter getSourceWriter(TreeLogger logger, GeneratorContext ctx,
JClassType serviceAsync) {
JPackage serviceIntfPkg = serviceAsync.getPackage();
String packageName = serviceIntfPkg == null ? "" : serviceIntfPkg.getName();
PrintWriter printWriter = ctx
.tryCreate(logger, packageName, getProxySimpleName());
if (printWriter == null) {
return null;
}

ClassSourceFileComposerFactory composerFactory
= new ClassSourceFileComposerFactory(packageName, getProxySimpleName());

String[] imports = new String[]{RemoteServiceProxy.class.getCanonicalName(),
ClientSerializationStreamWriter.class.getCanonicalName(),
GWT.class.getCanonicalName(), ResponseReader.class.getCanonicalName(),
SerializationException.class.getCanonicalName()};
for (String imp : imports) {
composerFactory.addImport(imp);
}

String rpcSuper = null;
try {
// retrieve user-defined superclass from module file
rpcSuper = ctx.getPropertyOracle()
.getPropertyValue(logger, "gwt.rpc.proxySuperclass");
if (rpcSuper != null) {
rpcSuper = rpcSuper.replaceAll("_", ".");
}
} catch (Exception e) {
}

// allow defining a custom superclass to customize the RPC implementation
composerFactory.setSuperclass(rpcSuper);
composerFactory.addImplementedInterface(
serviceAsync.getErasedType().getQualifiedSourceName());

composerFactory.addImplementedInterface(
serviceAsync.getErasedType().getQualifiedSourceName());

return composerFactory.createSourceWriter(ctx, printWriter);
}

Step 3: Write your own Proxy


This is where you come in, since you have to decide how you're going to transport the RPC payload, such as putting it as a URL GET parameter, a POST parameter, or using an OpenSocial container. Here is some skeleton code showing you how to override the doInvoke method. This example is pseudo-code for how you'd do it using gadgets.io.makeRequest() in an OpenSocial container.

public class RpcServiceProxy extends RemoteServiceProxy {

protected GadgetRpcServiceProxy(String moduleBaseURL,
String remoteServiceRelativePath, String serializationPolicyName,
Serializer serializer) {
super(moduleBaseURL, remoteServiceRelativePath, serializationPolicyName,
serializer);
}

static boolean isReturnValue(String encodedResponse) {
return encodedResponse.startsWith("//OK");
}

/**
* Return true if the encoded response contains a checked
* exception that was thrown by the method invocation.
*
* @param encodedResponse
* @return true if the encoded response contains a checked
* exception that was thrown by the method invocation
*/
static boolean isThrownException(String encodedResponse) {
return encodedResponse.startsWith("//EX");
}

public static final String RPC_PAYLOAD_PARAM="rpcpayload";

@Override
protected Request doInvoke(
final RequestCallbackAdapter.ResponseReader responseReader, String methodName, int invocationCount,
String requestData, final AsyncCallback tAsyncCallback) {

try {
makeRequest(getServiceEntryPoint(), "text/x-gwt-rpc; charset=utf-8", requestData, new AsyncCallback() {
public void onFailure(Throwable throwable) {
tAsyncCallback.onFailure(new InvocationException("Unable to initiate the asynchronous service invocation -- check the network connection"));
}

public void onSuccess(String encodedResponse) {
try {
if(isReturnValue(encodedResponse)) {
tAsyncCallback.onSuccess((T) responseReader.read(createStreamReader(encodedResponse)));
}
else if(isThrownException(encodedResponse)) {
tAsyncCallback.onFailure((Throwable)responseReader.read(createStreamReader(encodedResponse)));

}
else {
tAsyncCallback.onFailure(new InvocationException("Unknown return value type"));
}
} catch (SerializationException e) {
tAsyncCallback.onFailure(new InvocationException("Failure deserializing object "+e));
}
}
});
} catch (Exception ex) {
InvocationException iex = new InvocationException(
"Unable to initiate the asynchronous service invocation -- check the network connection",
ex);
tAsyncCallback.onFailure(iex);
} finally {
if (RemoteServiceProxy.isStatsAvailable()
&& RemoteServiceProxy.stats(RemoteServiceProxy.bytesStat(methodName,
invocationCount, requestData.length(), "requestSent"))) {
}
}
return null;
}

private native void makeRequest(String serviceEntryPoint, String contentType,
String requestData, AsyncCallback tAsyncCallback) /*-{
var params = {};
params[gadgets.io.RequestParameters.CONTENT_TYPE] = gadgets.io.ContentType.TEXT;

params[gadgets.io.RequestParameters.AUTHORIZATION]=gadgets.io.AuthorizationType.SIGNED;
params[gadgets.io.RequestParameters.METHOD]=gadgets.io.MethodType.GET;

gadgets.io.makeRequest(serviceEntryPoint+"?"+@org.timepedia.uberrpc.client.UberRpcServiceProxy::RPC_PAYLOAD_PARAM+"="+encodeURIComponent(requestData), function(resp) {
if(resp.errors && resp.errors.length > 0) {
tAsyncCallback.@com.google.gwt.user.client.rpc.AsyncCallback::onFailure(Ljava/lang/Throwable;)(null)
}
else {
tAsyncCallback.@com.google.gwt.user.client.rpc.AsyncCallback::onSuccess(Ljava/lang/Object;)(resp.text);
}
}, params);
}-*/;
}

Step 4: Modify RemoteServiceServlet


The last step is to modify RemoteServiceServlet so that it understands the new transport formats you've devised. Here's an example of one that would handle the incoming OpenSocial makeRequest(). This one handles GET or POST requests with the incoming payload as a form parameter.

public class GadgetServiceServlet extends RemoteServiceServlet {

@Override
protected void doGet(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse)
throws ServletException, IOException {
doPost(httpServletRequest, httpServletResponse);
}

@Override
protected String readContent(HttpServletRequest httpServletRequest)
throws ServletException, IOException {
String str = httpServletRequest.getMethod().equals("POST") ? RPCServletUtils
.readContentAsUtf8(httpServletRequest, false) : httpServletRequest
.getParameter(RpcServiceProxy.RPC_PAYLOAD_PARAM);
String ustr = URLDecoder.decode(str);
return ustr;
}
}

To use, you'd just subclass this servlet.

The Sky's the Limit


Once you get to this point, you can start imagining all of the cool things you can do. Cross-domain POSTs using the window.name trick. OAuth signed RPC requests, verified in the servlet, done by OpenSocial containers. FaceBook integration. RPC over JSON, Protocol Buffers, Thrift. I'm trying to cobble together some of this stuff for a future UberRPC module, but due to talks I'm giving at the upcoming Google I/O, I'm a little too swamped to make them release worthy at this point.

Much credit goes to Alex Epshteyn for his original proposal on the GWT Contributors list, which I picked up (over an inferior method I had been using to make Gadgets work), and integrated into a more graceful override of the default RPC behavior.

If you're going to Google I/O, I will be doing two talks this year. One on Progressive Enhancement using GWT and a new library I've written, and a second on Building an Application on Google's Open Stack, which is a walkthrough of a sophisticated GWT app which leverages about a dozen Google APIs.

-Ray

2 comments:

Pat Niemeyer said...

I was thinking of doing something like this to implement cross-domain RPC using script includes. This seems like something that would be useful to many people. Has anyone done this or started a project to work on it?

Justin Merz said...

Thank you so much. I have implemented your code above. Works great! I was hoping to add the script includes/JSON callbacks to the serialized object response. Any suggestions as to implementing this? ... ie, how would you go about inserting the function callback in RPCServletUtils.writeResponse()? Thanks again.