用Django REST Framework做的REST API,其中有個API有這樣的需求:
APP端請求這個API,服務器端從數據庫讀數據,返回json。返回的數據量稍微有些大,但是可能一年才修改一次,所以希望能夠僅在數據修改的時候才傳輸數據,讓APP端更新。
1. Last-Modified和ETag
HTTP響應頭Last-Modified和ETag都能實現這個需求,關於二者的詳細解釋,這篇文章說的簡單明了:http://www.iwms.net/n2029c12.aspx
原文摘抄如下:
1) 什么是”Last-Modified”?
在瀏覽器第一次請求某一個URL時,服務器端的返回狀態會是200,內容是你請求的資源,同時有一個Last-Modified的屬性標記此文件在服務期端最后被修改的時間,格式類似這樣:
Last-Modified: Fri, 12 May 2006 18:53:33 GMT
客戶端第二次請求此URL時,根據 HTTP 協議的規定,瀏覽器會向服務器傳送 If-Modified-Since 報頭,詢問該時間之后文件是否有被修改過:
If-Modified-Since: Fri, 12 May 2006 18:53:33 GMT
如果服務器端的資源沒有變化,則自動返回 HTTP 304 (Not Changed.)狀態碼,內容為空,這樣就節省了傳輸數據量。當服務器端代碼發生改變或者重啟服務器時,則重新發出資源,返回和第一次請求時類似。從而保證不向客戶端重復發出資源,也保證當服務器有變化時,客戶端能夠得到最新的資源。
2) 什么是”Etag”?
HTTP 協議規格說明定義ETag為“被請求變量的實體值” (參見 —— 章節 14.19)。 另一種說法是,ETag是一個可以與Web資源關聯的記號(token)。典型的Web資源可以一個Web頁,但也可能是JSON或XML文檔。服務器單獨負責判斷記號是什么及其含義,並在HTTP響應頭中將其傳送到客戶端,以下是服務器端返回的格式:
ETag: "50b1c1d4f775c61:df3"
客戶端的查詢更新格式是這樣的:
If-None-Match: W/"50b1c1d4f775c61:df3"
如果ETag沒改變,則返回狀態304然后不返回,這也和Last-Modified一樣。本人測試Etag主要在斷點下載時比較有用。
3) Last-Modified和Etags如何幫助提高性能?
聰明的開發者會把Last-Modified 和ETags請求的http報頭一起使用,這樣可利用客戶端(例如瀏覽器)的緩存。因為服務器首先產生 Last-Modified/Etag標記,服務器可在稍后使用它來判斷頁面是否已經被修改。本質上,客戶端通過將該記號傳回服務器要求服務器驗證其(客戶端)緩存。
過程如下:
- 客戶端請求一個頁面(A)。
- 服務器返回頁面A,並在給A加上一個Last-Modified/ETag。
- 客戶端展現該頁面,並將頁面連同Last-Modified/ETag一起緩存。
- 客戶再次請求頁面A,並將上次請求時服務器返回的Last-Modified/ETag一起傳遞給服務器。
- 服務器檢查該Last-Modified或ETag,並判斷出該頁面自上次客戶端請求之后還未被修改,直接返回響應304和一個空的響應體。
2. Django中添加Last-Modified和ETag
Django中有三個decorator,可以為響應添加Last-Modified和ETag,參看:https://docs.djangoproject.com/en/1.8/topics/conditional-view-processing/
- condition(etag_func=None, last_modified_func=None)
- etag(etag_func)
- last_modified(last_modified_func)
要注意的是,官網給的使用方法都是針對function-based view,如果你使用的是class-based view,需要在url配置中調用decorator,例如:
url(r'^rest/colours/$', etag(get_colour_checksum)(views.ColourList.as_view()), name='colour-list'),
要實現前面的需求的話,思路很簡單,獲得數據表上次修改的時間或者給數據表算個hash值就行了。
一開始搜到了一個Mysql獲取table最近更新時間的方法:http://stackoverflow.com/questions/307438/how-can-i-tell-when-a-mysql-table-was-last-updated
SELECT UPDATE_TIME FROM information_schema.tables WHERE TABLE_SCHEMA = 'dbname' AND TABLE_NAME = 'tabname'
在自己的數據庫上試了一下,返回卻是NULL。仔細一看評論,這個方法在MyISAM上有用,但是在InnoDB上沒有用。只好再找其他辦法。
后來又找到一個能查看Table Checksum的語句:http://dev.mysql.com/doc/refman/5.5/en/checksum-table.html
CHECKSUM TABLE tbl_name [, tbl_name] ... [ QUICK | EXTENDED ]
測試有用!於是就用這個語句作為了生成ETag的方法。
3. 通過cache framework添加Last-Modified和ETag
因為我使用了Django的cache framework(https://docs.djangoproject.com/en/1.8/topics/cache/),將該請求的緩存時間設置為5分鍾。所以有三個響應頭部按照我設置的5分鍾緩存時間被自動添加:
- Cache-Control: max-age=5m
- Last-Modified: 2015 Sep 27 11:38:00
- Expires: 2015 Sep 27 11:43:00
但是5分鍾之后,這個緩存會被刷新,Last-Modified的時間也會相應被刷新,這樣無法滿足我前面說到的需求。那么應該怎么加入ETag呢?
通過閱讀UpdateCacheMiddleware源碼,我發現它在處理response的時候調用了函數 patch_response_headers,該函數源碼如下:
def patch_response_headers(response, cache_timeout=None): """ Adds some useful headers to the given HttpResponse object: ETag, Last-Modified, Expires and Cache-Control Each header is only added if it isn't already set. cache_timeout is in seconds. The CACHE_MIDDLEWARE_SECONDS setting is used by default. """ if cache_timeout is None: cache_timeout = settings.CACHE_MIDDLEWARE_SECONDS if cache_timeout < 0: cache_timeout = 0 # Can't have max-age negative if settings.USE_ETAGS and not response.has_header('ETag'): if hasattr(response, 'render') and callable(response.render): response.add_post_render_callback(_set_response_etag) else: response = _set_response_etag(response) if not response.has_header('Last-Modified'): response['Last-Modified'] = http_date() if not response.has_header('Expires'): response['Expires'] = http_date(time.time() + cache_timeout) patch_cache_control(response, max_age=cache_timeout)
從代碼中可以看出:
- 如果Last-Modified, Expires之前未被設置,則這里按照cache的設置為他們賦值。
- 如果 settings.USE_ETAGS 是True,且ETag未被設置,則會將調用默認方法或自定義方法,給ETag賦值。
因為默認的方法 _set_response_etag 是用響應內容算出MD5值賦給ETag,只要數據未變,ETag值是不變的,可以滿足需求。
所以需要做的就是在settings里面加上USE_ETAG=True,就行了。
4. nginx對ETag的支持
通過前面的設置,我在開發機器上已經實現了ETag的添加,可以當我將代碼部署到生產環境再測試的時候,確發現ETag沒有了。生產環境用的是gunicorn+nginx,所以很自然就想到是nginx那邊出了問題。
網上大部分搜到的nginx ETag相關的文章都在說怎么為靜態文件添加ETag,和我的情況不一樣。
花了很久,最后找到了原因:nginx 1.7.3 之前的版本對ETag支持不好,開了gzip就會自動把ETag刪掉。參看:http://stackoverflow.com/questions/31125888/nginx-missing-etag-when-gzip-is-used
解決方法很簡單,升級nginx到最新版本就行,連配置都不用修改。升級方法參看:http://nginx.org/en/linux_packages.html