Completed implementation of the referrer SPDY push strategy.

This commit is contained in:
Simone Bordet 2012-05-05 18:26:42 +02:00
parent 362e011851
commit 14f8091252
4 changed files with 487 additions and 53 deletions

View File

@ -16,12 +16,14 @@
package org.eclipse.jetty.spdy.http;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.regex.Pattern;
import org.eclipse.jetty.spdy.api.Headers;
import org.eclipse.jetty.spdy.api.Stream;
@ -44,77 +46,126 @@ import org.eclipse.jetty.util.log.Logger;
* will have the CSS stylesheet as referrer, so there is some degree of recursion that
* needs to be handled.</p>
*
*
* TODO: this class is kind-of leaking since the resources map is always adding entries
* TODO: although these entries will be limited by the number of application pages.
* TODO: however, there is no ConcurrentLinkedHashMap yet in JDK (there is in Guava though)
* TODO: so we cannot use the built-in LRU features of LinkedHashMap
*
* TODO: Wikipedia maps URLs like http://en.wikipedia.org/wiki/File:PNG-Gradient_hex.png
* TODO: to text/html, so perhaps we need to improve isPushResource() by looking at the
* TODO: response Content-Type header, and not only at the URL extension
*/
public class ReferrerPushStrategy implements PushStrategy
{
private static final Logger logger = Log.getLogger(ReferrerPushStrategy.class);
private final ConcurrentMap<String, Set<String>> resources = new ConcurrentHashMap<>();
private List<String> mainSuffixes = new ArrayList<>();
private List<String> pushSuffixes = new ArrayList<>();
private final Set<Pattern> pushRegexps = new LinkedHashSet<>();
private final Set<Pattern> allowedPushOrigins = new LinkedHashSet<>();
public ReferrerPushStrategy()
{
this(Arrays.asList(".*\\.css", ".*\\.js", ".*\\.png", ".*\\.jpg", ".*\\.gif"));
}
public ReferrerPushStrategy(List<String> pushRegexps)
{
this(pushRegexps, Collections.<String>emptyList());
}
public ReferrerPushStrategy(List<String> pushRegexps, List<String> allowedPushOrigins)
{
for (String pushRegexp : pushRegexps)
this.pushRegexps.add(Pattern.compile(pushRegexp));
for (String allowedPushOrigin : allowedPushOrigins)
this.allowedPushOrigins.add(Pattern.compile(allowedPushOrigin.replace(".", "\\.").replace("*", ".*")));
}
@Override
public Set<String> apply(Stream stream, Headers requestHeaders, Headers responseHeaders)
{
Set<String> result = Collections.emptySet();
String scheme = requestHeaders.get("scheme").value();
String host = requestHeaders.get("host").value();
String origin = new StringBuilder(scheme).append("://").append(host).toString();
String url = requestHeaders.get("url").value();
if (!hasQueryString(url))
String absoluteURL = new StringBuilder(origin).append(url).toString();
logger.debug("Applying push strategy for {}", absoluteURL);
if (isValidMethod(requestHeaders.get("method").value()))
{
if (isMainResource(url, responseHeaders))
{
return pushResources(url);
result = pushResources(absoluteURL);
}
else if (isPushResource(url, responseHeaders))
{
String referrer = requestHeaders.get("referer").value();
Set<String> pushResources = resources.get(referrer);
if (pushResources == null || !pushResources.contains(url))
Headers.Header referrerHeader = requestHeaders.get("referer");
if (referrerHeader != null)
{
buildMetadata(url, referrer);
}
else
{
return pushResources(url);
String referrer = referrerHeader.value();
Set<String> pushResources = resources.get(referrer);
if (pushResources == null || !pushResources.contains(url))
buildMetadata(origin, url, referrer);
else
result = pushResources(absoluteURL);
}
}
}
return Collections.emptySet();
logger.debug("Push resources for {}: {}", absoluteURL, result);
return result;
}
private boolean hasQueryString(String url)
private boolean isValidMethod(String method)
{
return url.contains("?");
return "GET".equalsIgnoreCase(method);
}
private boolean isMainResource(String url, Headers responseHeaders)
{
// TODO
return false;
return !isPushResource(url, responseHeaders);
}
private boolean isPushResource(String url, Headers responseHeaders)
{
// TODO
for (Pattern pushRegexp : pushRegexps)
{
if (pushRegexp.matcher(url).matches())
return true;
}
return false;
}
private Set<String> pushResources(String url)
private Set<String> pushResources(String absoluteURL)
{
Set<String> pushResources = resources.get(url);
Set<String> pushResources = resources.get(absoluteURL);
if (pushResources == null)
return Collections.emptySet();
return Collections.unmodifiableSet(pushResources);
}
private void buildMetadata(String url, String referrer)
private void buildMetadata(String origin, String url, String referrer)
{
Set<String> pushResources = Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>());
Set<String> existing = resources.putIfAbsent(referrer, pushResources);
if (existing != null)
pushResources = existing;
pushResources.add(url);
if (referrer.startsWith(origin) || isPushOriginAllowed(origin))
{
Set<String> pushResources = resources.get(referrer);
if (pushResources == null)
{
pushResources = Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>());
Set<String> existing = resources.putIfAbsent(referrer, pushResources);
if (existing != null)
pushResources = existing;
}
pushResources.add(url);
logger.debug("Built push metadata for {}: {}", referrer, pushResources);
}
}
private boolean isPushOriginAllowed(String origin)
{
for (Pattern allowedPushOrigin : allowedPushOrigins)
{
if (allowedPushOrigin.matcher(origin).matches())
return true;
}
return false;
}
}

View File

@ -51,7 +51,9 @@ import org.eclipse.jetty.spdy.api.DataInfo;
import org.eclipse.jetty.spdy.api.Handler;
import org.eclipse.jetty.spdy.api.Headers;
import org.eclipse.jetty.spdy.api.ReplyInfo;
import org.eclipse.jetty.spdy.api.RstInfo;
import org.eclipse.jetty.spdy.api.Stream;
import org.eclipse.jetty.spdy.api.StreamStatus;
import org.eclipse.jetty.spdy.api.SynInfo;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
@ -255,10 +257,17 @@ public class ServerHTTPSPDYAsyncConnection extends AbstractHttpConnection implem
private void respond(Stream stream, int status)
{
Headers headers = new Headers();
headers.put("status", String.valueOf(status));
headers.put("version", "HTTP/1.1");
stream.reply(new ReplyInfo(headers, true));
if (stream.isUnidirectional())
{
stream.getSession().rst(new RstInfo(stream.getId(), StreamStatus.INTERNAL_ERROR));
}
else
{
Headers headers = new Headers();
headers.put("status", String.valueOf(status));
headers.put("version", "HTTP/1.1");
stream.reply(new ReplyInfo(headers, true));
}
}
private void close(Stream stream)
@ -277,7 +286,7 @@ public class ServerHTTPSPDYAsyncConnection extends AbstractHttpConnection implem
state = newState;
}
public void beginRequest(final Headers headers)
public void beginRequest(final Headers headers, final boolean endRequest)
{
this.headers = headers.isEmpty() ? null : headers;
post(new Runnable()
@ -288,6 +297,8 @@ public class ServerHTTPSPDYAsyncConnection extends AbstractHttpConnection implem
if (!headers.isEmpty())
updateState(State.REQUEST);
handle();
if (endRequest)
performEndRequest();
}
});
}
@ -347,17 +358,22 @@ public class ServerHTTPSPDYAsyncConnection extends AbstractHttpConnection implem
{
public void run()
{
if (state == State.HEADERS)
{
updateState(State.HEADERS_COMPLETE);
handle();
}
updateState(State.FINAL);
handle();
performEndRequest();
}
});
}
private void performEndRequest()
{
if (state == State.HEADERS)
{
updateState(State.HEADERS_COMPLETE);
handle();
}
updateState(State.FINAL);
handle();
}
public void async()
{
post(new Runnable()
@ -380,24 +396,30 @@ public class ServerHTTPSPDYAsyncConnection extends AbstractHttpConnection implem
if (replyInfo.getHeaders().get("status").value().startsWith("200") && !stream.isClosed())
{
// We have a 200 OK with some content to send
Headers.Header scheme = headers.get("scheme");
Headers.Header host = headers.get("host");
Headers.Header url = headers.get("url");
Set<String> pushResources = pushStrategy.apply(stream, this.headers, replyInfo.getHeaders());
for (String url : pushResources)
String referrer = new StringBuilder(scheme.value()).append("://").append(host.value()).append(url.value()).toString();
for (String pushURL : pushResources)
{
final Headers pushHeaders = new Headers();
pushHeaders.put("method", "GET");
pushHeaders.put("url", url);
pushHeaders.put("url", pushURL);
pushHeaders.put("version", "HTTP/1.1");
Headers.Header acceptEncoding = headers.get("accept-encoding");
if (acceptEncoding != null)
pushHeaders.put(acceptEncoding);
pushHeaders.put(scheme);
pushHeaders.put(host);
pushHeaders.put("referer", referrer);
// Remember support for gzip encoding
pushHeaders.put(headers.get("accept-encoding"));
stream.syn(new SynInfo(pushHeaders, false), getMaxIdleTime(), TimeUnit.MILLISECONDS, new Handler.Adapter<Stream>()
{
@Override
public void completed(Stream pushStream)
{
Synchronous pushConnection = new Synchronous(getConnector(), getEndPoint(), getServer(), connection, pushStrategy, pushStream);
pushConnection.beginRequest(pushHeaders);
pushConnection.endRequest();
pushConnection.beginRequest(pushHeaders, true);
}
});
}

View File

@ -85,7 +85,7 @@ public class ServerHTTPSPDYAsyncConnectionFactory extends ServerSPDYAsyncConnect
stream.setAttribute(CONNECTION_ATTRIBUTE, connection);
Headers headers = synInfo.getHeaders();
connection.beginRequest(headers);
connection.beginRequest(headers, synInfo.isClose());
if (headers.isEmpty())
{
@ -95,14 +95,9 @@ public class ServerHTTPSPDYAsyncConnectionFactory extends ServerSPDYAsyncConnect
else
{
if (synInfo.isClose())
{
connection.endRequest();
return null;
}
else
{
return this;
}
}
}

View File

@ -0,0 +1,366 @@
package org.eclipse.jetty.spdy.http;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.InetSocketAddress;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.handler.AbstractHandler;
import org.eclipse.jetty.spdy.AsyncConnectionFactory;
import org.eclipse.jetty.spdy.SPDYServerConnector;
import org.eclipse.jetty.spdy.api.DataInfo;
import org.eclipse.jetty.spdy.api.Headers;
import org.eclipse.jetty.spdy.api.ReplyInfo;
import org.eclipse.jetty.spdy.api.SPDY;
import org.eclipse.jetty.spdy.api.Session;
import org.eclipse.jetty.spdy.api.SessionFrameListener;
import org.eclipse.jetty.spdy.api.Stream;
import org.eclipse.jetty.spdy.api.StreamFrameListener;
import org.eclipse.jetty.spdy.api.SynInfo;
import org.junit.Assert;
import org.junit.Test;
public class ReferrerPushStrategyTest extends AbstractHTTPSPDYTest
{
@Override
protected SPDYServerConnector newHTTPSPDYServerConnector()
{
return new HTTPSPDYServerConnector()
{
private final AsyncConnectionFactory defaultAsyncConnectionFactory =
new ServerHTTPSPDYAsyncConnectionFactory(SPDY.V2, getByteBufferPool(), getExecutor(), getScheduler(), this, new ReferrerPushStrategy());
@Override
protected AsyncConnectionFactory getDefaultAsyncConnectionFactory()
{
return defaultAsyncConnectionFactory;
}
};
}
@Test
public void testAssociatedResourceIsPushed() throws Exception
{
InetSocketAddress address = startHTTPServer(new AbstractHandler()
{
@Override
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException
{
String url = request.getRequestURI();
PrintWriter output = response.getWriter();
if (url.endsWith(".html"))
output.print("<html><head/><body>HELLO</body></html>");
else if (url.endsWith(".css"))
output.print("body { background: #FFF; }");
baseRequest.setHandled(true);
}
});
Session session1 = startClient(address, null);
final CountDownLatch mainResourceLatch = new CountDownLatch(1);
Headers mainRequestHeaders = new Headers();
mainRequestHeaders.put("method", "GET");
String mainResource = "/index.html";
mainRequestHeaders.put("url", mainResource);
mainRequestHeaders.put("version", "HTTP/1.1");
mainRequestHeaders.put("scheme", "http");
mainRequestHeaders.put("host", "localhost:" + connector.getLocalPort());
session1.syn(new SynInfo(mainRequestHeaders, true), new StreamFrameListener.Adapter()
{
@Override
public void onData(Stream stream, DataInfo dataInfo)
{
dataInfo.consume(dataInfo.length());
if (dataInfo.isClose())
mainResourceLatch.countDown();
}
});
Assert.assertTrue(mainResourceLatch.await(5, TimeUnit.SECONDS));
final CountDownLatch associatedResourceLatch = new CountDownLatch(1);
Headers associatedRequestHeaders = new Headers();
associatedRequestHeaders.put("method", "GET");
associatedRequestHeaders.put("url", "/style.css");
associatedRequestHeaders.put("version", "HTTP/1.1");
associatedRequestHeaders.put("scheme", "http");
associatedRequestHeaders.put("host", "localhost:" + connector.getLocalPort());
associatedRequestHeaders.put("referer", "http://localhost:" + connector.getLocalPort() + mainResource);
session1.syn(new SynInfo(associatedRequestHeaders, true), new StreamFrameListener.Adapter()
{
@Override
public void onData(Stream stream, DataInfo dataInfo)
{
dataInfo.consume(dataInfo.length());
if (dataInfo.isClose())
associatedResourceLatch.countDown();
}
});
Assert.assertTrue(associatedResourceLatch.await(5, TimeUnit.SECONDS));
// Create another client, and perform the same request for the main resource, we expect the css being pushed
final CountDownLatch mainStreamLatch = new CountDownLatch(2);
final CountDownLatch pushDataLatch = new CountDownLatch(1);
Session session2 = startClient(address, new SessionFrameListener.Adapter()
{
@Override
public StreamFrameListener onSyn(Stream stream, SynInfo synInfo)
{
Assert.assertTrue(stream.isUnidirectional());
return new StreamFrameListener.Adapter()
{
@Override
public void onData(Stream stream, DataInfo dataInfo)
{
dataInfo.consume(dataInfo.length());
if (dataInfo.isClose())
pushDataLatch.countDown();
}
};
}
});
session2.syn(new SynInfo(mainRequestHeaders, true), new StreamFrameListener.Adapter()
{
@Override
public void onReply(Stream stream, ReplyInfo replyInfo)
{
Assert.assertFalse(replyInfo.isClose());
mainStreamLatch.countDown();
}
@Override
public void onData(Stream stream, DataInfo dataInfo)
{
dataInfo.consume(dataInfo.length());
if (dataInfo.isClose())
mainStreamLatch.countDown();
}
});
Assert.assertTrue(mainStreamLatch.await(5, TimeUnit.SECONDS));
Assert.assertTrue(pushDataLatch.await(5, TimeUnit.SECONDS));
}
@Test
public void testNestedAssociatedResourceIsPushed() throws Exception
{
InetSocketAddress address = startHTTPServer(new AbstractHandler()
{
@Override
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException
{
String url = request.getRequestURI();
PrintWriter output = response.getWriter();
if (url.endsWith(".html"))
output.print("<html><head/><body>HELLO</body></html>");
else if (url.endsWith(".css"))
output.print("body { background: #FFF; }");
else if (url.endsWith(".gif"))
output.print("\u0000");
baseRequest.setHandled(true);
}
});
Session session1 = startClient(address, null);
final CountDownLatch mainResourceLatch = new CountDownLatch(1);
Headers mainRequestHeaders = new Headers();
mainRequestHeaders.put("method", "GET");
String mainResource = "/index.html";
mainRequestHeaders.put("url", mainResource);
mainRequestHeaders.put("version", "HTTP/1.1");
mainRequestHeaders.put("scheme", "http");
mainRequestHeaders.put("host", "localhost:" + connector.getLocalPort());
session1.syn(new SynInfo(mainRequestHeaders, true), new StreamFrameListener.Adapter()
{
@Override
public void onData(Stream stream, DataInfo dataInfo)
{
dataInfo.consume(dataInfo.length());
if (dataInfo.isClose())
mainResourceLatch.countDown();
}
});
Assert.assertTrue(mainResourceLatch.await(5, TimeUnit.SECONDS));
final CountDownLatch associatedResourceLatch = new CountDownLatch(1);
Headers associatedRequestHeaders = new Headers();
associatedRequestHeaders.put("method", "GET");
String associatedResource = "/style.css";
associatedRequestHeaders.put("url", associatedResource);
associatedRequestHeaders.put("version", "HTTP/1.1");
associatedRequestHeaders.put("scheme", "http");
associatedRequestHeaders.put("host", "localhost:" + connector.getLocalPort());
associatedRequestHeaders.put("referer", "http://localhost:" + connector.getLocalPort() + mainResource);
session1.syn(new SynInfo(associatedRequestHeaders, true), new StreamFrameListener.Adapter()
{
@Override
public void onData(Stream stream, DataInfo dataInfo)
{
dataInfo.consume(dataInfo.length());
if (dataInfo.isClose())
associatedResourceLatch.countDown();
}
});
Assert.assertTrue(associatedResourceLatch.await(5, TimeUnit.SECONDS));
final CountDownLatch nestedResourceLatch = new CountDownLatch(1);
Headers nestedRequestHeaders = new Headers();
nestedRequestHeaders.put("method", "GET");
nestedRequestHeaders.put("url", "/image.gif");
nestedRequestHeaders.put("version", "HTTP/1.1");
nestedRequestHeaders.put("scheme", "http");
nestedRequestHeaders.put("host", "localhost:" + connector.getLocalPort());
nestedRequestHeaders.put("referer", "http://localhost:" + connector.getLocalPort() + associatedResource);
session1.syn(new SynInfo(nestedRequestHeaders, true), new StreamFrameListener.Adapter()
{
@Override
public void onData(Stream stream, DataInfo dataInfo)
{
dataInfo.consume(dataInfo.length());
if (dataInfo.isClose())
nestedResourceLatch.countDown();
}
});
Assert.assertTrue(nestedResourceLatch.await(5, TimeUnit.SECONDS));
// Create another client, and perform the same request for the main resource, we expect the css and the image being pushed
final CountDownLatch mainStreamLatch = new CountDownLatch(2);
final CountDownLatch pushDataLatch = new CountDownLatch(2);
Session session2 = startClient(address, new SessionFrameListener.Adapter()
{
@Override
public StreamFrameListener onSyn(Stream stream, SynInfo synInfo)
{
Assert.assertTrue(stream.isUnidirectional());
return new StreamFrameListener.Adapter()
{
@Override
public void onData(Stream stream, DataInfo dataInfo)
{
dataInfo.consume(dataInfo.length());
if (dataInfo.isClose())
pushDataLatch.countDown();
}
};
}
});
session2.syn(new SynInfo(mainRequestHeaders, true), new StreamFrameListener.Adapter()
{
@Override
public void onReply(Stream stream, ReplyInfo replyInfo)
{
Assert.assertFalse(replyInfo.isClose());
mainStreamLatch.countDown();
}
@Override
public void onData(Stream stream, DataInfo dataInfo)
{
dataInfo.consume(dataInfo.length());
if (dataInfo.isClose())
mainStreamLatch.countDown();
}
});
Assert.assertTrue(mainStreamLatch.await(5, TimeUnit.SECONDS));
Assert.assertTrue(pushDataLatch.await(5, TimeUnit.SECONDS));
}
@Test
public void testMainResourceWithReferrerIsNotPushed() throws Exception
{
InetSocketAddress address = startHTTPServer(new AbstractHandler()
{
@Override
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException
{
String url = request.getRequestURI();
PrintWriter output = response.getWriter();
if (url.endsWith(".html"))
output.print("<html><head/><body>HELLO</body></html>");
baseRequest.setHandled(true);
}
});
Session session1 = startClient(address, null);
final CountDownLatch mainResourceLatch = new CountDownLatch(1);
Headers mainRequestHeaders = new Headers();
mainRequestHeaders.put("method", "GET");
String mainResource = "/index.html";
mainRequestHeaders.put("url", mainResource);
mainRequestHeaders.put("version", "HTTP/1.1");
mainRequestHeaders.put("scheme", "http");
mainRequestHeaders.put("host", "localhost:" + connector.getLocalPort());
session1.syn(new SynInfo(mainRequestHeaders, true), new StreamFrameListener.Adapter()
{
@Override
public void onData(Stream stream, DataInfo dataInfo)
{
dataInfo.consume(dataInfo.length());
if (dataInfo.isClose())
mainResourceLatch.countDown();
}
});
Assert.assertTrue(mainResourceLatch.await(5, TimeUnit.SECONDS));
final CountDownLatch associatedResourceLatch = new CountDownLatch(1);
Headers associatedRequestHeaders = new Headers();
associatedRequestHeaders.put("method", "GET");
associatedRequestHeaders.put("url", "/home.html");
associatedRequestHeaders.put("version", "HTTP/1.1");
associatedRequestHeaders.put("scheme", "http");
associatedRequestHeaders.put("host", "localhost:" + connector.getLocalPort());
associatedRequestHeaders.put("referer", "http://localhost:" + connector.getLocalPort() + mainResource);
session1.syn(new SynInfo(associatedRequestHeaders, true), new StreamFrameListener.Adapter()
{
@Override
public void onData(Stream stream, DataInfo dataInfo)
{
dataInfo.consume(dataInfo.length());
if (dataInfo.isClose())
associatedResourceLatch.countDown();
}
});
Assert.assertTrue(associatedResourceLatch.await(5, TimeUnit.SECONDS));
// Create another client, and perform the same request for the main resource, we expect nothing being pushed
final CountDownLatch mainStreamLatch = new CountDownLatch(2);
final CountDownLatch pushLatch = new CountDownLatch(1);
Session session2 = startClient(address, new SessionFrameListener.Adapter()
{
@Override
public StreamFrameListener onSyn(Stream stream, SynInfo synInfo)
{
pushLatch.countDown();
return null;
}
});
session2.syn(new SynInfo(mainRequestHeaders, true), new StreamFrameListener.Adapter()
{
@Override
public void onReply(Stream stream, ReplyInfo replyInfo)
{
Assert.assertFalse(replyInfo.isClose());
mainStreamLatch.countDown();
}
@Override
public void onData(Stream stream, DataInfo dataInfo)
{
dataInfo.consume(dataInfo.length());
if (dataInfo.isClose())
mainStreamLatch.countDown();
}
});
Assert.assertTrue(mainStreamLatch.await(5, TimeUnit.SECONDS));
Assert.assertFalse(pushLatch.await(1, TimeUnit.SECONDS));
}
}