转:http://www.infoq.com/cn/articles/etagsjavascript
最近,大众对于REST风格应用架构表现出强烈兴趣,这代表Web的优雅设计开始受到人们的注意。如今,咱们逐渐理解了“3W架构(Architecture of the World Wide Web)”内在所蕴含的可伸缩性和弹性,并进一步探索运用其范式的方法。本文中,咱们将探究一个可被Web开发者利用的、不为人知的工具,不引人注意的“ETag响应头(ETag Response Header)”,以及如何将它集成进基于Spring和Hibernate的动态Web应用,以提高应用程序性能和可伸缩性。html
咱们将要使用的Spring框架应用是基于“宠物诊所(petclinic)”的。下载文件中包含了关于如何增长必要的配置及源码的说明,你能够本身尝试。前端
HTTP协议规格说明定义ETag为“被请求变量的实体值” (参见http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html —— 章节 14.19)。 另外一种说法是,ETag是一个能够与Web资源关联的记号(token)。典型的Web资源能够一个Web页,但也多是JSON或XML文档。服务器单独负责判断记号是什么及其含义,并在HTTP响应头中将其传送到客户端。java
聪明的服务器开发者会把ETags和GET请求的“If-None-Match”头一块儿使用,这样可利用客户端(例如浏览器)的缓存。由于服务器首先产生ETag,服务器可在稍后使用它来判断页面是否已经被修改。本质上,客户端经过将该记号传回服务器要求服务器验证其(客户端)缓存。spring
其过程以下:数据库
本文的其他部分将展现在基于Spring框架的Web应用中利用ETag的两种方法,该应用使用Spring MVC。首先咱们将使用Servlet 2.3 Filter,利用展示视图(rendered view)的MD5校验和(checksum)以实现生成ETag的方法(一个“浅显的”ETag实现)。 第二种方法使用更为复杂的方法追踪view中所使用的model,以肯定ETag有效性(一个“深刻的”ETag实现)。尽管咱们使用的是Spring MVC,但该技术能够应用于任何MVC风格的Web框架。apache
在咱们继续以前,强调一下这里所展示的是提高动态产生页面性能的技术。已有的优化技术也应做为总体优化和应用性能特性调整分析的一部分来考虑。(见下)。api
自顶向下的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)。哦,等等,这是对木工而言的!没错,可是它在这里也很适用!
咱们要考虑的第一种方法是建立一个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响应。
咱们在这里展现的方法对特定类型的页面是有用的。可是,该方法有两个缺点:
下一节,咱们将着眼于另外一种方法,其经过理解更多关于构造页面的底层数据来克服这些问题的某些限制。
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