转:http://www.infoq.com/cn/articles/etags
最近,大众对于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,服务器可在稍后使用它来判断页面是否已经被修改。本质上,客户端通过将该记号传回服务器要求服务器验证其(客户端)缓存。
其过程如下:
- 客户端请求一个页面(A)。
- 服务器返回页面A,并在给A加上一个ETag。
- 客户端展现该页面,并将页面连同ETag一起缓存。
- 客户再次请求页面A,并将上次请求时服务器返回的ETag一起传递给服务器。
- 服务器检查该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>
<filter-mapping>
<filter-name>ETag Content Filter</filter-name>
<url-pattern>/*.htm</url-pattern>
</filter-mapping>
清单 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”:
我们发起的第一个请求说明该用户已经看过了这个页面:
----------------------------------------------------------
http://localhost:8080/petclinic/owner.htm?ownerId=10
GET /petclinic/owner.htm?ownerId=10 HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4) Gecko/20070515 Firefox/2.0.0.4
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Cookie: JSESSIONID=13D2E0CB63897F4EDB56639E46D2BBD8
X-lori-time-1: 1182364348062
If-Modified-Since: Wed, 20 Jun 2007 18:29:03 GMT
If-None-Match: "-1"
HTTP/1.x 304 Not Modified
Server: Apache-Coyote/1.1
Date: Wed, 20 Jun 2007 18:32:30 GMT
我们现在应该做点修改,看看ETag是否改变了。我们给这个物主增加一个宠物:
----------------------------------------------------------
http://localhost:8080/petclinic/addPet.htm?ownerId=10
GET /petclinic/addPet.htm?ownerId=10 HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4) Gecko/20070515 Firefox/2.0.0.4
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Referer: http://localhost:8080/petclinic/owner.htm?ownerId=10
Cookie: JSESSIONID=13D2E0CB63897F4EDB56639E46D2BBD8
X-lori-time-1: 1182364356265
HTTP/1.x 200 OK
Server: Apache-Coyote/1.1
Pragma: No-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Cache-Control: no-cache, no-store
Content-Type: text/html;charset=ISO-8859-1
Content-Language: en-US
Content-Length: 2174
Date: Wed, 20 Jun 2007 18:32:57 GMT
----------------------------------------------------------
http://localhost:8080/petclinic/addPet.htm?ownerId=10
POST /petclinic/addPet.htm?ownerId=10 HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4) Gecko/20070515 Firefox/2.0.0.4
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Referer: http://localhost:8080/petclinic/addPet.htm?ownerId=10
Cookie: JSESSIONID=13D2E0CB63897F4EDB56639E46D2BBD8
X-lori-time-1: 1182364402968
Content-Type: application/x-www-form-urlencoded
Content-Length: 40
name=Noddy&birthDate=1000-11-11&typeId=5
HTTP/1.x 302 Moved Temporarily
Server: Apache-Coyote/1.1
Pragma: No-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Cache-Control: no-cache, no-store
Location: http://localhost:8080/petclinic/owner.htm?ownerId=10
Content-Language: en-US
Content-Length: 0
Date: Wed, 20 Jun 2007 18:33:23 GMT
因为对addPet.htm我们没有配置任何已知ETag,也没有设置头信息。现在,我们再一次查看id为10的物主。注意ETag这时是1:
----------------------------------------------------------
http://localhost:8080/petclinic/owner.htm?ownerId=10
GET /petclinic/owner.htm?ownerId=10 HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4) Gecko/20070515 Firefox/2.0.0.4
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Referer: http://localhost:8080/petclinic/addPet.htm?ownerId=10
Cookie: JSESSIONID=13D2E0CB63897F4EDB56639E46D2BBD8
X-lori-time-1: 1182364403109
If-Modified-Since: Wed, 20 Jun 2007 18:29:03 GMT
If-None-Match: "-1"
HTTP/1.x 200 OK
Server: Apache-Coyote/1.1
Etag: "1"
Last-Modified: Wed, 20 Jun 2007 18:33:36 GMT
Content-Type: text/html;charset=ISO-8859-1
Content-Language: en-US
Content-Length: 4317
Date: Wed, 20 Jun 2007 18:33:45 GMT
最后,我们再次查看id为10的物主。这次我们的ETag命中了,我们得到一个“304未修改”响应:
----------------------------------------------------------
http://localhost:8080/petclinic/owner.htm?ownerId=10
GET /petclinic/owner.htm?ownerId=10 HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4) Gecko/20070515 Firefox/2.0.0.4
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Cookie: JSESSIONID=13D2E0CB63897F4EDB56639E46D2BBD8
X-lori-time-1: 1182364493500
If-Modified-Since: Wed, 20 Jun 2007 18:33:36 GMT
If-None-Match: "1"
HTTP/1.x 304 Not Modified
Server: Apache-Coyote/1.1
Date: Wed, 20 Jun 2007 18:34:55 GMT
我们已经利用HTTP缓存节约了带宽和计算时间!
细粒度印记(The Fine Print):实践中,我们可以通过以更细粒度的跟踪对象变化来获得更大的功效,例如使用对象id。然而,这种使修改对象关联到view上的想法高度依赖应用程序的整体数据模型设计。这里的实现(ModifiedObjectTracker)是说明性的,有意为更多的探索提供想法。它并不是旨在生产环境中使用(比如它在簇中使用还不稳定)。一个可选的更深的考虑是使用数据库触发器来跟踪变化,让拦截器访问触发器所写入的表。
结论
我们已经看了两种使用ETag减少带宽和计算的方法。我希望本文已为你当下或将来基于Web的项目提供了精神食粮,并正确评价在底层利用ETag响应头的做法。
正如牛顿(Isaac Newton)的名言所说:“如果说我看得更远,那是因为我站在巨人的肩膀上。”REST风格应用的核心是简单、好的软件设计、不要重新发明轮子。我相信随着使用量和知名度的增长,针对基于Web应用的REST风格架构有益于主流应用开发的迁移,我期盼着它在我将来的项目中发挥更大的作用。
关于作者
Gavin Terrill 是BPS公司的首席技术执行官。Gavin已经有20多年的软件开发历史了,擅长企业Java应用程序,但仍拒绝扔掉他的TRS-80。闲暇时间Gavin喜欢航海、钓鱼、玩吉他、品红酒(不分先后顺序)。
感谢
我要感谢我的同事Patrick Bourke和Erick Dorvale的帮助,他们对这篇文章提供的反馈意见。
代码和说明可以从这里下载。
查看英文原文:Using ETags to Reduce Bandwith & Workload with Spring & Hibernate