Sort equal quality accept-encoding values based on server configured preference (#507)
* Sort equal quality accept-encoding values based on server configured preference. Add fixed size cache to reduce overhead of complex header parsing. #507 Signed-off-by: Mikko Tiihonen <mikko.tiihonen@nitorcreations.com> * Only look at the first Accept-Encoding header value in the request. Jetty has never supported handling of multiple headers before and the worst thing that can happen is that the static content is sent uncompressed * Rename tieBreakerFunction to secondaryOrderingFunction * Make accept-encoding header cache size configurable * Add back multiple accept-encoding header handling (with optimizations). Merge QuotedEncodingQualityCSV back to QuotedQualityCSV. Fix documentation on how to use precompressed servlet init parameter
This commit is contained in:
parent
9212a62d74
commit
aa8597c19e
|
@ -18,9 +18,12 @@
|
|||
|
||||
package org.eclipse.jetty.http;
|
||||
|
||||
import static java.lang.Integer.MIN_VALUE;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
/* ------------------------------------------------------------ */
|
||||
/**
|
||||
|
@ -41,15 +44,44 @@ public class QuotedQualityCSV implements Iterable<String>
|
|||
private final List<String> _values = new ArrayList<>();
|
||||
private final List<Double> _quality = new ArrayList<>();
|
||||
private boolean _sorted = false;
|
||||
private final Function<String, Integer> secondaryOrderingFunction;
|
||||
|
||||
/* ------------------------------------------------------------ */
|
||||
public QuotedQualityCSV(String... values)
|
||||
|
||||
/**
|
||||
* Sorts values with equal quality according to the length of the value String.
|
||||
*/
|
||||
public QuotedQualityCSV()
|
||||
{
|
||||
for (String v:values)
|
||||
addValue(v);
|
||||
this((s) -> s.length());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sorts values with equal quality according to given order.
|
||||
*/
|
||||
public QuotedQualityCSV(String[] serverPreferredValueOrder)
|
||||
{
|
||||
this((s) -> {
|
||||
for (int i=0;i<serverPreferredValueOrder.length;++i)
|
||||
if (serverPreferredValueOrder[i].equals(s))
|
||||
return serverPreferredValueOrder.length-i;
|
||||
|
||||
if ("*".equals(s))
|
||||
return serverPreferredValueOrder.length;
|
||||
|
||||
return MIN_VALUE;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders values with equal quality with the given function.
|
||||
*/
|
||||
public QuotedQualityCSV(Function<String, Integer> secondaryOrderingFunction)
|
||||
{
|
||||
this.secondaryOrderingFunction = secondaryOrderingFunction;
|
||||
}
|
||||
|
||||
|
||||
/* ------------------------------------------------------------ */
|
||||
public void addValue(String value)
|
||||
{
|
||||
|
@ -224,7 +256,7 @@ public class QuotedQualityCSV implements Iterable<String>
|
|||
_sorted=true;
|
||||
|
||||
Double last = ZERO;
|
||||
int len = Integer.MIN_VALUE;
|
||||
int lastOrderIndex = Integer.MIN_VALUE;
|
||||
|
||||
for (int i = _values.size(); i-- > 0;)
|
||||
{
|
||||
|
@ -232,20 +264,20 @@ public class QuotedQualityCSV implements Iterable<String>
|
|||
Double q = _quality.get(i);
|
||||
|
||||
int compare=last.compareTo(q);
|
||||
if (compare > 0 || (compare==0 && v.length()<len))
|
||||
if (compare>0 || (compare==0 && secondaryOrderingFunction.apply(v)<lastOrderIndex))
|
||||
{
|
||||
_values.set(i, _values.get(i + 1));
|
||||
_values.set(i + 1, v);
|
||||
_quality.set(i, _quality.get(i + 1));
|
||||
_quality.set(i + 1, q);
|
||||
last = ZERO;
|
||||
len=0;
|
||||
lastOrderIndex=0;
|
||||
i = _values.size();
|
||||
continue;
|
||||
}
|
||||
|
||||
last=q;
|
||||
len=v.length();
|
||||
lastOrderIndex=secondaryOrderingFunction.apply(v);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,9 @@
|
|||
|
||||
package org.eclipse.jetty.http;
|
||||
|
||||
import static org.hamcrest.Matchers.contains;
|
||||
import static org.junit.Assert.assertThat;
|
||||
|
||||
import org.hamcrest.Matchers;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
@ -143,4 +146,74 @@ public class QuotedQualityCSVTest
|
|||
"value1.0",
|
||||
"value0.5;p=v"));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ */
|
||||
|
||||
private static final String[] preferBrotli = {"br","gzip"};
|
||||
private static final String[] preferGzip = {"gzip","br"};
|
||||
private static final String[] noFormats = {};
|
||||
|
||||
@Test
|
||||
public void testFirefoxContentEncodingWithBrotliPreference()
|
||||
{
|
||||
QuotedQualityCSV values = new QuotedQualityCSV(preferBrotli);
|
||||
values.addValue("gzip, deflate, br");
|
||||
assertThat(values, contains("br", "gzip", "deflate"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFirefoxContentEncodingWithGzipPreference()
|
||||
{
|
||||
QuotedQualityCSV values = new QuotedQualityCSV(preferGzip);
|
||||
values.addValue("gzip, deflate, br");
|
||||
assertThat(values, contains("gzip", "br", "deflate"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFirefoxContentEncodingWithNoPreference()
|
||||
{
|
||||
QuotedQualityCSV values = new QuotedQualityCSV(noFormats);
|
||||
values.addValue("gzip, deflate, br");
|
||||
assertThat(values, contains("gzip", "deflate", "br"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testChromeContentEncodingWithBrotliPreference()
|
||||
{
|
||||
QuotedQualityCSV values = new QuotedQualityCSV(preferBrotli);
|
||||
values.addValue("gzip, deflate, sdch, br");
|
||||
assertThat(values, contains("br", "gzip", "deflate", "sdch"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testComplexEncodingWithGzipPreference()
|
||||
{
|
||||
QuotedQualityCSV values = new QuotedQualityCSV(preferGzip);
|
||||
values.addValue("gzip;q=0.9, identity;q=0.1, *;q=0.01, deflate;q=0.9, sdch;q=0.7, br;q=0.9");
|
||||
assertThat(values, contains("gzip", "br", "deflate", "sdch", "identity", "*"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testComplexEncodingWithBrotliPreference()
|
||||
{
|
||||
QuotedQualityCSV values = new QuotedQualityCSV(preferBrotli);
|
||||
values.addValue("gzip;q=0.9, identity;q=0.1, *;q=0, deflate;q=0.9, sdch;q=0.7, br;q=0.99");
|
||||
assertThat(values, contains("br", "gzip", "deflate", "sdch", "identity"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStarEncodingWithGzipPreference()
|
||||
{
|
||||
QuotedQualityCSV values = new QuotedQualityCSV(preferGzip);
|
||||
values.addValue("br, *");
|
||||
assertThat(values, contains("*", "br"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStarEncodingWithBrotliPreference()
|
||||
{
|
||||
QuotedQualityCSV values = new QuotedQualityCSV(preferBrotli);
|
||||
values.addValue("gzip, *");
|
||||
assertThat(values, contains("*", "gzip"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,9 +18,10 @@
|
|||
|
||||
package org.eclipse.jetty.server;
|
||||
|
||||
import static java.util.Arrays.stream;
|
||||
import static java.util.Collections.emptyList;
|
||||
import static org.eclipse.jetty.http.CompressedContentFormat.BR;
|
||||
import static org.eclipse.jetty.http.CompressedContentFormat.GZIP;
|
||||
import static org.eclipse.jetty.http.HttpFields.qualityList;
|
||||
import static org.eclipse.jetty.http.HttpHeaderValue.IDENTITY;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
|
@ -32,6 +33,7 @@ import java.util.Collection;
|
|||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import javax.servlet.AsyncContext;
|
||||
import javax.servlet.RequestDispatcher;
|
||||
|
@ -48,6 +50,7 @@ import org.eclipse.jetty.http.HttpHeader;
|
|||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.eclipse.jetty.http.PreEncodedHttpField;
|
||||
import org.eclipse.jetty.http.QuotedCSV;
|
||||
import org.eclipse.jetty.http.QuotedQualityCSV;
|
||||
import org.eclipse.jetty.io.WriterOutputStream;
|
||||
import org.eclipse.jetty.util.BufferUtil;
|
||||
import org.eclipse.jetty.util.Callback;
|
||||
|
@ -73,6 +76,9 @@ public abstract class ResourceService
|
|||
private boolean _dirAllowed=true;
|
||||
private boolean _redirectWelcome=false;
|
||||
private CompressedContentFormat[] _precompressedFormats=new CompressedContentFormat[0];
|
||||
private String[] _preferredEncodingOrder =new String[0];
|
||||
private final Map<String, List<String>> _preferredEncodingOrderCache = new ConcurrentHashMap<>();
|
||||
private int _encodingCacheSize=100;
|
||||
private boolean _pathInfoOnly=false;
|
||||
private boolean _etags=false;
|
||||
private HttpField _cacheControl;
|
||||
|
@ -126,6 +132,17 @@ public abstract class ResourceService
|
|||
public void setPrecompressedFormats(CompressedContentFormat[] precompressedFormats)
|
||||
{
|
||||
_precompressedFormats = precompressedFormats;
|
||||
_preferredEncodingOrder = stream(_precompressedFormats).map(f->f._encoding).toArray(String[]::new);
|
||||
}
|
||||
|
||||
public void setEncodingCacheSize(int encodingCacheSize)
|
||||
{
|
||||
_encodingCacheSize = encodingCacheSize;
|
||||
}
|
||||
|
||||
public int getEncodingCacheSize()
|
||||
{
|
||||
return _encodingCacheSize;
|
||||
}
|
||||
|
||||
public boolean isPathInfoOnly()
|
||||
|
@ -249,7 +266,7 @@ public abstract class ResourceService
|
|||
// Tell caches that response may vary by accept-encoding
|
||||
response.addHeader(HttpHeader.VARY.asString(),HttpHeader.ACCEPT_ENCODING.asString());
|
||||
|
||||
List<String> preferredEncodings = HttpFields.qualityList(request.getHeaders(HttpHeader.ACCEPT_ENCODING.asString()));
|
||||
List<String> preferredEncodings = getPreferredEncodingOrder(request);
|
||||
CompressedContentFormat precompressedContentEncoding = getBestPrecompressedContent(preferredEncodings, precompressedContents.keySet());
|
||||
if (precompressedContentEncoding!=null)
|
||||
{
|
||||
|
@ -285,6 +302,40 @@ public abstract class ResourceService
|
|||
}
|
||||
}
|
||||
|
||||
private List<String> getPreferredEncodingOrder(HttpServletRequest request)
|
||||
{
|
||||
Enumeration<String> headers = request.getHeaders(HttpHeader.ACCEPT_ENCODING.asString());
|
||||
if (!headers.hasMoreElements())
|
||||
return emptyList();
|
||||
|
||||
String key = headers.nextElement();
|
||||
if (headers.hasMoreElements())
|
||||
{
|
||||
StringBuilder sb = new StringBuilder(key.length()*2);
|
||||
do
|
||||
{
|
||||
sb.append(',').append(headers.nextElement());
|
||||
} while (headers.hasMoreElements());
|
||||
key = sb.toString();
|
||||
}
|
||||
|
||||
List<String> values=_preferredEncodingOrderCache.get(key);
|
||||
if (values==null)
|
||||
{
|
||||
QuotedQualityCSV encodingQualityCSV = new QuotedQualityCSV(_preferredEncodingOrder);
|
||||
encodingQualityCSV.addValue(key);
|
||||
values=encodingQualityCSV.getValues();
|
||||
|
||||
// keep cache size in check even if we get strange/malicious input
|
||||
if (_preferredEncodingOrderCache.size()>_encodingCacheSize)
|
||||
_preferredEncodingOrderCache.clear();
|
||||
|
||||
_preferredEncodingOrderCache.put(key,values);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private CompressedContentFormat getBestPrecompressedContent(List<String> preferredEncodings, Collection<CompressedContentFormat> availableFormats)
|
||||
{
|
||||
if (availableFormats.isEmpty())
|
||||
|
|
|
@ -81,9 +81,10 @@ import org.eclipse.jetty.util.resource.ResourceFactory;
|
|||
* found ending with ".gz" (default false)
|
||||
* (deprecated: use precompressed)
|
||||
*
|
||||
* precompressed If set to a comma separated list of file extensions, these
|
||||
* indicate compressed formats that are known to map to a mime-type
|
||||
* that may be listed in a requests Accept-Encoding header.
|
||||
* precompressed If set to a comma separated list of encoding types (that may be
|
||||
* listed in a requests Accept-Encoding header) to file
|
||||
* extension mappings to look for and serve. For example:
|
||||
* "br=.br,gzip=.gz,bzip2=.bz".
|
||||
* If set to a boolean True, then a default set of compressed formats
|
||||
* will be used, otherwise no precompressed formats.
|
||||
*
|
||||
|
@ -256,6 +257,10 @@ public class DefaultServlet extends HttpServlet implements ResourceFactory
|
|||
LOG.debug(e);
|
||||
}
|
||||
|
||||
int encodingHeaderCacheSize = getInitInt("encodingHeaderCacheSize", -1);
|
||||
if (encodingHeaderCacheSize >= 0)
|
||||
_resourceService.setEncodingCacheSize(encodingHeaderCacheSize);
|
||||
|
||||
String cc=getInitParameter("cacheControl");
|
||||
if (cc!=null)
|
||||
_resourceService.setCacheControl(new PreEncodedHttpField(HttpHeader.CACHE_CONTROL,cc));
|
||||
|
|
|
@ -1043,6 +1043,63 @@ public class DefaultServletTest
|
|||
assertResponseContains("ETag: "+etag,response);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDefaultBrotliOverGzip() throws Exception
|
||||
{
|
||||
testdir.ensureEmpty();
|
||||
File resBase = testdir.getPathFile("docroot").toFile();
|
||||
FS.ensureDirExists(resBase);
|
||||
File file0 = new File(resBase, "data0.txt");
|
||||
createFile(file0, "Hello Text 0");
|
||||
File file0br = new File(resBase, "data0.txt.br");
|
||||
createFile(file0br, "fake brotli");
|
||||
File file0gz = new File(resBase, "data0.txt.gz");
|
||||
createFile(file0gz, "fake gzip");
|
||||
|
||||
ServletHolder defholder = context.addServlet(DefaultServlet.class, "/");
|
||||
defholder.setInitParameter("precompressed", "true");
|
||||
defholder.setInitParameter("resourceBase", resBase.getAbsolutePath());
|
||||
|
||||
String response = connector.getResponses("GET /context/data0.txt HTTP/1.0\r\nHost:localhost:8080\r\nAccept-Encoding:gzip, compress, br\r\n\r\n");
|
||||
assertResponseContains("Content-Length: 11", response);
|
||||
assertResponseContains("fake br",response);
|
||||
assertResponseContains("Content-Type: text/plain",response);
|
||||
assertResponseContains("Vary: Accept-Encoding",response);
|
||||
assertResponseContains("Content-Encoding: br",response);
|
||||
|
||||
response = connector.getResponses("GET /context/data0.txt HTTP/1.0\r\nHost:localhost:8080\r\nAccept-Encoding:gzip, compress, br;q=0.9\r\n\r\n");
|
||||
assertResponseContains("Content-Length: 9", response);
|
||||
assertResponseContains("fake gzip",response);
|
||||
assertResponseContains("Content-Type: text/plain",response);
|
||||
assertResponseContains("Vary: Accept-Encoding",response);
|
||||
assertResponseContains("Content-Encoding: gzip",response);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCustomCompressionFormats() throws Exception
|
||||
{
|
||||
testdir.ensureEmpty();
|
||||
File resBase = testdir.getPathFile("docroot").toFile();
|
||||
FS.ensureDirExists(resBase);
|
||||
File file0 = new File(resBase, "data0.txt");
|
||||
createFile(file0, "Hello Text 0");
|
||||
File file0br = new File(resBase, "data0.txt.br");
|
||||
createFile(file0br, "fake brotli");
|
||||
File file0gz = new File(resBase, "data0.txt.gz");
|
||||
createFile(file0gz, "fake gzip");
|
||||
|
||||
ServletHolder defholder = context.addServlet(DefaultServlet.class, "/");
|
||||
defholder.setInitParameter("precompressed", "bzip2=.bz2,gzip=.gz,br=.br");
|
||||
defholder.setInitParameter("resourceBase", resBase.getAbsolutePath());
|
||||
|
||||
String response = connector.getResponses("GET /context/data0.txt HTTP/1.0\r\nHost:localhost:8080\r\nAccept-Encoding:bzip2, br, gzip\r\n\r\n");
|
||||
assertResponseContains("Content-Length: 9", response);
|
||||
assertResponseContains("fake gzip",response);
|
||||
assertResponseContains("Content-Type: text/plain",response);
|
||||
assertResponseContains("Vary: Accept-Encoding",response);
|
||||
assertResponseContains("Content-Encoding: gzip",response);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testIfModifiedSmall() throws Exception
|
||||
{
|
||||
|
|
Loading…
Reference in New Issue