/* * 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 } }