轉: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