Monday, June 04, 2007

Adding a second docroot to a webapp

For some type of webapps it is common to have "static" resources (e.g. images, css style sheets, HTML files, PDF files) served up by Apache or IIS and "dynamic" resources (e.g. JSPs, ASP files) served up by a Java application server or .NET web engine.

Why split resource serving this way since Java/.NET can server up both static and dynamic resources? Sometimes for performance - if there are a lot of static resources a webserver written in C/C++ can serve them up faster than Java/.NET. Sometimes the application content is updated separately to the application logic - for example, in the media business the content of a newspaper or magazine website is updated several times a day, in the finance industry company research reports are updated daily.

In development it can be a real pain to run a static webserver as well as a Java/.NET application server. At a minimum running both + a IDE + other tools can bring your PC to its knees. As an experiment I decided to see how difficult it was to get a Java application server to serve resources from both it application context - or docroot - as well as relative from another location, or an external docroot.

Here is a rough specification of how I expected this stuff should work:
  • The webapp should first look for a resource relative to the directory configured through the user-defined Java property externalDocBase (e.g. -DexternalDocBase=Z:\pubroot\today);
  • If the resource could not be found relative to externalDocBase then look for the resource relative to the webapp context root.
externalDocBase on Windows may refer to a network share; on UNIX it may refer to an NFS mount.

For static resources this can be achieved fairly easily by creating a servlet to serve up static files something like this:


package robertmaldon.servlet;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;

import javax.servlet.ServletContext;

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

public class ExternalDocBaseServlet extends HttpServlet {
private static org.apache.commons.logging.Log logger =
org.apache.commons.logging.LogFactory.getLog(ExternalDocBaseServlet.class);

private static String externalDocBase;

static {
externalDocBase = System.getProperty("externalDocBase");
if (externalDocBase != null) {
logger.info("externalDocBase set to [" + externalDocBase + "]");
}
}

/**
* Create a File object relative to the external doc base.
*/
private static File file(String name) {
return new File(externalDocBase, name);
}

public void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException {

ServletContext context = getServletContext();

File file = null;

// First see if the file can be served up from the external doc base
if (externalDocBase != null) {
file = file(request.getRequestURI());
}
if (file == null || !file.exists() || !file.canRead()) {
// Otherwise we look for the file relative to the application context
file = new File(context.getRealPath(request.getRequestURI()));
}

// Return a 404 if we could not find the file
if (!file.exists() || !file.canRead()) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}

// Determine the file MIME type.
String mimeType = context.getMimeType(file.getAbsolutePath());
if (mimeType == null) {
context.log("Could not get the MIME type of [" + file.getAbsolutePath() + "]");
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
return;
}

// Set content type
response.setContentType(mimeType);

// Set content size
response.setContentLength((int)file.length());

// Open the file and output streams
FileInputStream in = new FileInputStream(file);
OutputStream out = response.getOutputStream();

// Copy the contents of the file to the output stream in chunks
byte[] buf = new byte[1024];
int count = 0;
while ((count = in.read(buf)) >= 0) {
out.write(buf, 0, count);
}
in.close();
out.close();
}
}


and configuring this servlet to be the last servlet in the webapp (i.e. the default servlet) with the wild card mapping "/*" like this in web.xml:


<?xml version="1.0" encoding="ISO-8859-1"?>
<web-app 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"
version="2.4">
...
...
<!-- Last servlet in the webapp -->
<servlet>
<servlet-name>externalDocBaseServlet</servlet-name>

<servlet-class>robertmaldon.servlet.ExternalDocBaseServlet</servlet-class>
</servlet>

<servlet-mapping>
<servlet-name>externalDocBaseServlet</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>

</web-app>


This simple servlet (too simple?) can serve up any static resource, but what if you wanted to be able to serve up a dynamic resource like a JSP from an external docroot? Well, unfortunately this sort of behavior is application server specific.

For fun I dived into the source code for Tomcat 5.5.20 to discover how the JSP stuff works. Tomcat JSPs are compiled by a special servlet named org.apache.jasper.servlet.JspServlet, which is mapped to any file ending in .jsp. This servlet doesn't do much itself, delegating all of the hard work to helper classes. For the record here are the points in the Tomcat code that have to be modified to serve up a JSP relative to the externalDocBase :
  • org.apache.jasper.servlet.JspServlet.serviceJspFile(...) which check for the existence of the JSP file before delegating the work to helper classes;
  • org.apache.jasper.JspCompilationContext has a few "getResource" methods that locate or create a stream from the file.
Since we are diving into the Tomcat code, if you didn't want to define your own ExternalDocBaseServlet but instead wanted to modify the default Tomcat servlet org.apache.catalina.servlets.DefaultServlet then the place to add the externalDocBase logic is the org.apache.naming.resources.FileDirContext.file(String) method.

If you wanted to try this out for yourself then do the following:
  1. Download and unzip Tomcat 5.5.20
  2. Download my modifications to the Tomcat source code from here, unzip, and copy the compiled classes from externaldocbase-1.0\build to %TOMCAT_HOME%\common\classes
  3. Set the CATALINA_OPTS shell variable to point to your external doc root. e.g. set CATALINA_OPTS="-DexternalDocBase=Z:\pubroot\today"
  4. Run Tomcat. e.g. catalina.bat run
Enjoy!

1 comment:

Less said...

Dude, that's pretty sweet!

What's even better is that you can check if the user has a valid session, etc... Basically, I'm using it to handle reading reports that should only be available to a user with a validated session.

If I ever meet you, first beer is on me...