Programming Thoughts
Struts 2 - Server State per Browser Tab
Injected Private Session

The same page in different browser tabs that don't interfere with each other, improved

Users expect pages in different tabs to not interfere with each other's session data, even if they're the same page but for different records. The first attempt describes a workable but flawed solution. This article attempts to fix these flaws.

Reducing code for tab-specific data

Accessing tab-specific data involves session attributes using code like the following.

private static final String ATTR_NAME = ViewNumberAction.class + "_ENTRY"; ... getServletRequest().getSession().setAttribute(ATTR_NAME + getTabId(), id);

This involves adding the tab id to attribute names for Actions where users expect tab-separable pages and session data, which is almost all of them. This doesn't apply to shared session data, such as authentication and authorisations, which are typically injected by interceptors anyway. As Struts makes use of interceptor injected objects, an object masquerading as a session can be injected that knows the Action's tab id and invisibly insert the tab id into attribute names when calling getAttribute and setAttribute.

Session Decorator

Thus, Actions should use an injected, compatible substitute for HttpSession that stores and reads attributes as private and tab-specific. That is, the injected object should use an interface that extends HttpSession. This, of course, is based on the Decorator design pattern. In the uncommon situations where shared attributes are needed, alternate functions can be used. A partial definition is shown below.

/** * <P>Like {@link HttpSession}, stores sets of named data or attribute about a user between requests but data are * private to each browser tab, so different pages of the same browser don't clash when using the same name. Instances * are injected into Actions implementing {@link BrowserTabAware2} by the {@link BrowserTabInterceptor2} interceptor.</P> * * <P>User data, such as authentication, can be shared. When an attribute, identified by its name, is read, it can come * from a tab's private set or the shared set but not both. The normal set function, setAttribute, sets a private * attribute (unless it's already shared) whereas setSharedAttribute sets it as shared (also deleting private attributes * using the same name). Actions of the same application are expected to know shared attribute names.</P> * * <P>Implementations are wrappers around the request's session and shared attribute are stored in it, so such * attributes can be accessed by code that don't recognise this class.</P> */ public interface BrowserTabSession extends HttpSession { /** * Returns object bound to name, whether as private or shared attribute, or null if none. */ public Object getAttribute(String name); /** * Returns underlying request session. */ public HttpSession getHttpSession(); /** * Returns set of names of private, tab-specific attributes. */ public Set<String> getPrivateAttributeNames(); /** * Returns set of names of shared attributes. */ public Set<String> getSharedAttributeNames(); /** * Returns id of browser tab. */ public String getTabId(); /** * Binds an object to a name as a private, tab-specific attribute, replacing any existing value. If the attribute * already exists and is shared, the attribute remains shared. */ public void setAttribute(String name, Object value); /** * Like {@link #setAttribute}, binds an object to a name but as a shared attribute. If any private attributes have * the same name, they're removed. */ public void setSharedAttribute(String name, Object value); }

Implementation is just a wrapper around an HttpSession, partially written below.

public class BrowserTabSessionImpl implements BrowserTabSession { private static final String ATTR_NAME_PRIVATE_MAPS = BrowserTabSessionImpl.class + "_PRIVATE_MAPS"; private HttpSession httpSession; private String tabId; private Map<String,Map<String,Object>> privateMaps; public BrowserTabSessionImpl(HttpSession httpSession, String tabId) { this.httpSession = httpSession; this.tabId = tabId; privateMaps = (Map<String,Map<String,Object>>)httpSession.getAttribute(ATTR_NAME_PRIVATE_MAPS); if (privateMaps == null) { privateMaps = new HashMap<>(); httpSession.setAttribute(ATTR_NAME_PRIVATE_MAPS, privateMaps); } } ... protected Map<String,Object> getGuaranteedPrivateMap() { Map<String,Object> result; result = privateMaps.get(tabId); if (result == null) { result = new HashMap<>(); privateMaps.put(tabId, result); } return result; } ... protected void purgePrivateAttributes(String name) { for (Entry<String, Map<String, Object>> privateMapEntry: privateMaps.entrySet()) { privateMapEntry.getValue().remove(name); } } ... public Object getAttribute(String name) { Map<String,Object> privateMap; Object result; result = httpSession.getAttribute(name); if (result != null) { return result; } privateMap = privateMaps.get(tabId); if (privateMap == null) { return null; } result = privateMap.get(name); return result; } ... public void setAttribute(String name, Object value) { Map<String,Object> privateMap; Object sharedObject; sharedObject = httpSession.getAttribute(name); if (sharedObject == null) { privateMap = getGuaranteedPrivateMap(); privateMap.put(name, value); } else { httpSession.setAttribute(name, value); } } ... public void setSharedAttribute(String name, Object value) { httpSession.setAttribute(name, value); purgePrivateAttributes(name); } }

Note that shared attributes are just normal session attributes, making this compatible with non-browser tab aware Actions and Interceptors. Also, an attribute name cannot be both shared and tab-specific.

Struts 2 interceptors

Struts Actions must implement the following interface.

/** * Interface for Struts 2 actions that accept a browser tab id. */ public interface BrowserTabAware2 { public BrowserTabSession getBrowserTabSession(); public void setBrowserTabSession(BrowserTabSession value); }

The interceptor is pretty much the same as the previous version except it injects the decorated session instead of the tab id.

public class BrowserTabInterceptor2 extends AbstractInterceptor { private void setBrowserTabId(BrowserTabAware2 browserTabAware, ActionInvocation invocation) { BrowserTabSession browserTabSession; HttpServletRequest request; Cookie[] cookies; String cookieValue; try { request = invocation.getInvocationContext().getServletRequest(); cookies = request.getCookies(); } catch (Exception e) { cookies = null; } cookieValue = getBrowserTabId(cookies); cookieValue = (cookieValue != null)?cookieValue:""; browserTabSession = new BrowserTabSessionImpl(invocation.getInvocationContext().getServletRequest().getSession(), cookieValue); browserTabAware.setBrowserTabSession(browserTabSession); } @Override public String intercept(ActionInvocation invocation) throws Exception { String result; if (invocation.getAction() instanceof BrowserTabAware2) { setBrowserTabId((BrowserTabAware2)invocation.getAction(), invocation); } result = invocation.invoke(); return result; } }

How code is improved

Use of a decorated session eliminates forgetting to add getTabId() to attribute names. getServletRequest().getSession() must be replaced by getBrowserTabSession() but this is easier to find and replace. Besides, inserted fake sessions are useful for part 2. The example code becomes the following.

private static final String ATTR_NAME = ViewNumberAction.class + "_ENTRY"; ... getBrowserTabSession().setAttribute(ATTR_NAME + getTabId(), id);

Next part

Continued in Copying Private Session to New Tab.