Saturday, June 10, 2006

RoR style URLs with Spring MVC

One of the things that I find impressive about Ruby on Rails (RoR) is the simplicity of URLs used in RoR applications, and how they map back to the Controllers and View components. So in the RoR world, a URL of the form:

http://localhost:8080/app/entity/action/1234

means that the request would be forwarded by the web-application named "app" listening on port number 8080 on localhost to the Ruby EntityController class, which would then invoke the action() method on it with "1234" as an argument, then forward the request to the view component at app/entity/action.rhtml for presentation under the webserver's docroot.

This is, of course, both good and bad. It is good because it makes the application easier to understand and debug, both for the user and developer, and removes the need for some configuration, which can be a point of failure. It is bad for applications relying on security through obscurity, because malicious users can understand your application better too, so your application itself must be more security-concious for applications facing the outside world.

I have been thinking about how to do this using the Spring MVC framework, and it turns out to be quite simple. Here is how I did it. The flow in Spring is identical to the flow in RoR. However, I have changed the URL structure somewhat, to mimic what most Java developers and users are used to (including myself). So here is the new URL structure:

http://localhost:8080/app/entity/action.do?id=1234

would send the request to the DispatcherServlet configured in the "app" web application, which will forward it to the EntityController and call its action() method. The action() method would optionally consume the parameter id from the request. Lot of people associate the .do suffix with Struts, but I think its a nice convention to indicate that the URL points to some kind of Controller component, as opposed to static content indicated by .html, for example.

The DispatcherServlet is configured within the application's web.xml file. Currently, it is configured to respond to URLs ending with the .do suffix. The reference to the Spring Application Context, which contains references to beans that will be used by the DispatcherServlet, is set up by the ContextLoaderListener, as shown below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<web-app>
  <listener>
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
  </listener>
                                                                                
  <servlet>
    <servlet-name>app</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <load-on-startup>1</load-on-startup>
  </servlet>
  <servlet-mapping>
    <servlet-name>app</servlet-name>
    <url-pattern>*.do</url-pattern>
  </servlet-mapping>
                                                                                
</web-app>

The DispatcherServlet looks for a file called ${servlet.name}-servlet.xml (in our case app-servlet.xml), which typically contains references for the beans that the DispatcherServlet needs. I like to keep it in applicationContext-*.properties in my classpath WEB-INF/classes to make the application unit test friendly and include it from app-servlet.xml. There are three beans that really need to be customized to support RoR style URLs - the HandlerMapping to route the incoming URL, the MethodNameResolver to get the method name to invoke on the Controller which was routed to, and the ViewResolver to do the actual presentation. We also create an abstract class ActiveController which has some convenience method and extends the Spring MultiActionController, but that is just so as to enforce that all Controllers in such applications should be MultiActionControllers.

The HandlerMapping: ActiveControllerUrlHandlerMapping

This is a drop in replacement for standard handler mappings such as SimpleUrlHandlerMapping. Unlike the SimpleUrlHandlerMapping, which reads its mapping configuration once at startup, the ActiveControllerUrlHandlerMapping computes the controller, method and view names each time it is passed a request. It does this by parsing the request URI and pulling out the entity name and adding a "Controller" suffix. It will look this bean up in the ApplicationContext and complain if it cannot find it, so it is important to remember to configure each Controller according to the pattern ${entityName}Controller. Since it is parsing the URL anyway at this stage, it also computes and validates the method name and the view names to use, and sticks them into request attributes.

The requirement to have the Controller bean reference named in a certain way is not there in a RoR app, since it is an interpreted language, so a Controller and a Controller method becomes visible as soon as you drop the new code in the docroot. We could probably set this up to auto-detect a Controller as soon as it becomes visible in a certain package, but the approach would not be totally platform agnostic until Java comes out with a Package.getClasses() method.

The configuration for the handlerMapping looks like this:

1
2
3
4
  <bean id="handlerMapping" class="cnwk.prozac.utils.controllers.ActiveControllerUrlHandlerMapping">
     <property name="defaultHandler" ref="defaultHandler" />
   </bean>
  <bean id="defaultHandler" class="cnwk.prozac.utils.controllers.ActiveControllerDefaultHandler" /> 

Notice that there is no explicit URL pattern to controller mappings here. The defaultHandler is not strictly necessary, but can help to return user friendly results if the ActiveControllerUrlHandlerMapping does not find a valid controller or method to go to. In such cases, rather than throw a 404, it executes the ${defaultHandler}.info() method.

Heres the code for the ActiveControllerUrlHandlerMapping

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
import java.lang.reflect.Method;
 
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
 
import org.apache.commons.beanutils.MethodUtils;
import org.apache.log4j.Logger;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.AbstractUrlHandlerMapping;
 
/**
 * Sets up mappings between the URL pattern and the corresponding Controller
 * beans. The convention for the URL is as follows:
 * <pre>
 * http://host:port/${webAppName}/${entityName}/${methodName}?(${arg}=${value})* * </pre>
 * If no controller bean is found in the application context, then the
 * lookupHandler method returns null.
 * The defaultHandler can be set as it is a property of AbstractHandlerMapping.
 */
public class ActiveControllerUrlHandlerMapping extends AbstractUrlHandlerMapping {
     
    private static final Logger log = Logger.getLogger(ActiveControllerUrlHandlerMapping.class);
     
    public ActiveControllerUrlHandlerMapping() {
        super();
    }
 
    /**
     * Returns a configured ActiveController bean that the URL resolves to.
     * If the URL is malformed, or the ActiveController for the specified URL
     * is not configured, or if the handling method is not available in the
     * resolved ActiveController instance, the ActiveControllerDefaultHandler
     * is returned, with the appropriate error message in the request attribute.     
     * @param request the HttpServletRequest object.
     * @return the ActiveController for this request.
     */
    @Override
    protected Object getHandlerInternal(HttpServletRequest request) {
        String urlPath = request.getRequestURI();
        String[] entityAndMethod = parseEntityAndMethodNamesFromUrl(urlPath);
        if (entityAndMethod[0] == null && entityAndMethod[1] == null) {
            request.setAttribute(ActiveController.ATTR_ERROR_MESSAGE,
                "Malformed URL:[" + urlPath + "], must be of the form " +
                "/${webapp}/${entity}/${method}?[${arg}=${value}&...]");
            return null;
        }
        String requestedController = entityAndMethod[0] + "Controller";
        Object handler = getApplicationContext().getBean(requestedController);
        if (handler == null) {
            request.setAttribute(ActiveController.ATTR_ERROR_MESSAGE,
                "The ActiveController instance " + requestedController + 
                " is not configured in the ApplicationContext");
            return null;
        } else if (!(handler instanceof ActiveController)) {
            request.setAttribute(ActiveController.ATTR_ERROR_MESSAGE,
                "The bean " + requestedController + " is not a ActiveController");
            return null;
        } else {
            Method requestedMethod = MethodUtils.getAccessibleMethod(
                handler.getClass(), entityAndMethod[1],
                new Class[] {HttpServletRequest.class, HttpServletResponse.class});
            if (requestedMethod == null) {
                request.setAttribute(ActiveController.ATTR_ERROR_MESSAGE,
                    "The method " + entityAndMethod[1] + 
                    "(HttpServletRequest, HttpServletResponse):ModelAndView is not defined in " + 
                    requestedController);
                return null;
            } else {
                String returnTypeClassName = requestedMethod.getReturnType().getName();
                if (!(ModelAndView.class.getName().equals(returnTypeClassName))) {
                    request.setAttribute(ActiveController.ATTR_ERROR_MESSAGE,
                        "The method " + entityAndMethod[1] + " has incorrect return type " + 
                        returnTypeClassName + 
                        ", should be org.springframework.web.servlet.ModelAndView, " +
                        "check your code");
                    return null;
                }
            }
        }
        request.setAttribute(ActiveController.ATTR_VIEW_NAME, entityAndMethod[0] + 
            "/" + entityAndMethod[1]);
        request.setAttribute(ActiveController.ATTR_METHOD_NAME, entityAndMethod[1]);
        return handler;
    }
     
    /**
     * Returns the entity and method names from the URL.
     * @param urlPath
     * @return
     */
    private String[] parseEntityAndMethodNamesFromUrl(String urlPath) {
        String[] parts = urlPath.split("[\\/|\\&]");
        if (parts.length < 4) {
            return new String[] {null, null};
        }
        String[] entityAndMethodNames = new String[2];
        entityAndMethodNames[0] = parts[2];
        if (parts[3].indexOf(".") > -1) { // remove any trailing suffix
            entityAndMethodNames[1] = parts[3].split("\\.")[0];
        } else {
            entityAndMethodNames[1] = parts[3];
        }
        return entityAndMethodNames;
    }
}

The MethodNameResolver: ActiveControllerMethodNameResolver

The ActiveControllerMethodNameResolver simply retrieves the value of the request attribute that was set by the HandlerMapping bean when it parsed and validated the incoming URL. If there was an error parsing the URL, the Spring framework will pass it off to the defaultHandler and the method attribute would be null in the incoming request. So all this bean does is to check if the method attribute is null, and if so, set it to the string "info".

Here is how it is configured:

1
  <bean id="methodNameResolver" class="cnwk.prozac.utils.controllers.ActiveControllerMethodNameResolver" />

and here is the code for the ActiveControllerMethodNameResolver

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import javax.servlet.http.HttpServletRequest;
 
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.web.servlet.mvc.multiaction.AbstractUrlMethodNameResolver;
import org.springframework.web.servlet.mvc.multiaction.MethodNameResolver;
import org.springframework.web.servlet.mvc.multiaction.NoSuchRequestHandlingMethodException;
 
/**
 * Resolves the method name for the specified controller. If the method does
 * not exist, then it returns an error.
 */
public class ActiveControllerMethodNameResolver implements MethodNameResolver {
     
    private static Log log = LogFactory.getLog(ActiveControllerMethodNameResolver.class);
     
    public ActiveControllerMethodNameResolver() {
        super();
    }
 
    /**
     * Returns the method name that will be executed.
     * @see org.springframework.web.servlet.mvc.multiaction.MethodNameResolver#getHandlerMethodName(javax.servlet.http.HttpServletRequest)
     */
    public String getHandlerMethodName(HttpServletRequest request) 
            throws NoSuchRequestHandlingMethodException {
        // check to see if its already populated by ActiveControllerUrlHandlerMapping
        String methodName = (String) request.getAttribute(ActiveController.ATTR_METHOD_NAME);
        if (methodName == null) {
            return "info";
        }
    }
}

The ViewResolver: InternalResourceViewResolver

For the ViewResolver, we just use one of the standard view resolvers provided with Spring. The HandlerMapping already populates the view name as ${entityName}/${methodName}, and the ViewResolver is configured with a prefix and suffix as shown below:

1
2
3
4
5
  <bean id="viewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="prefix" value="/" />
    <property name="suffix" value=".jsp" />
    <property name="viewClass" value="org.springframework.web.servlet.view.JstlView" />
  </bean>

So in this case, a view name of the form "person/list" would be resolved by the JSP file person/list.jsp under the web application's docroot.

The Controller superclass: ActiveController

Finally, since all our Controllers need to be MultiActionControllers to avail of this RoR style URL mappings, we enforce this by requiring that all our Controller subclass the ActiveController class. If they do not, the HandlerMapping would refuse to resolve the URLs. The ActiveController does provide one useful method getDefaultViewName() which pulls out the view name attribute off the request. Here is the configuration.

1
2
3
  <bean id="activeController" class="cnwk.prozac.utils.controllers.ActiveController">
    <property name="methodNameResolver" ref="methodNameResolver" />
  </bean>

Notice that it needs a reference to the methodNameResolver, which is our ActiveControllerMethodName resolver. The code is here:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
 
import org.apache.log4j.Logger;
import org.springframework.context.ApplicationContextException;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.multiaction.MultiActionController;
 
/**
 * Base class for our RoR URL handling controllers.
 */
public class ActiveController extends MultiActionController {
 
    private final static Logger log = Logger.getLogger(ActiveController.class);
     
    public static final String ATTR_REQUEST_OBJECT = "_request";
    public static final String ATTR_ERROR_MESSAGE = "_error";
    public static final String ATTR_METHOD_NAME = "_methodName";
    public static final String ATTR_VIEW_NAME = "_viewName";
    public static final String DEFAULT_METHOD_NAME = "info";
     
    /**
     * Default ctor.
     * @throws ApplicationContextException if one is thrown.
     */
    public ActiveController() throws ApplicationContextException {
        super();
    }
 
    /**
     * Alternate ctor.
     * @param delegate the Object to delegate to.
     * @throws ApplicationContextException if one is thrown.
     */
    public ActiveController(Object delegate) throws ApplicationContextException {
        super(delegate);
    }
 
    /**
     * The default info() method which is called whenever a method cannot be
     * resolved. The info() method contains information about the URL sent, any
     * error messages, and (optionally) other information useful for debugging.
     * @param request a HttpServletRequest object.
     * @param response a HttpServletResponse object.
     * @return a ModelAndView
     * @throws Exception if one is thrown during processing.
     */
    public ModelAndView info(HttpServletRequest request, HttpServletResponse response) 
            throws Exception {
        ModelAndView mav = new ModelAndView();
        mav.addObject(ActiveController.ATTR_REQUEST_OBJECT, request);
        mav.setViewName(getDefaultViewName(request));
        return mav;
    }
     
    /**
     * Returns the method name from the request. The method name is populated
     * by the ActiveControllerUrlHandlerMapping. The method name is the view
     * name in our "convention over configuration" world. The exact mechanics
     * of view resolution (eg whether it goes to a JSP or a Tile) is controlled
     * by the ViewResolver class injected into the DispatcherServlet.
     * @param request the HttpServletRequest object.
     * @return the view name to forward to.
     */
    protected String getDefaultViewName(HttpServletRequest request) {
        return (String) request.getAttribute(ActiveController.ATTR_VIEW_NAME);
    }
}

Usage

Once the HandlerMapping, MethodNameResolver and the ViewResolver are configured, and these classes added to the system classpath, Controller classes will need to extend the ActiveController, and provide methods with the following signature:

public ModelAndView ${methodName}(HttpServletRequest request, 
  HttpServletResponse response) throws Exception;

and once the Controller class itself is configured as per the convention ${entityName}Controller, these methods would be automatically accessible to the web application at the URL /${webapp.name}/${entityName}/${methodName}.do.

In the course of doing some googling, I also found that the author of the blog MemeStorm has written an article here - Convention over Configuration in Spring MVC which describes an approach very similar to mine. It also contains many more articles on Spring MVC so its a good place to look for more insights about Spring.

3 comments (moderated to prevent spam):

Anonymous said...

IMHO, there is 2 errors:
1) The method should return a ModelAndView not a MethodAndView, right?

2) The method getHandlerMethodName() should return methodname, right?

Anyway, thanks a lot for the tuto!

Anonymous said...

Would you be kind to release the sources of your classes?
Thanks

Sujit Pal said...

Hi Jean-Eric, thanks for pointing out the errors, and thanks for the appreciation. For #1, I have fixed it in the post. The answer to #2 is yes, but not sure where you see the problem - it is returning the methodname as it should.

To answer your second post, I don't have the sources for this post anymore, but I did start a Sourceforge project AutoCRUD with this stuff, but never got around to doing anything with it after that.