CompressFilter.java
package com.ziesemer.utils.web.compressFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.GZIPOutputStream;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* <p>{@link Filter} to compress outgoing content, using either a {@link GZIPOutputStream} or {@link DeflaterOutputStream}.</p>
*
* <p>Follows the standards described in
* <a href="http://www.w3.org/Protocols/rfc2616/rfc2616.html">RFC 2616</a>
* as applicable, particularly sections 3 and 14.</p>
*
* <p>This filter supports Content-Encoding (14.11). While Transfer-Encoding (14.41)
* seems more appropriate, it is not currently widely used or accepted.
* Transfer-Encoding would allow for properly supporting ranges (14.35) that
* refer to the entity content rather than the encoded content.</p>
*
* <p>Is is recommended to include <code><dispatcher>REQUEST</dispatcher></code>
* into this filter's <code><filter-mapping/></code>(s) in <code>web.xml</code> to
* prevent output from being compressed multiple times (e.g. from <code><jsp:include/></code>),
* as defined in the <a href="http://jcp.org/aboutJava/communityprocess/final/jsr154/index.html"
* >Java Servlet 2.4 Specification</a> (see SRV.6.2.5).</p>
*
* <p>Accepted {@link javax.servlet.ServletConfig} parameters:</p>
* <ul>
* <li>Accept-Encoding: Configuration to use when multiple encodings are accepted by the client.
* The format of this configuration is the same as expected from the client, as described in
* <a href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3">RFC 2616 section 14.3</a>.
* Defaults to {@link #DEFAULT_ACCEPT_ENCODINGS}.</li>
* </ul>
*
* @author Mark A. Ziesemer
* <a href="http://www.ziesemer.com."><www.ziesemer.com></a>
*/
public class CompressFilter implements Filter{
public static final String DEFAULT_ACCEPT_ENCODINGS = "gzip;q=0.75,deflate;q=0.5";
public static final String ACCEPT_ENCODING = "Accept-Encoding";
public static final String IDENTITY = "identity";
protected static final Logger LOGGER = LoggerFactory.getLogger(CompressFilter.class);
// http://www.and.org/texts/server-http
protected static final Pattern ENCODINGS_PATTERN = Pattern.compile(
"\\s*(\\w+)(?:\\s*;\\s*q\\s*=\\s*([01](?:\\s*.\\s*\\d+)?)\\s*)?");
protected static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
protected Map<String, Short> configuredEncodings;
protected Comparator<String> matchingComparator;
protected Comparator<Short> reverseShortComparator;
// Cache for performance
protected String filterName;
public void init(final FilterConfig config) throws ServletException{
filterName = config.getFilterName();
String ae = config.getInitParameter(ACCEPT_ENCODING);
if(ae == null){
ae = DEFAULT_ACCEPT_ENCODINGS;
}
configuredEncodings = findAcceptEncodings(ae).names;
LOGGER.info("({}) Configured encodings: {}", filterName, configuredEncodings);
matchingComparator = new MatchedShortComparator(configuredEncodings);
reverseShortComparator = new ReverseShortComparator();
}
public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain)
throws IOException, ServletException{
if(request instanceof HttpServletRequest
&& response instanceof HttpServletResponse){
final HttpServletRequest req = (HttpServletRequest)request;
final HttpServletResponse res = (HttpServletResponse)response;
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.44
res.addHeader("Vary", ACCEPT_ENCODING);
final AcceptEncodingsResults aer = findAcceptEncodings(req);
LOGGER.debug("({}) Received Accept-Encodings: {}", filterName, aer.names);
final String enc = calculateEncoding(aer);
LOGGER.debug("({}) Calculated encoding: {}", filterName, enc);
if(enc == null){
res.sendError(HttpServletResponse.SC_NOT_ACCEPTABLE, ACCEPT_ENCODING);
return;
}
final CompressResponseWrapper crw;
if("gzip".equals(enc)){
crw = new CompressResponseWrapper(res, enc){
@Override
public ServletOutputStream getEncodingOutputStream() throws IOException{
return new CompressOutputStream(new GZIPOutputStream(res.getOutputStream()));
}
};
}else if("deflate".equals(enc)){
crw = new CompressResponseWrapper(res, enc){
@Override
public ServletOutputStream getEncodingOutputStream() throws IOException{
final Deflater def = new Deflater(Deflater.DEFAULT_COMPRESSION, true);
return new CompressOutputStream(new DeflaterOutputStream(res.getOutputStream(), def));
}
};
}else{
crw = null;
}
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.11
if(crw != null){
// It seems that if no content-length is specified, the servlet container will/should
// automatically set a transfer-coding of "chunked" and send the output appropriately,
// or send a content-length if the compressed content size is less than the server's
// response buffer, so not handling it here...
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6
chain.doFilter(req, crw);
crw.close();
return;
}
}
chain.doFilter(request, response);
}
protected boolean isValid(final Short s){
return (s != null) && (s > 0);
}
protected boolean isValidDef(final Short s){
return (s == null) || (s > 0);
}
protected boolean isValidIdentity(final AcceptEncodingsResults aer){
return isValidDef(aer.names.get(IDENTITY))
&& isValidDef(configuredEncodings.get(IDENTITY));
}
protected String calculateEncoding(final AcceptEncodingsResults aer){
for(final Map.Entry<Short, List<String>> e : aer.sortedValues.entrySet()){
final List<String> list = e.getValue();
Collections.sort(list, matchingComparator);
if(!list.isEmpty()){
final String n = list.get(0);
if(isValid(aer.names.get(n)) && isValid(configuredEncodings.get(n))){
return n;
}
if(IDENTITY.equals(n) && isValidIdentity(aer)){
return n;
}
}
}
// Try to fall-back to identity.
if(isValidIdentity(aer)){
return IDENTITY;
}
return null;
}
protected AcceptEncodingsResults findAcceptEncodings(final HttpServletRequest req){
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
final Enumeration<?> e = req.getHeaders(ACCEPT_ENCODING);
final StringBuilder sb = new StringBuilder();
while(e.hasMoreElements()){
sb.append(e.nextElement());
sb.append(',');
}
return findAcceptEncodings(sb.toString());
}
protected AcceptEncodingsResults findAcceptEncodings(final String encodes){
final AcceptEncodingsResults aer = new AcceptEncodingsResults(reverseShortComparator);
final Matcher m = ENCODINGS_PATTERN.matcher("");
for(final String s : encodes.split(",")){
m.reset(s);
if(m.matches()){
final String name = m.group(1);
// RFC2616 doesn't specify a "default" value; using the median.
short qValue = 500;
String qValueStr = m.group(2);
if(qValueStr != null){
qValueStr = WHITESPACE_PATTERN.matcher(qValueStr).replaceAll("");
qValue = (short)(Float.parseFloat(qValueStr) * 1000);
}
aer.names.put(name, qValue);
if(aer.sortedValues != null){
List<String> values = aer.sortedValues.get(qValue);
if(values == null){
values = new LinkedList<String>();
aer.sortedValues.put(qValue, values);
}
values.add(name);
}
}
}
return aer;
}
public void destroy(){
// Nothing to do.
}
}