/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.apache.jmeter.protocol.http.control;
import java.io.Serializable;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.Map;
import org.apache.commons.collections.map.LRUMap;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.utils.DateUtils;
import org.apache.jmeter.config.ConfigTestElement;
import org.apache.jmeter.engine.event.LoopIterationEvent;
import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult;
import org.apache.jmeter.protocol.http.util.HTTPConstants;
import org.apache.jmeter.testelement.TestIterationListener;
import org.apache.jmeter.testelement.TestStateListener;
import org.apache.jmeter.testelement.property.BooleanProperty;
import org.apache.jmeter.util.JMeterUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Handles HTTP Caching
*/
public class CacheManager extends ConfigTestElement implements TestStateListener, TestIterationListener, Serializable {
private static final Date EXPIRED_DATE = new Date(0L);
private static final long serialVersionUID = 235L;
private static final Logger log = LoggerFactory.getLogger(CacheManager.class);
private static final String[] CACHEABLE_METHODS = JMeterUtils.getPropDefault("cacheable_methods", "GET").split("[ ,]");
static {
log.info("Will only cache the following methods: "+Arrays.toString(CACHEABLE_METHODS));
}
//+ JMX attributes, do not change values
public static final String CLEAR = "clearEachIteration"; // $NON-NLS-1$
public static final String USE_EXPIRES = "useExpires"; // $NON-NLS-1$
public static final String MAX_SIZE = "maxSize"; // $NON-NLS-1$
//-
private transient InheritableThreadLocal<Map<String, CacheEntry>> threadCache;
private transient boolean useExpires; // Cached value
private static final int DEFAULT_MAX_SIZE = 5000;
private static final long ONE_YEAR_MS = 365*24*60*60*1000L;
/** used to share the cache between 2 cache managers
* @see CacheManager#createCacheManagerProxy()
* @since 3.0 */
private transient Map<String, CacheEntry> localCache;
public CacheManager() {
setProperty(new BooleanProperty(CLEAR, false));
setProperty(new BooleanProperty(USE_EXPIRES, false));
clearCache();
useExpires = false;
}
CacheManager(Map<String, CacheEntry> localCache, boolean useExpires) {
this.localCache = localCache;
this.useExpires = useExpires;
}
/*
* Holder for storing cache details.
* Perhaps add original response later?
*/
// package-protected to allow access by unit-test cases
static class CacheEntry{
private final String lastModified;
private final String etag;
private final Date expires;
public CacheEntry(String lastModified, Date expires, String etag){
this.lastModified = lastModified;
this.etag = etag;
this.expires = expires;
}
public String getLastModified() {
return lastModified;
}
public String getEtag() {
return etag;
}
@Override
public String toString(){
return lastModified+" "+etag;
}
public Date getExpires() {
return expires;
}
}
/**
* Save the Last-Modified, Etag, and Expires headers if the result is cacheable.
* Version for Java implementation.
* @param conn connection
* @param res result
*/
public void saveDetails(URLConnection conn, HTTPSampleResult res){
if (isCacheable(res) && !hasVaryHeader(conn)){
String lastModified = conn.getHeaderField(HTTPConstants.LAST_MODIFIED);
String expires = conn.getHeaderField(HTTPConstants.EXPIRES);
String etag = conn.getHeaderField(HTTPConstants.ETAG);
String url = conn.getURL().toString();
String cacheControl = conn.getHeaderField(HTTPConstants.CACHE_CONTROL);
String date = conn.getHeaderField(HTTPConstants.DATE);
setCache(lastModified, cacheControl, expires, etag, url, date);
}
}
private boolean hasVaryHeader(URLConnection conn) {
return conn.getHeaderField(HTTPConstants.VARY) != null;
}
/**
* Save the Last-Modified, Etag, and Expires headers if the result is
* cacheable. Version for Apache HttpClient implementation.
*
* @param method
* {@link HttpResponse} to extract header information from
* @param res
* result to decide if result is cacheable
*/
public void saveDetails(HttpResponse method, HTTPSampleResult res) {
if (isCacheable(res) && !hasVaryHeader(method)){
String lastModified = getHeader(method ,HTTPConstants.LAST_MODIFIED);
String expires = getHeader(method ,HTTPConstants.EXPIRES);
String etag = getHeader(method ,HTTPConstants.ETAG);
String cacheControl = getHeader(method, HTTPConstants.CACHE_CONTROL);
String date = getHeader(method, HTTPConstants.DATE);
setCache(lastModified, cacheControl, expires, etag, res.getUrlAsString(), date); // TODO correct URL?
}
}
private boolean hasVaryHeader(HttpResponse method) {
return getHeader(method, HTTPConstants.VARY) != null;
}
// helper method to save the cache entry
private void setCache(String lastModified, String cacheControl, String expires, String etag, String url, String date) {
if (log.isDebugEnabled()){
log.debug("setCache("
+ lastModified + ","
+ cacheControl + ","
+ expires + ","
+ etag + ","
+ url + ","
+ date
+ ")");
}
Date expiresDate = null; // i.e. not using Expires
if (useExpires) {// Check that we are processing Expires/CacheControl
final String MAX_AGE = "max-age=";
if(cacheControl != null && cacheControl.contains("no-store")) {
// We must not store an CacheEntry, otherwise a
// conditional request may be made
return;
}
if (expires != null) {
try {
expiresDate = org.apache.http.client.utils.DateUtils.parseDate(expires);
} catch (IllegalArgumentException e) {
if (log.isDebugEnabled()){
log.debug("Unable to parse Expires: '"+expires+"' "+e);
}
expiresDate = CacheManager.EXPIRED_DATE; // invalid dates must be treated as expired
}
}
// if no-cache is present, ensure that expiresDate remains null, which forces revalidation
if(cacheControl != null && !cacheControl.contains("no-cache")) {
// the max-age directive overrides the Expires header,
if(cacheControl.contains(MAX_AGE)) {
long maxAgeInSecs = Long.parseLong(
cacheControl.substring(cacheControl.indexOf(MAX_AGE)+MAX_AGE.length())
.split("[, ]")[0] // Bug 51932 - allow for optional trailing attributes
);
expiresDate=new Date(System.currentTimeMillis()+maxAgeInSecs*1000);
} else if(expires==null) { // No max-age && No expires
if(!StringUtils.isEmpty(lastModified) && !StringUtils.isEmpty(date)) {
try {
Date responseDate = DateUtils.parseDate( date );
Date lastModifiedAsDate = DateUtils.parseDate( lastModified );
// see https://developer.mozilla.org/en/HTTP_Caching_FAQ
// see http://www.ietf.org/rfc/rfc2616.txt#13.2.4
expiresDate=new Date(System.currentTimeMillis()
+Math.round((responseDate.getTime()-lastModifiedAsDate.getTime())*0.1));
} catch(IllegalArgumentException e) {
// date or lastModified may be null or in bad format
if(log.isWarnEnabled()) {
log.warn("Failed computing expiration date with following info:"
+lastModified + ","
+ cacheControl + ","
+ expires + ","
+ etag + ","
+ url + ","
+ date);
}
// TODO Can't see anything in SPEC
expiresDate = new Date(System.currentTimeMillis()+ONE_YEAR_MS);
}
} else {
// TODO Can't see anything in SPEC
expiresDate = new Date(System.currentTimeMillis()+ONE_YEAR_MS);
}
}
// else expiresDate computed in (expires!=null) condition is used
}
}
getCache().put(url, new CacheEntry(lastModified, expiresDate, etag));
}
// Apache HttpClient
private String getHeader(HttpResponse method, String name) {
org.apache.http.Header hdr = method.getLastHeader(name);
return hdr != null ? hdr.getValue() : null;
}
/*
* Is the sample result OK to cache?
* i.e is it in the 2xx range or equal to 304, and is it a cacheable method?
*/
private boolean isCacheable(HTTPSampleResult res){
final String responseCode = res.getResponseCode();
return isCacheableMethod(res)
&& (("200".compareTo(responseCode) <= 0 // $NON-NLS-1$
&& "299".compareTo(responseCode) >= 0) // $NON-NLS-1$
|| "304".equals(responseCode)); // $NON-NLS-1$
}
private boolean isCacheableMethod(HTTPSampleResult res) {
final String resMethod = res.getHTTPMethod();
for(String method : CACHEABLE_METHODS) {
if (method.equalsIgnoreCase(resMethod)) {
return true;
}
}
return false;
}
/**
* Check the cache, and if there is a match, set the headers:
* <ul>
* <li>If-Modified-Since</li>
* <li>If-None-Match</li>
* </ul>
* Apache HttpClient version.
* @param url {@link URL} to look up in cache
* @param request where to set the headers
*/
public void setHeaders(URL url, HttpRequestBase request) {
CacheEntry entry = getCache().get(url.toString());
if (log.isDebugEnabled()){
log.debug(request.getMethod()+"(OAH) "+url.toString()+" "+entry);
}
if (entry != null){
final String lastModified = entry.getLastModified();
if (lastModified != null){
request.setHeader(HTTPConstants.IF_MODIFIED_SINCE, lastModified);
}
final String etag = entry.getEtag();
if (etag != null){
request.setHeader(HTTPConstants.IF_NONE_MATCH, etag);
}
}
}
/**
* Check the cache, and if there is a match, set the headers:
* <ul>
* <li>If-Modified-Since</li>
* <li>If-None-Match</li>
* </ul>
* @param url {@link URL} to look up in cache
* @param conn where to set the headers
*/
public void setHeaders(HttpURLConnection conn, URL url) {
CacheEntry entry = getCache().get(url.toString());
if (log.isDebugEnabled()){
log.debug(conn.getRequestMethod()+"(Java) "+url.toString()+" "+entry);
}
if (entry != null){
final String lastModified = entry.getLastModified();
if (lastModified != null){
conn.addRequestProperty(HTTPConstants.IF_MODIFIED_SINCE, lastModified);
}
final String etag = entry.getEtag();
if (etag != null){
conn.addRequestProperty(HTTPConstants.IF_NONE_MATCH, etag);
}
}
}
/**
* Check the cache, if the entry has an expires header and the entry has not expired, return true<br>
* @param url {@link URL} to look up in cache
* @return <code>true</code> if entry has an expires header and the entry has not expired, else <code>false</code>
*/
public boolean inCache(URL url) {
CacheEntry entry = getCache().get(url.toString());
if (log.isDebugEnabled()){
log.debug("inCache "+url.toString()+" "+entry);
}
if (entry != null){
final Date expiresDate = entry.getExpires();
if (expiresDate != null) {
if (expiresDate.after(new Date())) {
if (log.isDebugEnabled()){
log.debug("Expires= " + expiresDate + " (Valid)");
}
return true;
} else {
if (log.isDebugEnabled()){
log.debug("Expires= " + expiresDate + " (Expired)");
}
}
}
}
return false;
}
private Map<String, CacheEntry> getCache() {
return localCache != null?localCache:threadCache.get();
}
public boolean getClearEachIteration() {
return getPropertyAsBoolean(CLEAR);
}
public void setClearEachIteration(boolean clear) {
setProperty(new BooleanProperty(CLEAR, clear));
}
public boolean getUseExpires() {
return getPropertyAsBoolean(USE_EXPIRES);
}
public void setUseExpires(boolean expires) {
setProperty(new BooleanProperty(USE_EXPIRES, expires));
}
/**
* @return int cache max size
*/
public int getMaxSize() {
return getPropertyAsInt(MAX_SIZE, DEFAULT_MAX_SIZE);
}
/**
* @param size int cache max size
*/
public void setMaxSize(int size) {
setProperty(MAX_SIZE, size, DEFAULT_MAX_SIZE);
}
@Override
public void clear(){
super.clear();
clearCache();
}
private void clearCache() {
log.debug("Clear cache");
threadCache = new InheritableThreadLocal<Map<String, CacheEntry>>(){
@Override
protected Map<String, CacheEntry> initialValue(){
// Bug 51942 - this map may be used from multiple threads
@SuppressWarnings("unchecked") // LRUMap is not generic currently
Map<String, CacheEntry> map = new LRUMap(getMaxSize());
return Collections.synchronizedMap(map);
}
};
}
/**
* create a cache manager that share the underlying cache of the current one
* it allows to use the same cache in different threads which does not inherit from each other
* @return a cache manager that share the underlying cache of the current one
* @since 3.0
*/
public CacheManager createCacheManagerProxy() {
return new CacheManager(getCache(), this.useExpires);
}
@Override
public void testStarted() {
}
@Override
public void testEnded() {
}
@Override
public void testStarted(String host) {
}
@Override
public void testEnded(String host) {
}
@Override
public void testIterationStart(LoopIterationEvent event) {
if (getClearEachIteration()) {
clearCache();
}
useExpires = getUseExpires(); // cache the value
}
}