分类 标签 存档 ME! 黑客派 订阅 搜索

使用 ETags 减少 Web 应用带宽和负载

367 浏览

介绍

最近,大众对于 REST 风格应用架构表现出强烈兴趣,这表明 Web 的优雅设计开始受到人们的注意。现在,我们逐渐理解了 “3W 架构(Architecture of the World Wide Web)” 内在所蕴含的可伸缩性和弹性,并进一步探索运用其范式的方法。本文中,我们将探究一个可被 Web 开发者利用的、鲜为人知的工具,不引人注意的 “ETag 响应头(ETag Response Header)”,以及如何将它集成进基于 Spring 和 Hibernate 的动态 Web 应用,以提升应用程序性能和可伸缩性。

我们将要使用的 Spring 框架应用是基于 “宠物诊所(petclinic)” 的。下载文件中包含了关于如何增加必要的配置及源码的说明,你可以自己尝试。

什么是 “ETag”?

HTTP 协议规格说明定义 ETag 为 “被请求变量的实体值” (参见 http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html —— 章节 14.19)。 另一种说法是,ETag 是一个可以与 Web 资源关联的记号(token)。典型的 Web 资源可以一个 Web 页,但也可能是 JSON 或 XML 文档。服务器单独负责判断记号是什么及其含义,并在 HTTP 响应头中将其传送到客户端。

ETag 如何帮助提升性能?

聪明的服务器开发者会把 ETags 和 GET 请求的 “If-None-Match” 头一起使用,这样可利用客户端(例如浏览器)的缓存。因为服务器首 先产生 ETag,服务器可在稍后使用它来判断页面是否已经被修改。本质上,客户端通过将该记号传回服务器要求服务器验证其(客户端)缓存。

其过程如下:

  1. 客户端请求一个页面(A)。
  2. 服务器返回页面 A,并在给 A 加上一个 ETag。
  3. 客户端展现该页面,并将页面连同 ETag 一起缓存。
  4. 客户再次请求页面 A,并将上次请求时服务器返回的 ETag 一起传递给服务器。
  5. 服务器检查该 ETag,并判断出该页面自上次客户端请求之后还未被修改,直接返回响应 304(未修改——Not Modified)和一个空的响应体。

本文的其余部分将展示在基于 Spring 框架的 Web 应用中利用 ETag 的两种方法,该应用使用 Spring MVC。首先我们将使用 Servlet 2.3 Filter,利用展现视图(rendered view)的 MD5 校验和(checksum)以实现生成 ETag 的方法(一个 “浅显的”ETag 实现)。 第二种方法使用更为复杂的方法追踪 view 中所使用的 model,以确定 ETag 有效性(一个 “深入的”ETag 实现)。尽管我们使用的是 Spring MVC,但该技术可以应用于任何 MVC 风格的 Web 框架。

在我们继续之前,强调一下这里所展现的是提升动态产生页面性能的技术。已有的优化技术也应作为整体优化和应用性能特性调整分析的一部分来考虑。(见下)。

自顶向下的 Web 缓存

本文主要涉及对动态生成页面使用 HTTP 缓存技术。当考虑提升 Web 应用的性能的时候,应采取一个整体的、自顶向下的方法。为了这一目的,理解 HTTP 请求经过的各层是很重要的,应用哪些适当的技术取决于你所关注的热点。例如:

  • 将 Apache 作为 Servlet 容器的前端,来处理如图片和 javascript 脚本这样的静态文件,而且还可以使用FileETag 指令创建 ETag 响应头。
  • 使用针对 javascript 文件的优化技术,如将多个文件合并到一个文件中以及压缩空格。
  • 利用 GZip 和缓存控制头(Cache-Control headers)。
  • 为确定你的 Spring 框架应用的痛处所在,可以考虑使用 JamonPerformanceMonitorInterceptor
  • 确信你充分利用 ORM 工具的缓存机制,因此对象不需要从数据库中频繁的再生。花时间确定如何让查询缓存为你工作是值得的。
  • 确保你最小化数据库中获取的数据量,尤其是大的列表。如果每个页面只请求大列表的一个小子集,那么大列表的数据应由其中某个页面一次获得。
  • 使放入到 HTTP session 中的数据量最小。这样内存得到释放,而且当将应用集群的时候也会有所帮助。
  • 使用数据库明细(database profiling)工具来查看在查询的时候使用了什么索引,在更新的时候整个表没有被上锁。

当然,应用性能优化的至理名言是:两次测量,一次剪裁(measure twice, cut once)。哦,等等,这是对木工而言的!没错,但是它在这里也很适用!


ETag Filter 内容体

我们要考虑的第一种方法是创建一个 Servlet Filter,它将基于页面(MVC 中的 “View”)的内容产生其 ETag 记号。乍一看,使用这种方法所获得的任何性能提升看起来都是违反直觉的。我们仍然不得不产生页面,而且还增加了产生记号的计算时间。然而,这里的想法是减 少带宽使用。在大的响应时间情形下,如你的主机和客户端分布在这个星球的两端,这很大程度上是有益的。我曾见过东京办公室使用纽约服务器上托管的应用,其 响应时间达到了 350 ms。随着并发用户数的增长,这将变成巨大的瓶颈。

代码

我们用来产生记号的技术是基于从页面内容计算 MD5 哈希值。这通过在响应之上创建一个包装器来实现。该包装器使用字节数组来保存所产生的内容,在 filter 链处理完成之后我们利用数组的 MD5 哈希值计算记号。

doFilter 方法的实现如下所示。

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException,
ServletException {
HttpServletRequest servletRequest = (HttpServletRequest) req;
HttpServletResponse servletResponse = (HttpServletResponse) res; ByteArrayOutputStream baos = new ByteArrayOutputStream();
ETagResponseWrapper wrappedResponse = new ETagResponseWrapper(servletResponse, baos);
chain.doFilter(servletRequest, wrappedResponse); byte[] bytes = baos.toByteArray(); String token = '"' + ETagComputeUtils.getMd5Digest(bytes) + '"';
servletResponse.setHeader("ETag", token); // always store the ETag in the header String previousToken = servletRequest.getHeader("If-None-Match"); if (previousToken != null && previousToken.equals(token)) { // compare previous token with current one logger.debug("ETag match: returning 304 Not Modified");
servletResponse.sendError(HttpServletResponse.SC_NOT_MODIFIED); // use the same date we sent when we created the ETag the first time through servletResponse.setHeader("Last-Modified", servletRequest.getHeader("If-Modified-Since"));
} else { // first time through - set last modified time to now Calendar cal = Calendar.getInstance();
cal.set(Calendar.MILLISECOND, 0); Date lastModified = cal.getTime();
servletResponse.setDateHeader("Last-Modified", lastModified.getTime());

      logger.debug("Writing body content");
     servletResponse.setContentLength(bytes.length);
     ServletOutputStream sos = servletResponse.getOutputStream();
     sos.write(bytes);
     sos.flush();
     sos.close();
 }

}

清单 1:ETagContentFilter.doFilter

你需注意到,我们还设置了 Last-Modified 头。这被认为是为服务器产生内容的正确形式,因为其迎合了不认识 ETag 头的客户端。

下面的例子使用了一个工具类 EtagComputeUtils 来产生对象所对应的字节数组,并处理 MD5 摘要逻辑。我使用了 javax.security MessageDigest 来计算 MD5 哈希码。

public static byte[] serialize(Object obj) throws IOException { byte[] byteArray = null; ByteArrayOutputStream baos = null; ObjectOutputStream out = null;
try { // These objects are closed in the finally.
baos = new ByteArrayOutputStream();
out = new ObjectOutputStream(baos);
out.writeObject(obj);
byteArray = baos.toByteArray();
} finally { if (out != null) {
out.close();
}
} return byteArray;
} public static String getMd5Digest(byte[] bytes) {
MessageDigest md; try {
md = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) { throw new RuntimeException("MD5 cryptographic algorithm is not available.", e);
} byte[] messageDigest = md.digest(bytes);
BigInteger number = new BigInteger(1, messageDigest); // prepend a zero to get a "proper" MD5 hash value StringBuffer sb = new StringBuffer('0');
sb.append(number.toString(16)); return sb.toString();
}


清单 2:ETagComputeUtils

直接在 web.xml 中配置 filter。

<filter>
<filter-name>ETag Content Filter</filter-name>
<filter-class>org.springframework.samples.petclinic.web.ETagContentFilter</filter-class>
</filter>

  &lt;filter-mapping&gt;
   &lt;filter-name&gt;ETag Content Filter&lt;/filter-name&gt;
   &lt;url-pattern&gt;/*.htm&lt;/url-pattern&gt;
 &lt;/filter-mapping&gt;</pre>

清单 3:web.xml 中配置 filter。

每个. htm 文件将被 EtagContentFilter 过滤,如果页面自上次客户端请求后没有改变,它将返回一个空内容体的 HTTP 响应。

我们在这里展示的方法对特定类型的页面是有用的。但是,该方法有两个缺点:

  • 我们是在页面已经被展现在服务器之后计算 ETag 的,但是在返回客户端之前。如果有 Etag 匹配,实际上并不需要再为 model 装进数据,因为要展现的页面不需要发送回客户端。
  • 对于类似于在页脚显示日期时间这样的页面,即使内容实际上并没有改变,每个页面也将是不同的。

下一节,我们将着眼于另一种方法,其通过理解更多关于构造页面的底层数据来克服这些问题的某些限制。

ETag 拦截器(Interceptor)

Spring MVC HTTP 请求处理途径中包括了在一个 controller 前插接拦截器(Interceptor)的能力,因而有机会处理请求。这儿是应用我们 ETag 比较逻辑的 理想场所,因此如果我们发现构建一个页面的数据没有发生变化,我们可以避免进一步处理。

这儿的诀窍是你怎么知道构成页面的数据已经改变了?为了达到本文的目的,我创建了一个简单的 ModifiedObjectTracker,它通过 Hibernate 事件侦听器清楚地知道插入、更新和删除操作。该追踪器为应用程序的每个 view 维护一个唯一的号码,以及一个关于哪些 Hibernate 实体影响每个 view 的映射。每当一个 POJO 被改变了,使用了该实体的 view 的计数器就加 1。我们使用该计数值作为 ETag,这样 当客户端将 ETag 送回时我们就知道页面背后的一个或多个对象是否被修改了。

代码

我们就从 ModifiedObjectTracker 开始吧:

public interface ModifiedObjectTracker { void notifyModified(> String entity);
}


够简单吧?这个实现还有一点更有趣的。任何时候一个实体改变了,我们就更新每个受其影响的 view 的计数器:

public void notifyModified(String entity) { // entityViewMap is a map of entity -> list of view names List views = getEntityViewMap().get(entity); if (views == null) { return; // no views are configured for this entity } synchronized (counts) { for (String view : views) { Integer count = counts.get(view);
counts.put(view, ++count);
}
}
}


一个 “改变” 就是插入、更新或者删除。这里给出的是侦听删除操作的处理器(配置为 Hibernate 3 LocalSessionFactoryBean 上的事件侦听器):

public class DeleteHandler extends DefaultDeleteEventListener { private ModifiedObjectTracker tracker; public void onDelete(DeleteEvent event) throws HibernateException {
getModifiedObjectTracker().notifyModified(event.getEntityName());
} public ModifiedObjectTracker getModifiedObjectTracker() { return tracker;
} public void setModifiedObjectTracker(ModifiedObjectTracker tracker) { this.tracker = tracker;
}
}


ModifiedObjectTracker 通过 Spring 配置被注入到 DeleteHandler 中。还有一个 SaveOrUpdateHandler 来处理新建或更新 POJO。

如果客户端发送回当前有效的 ETag(意味着自上次请求之后我们的内容没有改变),我们将阻止更多的处理,以实现我们的性能提升。在 Spring MVC 里,我们可以使用 HandlerInterceptorAdaptor 并覆盖 preHandle 方法:

public final boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException { String method = request.getMethod();
if (!"GET".equals(method)) return true; String previousToken = request.getHeader("If-None-Match"); String token = getTokenFactory().getToken(request); // compare previous token with current one if ((token != null) && (previousToken != null && previousToken.equals('"' + token + '"'))) {
response.sendError(HttpServletResponse.SC_NOT_MODIFIED); // re-use original last modified timestamp response.setHeader("Last-Modified", request.getHeader("If-Modified-Since")) return false; // no further processing required } // set header for the next time the client calls if (token != null) {
response.setHeader("ETag", '"' + token + '"'); // first time through - set last modified time to now Calendar cal = Calendar.getInstance();
cal.set(Calendar.MILLISECOND, 0); Date lastModified = cal.getTime();
response.setDateHeader("Last-Modified", lastModified.getTime());
} return true;
}


我们首先确信我们正在处理 GET 请求(与 PUT 一起的 ETag 可以用来检测不一致的更新,但其超出了本文的范围。)。如果该记号与上次我们发送的记 号相匹配,我们返回一个 “304 未修改” 响应并 “短路” 请求处理链的其余部分。否则,我们设置 ETag 响应头以便为下一次客户端请求做好准备。

你需注意到我们将产生记号逻辑抽出到一个接口中,这样可以插接不同的实现。该接口有一个方法:

public interface ETagTokenFactory { String getToken(HttpServletRequest request);
}

为了把代码清单减至最小,SampleTokenFactory 的简单实现还担当了 ETagTokenFactory 的角色。本例中,我们通过简单返回请求 URI 的更改计数值来产生记号:

public String getToken(HttpServletRequest request) {String view = request.getRequestURI(); Integer count = counts.get(view); if (count == null) { return null;
} return count.toString();
}

 

大功告成!

会话

这里,如果什么也没改变,我们的拦截器将阻止任何搜集数据或展现 view 的开销。现在,让我们看看 HTTP 头(借助于LiveHTTPHeaders),看看到底发生了什么。下载文件中包含了配置该拦截器的说明,因此 owner.htm“能够使用 ETag”:

我们发起的第一个请求说明该用户已经看过了这个页面:


欢迎注册黑客派社区,开启你的博客之旅。让学习和分享成为一种习惯!

评论  
留下你的脚步
推荐阅读