Programming Thoughts
Struts 2 - Post/Redirect/Get
Post/Redirect/Get

Struts 2 framework is not helping with good practice

Struts 2 is a popular MVC framework for Java-based web applications. Like all good frameworks, it does a lot of drudge work and is highly flexible and extensible. It should encourage good design and practice by default but it gets in the way of the Post/Redirect/Get (PRG) design pattern.

The problem

If a web service accepts a POST request and displays a response page, the browser still refers to the URL and refreshing will resubmit the POST request. Users do odd things, so if it can go wrong, it will. The URL can even be bookmarked, recreating the POST request later. The way to avoid this is the Post/Redirect/Get (PRG) pattern, where after processing a POST request, the server instructs the brower to redirect to a different URL to get the results. This is explained in more detail in Redirect After Post.

This is also shown with images copied from the Wikipedia Post/Redirect/Get page.

Figure 1: Double POST problem encountered in user agents

Figure 2: Double POST problem above being solved by PRG

Sadly, the Struts 2 modus operandi is like figure 1 above, where the Action responding to a POST request is expected to be the primary model in the Value Stack for composing the response. Redirecting to another Action to compose the response, to behave like figure 2 above, creates another request, losing processing messages and form values, even a simple success message. This frustrates users. Thus, the purpose of this series of articles is to work around this design.

Preserving user messages

So, how to preserve user data from one request to the next? Sessions, of course. How to avoid lots of boilerplate code? Interceptors. There's also a distinction between Struts Actions that write user messages, usually handling POST requests, and those retrieving messages for display, handling the GET request. Both must implement ValidationAware, which the default ActionSupport does.

For the POST Action, action messages, action errors, and field errors need to be stored in an object like the following.

public static class StoredMessages { private Collection<String> actionErrors, actionMessages; private Map<String,List<String>> fieldErrors; public StoredMessages() { actionErrors = new ArrayList<String>(); actionMessages = new ArrayList<String>(); fieldErrors = new HashMap<String,List<String>>(); } public Collection<String> getActionErrors() { return actionErrors; } public void setActionErrors(Collection<String> actionErrors) { this.actionErrors = actionErrors; } public Collection<String> getActionMessages() { return actionMessages; } public void setActionMessages(Collection<String> actionMessages) { this.actionMessages = actionMessages; } public Map<String,List<String>> getFieldErrors() { return fieldErrors; } public void setFieldErrors(Map<String,List<String>> fieldErrors) { this.fieldErrors = fieldErrors; } }

The POST Action interceptor simply reads the messages and stores them, as shown below. The interceptor is best near the top of the interceptor stack, so it can pick up messages from other interceptors that might write any.

public static final String SESSION_STORED_MESSAGES = MessageStoreInterceptor.class + "_STORED_MESSAGES"; @Override public String intercept(ActionInvocation invocation) throws Exception { ValidationAware validationAware; StoredMessages storedMessages; String result; List<String> fieldErrors; Map<String,List<String>> allFieldErrors; result = invocation.invoke(); if (invocation.getAction() instanceof ValidationAware) { validationAware = (ValidationAware)invocation.getAction(); // Merge with any existing stored messages storedMessages = (StoredMessages)ActionContext.getContext().getSession().get(SESSION_STORED_MESSAGES); if (storedMessages == null) { storedMessages = new StoredMessages(); } storedMessages.getActionErrors().addAll(validationAware.getActionErrors()); storedMessages.getActionMessages().addAll(validationAware.getActionMessages()); allFieldErrors = storedMessages.getFieldErrors(); for (Entry<String,List<String>> entrySet: validationAware.getFieldErrors().entrySet()) { fieldErrors = allFieldErrors.get(entrySet.getKey()); if (fieldErrors == null) { fieldErrors = new ArrayList<String>(); } fieldErrors.addAll(entrySet.getValue()); allFieldErrors.put(entrySet.getKey(), fieldErrors); } ActionContext.getContext().getSession().put(SESSION_STORED_MESSAGES, storedMessages); } return result; }

The GET Action interceptor, the stored messages are read and injected into the Action, as shown below. It includes a disabled parameter so self-refreshing popup windows can be configured to not consume messages. The interceptor is best near the top of the interceptor stack in case other interceptors need to know.

private boolean disabled; public boolean getDisabled() { return disabled; } public void setDisabled(boolean value) { disabled = value; } @Override public String intercept(ActionInvocation invocation) throws Exception { StoredMessages storedMessages; ValidationAware validationAware; Object rawObject; if (!disabled && invocation.getAction() instanceof ValidationAware) { rawObject = ActionContext.getContext().getSession().get(MessageStoreInterceptor.SESSION_STORED_MESSAGES); if (rawObject != null) { if (rawObject instanceof StoredMessages) { storedMessages = (StoredMessages)rawObject; validationAware = (ValidationAware)invocation.getAction(); validationAware.setActionErrors(storedMessages.getActionErrors()); validationAware.setActionMessages(storedMessages.getActionMessages()); validationAware.setFieldErrors(storedMessages.getFieldErrors()); ActionContext.getContext().getSession().remove(MessageStoreInterceptor.SESSION_STORED_MESSAGES); } } } return invocation.invoke(); }

Preserving forms

For the POST Action, in an interceptor, after the result = invocation.invoke() line, store the action, or model for model driven actions, in session. The interceptor is best near the top of the interceptor stack, in case other interceptors alter forms.

For the GET Action, in an interceptor, before the result = invocation.invoke() line, read the stored form, write it to the target Action, then remove it from session. However, writing to the action is the difficult part. I prefer to use reflection to find and inject into the appropriate field. Use code like the fragments below to find appropriate fields, check if one can accept the form, and set the field. Like retrieving messages above, consider a disable parameter for the interceptor so self-refreshing popup windows don't grab stored forms.

fields = new ArrayList<Field>(); for (Class<?> c = invocation.getAction().getClass(); c != null; c = c.getSuperclass()) { fields.addAll(Arrays.asList(c.getDeclaredFields())); } ... for (Field field: fields) { if (field.getType().isAssignableFrom(storedForm.getForm().getClass())) { field.setAccessible(true); field.set(invocation.getAction(), storedForm.getForm());

This is a simplification and, for reasons explained in later articles, might hit technical problems. See Use ModelDriven and More Form Injection.

Next part

Continued in Forwarding to View Action.