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.
Struts 2 has useful mechanisms for the complexities of requests, such as keeping processing messages for the user in the current request, so the web page code can retrieve it for display. However, redirecting to another URL is a new request, losing the message. Worse, if there's a form validation error, the form is lost. Not only is it hard to tell the user of a form rejection, it's hard to present the rejected form so he can fix it. This is how to make users frustrated with the service and stop using it.
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.