This blog post presents a very simple output cache solution presented in the form of a Java Servlet filter. The output cache works together with DD4T Java, although it is a pretty generic solution.
The idea behind this output cache is to store the response output in a memory cache, such that a subsequent request to the same URL will load the cached response content from cache, instead of letting it be processed again through the normal request processing pipeline. This approach has, of course, a few constraints — namely, the biggest being there cannot be any personalization in the response based on users. The response is the same for several requests which might belong to several users, sessions, cookies, etc. Hence this ‘simple’ output cache.
The output cache filter uses the incoming request URL as a key in the local memory cache. If a previously cached response is found in cache, it will be served; otherwise, the request is let go through the processing pipeline and the resulting response is then cached, such that a subsequent request for the same URL path will be served from cache.
The filter uses the concept of ‘cached response’, which not only caches the actual response content, but it also caches additional attributes such as mime type, encoding, content length, headers and status code.
This particular DD4T implementation relies on a DD4T CacheProvider and CacheInvalidator to store the cache responses in cache and to invalidate them respectively.
Invalidation occurs when the Page whose response was cached is published again (or unpublished). At that moment, the cache entry corresponding to the page URL path gets expired, fact that will trigger either a reload of the expired page (should it be requested again), or an eviction from cache (in case said page is not requested again with a given time interval).
Enough talk, let’s see the code… :)
OutputCache Filter
The entire logic is contained in the doFilter() method. Namely, a cache lookup is made for an CacheResponse object corresponding to the requested URL path. If such entry exists, the CachedResponse object is sent back. Otherwise, the request goes through the processing pipeline and a response wrapper is used that would collect all headers, status code, response content, mime type, etc. We use this response wrapper to build the CachedResponse object and then we store the latter in the the cache.
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String url = HttpUtils.getOriginalUri(request);
url = HttpUtils.appendDefaultPageIfRequired(url);
String key = getKey(url);
CacheElement<CachedResponse> cacheElement = cacheProvider.loadFromLocalCache(key);
CachedResponse cachedResponse = null;
if (cacheElement.isExpired()) {
synchronized (cacheElement) {
if (cacheElement.isExpired()) {
CharResponseWrapper responseWrapper = new CharResponseWrapper(response);
chain.doFilter(request, responseWrapper);
String output = responseWrapper.toString();
cachedResponse = new CachedResponse(responseWrapper);
cacheElement.setPayload(cachedResponse);
GenericPage page = getPage(url);
if (page == null) {
cacheProvider.storeInItemCache(key, cacheElement);
} else {
TCMURI tcmuri = new TCMURI(page.getId());
cacheProvider.storeInItemCache(key, cacheElement, tcmuri.getPublicationId(), tcmuri.getItemId());
}
} else {
cachedResponse = cacheElement.getPayload();
}
}
} else {
cachedResponse = cacheElement.getPayload();
}
sendCachedResponse(request, response, cachedResponse);
}
The helper method HttpUtils.getOriginalUri returns the original request URL. If a forward header is present in the request, it will return the URL before the forward took place.
Method HttpUtils.appendDefaultPageIfRequired will append a default page (e.g. /index.html) if the current request is to a directory. The reason this is done is to facilitate the DD4T PageFactory lookup for the page model.
If a page model is found in the Content Delivery Database, the Page item id and Publication id are used to set a dependency on the CachedResponse. This ensures the cache will be invalidated once the Page is (re-)published. Otherwise, if page is not found in CD DB, the CachedResponse is stored in cache with a dummy TTL.
Another method of interest is sendCachedResponse. This method takes care of populating the current response with values from the CachedResponse object. Also the method handles request/response caching headers, such as IfModifiedSince and NotModified.
private void sendCachedResponse(HttpServletRequest request, HttpServletResponse response, CachedResponse cachedResponse) throws IOException {
if (cachedResponse != null) {
if (request.getHeader(IF_MODIFIED_SINCE) != null) {
try {
DateTime lmDate = CachedResponse.parseDateFromHeader(cachedResponse.getHeader(LAST_MODIFIED));
String modifiedSince = request.getHeader(IF_MODIFIED_SINCE);
if (modifiedSince.contains(";")) {
modifiedSince = modifiedSince.substring(0, modifiedSince.indexOf(";"));
}
DateTime imsDate = CachedResponse.parseDateFromHeader(modifiedSince);
if (!lmDate.isAfter(imsDate)) {
// the last modified date is OLDER than the 'if modified since' date
// we will return a 304 Not Modified
response.setStatus(304);
response.setHeader(LAST_MODIFIED, cachedResponse.getHeader(LAST_MODIFIED));
return;
}
} catch (ParseException | IllegalArgumentException e) {
LOG.error("Error parsing Header.", e);
}
}
response.setStatus(cachedResponse.getStatus());
response.setContentType(cachedResponse.getContentType());
for (Map.Entry<String, String> entry : cachedResponse.getHeaders().entrySet()) {
response.setHeader(entry.getKey(), entry.getValue());
}
String content = cachedResponse.getContent();
if (StringUtils.isNotEmpty(content)) {
response.setContentLength(cachedResponse.getContentLength());
PrintWriter writer = response.getWriter();
writer.print(content);
}
}
}
Finally, it’s worth mentioning the CachedResponse object that contains the following properties and functionality:
public class CachedResponse {
private static DateTimeFormatter dateFormat = DateTimeFormat.forPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'")
.withZoneUTC();
private String content;
private int status;
private String contentType;
private int contentLength;
private Map<String, String> headers;
public CachedResponse(CharResponseWrapper responseWrapper) {
content = responseWrapper.toString();
status = responseWrapper.getStatus();
contentType = responseWrapper.getContentType();
contentLength = StringUtils.isEmpty(content) ? 0 : content.length();
headers = new HashMap<>();
setHeaders(responseWrapper);
}
static DateTime parseDateFromHeader(String dateString) throws ParseException {
return dateFormat.parseDateTime(dateString);
}
static String formatDateForHeader(DateTime date) {
return dateFormat.print(date);
}
private void setHeaders(CharResponseWrapper responseWrapper) {
for (String name : responseWrapper.getHeaderNames()) {
String value = responseWrapper.getHeader(name);
if (name.equals("Last-Modified"))
continue;
headers.put(name, value);
}
headers.put("Last-Modified", formatDateForHeader(new DateTime(DateTimeZone.UTC)));
}
public String getHeader(String name) {
return headers.get(name);
}
}
Configuration Bits
In descriptor web.xml, add the following filter definition and filter mapping:
<filter>
<filter-name>OutputCacheFilter</filter-name>
<filter-class>com.anchorage.web.filters.OutputCacheFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>OutputCacheFilter</filter-name>
<url-pattern>*.html</url-pattern>
<dispatcher>REQUEST</dispatcher>
</filter-mapping>
All is left for me now is to bid you "happy caching!" :)
Comments