导言
REST方式的应用程序构架在近日所产生的巨大影响突出了Web应用程序的优雅设计的重要性。现在人们开始理解“WWW架构”内在的可测量性及弹性,并且已经开始探索使用其范例的更好的方式。在本文中,我们将讨论一个Web应用开发工具——“简陋的、卑下的”ETags,以及如何在基于SpringFramework的动态Web应用程序中集成这个工具,来提高应用的性能及可测性。
我们将要使用的基于Spring的应用程序是基于“petclinic”(宠物门诊?)的一个应用。在您下载的程序包中,包含了如何加入必要的配置和源代码让你亲自体验该程序的介绍。
什么是ETag
在HTTP协议规范中,ETag被定义为“被请求的变量的实体值”。(
参见 http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html - Section 14.19。)换句话说,ETag是一个与Web资源相关联的标记。典型的Web资源是一个Web页面,但也可以是一个JSON格式或者XML格式的文档。服务器可以指出一个标记是什么及其意义,并将这个标记放在HTTP头重传送给客户端。
ETag如何提高应用程序性能
ETag和一个GET请求的“If-None-Match”头信息一起使用,服务器开发者以此来使用客户端缓存的优势。服务器在客户端的一次请求时产生ETag,并在以后的请求中判断被请求资源是否发生了变化。确切的说,客户端将这个标记传回给服务器,来验证它自己的缓存是否有效。
整个处理过程如下:
客户端请求页面A
服务器响应,返回页面A,附加ETag
客户端显示A,并将页面和ETag一并缓存
客户端再次请求页面A,请求中包含了上次请求页面A时返回的ETag
服务器检查客户端发送过来的ETag,并确定页面A在该客户端上次请求后到现在没有发生过变化,因此,发送一个304(未改变)响应头给客户端,附带一个空的响应体。
文章的剩余部分将讨论在基于SpringFramework的使用SpringMVC的Web应用程序中使用ETag两种方式。首先,我们将通过一个Servlet2.3 过滤器,使用由计算请求返回结果的MD5值而产生的ETag(一个简单的ETag实现)。第二种方式使用一种更加“专业”的方式通过跟踪页面呈现所用到的模型的变化来确定ETag的有效性(一个“专业”的ETag实现)。虽然我们在这里使用了Spring MVC,但这个技术适用于其他任何的MVC框架。
在继续之前,我们有必要明确,ETag技术是为了希望改进动态产生的页面的访问速度而提出的。作为一个完整的性能优化方案和性能分析,其他的性能优化技术依然应当被考虑。
自顶向下的Web缓存
本文首先讨论将HTTP缓存技术应用于动态页面。寻求Web应用程序优化方案时,我们应当采用一个完整的,自顶向下的步骤。从根本上说,理解HTTP请求的过程是很重要的,采用哪种具体的技术取决于你在什么场合。例如:
Apache可以放在你的Servlet容易之前,来接受如图片,js请求,同时也可以使用FileETag指令产生ETag响应头。
使用Javascript优化技术,例如将多个js文件合并,并去除空格等无用信息。
利用GZip和Cache-Control响应头。
使用JamonPerformanceMonitorInterceptor确定你的Spring应用系统中的性能瓶颈。
确定你充分地使用了ORM工具的缓存机制,从而使得实体信息不是频繁的从数据库中重新加载。搞清楚如何让查询缓存很好的工作需要一定的时间。
确保尽量少聪数据库中重新加载数据,特别是一些大的列表。大列表应当被按页分割,对每一页的请求返回大列表的一个小的子集。
Session中保存尽量少的信息。这降低了内存要求,在建立应用层集群时将会显得非常有用。
使用一个数据库调试工具,确定查询时使用了哪些索引,查询时数据表将不会被锁定。
当然了,性能优化的最佳格言是适用的:测量两次,切割一次。(多次测试后再修改)
等等,上面的话是对木匠说的,但虽然如此,它一样适用于我们!
一个内容主体ETag过滤器
我们将看到的第一种方式是建立一个Servlet过滤器基于页面内容(MVC中的View)来产生ETag标记。乍一看,使用这种方式对性能的提升似乎没什么大的作用。服务器依然需要声称页面,并且增加了计算标记值的时间。但是,在这里我们的目的是减少带宽占用。这对于很多的反应时间很长的情形是一个很大的益处,例如如果你的应用的服务器和客户端分别在地球的不同半球上。我曾看到一个从东京发出的对纽约的某台服务器的请求,响应长达350毫秒。考虑并发用户因素后,这将成为一个重大的瓶颈。
代码
我们用于产生标记的技术是计算页面返回内容的MD5值。创建一个响应包装器将完成这个工作。包装器使用一个字节数组来保存返回内容,在过滤器链处理完成之后,我们计算这个字节数组的MD5哈希值。
doFilter方法的实现如下:
1public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException,
2 ServletException {
3 HttpServletRequest servletRequest = (HttpServletRequest) req;
4 HttpServletResponse servletResponse = (HttpServletResponse) res;
5
6 ByteArrayOutputStream baos = new ByteArrayOutputStream();
7 ETagResponseWrapper wrappedResponse = new ETagResponseWrapper(servletResponse, baos);
8 chain.doFilter(servletRequest, wrappedResponse);
9
10 byte[] bytes = baos.toByteArray();
11
12 String token = '"' + ETagComputeUtils.getMd5Digest(bytes) + '"';
13 servletResponse.setHeader("ETag", token); // always store the ETag in the header
14
15 String previousToken = servletRequest.getHeader("If-None-Match");
16 if (previousToken != null && previousToken.equals(token)) { // compare previous token with current one
17 logger.debug("ETag match: returning 304 Not Modified");
18 servletResponse.sendError(HttpServletResponse.SC_NOT_MODIFIED);
19 // use the same date we sent when we created the ETag the first time through
20 servletResponse.setHeader("Last-Modified", servletRequest.getHeader("If-Modified-Since"));
21 } else { // first time through - set last modified time to now
22 Calendar cal = Calendar.getInstance();
23 cal.set(Calendar.MILLISECOND, 0);
24 Date lastModified = cal.getTime();
25 servletResponse.setDateHeader("Last-Modified", lastModified.getTime());
26
27 logger.debug("Writing body content");
28 servletResponse.setContentLength(bytes.length);
29 ServletOutputStream sos = servletResponse.getOutputStream();
30 sos.write(bytes);
31 sos.flush();
32 sos.close();
33 }
34 }
Listing 1: ETagContentFilter.doFilter
应该注意到,我们设置了“Last-Modified”响应头。这是因为我们需要组织良好的内容格式,以对应哪些无法理解ETag响应头的客户端。
上面的示例代码用到了一个EtagComputeUtils工具类来产生一个对象的字节数组表示并处理MD5杂凑逻辑。在这里我使用javax.security.MessageDigest来计算MD5值。
1public static byte[] serialize(Object obj) throws IOException {
2 byte[] byteArray = null;
3 ByteArrayOutputStream baos = null;
4 ObjectOutputStream out = null;
5 try {
6 // These objects are closed in the finally.
7 baos = new ByteArrayOutputStream();
8 out = new ObjectOutputStream(baos);
9 out.writeObject(obj);
10 byteArray = baos.toByteArray();
11 } finally {
12 if (out != null) {
13 out.close();
14 }
15 }
16 return byteArray;
17 }
18
19 public static String getMd5Digest(byte[] bytes) {
20 MessageDigest md;
21 try {
22 md = MessageDigest.getInstance("MD5");
23 } catch (NoSuchAlgorithmException e) {
24 throw new RuntimeException("MD5 cryptographic algorithm is not available.", e);
25 }
26 byte[] messageDigest = md.digest(bytes);
27 BigInteger number = new BigInteger(1, messageDigest);
28 // prepend a zero to get a "proper" MD5 hash value
29 StringBuffer sb = new StringBuffer('0');
30 sb.append(number.toString(16));
31 return sb.toString();
32 }
33Listing 2: ETagComputeUtils
在Web.xml中调用这个过滤器是很简单的:
1<filter>
2 <filter-name>ETag Content Filter</filter-name>
3 <filter-class>org.springframework.samples.petclinic.web.ETagContentFilter</filter-class>
4</filter>
5
6<filter-mapping>
7 <filter-name>ETag Content Filter</filter-name>
8 <url-pattern>/*.htm</url-pattern>
9</filter-mapping>
Listing 3: Configuration of the filter in web.xml.
每一个htm文件将被EtagContentFilter过滤,如果该文件在上次请求后没有发生变化,则返回一个空的HTTP响应体。
上面讨论的方式对于确定类型的页面很有用,但也有一些缺点。
页面在服务器段生成之后,在返回给客户端之前,我们计算了ETag值,如果ETag匹配,那么我们实在是没有必要去取出模型数据,因为渲染出来的页面将不会返回给客户端。
对于在页脚呈现日期和时间的页面,每次请求都是不同的,即使页面的主题内容并没有发生改变。
下面,我们将看到另一种可选的方法——通过理解构建页面的底层数据来解决上面的限制带来的问题。
ETag拦截器
Spring MVC中的HTTP请求传递途径包含了一种可以在控制器处理请求之前插入一个拦截器的能力。这对于插入ETag对比逻辑来说是一个极其合适的切入点,在这里,如果发现构建页面的数据没有发生变化,我们就可以停止更进一步的处理。
这里的诀窍是如何知道构建所请求的页面的数据没有发生变化。为了本文的目的,我创建了一个简单的ModifiedObjectTracker,通过Hiberante事件监听器来跟踪新增、更新、删除操作。跟踪器将为每一个页面保持一个为一个数字,以及一个影响到该页面的持久化实体的Map。如果一个POJO发生了变化,那么一个技术其将增加所有用到了这个POJO的页面对应的数字。将这个数字作为ETag,当客户端将ETag返回时,我们将会知道一个页面所用到的模型是否发生了变化。
代码
从ModifiedObjectTracker开始:
1 public interface ModifiedObjectTracker {
2 void notifyModified(> String entity);
3 }
很简单吧?它的实现会比较有意思。每当一个实体发生了变化,我们为每一个用到了该实体的页面更新对应的计数器。
1 public void notifyModified(String entity) {
2 // entityViewMap is a map of entity -> list of view names
3 List views = getEntityViewMap().get(entity);
4
5 if (views == null) {
6 return; // no views are configured for this entity
7 }
8
9 synchronized (counts) {
10 for (String view : views) {
11 Integer count = counts.get(view);
12 counts.put(view, ++count);
13 }
14 }
15 }
一次“变化”就是一次新增、修改或者删除操作。下面是针对删除操作的处理器列表(作为事件监听器配置在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处理实体的新增和修改。
如果客户端发回了一个当前有效的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请求(ETag可以在客户端发出PUT请求时验证更新是否冲突,但那已经超出了本文的范围)。如果标记和服务器上次返回的标记相匹配,则返回一个304位发生改变响应,并绕过后面的处理链。否则,我们设置一个ETag响应头,以备客户端下次请求同样的页面。
可以看到,我将产生标记的逻辑抽象出来形成了一个接口,如此我们则可以使用不同的标记生成策略。该接口只有一个方法:
public interface ETagTokenFactory {
String getToken(HttpServletRequest request);
}
为了少列出一些代码,我的SampleTokenFactory实现同时承担了ETagTokenFactory的任务。如此,我们简单的将被请求的URL的修改次数作为标记返回。
public String getToken(HttpServletRequest request) {
String view = request.getRequestURI();
Integer count = counts.get(view);
if (count == null) {
return null;
}
return count.toString();
}
就这样!
讨论
在这里,我们的拦截器将在没有相关数据发生变化时阻止一切收集数据和渲染页面的处理过程。现在,让我们来看一下HTTP头,以及在表象之下到底发生了些什么。示例程序中包含了使得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是否改变。为这个Owner增加了一个Pet:
----------------------------------------------------------
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,所以不设置相关的响应头。现在,我们再次访问Owener 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
最后,我们再次请求Owener 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: 事实上,采用更细粒度的对象变化跟踪,例如使用对象标识。可以更大程度的提高效率。但是,页面和实体之间的关联很大程度上是由系统中的数据模型设计决定的。上面的实现(ModifiedObjectTracker)是一个说明性的例子,谜底是为更深入的尝试提供思路。上面的实现的目的不是应用于实际的生产环境中(例如不适用于集群环境),一种更远的考虑是使用数据库的触发器跟踪数据变化,让拦截器监测触发器输出结果所在的数据表。
结论
我们已经看到了使用ETag降低贷款占用和缩短处理周期的两种方法。我所希望的是这篇文章为你现在和将来的Web应用项目提供了一种思路,以及对底层的ETag响应头的正确理解和使用。
正如牛顿所说,“如果我看得更远,那是因为我站在巨人的肩膀上”。作为REST的核心,这种风格的应用程序讲的是简单、优雅的软件设计,不重复发明轮子。我相信了解和使用REST风格的架构的核心是主流应用程序开发的一个好的发展,并且我盼望着在以后的开发中能够抬起它的未来。
关于作者
Gavin Terrill是BPS的CTO。从事Java企业级应用开发20年以上。现在依然拒绝发布他的TRS-80。在空闲时间,Gavin喜欢航行、钓鱼、吉他和一饮而尽高质量的红葡萄酒(不一定要按这个顺序来)。
感谢
感谢我的同事 Patrick Bourke 和Erick Dorvale对本文的反馈。