/*
* Copyright (c) xlightweb.org, 2008 - 2009. All rights reserved.
*
* This library 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.1 of the License, or (at your option) any later version.
*
* This library 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 library; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*
* Please refer to the LGPL license at: http://www.gnu.org/copyleft/lesser.txt
* The latest copy of this software may be found on http://www.xlightweb.org/
*/
package org.xlightweb.client;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.TimerTask;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.xlightweb.HttpUtils;
import org.xlightweb.IHttpRequest;
import org.xlightweb.IHttpResponse;
import org.xlightweb.IHttpResponseHandler;
import org.xlightweb.IHttpResponseHeader;
import org.xlightweb.InvokeOn;
import org.xsocket.Execution;
/**
* cache
*
* @author grro@xlightweb.org
*/
final class HttpCache implements IHttpCache {
private static final Logger LOG = Logger.getLogger(HttpCache.class.getName());
private static final String SEPARATOR = "*";
private static final int DEFAULT_VALIDATION_BASED_CACHE_ENTRY_MAX_TIME_MILLIS = 1 * 60 * 1000;
private int validationBasedCacheMaxTimeMillis = DEFAULT_VALIDATION_BASED_CACHE_ENTRY_MAX_TIME_MILLIS;
private static final long TIMERTASK_PERIOD = 60 * 1000;
private final TimerTask timerTask;
private final HttpClient httpClient;
private boolean isSharedCache = true;
private final EntryCache entries = new EntryCache();
public HttpCache(HttpClient httpClient) {
this.httpClient = httpClient;
timerTask = new TimerTask() {
public void run() {
entries.checkTimeouts();
}
};
HttpClientConnection.schedule(timerTask, TIMERTASK_PERIOD, TIMERTASK_PERIOD);
}
public void setSharedCache(boolean isSharedCache) {
this.isSharedCache = isSharedCache;
entries.clear();
}
public boolean isSharedCache() {
return isSharedCache;
}
public void setMaxSize(int sizeBytes) {
entries.setMaxSize(sizeBytes);
}
public int getMaxSize() {
return entries.getMaxSize();
}
public int getMaxSizeCacheEntry() {
return entries.getMaxSizeCacheEntry();
}
public int getCurrentSize() {
return entries.getCurrentSize();
}
public Collection<CacheEntry> getEntries() {
return entries.getEntries();
}
public void close() throws IOException {
entries.close();
timerTask.cancel();
}
public static boolean isCacheable(IHttpRequest request) {
try {
// If the request contains an "Authorization:" header, the response will not be cached
if (request.getHeader("Authorization") != null) {
return false;
}
// only GET or POST is supported
if (!request.getMethod().equalsIgnoreCase("GET") && !request.getMethod().equalsIgnoreCase("POST")) {
return false;
}
// do not handle validation based cache request (ETag, Modification-Date) -> could be done in the future
if ((request.getHeader("If-None-Match") != null) || (request.getHeader("If-Modified-Since") != null)) {
return false;
}
return true;
} catch (Exception e) {
return false;
}
}
public static boolean isCacheable(IHttpResponse response, boolean isSharedCache) {
try {
// cache only specific response status
if (!isCacheableSuccess(response.getStatus()) && !isCacheableRedirect(response.getStatus())) {
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("non-cacheable response received (status: " + response.getStatus() + ")");
}
return false;
}
// do not cache pragma header 'no-cache'
String pragmaHeader = response.getHeader("Pragma");
if ((pragmaHeader != null) && (pragmaHeader.equalsIgnoreCase("no-cache"))) {
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("non-cacheable response received (Pragma: no-cache)");
}
return false;
}
// check expired based
String expires = response.getHeader("Expires");
if (expires != null) {
Date date = HttpUtils.parseHttpDateString(expires);
if (date == null) {
return false;
} else if (date.getTime() < System.currentTimeMillis()) {
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("non-cacheable response received (Expires: " + expires + ")");
}
return false;
}
}
// check cache control header
String cacheControl = response.getHeader("Cache-Control");
if (cacheControl != null) {
for (String directive : cacheControl.split(",")) {
directive = directive.trim();
if (directive.equalsIgnoreCase("no-cache") || directive.equalsIgnoreCase("no-store")) {
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("non-cacheable response received (Cache-Control: " + cacheControl + ")");
}
return false;
}
if (isSharedCache && directive.equalsIgnoreCase("private")) {
return false;
}
}
}
// do not cache response contains vary
if (response.getHeader("Vary") != null) {
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("non-cacheable response received (includes Vary header)");
}
return false;
}
return true;
} catch (Exception e) {
return false;
}
}
private static boolean isCacheableSuccess(int statusCode) {
return ((statusCode >= 200) && (statusCode <= 201));
}
private static boolean isCacheableRedirect(int statusCode) {
return ((statusCode >= 301) && (statusCode <= 302));
}
public CacheEntry get(IHttpRequest request, Date minFresh) throws IOException {
CacheEntry ce = null;
synchronized (this) {
ce = entries.getEntry(request);
}
if (ce != null) {
if (ce.isExpired(minFresh)) {
return null;
} else {
return ce;
}
} else {
return null;
}
}
private CacheEntry newCacheEntry(IHttpRequest request, long networkLatency, IHttpResponse response) {
try {
///////////////////////////////
// CHECK EXPIRED BASED
String cacheControl = response.getHeader("Cache-Control");
// handle cache-control header
if (cacheControl != null) {
CacheEntry cacheEntry = new CacheControlCacheEntry(request, response, networkLatency, cacheControl, isSharedCache);
if (cacheEntry.isExpired(new Date())) {
return null;
} else {
return cacheEntry;
}
}
// handler expires header
String expire = response.getHeader("Expires");
if (expire != null) {
CacheEntry cacheEntry = new ExpiresCacheEntry(request, response, networkLatency, expire, isSharedCache);
if (cacheEntry.isExpired(new Date())) {
return null;
} else {
return cacheEntry;
}
}
if (request.getMethod().equalsIgnoreCase("GET")) {
///////////////////////////////
// CHECK VALIDATION BASED
String eTag = response.getHeader("ETag");
String lastModified = response.getHeader("Last-Modified");
if ((eTag != null) || (lastModified != null)) {
CacheEntry cacheEntry = new CacheEntry(request, response);
if (cacheEntry.isExpired(new Date())) {
return null;
} else {
return cacheEntry;
}
}
}
//////////////////////////////////
// DEFAULT Handling
// handle permantent redirect
if (response.getStatus() == 301) {
return new ExpiresCacheEntry(request, response, networkLatency, new Date(System.currentTimeMillis() + (30L * 24L * 60L * 60L * 1000L)), isSharedCache);
}
} catch (Exception e) {
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("error occured by checking if cacheable" + e.toString());
}
}
return null;
}
public void register(IHttpRequest request, long networkLatency, IHttpResponse response) throws IOException {
CacheEntry ce = newCacheEntry(request, networkLatency, response);
register(ce);
}
private void register(CacheEntry ce) throws IOException {
if (ce == null) {
return;
}
synchronized (this) {
entries.putEntry(ce);
}
}
public void deregister(IHttpRequest request) throws IOException {
synchronized (this) {
entries.removeEntry(request);
}
}
@Override
public String toString() {
return entries.toString();
}
private static final class EntryCache extends LinkedHashMap<String, CacheEntry> {
private static final long serialVersionUID = -2886392185640417068L;
private static final int DEFAULT_ENTRY_SIZE_THRESHOLD = 10;
private int entrySizeThreshold = DEFAULT_ENTRY_SIZE_THRESHOLD;
private int currentSize = 0;
private int maxSize = 0;
void checkTimeouts() {
Date currentDate = new Date();
for (CacheEntry cacheEntry : getEntries()) {
try {
if (cacheEntry.isExpired(currentDate)) {
removeEntry(cacheEntry.getRequest());
}
} catch (IOException ioe) {
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("could not vaildate/remove cache entry " + cacheEntry.getRequest().getRequestUrl().toString() + " " + ioe.toString());
}
}
}
}
public Collection<CacheEntry> getEntries() {
EntryCache copy = (EntryCache) clone();
return copy.values();
}
public void setMaxSize(int sizeBytes) {
maxSize = sizeBytes;
}
public int getMaxSize() {
return maxSize;
}
public synchronized int getCurrentSize() {
return currentSize;
}
protected boolean removeEldestEntry(java.util.Map.Entry<String, CacheEntry> eldest) {
if (currentSize > maxSize) {
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("removing eldest entry (current size " + currentSize + " is larger than max size " + maxSize + ")");
}
currentSize -= eldest.getValue().getSize();
return true;
}
return false;
}
private String computeFingerprint(IHttpRequest request) throws IOException {
StringBuilder sb = new StringBuilder(request.getMethod() + SEPARATOR + request.getRequestUrl().toString());
List<String> headers = new ArrayList<String>(request.getHeaderNameSet());
Collections.sort(headers);
for (String header : headers) {
if (header.equalsIgnoreCase("User-Agent") ||
header.equalsIgnoreCase("Referer") ||
header.equalsIgnoreCase("Cache-Control") ||
header.equalsIgnoreCase("Connection") ||
header.equalsIgnoreCase("Keep-Alive") ||
header.equalsIgnoreCase("Proxy-Authenticate") ||
header.equalsIgnoreCase("Proxy-Authorization") ||
header.equalsIgnoreCase("TE")) {
continue;
}
sb.append(header + SEPARATOR);
for (String value : request.getHeaderList(header)) {
sb.append(value + SEPARATOR);
}
}
if (request.hasBody()) {
sb.append(request.getNonBlockingBody().toString());
}
return sb.toString();
}
public CacheEntry getEntry(IHttpRequest request) throws IOException {
return super.get(computeFingerprint(request));
}
int getMaxSizeCacheEntry() {
return (maxSize / entrySizeThreshold);
}
public synchronized CacheEntry putEntry(CacheEntry entry) throws IOException {
int size = entry.getSize();
// very large entries will not be cached
if (size > getMaxSizeCacheEntry()) {
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("entry " + entry.getRequest().getRequestUrl().toString() + " will not be cached (is to large: " + entry.getSize() + "bytes)");
}
return null;
}
CacheEntry removed = super.put(computeFingerprint(entry.getRequest()), entry);
currentSize += size;
if (removed != null) {
currentSize -= removed.getSize();
}
return removed;
}
public synchronized CacheEntry removeEntry(IHttpRequest request) throws IOException {
String fingerprint = computeFingerprint(request);
CacheEntry removed = super.remove(fingerprint);
if (removed != null) {
currentSize -= removed.getSize();
}
return removed;
}
public void close() throws IOException {
clear();
}
};
private static Date computeExpireDate(String expire, long networkLatency) {
if ((HttpUtils.parseHttpDateString(expire).getTime() - networkLatency) > 0) {
return new Date(HttpUtils.parseHttpDateString(expire).getTime() - networkLatency);
} else {
return new Date(0);
}
}
interface IValidationHandler {
void onRevalidated(boolean isNotModified, CacheEntry ce);
void onException(IOException ioe);
}
class CacheEntry {
private final IHttpRequest request;
private final IHttpResponse response;
private final int size;
private final Date cacheDate;
private final Date defaultExpireDate = new Date(System.currentTimeMillis() + validationBasedCacheMaxTimeMillis);
public CacheEntry(IHttpRequest request, IHttpResponse response) throws IOException {
this(request, response, new Date());
}
public CacheEntry(IHttpRequest request, IHttpResponse response, Date cacheDate) throws IOException {
this.request = request;
this.response = response;
this.cacheDate = cacheDate;
int i = request.getRequestHeader().toString().length() +
response.getResponseHeader().toString().length();
if (request.hasBody()) {
i += request.getNonBlockingBody().available();
}
if (response.hasBody()) {
i += response.getNonBlockingBody().available();
}
size = i;
}
final IHttpRequest getRequest() {
return request;
}
final IHttpResponse getResponse() {
return response;
}
final int getSize() {
return size;
}
Date getCacheDate() {
return cacheDate;
}
Date getExpireDate() {
return defaultExpireDate;
}
boolean mustRevalidate(Date currentDate) {
return true;
}
boolean supportsRevalidate() {
return ((response.getHeader("Etag") != null) || (response.getHeader("Last-Modified") != null));
}
final boolean isAfter(Date date) {
if (date.after(cacheDate)) {
return true;
}
return false;
}
final boolean isExpired(Date currentDate) {
if (currentDate.after(getExpireDate())) {
return true;
}
return false;
}
void revalidate(final IValidationHandler hdl) throws IOException {
IHttpRequest requestCopy = HttpUtils.copy(request);
if (response.getHeader("Etag") != null) {
requestCopy.setHeader("IF-None-Match", response.getHeader("Etag"));
} else {
requestCopy.setHeader("If-Modified-Since", response.getHeader("Last-Modified"));
}
requestCopy.setAttribute(CacheHandler.SKIP_CACHE_HANDLING, "true");
IHttpResponseHandler respHdl = new IHttpResponseHandler() {
@Execution(Execution.NONTHREADED)
@InvokeOn(InvokeOn.MESSAGE_RECEIVED)
public void onResponse(IHttpResponse response) throws IOException {
if (response.getStatus() == 304) {
CacheEntry entry = updateCacheEntryOnRevaildation(request, response.getResponseHeader());
register(entry);
hdl.onRevalidated(true, CacheEntry.this);
} else {
if (isCacheableSuccess(response.getStatus())) {
CacheEntry entry = updateCacheEntry(request, response);
register(entry);
} else {
deregister(request);
}
hdl.onRevalidated(false, CacheEntry.this);
}
}
public void onException(IOException ioe) throws IOException {
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("got exception by revalidating "+ ioe.toString());
}
deregister(request);
hdl.onException(ioe);
}
};
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("revalidating request " + requestCopy.getRequestUrl().toString());
}
httpClient.send(requestCopy, respHdl);
}
final CacheEntry updateCacheEntryOnRevaildation(IHttpRequest request, IHttpResponseHeader responseHeader) throws IOException {
/* RFC 2616:
* If a cache uses a received 304 response to update a cache entry, the
* cache MUST update the entry to reflect any new field values given in
* the response.
*/
for (String headername : responseHeader.getHeaderNameSet()) {
response.removeHeader(headername);
for (String headervalue : responseHeader.getHeaderList(headername)) {
response.addHeader(headername, headervalue);
}
}
return updateCacheEntry(request, response);
}
CacheEntry updateCacheEntry(IHttpRequest request, IHttpResponse response) throws IOException {
return new CacheEntry(request, response, cacheDate);
}
final IHttpResponse newResponse() throws IOException {
IHttpResponse response = HttpUtils.copy(getResponse());
enhanceCachedResponse(response);
return response;
}
void enhanceCachedResponse(IHttpResponse response) {
}
}
private abstract class ExpiredBasedCacheEntry extends CacheEntry {
private final boolean isShared;
private final long networktLatency;
public ExpiredBasedCacheEntry(IHttpRequest request, IHttpResponse response, long networkLatency, boolean isShared) throws IOException {
super(request, response);
this.networktLatency = networkLatency;
this.isShared = isShared;
}
final CacheEntry updateCacheEntry(IHttpResponse response) throws IOException {
return newCacheEntry(getRequest(), networktLatency, response);
}
@Override
void enhanceCachedResponse(IHttpResponse response) {
String cacheControl = response.getHeader("Cache-Control");
if (cacheControl != null) {
StringBuilder sb = new StringBuilder();
for (String entry : cacheControl.split(",")) {
entry = entry.trim();
if (isShared) {
if (entry.toLowerCase().startsWith("max-age=")) {
entry = "max-age=" + ((getExpireDate().getTime() - System.currentTimeMillis()) / 1000);
}
} else {
if (entry.toLowerCase().startsWith("s-maxage=")) {
entry = "s-maxage=" + ((getExpireDate().getTime() - System.currentTimeMillis()) / 1000);
}
}
sb.append(entry + ", ");
}
if (sb.length() > 0) {
sb.setLength(sb.length() - 2);
}
response.setHeader("Cache-Control", sb.toString());
}
}
}
private final class ExpiresCacheEntry extends ExpiredBasedCacheEntry {
private final Date expireDate;
public ExpiresCacheEntry(IHttpRequest request, IHttpResponse response, long networkLatency, String expire, boolean isShared) throws IOException {
this(request, response, networkLatency, computeExpireDate(expire, networkLatency), isShared);
}
public ExpiresCacheEntry(IHttpRequest request, IHttpResponse response, long networkLatency, Date expireDate, boolean isShared) throws IOException {
super(request, response, networkLatency, isShared);
this.expireDate = expireDate;
}
Date getExpireDate() {
return expireDate;
}
@Override
boolean mustRevalidate(Date currentDate) {
return false;
}
}
private final class CacheControlCacheEntry extends ExpiredBasedCacheEntry {
private boolean mustRevalidate;
private Date expireDate;
public CacheControlCacheEntry(IHttpRequest request, IHttpResponse response, long networkLatency, String cacheControl, boolean isShared) throws IOException {
super(request, response, networkLatency, isShared);
for (String directive : cacheControl.split(",")) {
directive = directive.trim();
if (directive.equalsIgnoreCase("no-cache") || directive.equalsIgnoreCase("no-store")) {
expireDate = new Date(0);
return;
}
if (isShared && directive.equalsIgnoreCase("private")) {
expireDate = new Date(0);
return;
}
if (directive.equalsIgnoreCase("must-revalidate")) {
mustRevalidate = true;
}
if (isShared && (directive.equalsIgnoreCase("proxy-revalidate"))) {
mustRevalidate = true;
}
if (directive.toLowerCase().startsWith("max-age=")) {
long maxAgeMillis = Long.parseLong(directive.substring("max-age=".length(), directive.length())) * 1000L;
if ((maxAgeMillis - networkLatency) > 0) {
expireDate = new Date(System.currentTimeMillis() + (maxAgeMillis - networkLatency));
} else {
expireDate = new Date(0);
}
}
}
}
Date getExpireDate() {
return expireDate;
}
@Override
boolean mustRevalidate(Date currentDate) {
return mustRevalidate || currentDate.after(getExpireDate());
}
}
}