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:
Mikko Tiihonen 2016-04-15 13:04:01 +03:00 committed by Greg Wilkins
parent 9212a62d74
commit aa8597c19e
5 changed files with 231 additions and 13 deletions

View File

@ -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);
}
}

View File

@ -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"));
}
}

View File

@ -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())

View File

@ -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));

View File

@ -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
{