/*
* Weblounge: Web Content Management System
* Copyright (c) 2003 - 2011 The Weblounge Team
* http://entwinemedia.com/weblounge
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package ch.entwine.weblounge.cache.impl;
import static ch.entwine.weblounge.common.impl.request.Http11Constants.HEADER_IF_MODIFIED_SINCE;
import static ch.entwine.weblounge.common.impl.request.Http11Constants.HEADER_IF_NONE_MATCH;
import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
import ch.entwine.weblounge.cache.CacheListener;
import ch.entwine.weblounge.cache.CacheService;
import ch.entwine.weblounge.cache.StreamFilter;
import ch.entwine.weblounge.cache.impl.handle.TaggedCacheHandle;
import ch.entwine.weblounge.common.Times;
import ch.entwine.weblounge.common.impl.util.config.ConfigurationUtils;
import ch.entwine.weblounge.common.request.CacheHandle;
import ch.entwine.weblounge.common.request.CacheTag;
import ch.entwine.weblounge.common.request.WebloungeRequest;
import ch.entwine.weblounge.common.request.WebloungeResponse;
import net.sf.ehcache.Cache;
import net.sf.ehcache.CacheManager;
import net.sf.ehcache.Element;
import net.sf.ehcache.Status;
import net.sf.ehcache.config.CacheConfiguration;
import net.sf.ehcache.config.Configuration;
import net.sf.ehcache.config.ConfigurationFactory;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.osgi.service.cm.ConfigurationException;
import org.osgi.service.cm.ManagedService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Dictionary;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.servlet.ServletResponse;
import javax.servlet.ServletResponseWrapper;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* Default implementation of the <code>CacheService</code> that is used to store
* rendered pages and page elements so that they may be served out of the cache
* upon the next request.
* <p>
* The service itself has no logic implemented besides configuring, starting and
* stopping the cache. The actual caching is provided by the
* <code>CacheManager</code>.
*/
public class CacheServiceImpl implements CacheService, ManagedService {
/** Logging facility provided by log4j */
private static final Logger logger = LoggerFactory.getLogger(CacheServiceImpl.class);
/** Path to cache configuration */
private static final String CACHE_MANAGER_CONFIG = "/ehcache/config.xml";
/** Name of the weblounge cache debug header */
private static final String CACHE_DEBUG_HEADER = "X-Cache-Debug";
/** Name of the weblounge cache header */
private static final String CACHE_KEY_HEADER = "X-Cache-Key";
/** Configuration key prefix for content repository configuration */
public static final String OPT_PREFIX = "cache";
/** Configuration key for the enabled state of the cache */
public static final String OPT_ENABLE = OPT_PREFIX + ".enable";
/** The default value for "enable" configuration property */
private static final boolean DEFAULT_ENABLE = true;
/** Configuration key for the debugging option */
public static final String OPT_DEBUG = OPT_PREFIX + ".debug";
/** The default value for "debug" configuration property */
private static final boolean DEFAULT_DEBUG = true;
/** Configuration key for the cache identifier */
public static final String OPT_ID = OPT_PREFIX + ".id";
/** Configuration key for the cache name */
public static final String OPT_NAME = OPT_PREFIX + ".name";
/** Configuration key indicating that a clear() operation is required */
public static final String OPT_CLEAR = OPT_PREFIX + ".clear";
/** Configuration key for the path to the cache's disk store */
public static final String OPT_DISKSTORE_PATH = OPT_PREFIX + ".diskStorePath";
/** Configuration key for the persistence of the cache */
public static final String OPT_DISK_PERSISTENT = OPT_PREFIX + ".diskPersistent";
/** The default value for "disk persistent" configuration property */
private static final boolean DEFAULT_DISK_PERSISTENT = false;
/** Configuration key for the overflow to disk setting */
public static final String OPT_OVERFLOW_TO_DISK = OPT_PREFIX + ".overflowToDisk";
/** The default value for "overflow to disk" configuration property */
private static final boolean DEFAULT_OVERFLOW_TO_DISK = false;
/** Configuration key for the statistics setting */
public static final String OPT_ENABLE_STATISTICS = OPT_PREFIX + ".statistics";
/** The default value for "statistics enabled" configuration property */
private static final boolean DEFAULT_STATISTICS_ENABLED = true;
/** Configuration key for the maximum number of elements in memory */
public static final String OPT_MAX_ELEMENTS_IN_MEMORY = OPT_PREFIX + ".maxElementsInMemory";
/** The default value for "max elements in memory" configuration property */
private static final int DEFAULT_MAX_ELEMENTS_IN_MEMORY = 1000;
/** Configuration key for the maximum number of elements on disk */
public static final String OPT_MAX_ELEMENTS_ON_DISK = OPT_PREFIX + ".maxElementsOnDisk";
/** The default value for "max elements on disk" configuration property */
private static final int DEFAULT_MAX_ELEMENTS_ON_DISK = 0;
/** Configuration key for the time to idle setting */
public static final String OPT_TIME_TO_IDLE = OPT_PREFIX + ".timeToIdle";
/** The default value for "seconds to idle" configuration property */
private static final int DEFAULT_TIME_TO_IDLE = 0;
/** Configuration key for the time to live setting */
public static final String OPT_TIME_TO_LIVE = OPT_PREFIX + ".timeToLive";
/** The default value for "seconds to live" configuration property */
private static final int DEFAULT_TIME_TO_LIVE = (int) (Times.MS_PER_DAY / 1000);
/** Make the cache persistent between reboots? */
protected boolean diskPersistent = DEFAULT_DISK_PERSISTENT;
/** Write overflow elements from memory to disk? */
protected boolean overflowToDisk = DEFAULT_OVERFLOW_TO_DISK;
/** Maximum number of elements in memory */
protected int maxElementsInMemory = DEFAULT_MAX_ELEMENTS_IN_MEMORY;
/** Maximum number of elements in memory */
protected int maxElementsOnDisk = DEFAULT_MAX_ELEMENTS_ON_DISK;
/** Number of seconds for an element to live from its last access time */
protected int timeToIdle = DEFAULT_TIME_TO_IDLE;
/** Number of seconds for an element to live from its creation date */
protected int timeToLive = DEFAULT_TIME_TO_LIVE;
/** Whether cache statistics are enabled */
protected boolean statisticsEnabled = DEFAULT_STATISTICS_ENABLED;
/** Identifier for the default cache */
private static final String DEFAULT_CACHE = "site";
/** The ehache cache manager */
protected CacheManager cacheManager = null;
/** True if the cache is enabled */
protected boolean enabled = true;
/** True if additional response headers are enabled */
protected boolean debug = false;
/** The stream filter */
protected StreamFilter filter = null;
/** Cache identifier */
protected String id = null;
/** Cache name */
protected String name = null;
/** Path to the local disk store */
protected String diskStorePath = null;
/**
* True to indicate that everything went fine with the setup of the disk store
*/
protected boolean diskStoreEnabled = true;
/** Transactions that are currently being processed */
protected Map<String, CacheTransaction> transactions = null;
/** List of registered cache listeners */
protected List<CacheListener> cacheListeners = null;
/**
* Creates a new cache with the given identifier and name.
*
* @param id
* the cache identifier
* @param name
* the cache name
* @param diskStorePath
* the cache's disk store
*/
public CacheServiceImpl(String id, String name, String diskStorePath) {
if (StringUtils.isBlank(id))
throw new IllegalArgumentException("Cache id cannot be blank");
if (StringUtils.isBlank(name))
throw new IllegalArgumentException("Cache name cannot be blank");
this.id = id;
this.name = name;
this.diskStorePath = diskStorePath;
this.transactions = new HashMap<String, CacheTransaction>();
this.cacheListeners = new ArrayList<CacheListener>();
init(id, name, diskStorePath);
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.cache.CacheService#addCacheListener(ch.entwine.weblounge.cache.CacheListener)
*/
public void addCacheListener(CacheListener listener) {
if (!cacheListeners.contains(listener))
cacheListeners.add(listener);
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.cache.CacheService#removeCacheListener(ch.entwine.weblounge.cache.CacheListener)
*/
public void removeCacheListener(CacheListener listener) {
cacheListeners.remove(listener);
}
/**
* Initializes the cache service with an identifier, a name and a path to the
* local disk store (if applicable).
*
* @param id
* the cache identifier
* @param name
* the cache name
* @param diskStorePath
* path to the local disk store
*/
private void init(String id, String name, String diskStorePath) {
InputStream configInputStream = null;
try {
configInputStream = getClass().getClassLoader().getResourceAsStream(CACHE_MANAGER_CONFIG);
Configuration cacheManagerConfig = ConfigurationFactory.parseConfiguration(configInputStream);
cacheManagerConfig.getDiskStoreConfiguration().setPath(diskStorePath);
cacheManager = new CacheManager(cacheManagerConfig);
cacheManager.setName(id);
} finally {
IOUtils.closeQuietly(configInputStream);
}
// Check the path to the cache
if (StringUtils.isNotBlank(diskStorePath)) {
File file = new File(diskStorePath);
try {
if (!file.exists())
FileUtils.forceMkdir(file);
if (!file.isDirectory())
throw new IOException();
if (!file.canWrite())
throw new IOException();
} catch (IOException e) {
logger.warn("Unable to create disk store for cache '{}' at {}", id, diskStorePath);
logger.warn("Persistent cache will be disabled for '{}'", id);
diskPersistent = false;
diskStoreEnabled = false;
}
} else {
diskStoreEnabled = false;
}
// Configure the cache
CacheConfiguration cacheConfig = new CacheConfiguration();
cacheConfig.setName(DEFAULT_CACHE);
cacheConfig.setDiskPersistent(diskPersistent && diskStoreEnabled);
cacheConfig.setOverflowToDisk(overflowToDisk && diskStoreEnabled);
if (overflowToDisk && diskStoreEnabled) {
cacheConfig.setDiskStorePath(diskStorePath);
cacheConfig.setMaxElementsOnDisk(maxElementsOnDisk);
}
cacheConfig.setEternal(false);
cacheConfig.setMaxElementsInMemory(maxElementsInMemory);
cacheConfig.setStatistics(statisticsEnabled);
cacheConfig.setTimeToIdleSeconds(timeToIdle);
cacheConfig.setTimeToLiveSeconds(timeToLive);
Cache cache = new Cache(cacheConfig);
cacheManager.addCache(cache);
if (overflowToDisk)
logger.info("Cache extension for site '{}' created at {}", id, cacheManager.getDiskStorePath());
else
logger.info("In-memory cache for site '{}' created", id);
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.cache.CacheService#shutdown()
*/
public void shutdown() {
if (cacheManager == null)
return;
for (String cacheName : cacheManager.getCacheNames()) {
Cache cache = cacheManager.getCache(cacheName);
cache.dispose();
}
cacheManager.shutdown();
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.cache.CacheService#getIdentifier()
*/
public String getIdentifier() {
return id;
}
/**
* Configures the caching service. Available options are:
* <ul>
* <li><code>size</code> - the maximum cache size</li>
* <li><code>filters</code> - the name of the output filters</li>
* </ul>
*
* @see org.osgi.service.cm.ManagedService#updated(java.util.Dictionary)
*/
@SuppressWarnings("rawtypes")
public void updated(Dictionary properties) throws ConfigurationException {
if (properties == null)
return;
// Do we need to clear the cache?
boolean clear = ConfigurationUtils.isTrue((String) properties.get(OPT_CLEAR), false);
if (clear) {
clear();
}
// Enabled status
enabled = ConfigurationUtils.isTrue((String) properties.get(OPT_ENABLE), DEFAULT_ENABLE);
logger.debug("Cache is {}", diskPersistent ? "enabled" : "disabled");
debug = ConfigurationUtils.isTrue((String) properties.get(OPT_DEBUG), DEFAULT_DEBUG);
logger.debug("Cache is {}", diskPersistent ? "enabled" : "disabled");
// Disk persistence
diskPersistent = ConfigurationUtils.isTrue((String) properties.get(OPT_DISK_PERSISTENT), DEFAULT_DISK_PERSISTENT);
logger.debug("Cache persistance between reboots is {}", diskPersistent ? "on" : "off");
// Statistics
statisticsEnabled = ConfigurationUtils.isTrue((String) properties.get(OPT_ENABLE_STATISTICS), DEFAULT_STATISTICS_ENABLED);
logger.debug("Cache statistics are {}", statisticsEnabled ? "enabled" : "disabled");
// Max elements in memory
try {
maxElementsInMemory = ConfigurationUtils.getValue((String) properties.get(OPT_MAX_ELEMENTS_IN_MEMORY), DEFAULT_MAX_ELEMENTS_IN_MEMORY);
logger.debug("Cache will keep {} elements in memory", maxElementsInMemory > 0 ? "up to " + maxElementsInMemory : "all");
} catch (NumberFormatException e) {
logger.warn("Value for cache setting '" + OPT_MAX_ELEMENTS_IN_MEMORY + "' is malformed: " + (String) properties.get(OPT_MAX_ELEMENTS_IN_MEMORY));
logger.warn("Cache setting '" + OPT_MAX_ELEMENTS_IN_MEMORY + "' set to default value of " + DEFAULT_MAX_ELEMENTS_IN_MEMORY);
maxElementsInMemory = DEFAULT_MAX_ELEMENTS_IN_MEMORY;
}
// Max elements on disk
try {
maxElementsOnDisk = ConfigurationUtils.getValue((String) properties.get(OPT_MAX_ELEMENTS_ON_DISK), DEFAULT_MAX_ELEMENTS_ON_DISK);
logger.debug("Cache will keep {} elements on disk", maxElementsOnDisk > 0 ? "up to " + maxElementsOnDisk : "all");
} catch (NumberFormatException e) {
logger.warn("Value for cache setting '" + OPT_MAX_ELEMENTS_ON_DISK + "' is malformed: " + (String) properties.get(OPT_MAX_ELEMENTS_ON_DISK));
logger.warn("Cache setting '" + OPT_MAX_ELEMENTS_ON_DISK + "' set to default value of " + DEFAULT_MAX_ELEMENTS_ON_DISK);
maxElementsOnDisk = DEFAULT_MAX_ELEMENTS_ON_DISK;
}
// Overflow to disk
overflowToDisk = ConfigurationUtils.isTrue((String) properties.get(OPT_OVERFLOW_TO_DISK), DEFAULT_OVERFLOW_TO_DISK);
// Time to idle
try {
timeToIdle = ConfigurationUtils.getValue((String) properties.get(OPT_TIME_TO_IDLE), DEFAULT_TIME_TO_IDLE);
logger.debug("Cache time to idle is set to ", timeToIdle > 0 ? timeToIdle + "s" : "unlimited");
} catch (NumberFormatException e) {
logger.warn("Value for cache setting '" + OPT_TIME_TO_IDLE + "' is malformed: " + (String) properties.get(OPT_TIME_TO_IDLE));
logger.warn("Cache setting '" + OPT_TIME_TO_IDLE + "' set to default value of " + DEFAULT_TIME_TO_IDLE);
timeToIdle = DEFAULT_TIME_TO_IDLE;
}
// Time to live
try {
timeToLive = ConfigurationUtils.getValue((String) properties.get(OPT_TIME_TO_LIVE), DEFAULT_TIME_TO_LIVE);
logger.debug("Cache time to live is set to ", timeToIdle > 0 ? timeToLive + "s" : "unlimited");
} catch (NumberFormatException e) {
logger.warn("Value for cache setting '" + OPT_TIME_TO_LIVE + "' is malformed: " + (String) properties.get(OPT_TIME_TO_LIVE));
logger.warn("Cache setting '" + OPT_TIME_TO_LIVE + "' set to default value of " + DEFAULT_TIME_TO_LIVE);
timeToLive = DEFAULT_TIME_TO_LIVE;
}
for (String cacheId : cacheManager.getCacheNames()) {
Cache cache = cacheManager.getCache(cacheId);
if (cache == null)
continue;
CacheConfiguration config = cache.getCacheConfiguration();
config.setOverflowToDisk(overflowToDisk && diskStoreEnabled);
if (overflowToDisk && diskStoreEnabled) {
config.setMaxElementsOnDisk(maxElementsOnDisk);
}
config.setDiskPersistent(diskPersistent && diskStoreEnabled);
config.setStatistics(statisticsEnabled);
config.setMaxElementsInMemory(maxElementsInMemory);
config.setTimeToIdleSeconds(timeToIdle);
config.setTimeToLiveSeconds(timeToLive);
}
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.common.request.ResponseCache#resetStatistics()
*/
public void resetStatistics() {
for (String cacheId : cacheManager.getCacheNames()) {
Cache cache = cacheManager.getCache(cacheId);
cache.setStatisticsEnabled(false);
cache.setStatisticsEnabled(true);
}
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.common.request.ResponseCache#clear()
*/
public void clear() {
cacheManager.clearAll();
logger.info("Cache '{}' cleared", id);
for (CacheListener listener : cacheListeners) {
listener.cacheCleared();
}
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.common.request.ResponseCache#preload(ch.entwine.weblounge.common.request.CacheTag[])
*/
public void preload(CacheTag[] tags) {
if (tags == null || tags.length == 0)
throw new IllegalArgumentException("Tags cannot be null or empty");
Cache cache = cacheManager.getCache(DEFAULT_CACHE);
// Get the matching keys and load the elements into the cache
Collection<Object> keys = getKeysForPrimaryTags(cache, tags);
for (Object key : keys) {
cache.load(key);
}
logger.info("Loaded first {} elements of cache '{}' into memory", keys.size(), id);
}
/**
* Returns those keys from the given cache that contain at least all the tags
* as defined in the <code>tags</code> array.
*
* @param cache
* the cache
* @param tags
* the set of tags
* @return the collection of matching keys
*/
private Collection<Object> getKeysForPrimaryTags(Cache cache, CacheTag[] tags) {
// Create the parts of the key to look for
List<String> keyParts = new ArrayList<String>(tags.length);
for (CacheTag tag : tags) {
StringBuffer b = new StringBuffer(tag.getName()).append("=").append(tag.getValue());
keyParts.add(b.toString());
}
// Collect those keys that contain all relevant parts
Collection<Object> keys = new ArrayList<Object>();
key: for (Object k : cache.getKeys()) {
String key = k.toString();
for (String keyPart : keyParts) {
if (!key.contains(keyPart))
continue key;
}
keys.add(k);
}
return keys;
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.common.request.ResponseCache#createCacheableResponse(javax.servlet.http.HttpServletRequest,
* javax.servlet.http.HttpServletResponse)
*/
public HttpServletResponse createCacheableResponse(
HttpServletRequest request, HttpServletResponse response) {
return new CacheableHttpServletResponse(response);
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.common.request.ResponseCache#startResponse(ch.entwine.weblounge.common.request.CacheTag[],
* ch.entwine.weblounge.common.request.WebloungeRequest,
* ch.entwine.weblounge.common.request.WebloungeResponse, long, long)
*/
public CacheHandle startResponse(CacheTag[] uniqueTags,
WebloungeRequest request, WebloungeResponse response,
long expirationTime, long revalidationTime) {
CacheHandle hdl = new TaggedCacheHandle(uniqueTags, expirationTime, revalidationTime);
return startResponse(hdl, request, response);
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.common.request.ResponseCache#startResponse(ch.entwine.weblounge.common.request.CacheHandle,
* ch.entwine.weblounge.common.request.WebloungeRequest,
* ch.entwine.weblounge.common.request.WebloungeResponse)
*/
public CacheHandle startResponse(CacheHandle handle,
WebloungeRequest request, WebloungeResponse response) {
// Check whether the response has been properly wrapped
CacheableHttpServletResponse cacheableResponse = unwrapResponse(response);
if (cacheableResponse == null) {
throw new IllegalStateException("Cached response is not properly wrapped");
}
// While disabled, don't do lookups but return immediately
if (!enabled) {
cacheableResponse.startTransaction(handle, filter);
return handle;
}
// Make sure the cache is still alive
if (cacheManager.getStatus() != Status.STATUS_ALIVE) {
logger.debug("Cache '{}' has unexpected status '{}'", request.getSite().getIdentifier(), cacheManager.getStatus());
return null;
}
// Load the cache
Cache cache = cacheManager.getCache(DEFAULT_CACHE);
if (cache == null)
throw new IllegalStateException("No cache found for site '" + id + "'");
// Try to load the content from the cache
Element element = cache.get(new CacheEntryKey(handle.getKey()));
// Is the element already beyond its lifetime?
if (element != null) {
long expirationTime = element.getExpirationTime();
if (expirationTime < System.currentTimeMillis()) {
logger.debug("Cache element {} of cache {} has expired", request, id);
cache.remove(handle.getKey());
element = null;
}
}
// If it exists, write the contents back to the response
if (element != null && element.getValue() != null) {
try {
logger.debug("Answering {} from cache '{}'", request, id);
writeCacheEntry(element, handle, request, response);
return null;
} catch (IOException e) {
logger.debug("Error writing cached response to client");
return null;
}
}
// Make sure that there are no two transactions producing the same content.
// If there is a transaction already working on specific content, have
// this transaction simply wait for the outcome.
synchronized (transactions) {
CacheTransaction tx = transactions.get(handle.getKey());
if (tx != null) {
try {
logger.debug("Waiting for cache transaction {} to be finished", request);
while (transactions.containsKey(handle.getKey())) {
transactions.wait(1000);
// Was this a notify or a timeout?
if (transactions.get(handle.getKey()) != null) {
logger.debug("After waiting 1s, cache entry {} is still being worked on", handle.getKey());
response.setStatus(SC_SERVICE_UNAVAILABLE);
return null;
}
}
} catch (InterruptedException e) {
// Done sleeping!
}
}
// The cache might have been shut down in the meantime
if (cacheManager.getStatus() == Status.STATUS_ALIVE) {
element = cache.get(handle.getKey());
} else {
logger.debug("Cache '{}' changed status to '{}'", request.getSite().getIdentifier(), cacheManager.getStatus());
}
if (element == null) {
tx = cacheableResponse.startTransaction(handle, filter);
transactions.put(handle.getKey(), tx);
logger.debug("Starting work on cached version of {}", request);
}
}
// If we were waiting for an active cache transaction, let's try again
if (element != null && element.getValue() != null) {
try {
logger.debug("Answering {} from cache '{}'", request, id);
writeCacheEntry(element, handle, request, response);
return null;
} catch (IOException e) {
logger.warn("Error writing cached response to client");
return null;
}
}
// Apparently, we need to get it done ourselves
return handle;
}
/**
* Writes the cache element to the response, setting the cache headers
* according to the settings found on the element.
*
* @param element
* the cache contents
* @param handle
* the cache handle
* @param request
* the request
* @param response
* the response
* @throws IOException
* if writing the cache contents to the response fails
*/
private void writeCacheEntry(Element element, CacheHandle handle,
WebloungeRequest request, WebloungeResponse response) throws IOException {
CacheEntry entry = (CacheEntry) element.getValue();
// Check what the client has available locally
String eTag = request.getHeader(HEADER_IF_NONE_MATCH);
long clientCacheDate = 0;
try {
clientCacheDate = request.getDateHeader(HEADER_IF_MODIFIED_SINCE);
} catch (IllegalArgumentException e) {
logger.debug("The client provided a malformed '{}' date header: '{}'", HEADER_IF_MODIFIED_SINCE, request.getHeader(HEADER_IF_MODIFIED_SINCE));
}
// Do we have a more recent version?
boolean isModified = !entry.notModified(clientCacheDate) && !entry.matches(eTag);
// Write the response headers
if (isModified) {
entry.getHeaders().apply(response);
writeContentHeaders(response, entry);
}
writeCacheHeaders(response, entry, isModified);
// Add the X-Cache-Key header
if (debug || request.getHeader(CACHE_DEBUG_HEADER) != null) {
StringBuffer cacheKeyHeader = new StringBuffer(name);
cacheKeyHeader.append(" (").append(handle.getKey()).append(")");
response.addHeader(CACHE_KEY_HEADER, cacheKeyHeader.toString());
}
// Check the headers first. Maybe we don't need to send anything but
// a not-modified back
if (isModified) {
response.getOutputStream().write(entry.getContent());
} else {
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
}
response.flushBuffer();
}
/**
* Writes the headers that are relevant for proper content handling based on
* the cache entry.
*
* @param response
* the response
* @param entry
* the cache entry
*/
private void writeContentHeaders(WebloungeResponse response, CacheEntry entry) {
response.setContentType(entry.getContentType());
response.setCharacterEncoding(entry.getEncoding());
response.setContentLength(entry.getContent().length);
}
/**
* Writes the headers that are relevant for proper caching based on the cache
* entry.
*
* @param response
* the response
* @param entry
* the cache entry
* @param isModified
* <code>true</code> if the client asked for the content only if the
* content is more recent that what was cached locally
*/
private void writeCacheHeaders(WebloungeResponse response, CacheEntry entry,
boolean isModified) {
long expirationDate = System.currentTimeMillis() + entry.getClientRevalidationTime();
long revalidationTimeInSeconds = entry.getClientRevalidationTime() / 1000;
// Send Cache directives, ETag and Last-Modified
if (isModified) {
response.setHeader("Cache-Control", "private, max-age=" + revalidationTimeInSeconds + ", must-revalidate");
response.setHeader("ETag", entry.getETag());
response.setDateHeader("Last-Modified", entry.getModificationDate());
}
// Set the current date
response.setDateHeader("Date", System.currentTimeMillis());
// This header must be set, otherwise it defaults to
// "Thu, 01-Jan-1970 00:00:00 GMT"
response.setDateHeader("Expires", expirationDate);
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.common.request.ResponseCache#endResponse(ch.entwine.weblounge.common.request.WebloungeResponse)
*/
public boolean endResponse(WebloungeResponse response) {
CacheableHttpServletResponse cacheableResponse = unwrapResponse(response);
if (cacheableResponse == null)
return false;
// Discard any cached content while disabled
if (!enabled)
return true;
// Make sure the cache is still available and active
if (cacheManager.getStatus() != Status.STATUS_ALIVE) {
logger.debug("Cache '{}' has unexpected status '{}'", cacheManager.getName(), cacheManager.getStatus());
return false;
}
// Load the cache
Cache cache = cacheManager.getCache(DEFAULT_CACHE);
if (cache == null) {
logger.debug("Cache for {} was deactivated, response is not being cached", response);
return false;
}
// Finish writing the element
CacheTransaction tx = cacheableResponse.endOutput();
// Is the response ready to be cached?
if (tx == null) {
logger.debug("Response to {} was not associated with a transaction", response);
return false;
}
// Important note: Do not return prior to this try block if there is a
// transaction associated with the request.
try {
// Is the response ready to be cached?
if (tx.isValid() && response.isValid() && response.getStatus() == HttpServletResponse.SC_OK) {
logger.trace("Writing response for {} to the cache", response);
CacheHandle cacheHdl = tx.getHandle();
String encoding = cacheableResponse.getCharacterEncoding();
CacheEntry entry = new CacheEntry(cacheHdl, tx.getContent(), encoding, tx.getHeaders());
Element element = new Element(new CacheEntryKey(cacheHdl), entry);
element.setTimeToLive((int) (cacheHdl.getCacheExpirationTime() / 1000));
cache.put(element);
// Write cache and content relevant headers
writeCacheHeaders(response, entry, true);
writeContentHeaders(response, entry);
// Inform listeners
for (CacheListener listener : cacheListeners) {
listener.cacheEntryAdded(cacheHdl);
}
} else if (tx.isValid() && response.isValid()) {
logger.trace("Skip caching of response for {}: {}", response, response.getStatus());
response.setDateHeader("Expires", System.currentTimeMillis() + tx.getHandle().getCacheExpirationTime());
} else {
logger.debug("Response to {} was invalid and is not being cached", response);
}
return tx.isValid() && response.isValid();
} finally {
// Mark the current transaction as finished and notify anybody who was
// waiting for it to be finished
synchronized (transactions) {
transactions.remove(tx.getHandle().getKey());
logger.debug("Caching of {} finished", response);
transactions.notifyAll();
}
try {
if (!response.isCommitted())
response.flushBuffer();
} catch (IOException e) {
String message = e.getMessage();
// This is debug, as the client may have closed the connection
logger.debug("Error flushing response: {}", message);
}
}
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.common.request.ResponseCache#invalidate(ch.entwine.weblounge.common.request.WebloungeResponse)
*/
public void invalidate(WebloungeResponse response) {
CacheableHttpServletResponse cacheableResponse = unwrapResponse(response);
if (cacheableResponse == null || cacheableResponse.getTransaction() == null)
return;
cacheableResponse.invalidate();
CacheTransaction tx = cacheableResponse.getTransaction();
invalidate(tx.getHandle());
logger.debug("Removed {} from cache '{}'", response, id);
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.common.request.ResponseCache#invalidate(ch.entwine.weblounge.common.request.CacheTag[],
* boolean)
*/
public void invalidate(CacheTag[] tags, boolean partialMatches) {
if (tags == null || tags.length == 0)
throw new IllegalArgumentException("Tags cannot be null or empty");
// Load the cache
Cache cache = cacheManager.getCache(DEFAULT_CACHE);
// Inform listeners
for (CacheListener listener : cacheListeners) {
listener.cacheSetInvalidated(tags);
}
// Remove the objects matched by the tags
long removed = 0;
for (Object key : getKeysForTags(cache, tags, partialMatches)) {
if (cache.remove(key))
removed++;
}
logger.debug("Removed {} elements from cache '{}'", removed, id);
}
/**
* Returns those keys from the given cache that contain all or any of the tags
* as defined in the <code>tags</code> array.
*
* @param cache
* the cache
* @param tags
* the set of tags
* @param partialMatches
* <code>true</code> to include partial matches, where only one of
* the tag matches instead of all
* @return the collection of matching keys
*/
private Collection<Object> getKeysForTags(Cache cache, CacheTag[] tags,
boolean partialMatches) {
// Create the parts of the key to look for
List<String> keyParts = new ArrayList<String>(tags.length);
for (CacheTag tag : tags) {
StringBuffer b = new StringBuffer(tag.getName()).append("=").append(tag.getValue());
keyParts.add(b.toString());
}
// Collect those keys that contain all relevant parts
Collection<Object> cacheKeys = new ArrayList<Object>();
key: for (Object k : cache.getKeys()) {
String key = ((CacheEntryKey) k).tags;
for (String keyPart : keyParts) {
if (!key.contains(keyPart) && !partialMatches) {
continue key;
} else if (key.contains(keyPart)) {
cacheKeys.add(k);
continue key;
}
}
}
return cacheKeys;
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.common.request.ResponseCache#invalidate(ch.entwine.weblounge.common.request.CacheHandle)
*/
public void invalidate(CacheHandle handle) {
if (handle == null)
throw new IllegalArgumentException("Handle cannot be null");
// Make sure the cache is still available and active
if (cacheManager.getStatus() != Status.STATUS_ALIVE) {
logger.debug("Cache '{}' has unexpected status '{}'", cacheManager.getName(), cacheManager.getStatus());
return;
}
// Load the cache
Cache cache = cacheManager.getCache(DEFAULT_CACHE);
if (cache == null) {
logger.debug("Cache for {} was deactivated, response is not being invalidated");
return;
}
cache.remove(handle.getKey());
// Mark the current transaction as finished and notify anybody that was
// waiting for it to be finished
synchronized (transactions) {
transactions.remove(handle.getKey());
transactions.notifyAll();
}
logger.debug("Removed {} from cache '{}'", handle.getKey(), id);
// Inform listeners
for (CacheListener listener : cacheListeners) {
listener.cacheEntryRemoved(handle);
}
}
/**
* Extracts the <code>CacheableServletResponse</code> from its wrapper(s).
*
* @param response
* the original response
* @return the wrapped <code>CacheableServletResponse</code> or
* <code>null</code> if the response is not cacheable
*/
private static CacheableHttpServletResponse unwrapResponse(
ServletResponse response) {
while (response != null) {
if (response instanceof CacheableHttpServletResponse)
return (CacheableHttpServletResponse) response;
if (!(response instanceof ServletResponseWrapper))
break;
response = ((ServletResponseWrapper) response).getResponse();
}
return null;
}
/**
* {@inheritDoc}
*
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
return name;
}
}