Gavin Terrill 發表于2007.7.11 上午 8:45

討論區:Java 主題:Web框架、集群及緩存

導言

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對本文的反饋。