// // ======================================================================== // Copyright (c) 1995-2017 Mort Bay Consulting Pty. Ltd. // ------------------------------------------------------------------------ // All rights reserved. This program and the accompanying materials // are made available under the terms of the Eclipse Public License v1.0 // and Apache License v2.0 which accompanies this distribution. // // The Eclipse Public License is available at // http://www.eclipse.org/legal/epl-v10.html // // The Apache License v2.0 is available at // http://www.opensource.org/licenses/apache2.0.php // // You may elect to redistribute this code under either of these licenses. // ======================================================================== // package org.eclipse.jetty.server; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.channels.ReadableByteChannel; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.Map; import java.util.SortedSet; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.eclipse.jetty.http.CompressedContentFormat; import org.eclipse.jetty.http.DateGenerator; import org.eclipse.jetty.http.HttpContent; import org.eclipse.jetty.http.HttpField; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.MimeTypes; import org.eclipse.jetty.http.MimeTypes.Type; import org.eclipse.jetty.http.PreEncodedHttpField; import org.eclipse.jetty.http.PrecompressedHttpContent; import org.eclipse.jetty.http.ResourceHttpContent; import org.eclipse.jetty.util.BufferUtil; import org.eclipse.jetty.util.log.Log; import org.eclipse.jetty.util.log.Logger; import org.eclipse.jetty.util.resource.Resource; import org.eclipse.jetty.util.resource.ResourceFactory; /** * Caching HttpContent.Factory */ public class CachedContentFactory implements HttpContent.ContentFactory { private static final Logger LOG = Log.getLogger(CachedContentFactory.class); private final static Map<CompressedContentFormat, CachedPrecompressedHttpContent> NO_PRECOMPRESSED = Collections.unmodifiableMap(Collections.emptyMap()); private final ConcurrentMap<String,CachedHttpContent> _cache; private final AtomicInteger _cachedSize; private final AtomicInteger _cachedFiles; private final ResourceFactory _factory; private final CachedContentFactory _parent; private final MimeTypes _mimeTypes; private final boolean _etags; private final CompressedContentFormat[] _precompressedFormats; private final boolean _useFileMappedBuffer; private int _maxCachedFileSize = 128*1024*1024; private int _maxCachedFiles= 2048; private int _maxCacheSize = 256*1024*1024; /* ------------------------------------------------------------ */ /** Constructor. * @param parent the parent resource cache * @param factory the resource factory * @param mimeTypes Mimetype to use for meta data * @param useFileMappedBuffer true to file memory mapped buffers * @param etags true to support etags * @param precompressedFormats array of precompression formats to support */ public CachedContentFactory(CachedContentFactory parent, ResourceFactory factory, MimeTypes mimeTypes,boolean useFileMappedBuffer,boolean etags,CompressedContentFormat[] precompressedFormats) { _factory = factory; _cache=new ConcurrentHashMap<String,CachedHttpContent>(); _cachedSize=new AtomicInteger(); _cachedFiles=new AtomicInteger(); _mimeTypes=mimeTypes; _parent=parent; _useFileMappedBuffer=useFileMappedBuffer; _etags=etags; _precompressedFormats=precompressedFormats; } /* ------------------------------------------------------------ */ public int getCachedSize() { return _cachedSize.get(); } /* ------------------------------------------------------------ */ public int getCachedFiles() { return _cachedFiles.get(); } /* ------------------------------------------------------------ */ public int getMaxCachedFileSize() { return _maxCachedFileSize; } /* ------------------------------------------------------------ */ public void setMaxCachedFileSize(int maxCachedFileSize) { _maxCachedFileSize = maxCachedFileSize; shrinkCache(); } /* ------------------------------------------------------------ */ public int getMaxCacheSize() { return _maxCacheSize; } /* ------------------------------------------------------------ */ public void setMaxCacheSize(int maxCacheSize) { _maxCacheSize = maxCacheSize; shrinkCache(); } /* ------------------------------------------------------------ */ /** * @return Returns the maxCachedFiles. */ public int getMaxCachedFiles() { return _maxCachedFiles; } /* ------------------------------------------------------------ */ /** * @param maxCachedFiles The maxCachedFiles to set. */ public void setMaxCachedFiles(int maxCachedFiles) { _maxCachedFiles = maxCachedFiles; shrinkCache(); } /* ------------------------------------------------------------ */ public boolean isUseFileMappedBuffer() { return _useFileMappedBuffer; } /* ------------------------------------------------------------ */ public void flushCache() { if (_cache!=null) { while (_cache.size()>0) { for (String path : _cache.keySet()) { CachedHttpContent content = _cache.remove(path); if (content!=null) content.invalidate(); } } } } /* ------------------------------------------------------------ */ @Deprecated public HttpContent lookup(String pathInContext) throws IOException { return getContent(pathInContext,_maxCachedFileSize); } /* ------------------------------------------------------------ */ /** Get a Entry from the cache. * Get either a valid entry object or create a new one if possible. * * @param pathInContext The key into the cache * @param maxBufferSize The maximum buffer to allocated for this request. For cached content, a larger buffer may have * previously been allocated and returned by the {@link HttpContent#getDirectBuffer()} or {@link HttpContent#getIndirectBuffer()} calls. * @return The entry matching <code>pathInContext</code>, or a new entry * if no matching entry was found. If the content exists but is not cachable, * then a {@link ResourceHttpContent} instance is return. If * the resource does not exist, then null is returned. * @throws IOException Problem loading the resource */ @Override public HttpContent getContent(String pathInContext,int maxBufferSize) throws IOException { // Is the content in this cache? CachedHttpContent content =_cache.get(pathInContext); if (content!=null && (content).isValid()) return content; // try loading the content from our factory. Resource resource=_factory.getResource(pathInContext); HttpContent loaded = load(pathInContext,resource,maxBufferSize); if (loaded!=null) return loaded; // Is the content in the parent cache? if (_parent!=null) { HttpContent httpContent=_parent.getContent(pathInContext,maxBufferSize); if (httpContent!=null) return httpContent; } return null; } /* ------------------------------------------------------------ */ /** * @param resource the resource to test * @return True if the resource is cacheable. The default implementation tests the cache sizes. */ protected boolean isCacheable(Resource resource) { if (_maxCachedFiles<=0) return false; long len = resource.length(); // Will it fit in the cache? return (len>0 && (_useFileMappedBuffer || (len<_maxCachedFileSize && len<_maxCacheSize))); } /* ------------------------------------------------------------ */ private HttpContent load(String pathInContext, Resource resource, int maxBufferSize) throws IOException { if (resource == null || !resource.exists()) return null; if (resource.isDirectory()) return new ResourceHttpContent(resource,_mimeTypes.getMimeByExtension(resource.toString()),getMaxCachedFileSize()); // Will it fit in the cache? if (isCacheable(resource)) { CachedHttpContent content = null; // Look for precompressed resources if (_precompressedFormats.length > 0) { Map<CompressedContentFormat, CachedHttpContent> precompresssedContents = new HashMap<>(_precompressedFormats.length); for (CompressedContentFormat format : _precompressedFormats) { String compressedPathInContext = pathInContext + format._extension; CachedHttpContent compressedContent = _cache.get(compressedPathInContext); if (compressedContent == null || compressedContent.isValid()) { compressedContent = null; Resource compressedResource = _factory.getResource(compressedPathInContext); if (compressedResource.exists() && compressedResource.lastModified() >= resource.lastModified() && compressedResource.length() < resource.length()) { compressedContent = new CachedHttpContent(compressedPathInContext,compressedResource,null); CachedHttpContent added = _cache.putIfAbsent(compressedPathInContext,compressedContent); if (added != null) { compressedContent.invalidate(); compressedContent = added; } } } if (compressedContent != null) precompresssedContents.put(format,compressedContent); } content = new CachedHttpContent(pathInContext,resource,precompresssedContents); } else content = new CachedHttpContent(pathInContext,resource,null); // Add it to the cache. CachedHttpContent added = _cache.putIfAbsent(pathInContext,content); if (added != null) { content.invalidate(); content = added; } return content; } // Look for non Cacheable precompressed resource or content String mt = _mimeTypes.getMimeByExtension(pathInContext); if (_precompressedFormats.length > 0) { // Is the precompressed content cached? Map<CompressedContentFormat, HttpContent> compressedContents = new HashMap<>(); for (CompressedContentFormat format : _precompressedFormats) { String compressedPathInContext = pathInContext + format._extension; CachedHttpContent compressedContent = _cache.get(compressedPathInContext); if (compressedContent != null && compressedContent.isValid() && compressedContent.getResource().lastModified() >= resource.lastModified()) compressedContents.put(format,compressedContent); // Is there a precompressed resource? Resource compressedResource = _factory.getResource(compressedPathInContext); if (compressedResource.exists() && compressedResource.lastModified() >= resource.lastModified() && compressedResource.length() < resource.length()) compressedContents.put(format, new ResourceHttpContent(compressedResource,_mimeTypes.getMimeByExtension(compressedPathInContext),maxBufferSize)); } if (!compressedContents.isEmpty()) return new ResourceHttpContent(resource,mt,maxBufferSize,compressedContents); } return new ResourceHttpContent(resource,mt,maxBufferSize); } /* ------------------------------------------------------------ */ private void shrinkCache() { // While we need to shrink while (_cache.size()>0 && (_cachedFiles.get()>_maxCachedFiles || _cachedSize.get()>_maxCacheSize)) { // Scan the entire cache and generate an ordered list by last accessed time. SortedSet<CachedHttpContent> sorted= new TreeSet<CachedHttpContent>( new Comparator<CachedHttpContent>() { public int compare(CachedHttpContent c1, CachedHttpContent c2) { if (c1._lastAccessed<c2._lastAccessed) return -1; if (c1._lastAccessed>c2._lastAccessed) return 1; if (c1._contentLengthValue<c2._contentLengthValue) return -1; return c1._key.compareTo(c2._key); } }); for (CachedHttpContent content : _cache.values()) sorted.add(content); // Invalidate least recently used first for (CachedHttpContent content : sorted) { if (_cachedFiles.get()<=_maxCachedFiles && _cachedSize.get()<=_maxCacheSize) break; if (content==_cache.remove(content.getKey())) content.invalidate(); } } } /* ------------------------------------------------------------ */ protected ByteBuffer getIndirectBuffer(Resource resource) { try { return BufferUtil.toBuffer(resource,true); } catch(IOException|IllegalArgumentException e) { LOG.warn(e); return null; } } /* ------------------------------------------------------------ */ protected ByteBuffer getMappedBuffer(Resource resource) { // Only use file mapped buffers for cached resources, otherwise too much virtual memory commitment for // a non shared resource. Also ignore max buffer size try { if (_useFileMappedBuffer && resource.getFile()!=null && resource.length()<Integer.MAX_VALUE) return BufferUtil.toMappedBuffer(resource.getFile()); } catch(IOException|IllegalArgumentException e) { LOG.warn(e); } return null; } /* ------------------------------------------------------------ */ protected ByteBuffer getDirectBuffer(Resource resource) { try { return BufferUtil.toBuffer(resource,true); } catch(IOException|IllegalArgumentException e) { LOG.warn(e); } return null; } /* ------------------------------------------------------------ */ @Override public String toString() { return "ResourceCache["+_parent+","+_factory+"]@"+hashCode(); } /* ------------------------------------------------------------ */ /* ------------------------------------------------------------ */ /** MetaData associated with a context Resource. */ public class CachedHttpContent implements HttpContent { final String _key; final Resource _resource; final int _contentLengthValue; final HttpField _contentType; final String _characterEncoding; final MimeTypes.Type _mimeType; final HttpField _contentLength; final HttpField _lastModified; final long _lastModifiedValue; final HttpField _etag; final Map<CompressedContentFormat, CachedPrecompressedHttpContent> _precompressed; volatile long _lastAccessed; AtomicReference<ByteBuffer> _indirectBuffer=new AtomicReference<ByteBuffer>(); AtomicReference<ByteBuffer> _directBuffer=new AtomicReference<ByteBuffer>(); /* ------------------------------------------------------------ */ CachedHttpContent(String pathInContext,Resource resource,Map<CompressedContentFormat, CachedHttpContent> precompressedResources) { _key=pathInContext; _resource=resource; String contentType = _mimeTypes.getMimeByExtension(_resource.toString()); _contentType=contentType==null?null:new PreEncodedHttpField(HttpHeader.CONTENT_TYPE,contentType); _characterEncoding = _contentType==null?null:MimeTypes.getCharsetFromContentType(contentType); _mimeType = _contentType==null?null:MimeTypes.CACHE.get(MimeTypes.getContentTypeWithoutCharset(contentType)); boolean exists=resource.exists(); _lastModifiedValue=exists?resource.lastModified():-1L; _lastModified=_lastModifiedValue==-1?null :new PreEncodedHttpField(HttpHeader.LAST_MODIFIED,DateGenerator.formatDate(_lastModifiedValue)); _contentLengthValue=exists?(int)resource.length():0; _contentLength=new PreEncodedHttpField(HttpHeader.CONTENT_LENGTH,Long.toString(_contentLengthValue)); if (_cachedFiles.incrementAndGet()>_maxCachedFiles) shrinkCache(); _lastAccessed=System.currentTimeMillis(); _etag=CachedContentFactory.this._etags?new PreEncodedHttpField(HttpHeader.ETAG,resource.getWeakETag()):null; if (precompressedResources != null) { _precompressed = new HashMap<>(precompressedResources.size()); for (Map.Entry<CompressedContentFormat, CachedHttpContent> entry : precompressedResources.entrySet()) { _precompressed.put(entry.getKey(),new CachedPrecompressedHttpContent(this,entry.getValue(),entry.getKey())); } } else { _precompressed = NO_PRECOMPRESSED; } } /* ------------------------------------------------------------ */ public String getKey() { return _key; } /* ------------------------------------------------------------ */ public boolean isCached() { return _key!=null; } /* ------------------------------------------------------------ */ public boolean isMiss() { return false; } /* ------------------------------------------------------------ */ @Override public Resource getResource() { return _resource; } /* ------------------------------------------------------------ */ @Override public HttpField getETag() { return _etag; } /* ------------------------------------------------------------ */ @Override public String getETagValue() { return _etag.getValue(); } /* ------------------------------------------------------------ */ boolean isValid() { if (_lastModifiedValue==_resource.lastModified() && _contentLengthValue==_resource.length()) { _lastAccessed=System.currentTimeMillis(); return true; } if (this==_cache.remove(_key)) invalidate(); return false; } /* ------------------------------------------------------------ */ protected void invalidate() { ByteBuffer indirect=_indirectBuffer.get(); if (indirect!=null && _indirectBuffer.compareAndSet(indirect,null)) _cachedSize.addAndGet(-BufferUtil.length(indirect)); ByteBuffer direct=_directBuffer.get(); if (direct!=null && !BufferUtil.isMappedBuffer(direct) && _directBuffer.compareAndSet(direct,null)) _cachedSize.addAndGet(-BufferUtil.length(direct)); _cachedFiles.decrementAndGet(); _resource.close(); } /* ------------------------------------------------------------ */ @Override public HttpField getLastModified() { return _lastModified; } /* ------------------------------------------------------------ */ @Override public String getLastModifiedValue() { return _lastModified==null?null:_lastModified.getValue(); } /* ------------------------------------------------------------ */ @Override public HttpField getContentType() { return _contentType; } /* ------------------------------------------------------------ */ @Override public String getContentTypeValue() { return _contentType==null?null:_contentType.getValue(); } /* ------------------------------------------------------------ */ @Override public HttpField getContentEncoding() { return null; } /* ------------------------------------------------------------ */ @Override public String getContentEncodingValue() { return null; } /* ------------------------------------------------------------ */ @Override public String getCharacterEncoding() { return _characterEncoding; } /* ------------------------------------------------------------ */ @Override public Type getMimeType() { return _mimeType; } /* ------------------------------------------------------------ */ @Override public void release() { } /* ------------------------------------------------------------ */ @Override public ByteBuffer getIndirectBuffer() { ByteBuffer buffer = _indirectBuffer.get(); if (buffer==null) { ByteBuffer buffer2=CachedContentFactory.this.getIndirectBuffer(_resource); if (buffer2==null) LOG.warn("Could not load "+this); else if (_indirectBuffer.compareAndSet(null,buffer2)) { buffer=buffer2; if (_cachedSize.addAndGet(BufferUtil.length(buffer))>_maxCacheSize) shrinkCache(); } else buffer=_indirectBuffer.get(); } if (buffer==null) return null; return buffer.slice(); } /* ------------------------------------------------------------ */ @Override public ByteBuffer getDirectBuffer() { ByteBuffer buffer = _directBuffer.get(); if (buffer==null) { ByteBuffer mapped = CachedContentFactory.this.getMappedBuffer(_resource); ByteBuffer direct = mapped==null?CachedContentFactory.this.getDirectBuffer(_resource):mapped; if (direct==null) LOG.warn("Could not load "+this); else if (_directBuffer.compareAndSet(null,direct)) { buffer=direct; if (mapped==null && _cachedSize.addAndGet(BufferUtil.length(buffer))>_maxCacheSize) shrinkCache(); } else buffer=_directBuffer.get(); } if (buffer==null) return null; return buffer.asReadOnlyBuffer(); } /* ------------------------------------------------------------ */ @Override public HttpField getContentLength() { return _contentLength; } /* ------------------------------------------------------------ */ @Override public long getContentLengthValue() { return _contentLengthValue; } /* ------------------------------------------------------------ */ @Override public InputStream getInputStream() throws IOException { ByteBuffer indirect = getIndirectBuffer(); if (indirect!=null && indirect.hasArray()) return new ByteArrayInputStream(indirect.array(),indirect.arrayOffset()+indirect.position(),indirect.remaining()); return _resource.getInputStream(); } /* ------------------------------------------------------------ */ @Override public ReadableByteChannel getReadableByteChannel() throws IOException { return _resource.getReadableByteChannel(); } /* ------------------------------------------------------------ */ @Override public String toString() { return String.format("CachedContent@%x{r=%s,e=%b,lm=%s,ct=%s,c=%d}",hashCode(),_resource,_resource.exists(),_lastModified,_contentType,_precompressed.size()); } /* ------------------------------------------------------------ */ @Override public Map<CompressedContentFormat,? extends HttpContent> getPrecompressedContents() { if (_precompressed.size()==0) return null; Map<CompressedContentFormat, CachedPrecompressedHttpContent> ret=_precompressed; for (Map.Entry<CompressedContentFormat, CachedPrecompressedHttpContent> entry:_precompressed.entrySet()) { if (!entry.getValue().isValid()) { if (ret == _precompressed) ret = new HashMap<>(_precompressed); ret.remove(entry.getKey()); } } return ret; } } /* ------------------------------------------------------------ */ /* ------------------------------------------------------------ */ /* ------------------------------------------------------------ */ public class CachedPrecompressedHttpContent extends PrecompressedHttpContent { private final CachedHttpContent _content; private final CachedHttpContent _precompressedContent; private final HttpField _etag; CachedPrecompressedHttpContent(CachedHttpContent content, CachedHttpContent precompressedContent, CompressedContentFormat format) { super(content,precompressedContent,format); _content=content; _precompressedContent=precompressedContent; _etag=(CachedContentFactory.this._etags)?new PreEncodedHttpField(HttpHeader.ETAG,_content.getResource().getWeakETag(format._etag)):null; } public boolean isValid() { return _precompressedContent.isValid() && _content.isValid() && _content.getResource().lastModified() <= _precompressedContent.getResource().lastModified(); } @Override public HttpField getETag() { if (_etag!=null) return _etag; return super.getETag(); } @Override public String getETagValue() { if (_etag!=null) return _etag.getValue(); return super.getETagValue(); } @Override public String toString() { return "Cached"+super.toString(); } } }