View Javadoc

1   //
2   //  ========================================================================
3   //  Copyright (c) 1995-2016 Mort Bay Consulting Pty. Ltd.
4   //  ------------------------------------------------------------------------
5   //  All rights reserved. This program and the accompanying materials
6   //  are made available under the terms of the Eclipse Public License v1.0
7   //  and Apache License v2.0 which accompanies this distribution.
8   //
9   //      The Eclipse Public License is available at
10  //      http://www.eclipse.org/legal/epl-v10.html
11  //
12  //      The Apache License v2.0 is available at
13  //      http://www.opensource.org/licenses/apache2.0.php
14  //
15  //  You may elect to redistribute this code under either of these licenses.
16  //  ========================================================================
17  //
18  
19  
20  package org.eclipse.jetty.servlets;
21  
22  import java.io.IOException;
23  import java.util.Collections;
24  import java.util.HashMap;
25  import java.util.HashSet;
26  import java.util.Map;
27  import java.util.Set;
28  import java.util.TreeSet;
29  import java.util.concurrent.ConcurrentHashMap;
30  import java.util.concurrent.ConcurrentMap;
31  import java.util.concurrent.TimeUnit;
32  import java.util.concurrent.atomic.AtomicLong;
33  
34  import javax.servlet.Filter;
35  import javax.servlet.FilterChain;
36  import javax.servlet.FilterConfig;
37  import javax.servlet.RequestDispatcher;
38  import javax.servlet.ServletException;
39  import javax.servlet.ServletRequest;
40  import javax.servlet.ServletResponse;
41  import javax.servlet.http.HttpServletRequest;
42  
43  import org.eclipse.jetty.http.HttpField;
44  import org.eclipse.jetty.http.HttpFields;
45  import org.eclipse.jetty.http.HttpHeader;
46  import org.eclipse.jetty.http.HttpURI;
47  import org.eclipse.jetty.http.HttpVersion;
48  import org.eclipse.jetty.server.Dispatcher;
49  import org.eclipse.jetty.server.Request;
50  import org.eclipse.jetty.util.StringUtil;
51  import org.eclipse.jetty.util.URIUtil;
52  import org.eclipse.jetty.util.annotation.ManagedAttribute;
53  import org.eclipse.jetty.util.annotation.ManagedObject;
54  import org.eclipse.jetty.util.annotation.ManagedOperation;
55  import org.eclipse.jetty.util.log.Log;
56  import org.eclipse.jetty.util.log.Logger;
57  
58  /**
59   * <p>A filter that builds a cache of secondary resources associated
60   * to primary resources.</p>
61   * <p>A typical request for a primary resource such as {@code index.html}
62   * is immediately followed by a number of requests for secondary resources.
63   * Secondary resource requests will have a {@code Referer} HTTP header
64   * that points to {@code index.html}, which is used to associate the secondary
65   * resource to the primary resource.</p>
66   * <p>Only secondary resources that are requested within a (small) time period
67   * from the request of the primary resource are associated with the primary
68   * resource.</p>
69   * <p>This allows to build a cache of secondary resources associated with
70   * primary resources. When a request for a primary resource arrives, associated
71   * secondary resources are pushed to the client, unless the request carries
72   * {@code If-xxx} header that hint that the client has the resources in its
73   * cache.</p>
74   */
75  @ManagedObject("Push cache based on the HTTP 'Referer' header")
76  public class PushCacheFilter implements Filter
77  {
78      private static final Logger LOG = Log.getLogger(PushCacheFilter.class);
79  
80      private final Set<Integer> _ports = new HashSet<>();
81      private final Set<String> _hosts = new HashSet<>();
82      private final ConcurrentMap<String, PrimaryResource> _cache = new ConcurrentHashMap<>();
83      private long _associatePeriod = 4000L;
84      private int _maxAssociations = 16;
85      private long _renew = System.nanoTime();
86  
87      @Override
88      public void init(FilterConfig config) throws ServletException
89      {
90          String associatePeriod = config.getInitParameter("associatePeriod");
91          if (associatePeriod != null)
92              _associatePeriod = Long.parseLong(associatePeriod);
93  
94          String maxAssociations = config.getInitParameter("maxAssociations");
95          if (maxAssociations != null)
96              _maxAssociations = Integer.parseInt(maxAssociations);
97  
98          String hosts = config.getInitParameter("hosts");
99          if (hosts != null)
100             Collections.addAll(_hosts, StringUtil.csvSplit(hosts));
101 
102         String ports = config.getInitParameter("ports");
103         if (ports != null)
104             for (String p : StringUtil.csvSplit(ports))
105                 _ports.add(Integer.parseInt(p));
106 
107         // Expose for JMX.
108         config.getServletContext().setAttribute(config.getFilterName(), this);
109 
110         if (LOG.isDebugEnabled())
111             LOG.debug("period={} max={} hosts={} ports={}", _associatePeriod, _maxAssociations, _hosts, _ports);
112     }
113 
114     @Override
115     public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException
116     {
117         if (HttpVersion.fromString(req.getProtocol()).getVersion() < 20)
118         {
119             chain.doFilter(req, resp);
120             return;
121         }
122 
123         long now = System.nanoTime();
124         HttpServletRequest request = (HttpServletRequest)req;
125 
126         // Iterating over fields is more efficient than multiple gets
127         HttpFields fields = Request.getBaseRequest(request).getHttpFields();
128         boolean conditional = false;
129         String referrer = null;
130         loop:
131         for (int i = 0; i < fields.size(); i++)
132         {
133             HttpField field = fields.getField(i);
134             HttpHeader header = field.getHeader();
135             if (header == null)
136                 continue;
137 
138             switch (header)
139             {
140                 case IF_MATCH:
141                 case IF_MODIFIED_SINCE:
142                 case IF_NONE_MATCH:
143                 case IF_UNMODIFIED_SINCE:
144                     conditional = true;
145                     break loop;
146 
147                 case REFERER:
148                     referrer = field.getValue();
149                     break;
150 
151                 default:
152                     break;
153             }
154         }
155 
156         if (LOG.isDebugEnabled())
157             LOG.debug("{} {} referrer={} conditional={}", request.getMethod(), request.getRequestURI(), referrer, conditional);
158 
159         String path = URIUtil.addPaths(request.getServletPath(), request.getPathInfo());
160         String query = request.getQueryString();
161         if (query != null)
162             path += "?" + query;
163         if (referrer != null)
164         {
165             HttpURI referrerURI = new HttpURI(referrer);
166             String host = referrerURI.getHost();
167             int port = referrerURI.getPort();
168             if (port <= 0)
169                 port = request.isSecure() ? 443 : 80;
170 
171             boolean referredFromHere = _hosts.size() > 0 ? _hosts.contains(host) : host.equals(request.getServerName());
172             referredFromHere &= _ports.size() > 0 ? _ports.contains(port) : port == request.getServerPort();
173 
174             if (referredFromHere)
175             {
176                 if ("GET".equalsIgnoreCase(request.getMethod()))
177                 {
178                     String referrerPath = referrerURI.getPath();
179                     if (referrerPath == null)
180                         referrerPath = "/";
181                     if (referrerPath.startsWith(request.getContextPath()))
182                     {
183                         String referrerPathNoContext = referrerPath.substring(request.getContextPath().length());
184                         if (!referrerPathNoContext.equals(path))
185                         {
186                             PrimaryResource primaryResource = _cache.get(referrerPathNoContext);
187                             if (primaryResource != null)
188                             {
189                                 long primaryTimestamp = primaryResource._timestamp.get();
190                                 if (primaryTimestamp != 0)
191                                 {
192                                     RequestDispatcher dispatcher = request.getServletContext().getRequestDispatcher(path);
193                                     if (now - primaryTimestamp < TimeUnit.MILLISECONDS.toNanos(_associatePeriod))
194                                     {
195                                         ConcurrentMap<String, RequestDispatcher> associated = primaryResource._associated;
196                                         // Not strictly concurrent-safe, just best effort to limit associations.
197                                         if (associated.size() <= _maxAssociations)
198                                         {
199                                             if (associated.putIfAbsent(path, dispatcher) == null)
200                                             {
201                                                 if (LOG.isDebugEnabled())
202                                                     LOG.debug("Associated {} to {}", path, referrerPathNoContext);
203                                             }
204                                         }
205                                         else
206                                         {
207                                             if (LOG.isDebugEnabled())
208                                                 LOG.debug("Not associated {} to {}, exceeded max associations of {}", path, referrerPathNoContext, _maxAssociations);
209                                         }
210                                     }
211                                     else
212                                     {
213                                         if (LOG.isDebugEnabled())
214                                             LOG.debug("Not associated {} to {}, outside associate period of {}ms", path, referrerPathNoContext, _associatePeriod);
215                                     }
216                                 }
217                             }
218                         }
219                         else
220                         {
221                             if (LOG.isDebugEnabled())
222                                 LOG.debug("Not associated {} to {}, referring to self", path, referrerPathNoContext);
223                         }
224                     }
225                 }
226             }
227             else
228             {
229                 if (LOG.isDebugEnabled())
230                     LOG.debug("External referrer {}", referrer);
231             }
232         }
233 
234         // Push some resources?
235         PrimaryResource primaryResource = _cache.get(path);
236         if (primaryResource == null)
237         {
238             PrimaryResource t = new PrimaryResource();
239             primaryResource = _cache.putIfAbsent(path, t);
240             primaryResource = primaryResource == null ? t : primaryResource;
241             primaryResource._timestamp.compareAndSet(0, now);
242             if (LOG.isDebugEnabled())
243                 LOG.debug("Cached primary resource {}", path);
244         }
245         else
246         {
247             long last = primaryResource._timestamp.get();
248             if (last < _renew && primaryResource._timestamp.compareAndSet(last, now))
249             {
250                 primaryResource._associated.clear();
251                 if (LOG.isDebugEnabled())
252                     LOG.debug("Clear associated resources for {}", path);
253             }
254         }
255 
256         // Push associated for non conditional
257         if (!conditional && !primaryResource._associated.isEmpty())
258         {
259             for (RequestDispatcher dispatcher : primaryResource._associated.values())
260             {
261                 if (LOG.isDebugEnabled())
262                     LOG.debug("Pushing {} for {}", dispatcher, path);
263                 ((Dispatcher)dispatcher).push(request);
264             }
265         }
266 
267         chain.doFilter(request, resp);
268     }
269 
270     @Override
271     public void destroy()
272     {
273         clearPushCache();
274     }
275 
276     @ManagedAttribute("The push cache contents")
277     public Map<String, String> getPushCache()
278     {
279         Map<String, String> result = new HashMap<>();
280         for (Map.Entry<String, PrimaryResource> entry : _cache.entrySet())
281         {
282             PrimaryResource resource = entry.getValue();
283             String value = String.format("size=%d: %s", resource._associated.size(), new TreeSet<>(resource._associated.keySet()));
284             result.put(entry.getKey(), value);
285         }
286         return result;
287     }
288 
289     @ManagedOperation(value = "Renews the push cache contents", impact = "ACTION")
290     public void renewPushCache()
291     {
292         _renew = System.nanoTime();
293     }
294 
295     @ManagedOperation(value = "Clears the push cache contents", impact = "ACTION")
296     public void clearPushCache()
297     {
298         _cache.clear();
299     }
300 
301     private static class PrimaryResource
302     {
303         private final ConcurrentMap<String, RequestDispatcher> _associated = new ConcurrentHashMap<>();
304         private final AtomicLong _timestamp = new AtomicLong();
305     }
306 }