頁面片段緩存(一)
一般,頁面上會分為很多部分,而不同的部分更新的頻率是不一樣的。如果對整個頁面采用統一的緩存策略則不太合適,
而且很多系統的頁面左上角都有一個該死的“Welcome XXX”。這種特定于用戶的信息我們是不能緩存的。對于這些情況我們就需要使用片段緩存了。對頁面不同的部分(片段)施加不同的緩存策略,而要使用片段緩存,首先就得對頁面進行切分。土一點的辦法可以用iframe,用iframe將頁面劃分為一塊塊的,不過我總覺得iframe是個邪惡的東西。好點的辦法可以用Ajax單獨的請求這個片段的內容然后再填充,看起來挺美好的。不過使用Ajax也有一些限制:
1、如果頁面上有許多片段,使用太多的這種技術,會有很多請求發送到服務器,HTTP對同一個域名有連接的限制,這樣會降低并發連接的效率。
2、如果說第一個不是什么問題,那么還有一點可能對用戶體驗不友好。比如有一個片段可能響應慢點,造成頁面閃爍。不過如果前面兩點都可以克服,這個方案還是可以的。可惡的是我們的客戶(此處省略500字),說他們的大多數用戶處于一個禁用JavaScript的環境里。好吧,這個方案也不能使用了。如是我們進行了一系列其他關于片段緩存的嘗試:
我們的系統使用的是Spring+Hibernate+Oracle技術,模板引擎使用的是Apache Velocity。假設下面的片段是我們要緩存的內容:
#foreach($book in $books)
<li><a href="/book/books/$book.id">$book.name</a>---<a href="/book/books/edit/$book.id">Edit</a> -- <a href="/book/books/delete/$book.id">Delete</a></li>
#end
</ul>
顯示一個圖書列表。對這個頁面改動最小的辦法是加上一個標簽,被這個標簽包圍的片段就是緩存的:
<ul>
#foreach($book in $books)
<li><a href="/book/books/$book.id">$book.name</a>---<a href="/book/books/edit/$book.id">Edit</a> -- <a href="/book/books/delete/$book.id">Delete</a></li>
#end
</ul>
#end
而不再需要解析這個圖書列表了。
有了這個想法,我們就需要找到如何讓Velocity解析我們的標簽的方案。很好,Velocity是支持自定義標簽的:
@Override
public String getName() {
return "cache";
}
@Override
public int getType() {
return BLOCK;
}
@Override
public boolean render(InternalContextAdapter context, Writer writer, Node node)
throws IOException, ResourceNotFoundException, ParseErrorException, MethodInvocationException {
Node keyNode = node.jjtGetChild(0);
String cacheKey = (String) keyNode.value(context);
String cacheHtml = cacheHtml = (String) CacheManager.getInstance().get(cacheKey);
if (StringUtils.isEmpty(cacheHtml)) {
Node bodyNode = node.jjtGetChild(1);
Writer tempWriter = new StringWriter();
bodyNode.render(context, tempWriter);
cacheHtml = tempWriter.toString();
CacheManager.getInstance().set(cacheKey, cacheHtml);
}
writer.write(cacheHtml);
return true;
}
}
關于Velocity的自定義標簽的使用我會在后面稍作解釋。
最主要的邏輯在render方法里,我們先根據cache key去緩存里取,如果沒取到再使用代碼render,然后render的結果放到緩存中。很典型的緩存使用場景是不。再來看看控制器端得代碼:
@RequestMapping("/books")
public class BookController {
private BookDAO bookDAO;
@Autowired
public BookController(BookDAO bookDAO) {
this.bookDAO = bookDAO;
}
@RequestMapping(value = {"", "index.html"}, method = RequestMethod.GET)
public ModelAndView index() {
return new ModelAndView("list", "books", bookDAO.findAll());
}
}
控制器很簡單,調用DAO,將所有圖書列出來即可。正在我們高興這么棘手的問題被解決的時候,問題來了:
我們的緩存是為了什么?總不是為了節約Velocity解析的時間吧。我想大家應該都知道,最主要的還是為了節約這次bookDAO.findAll()查詢數據庫的時間。但是回過頭看看我們的方案。不管我們的cache命沒命中,這個bookDAO.findAll()都會執行一次,因為控制器的執行是在視圖render之前發生的。我們唯一節省的是Velocity解析的時間,杯具。
找到了問題的答案,尋找解決辦法就容易了。我們要做的就是在緩存沒有命中的時候才執行查詢,那么這個數據查詢就必須放到cache標簽內部做。但是我們的cache標簽可不是為了一個片段啊,有很多片段,而各種片段取數據的方式卻不同。
嗯,你還記得接口么?還記得計算機里所有的問題都可以通過中間層解決的這個名言么?按照這個思路我們如此設計cache標簽:
<ul>
#foreach($book in $books)
<li><a href="/book/books/$book.id">$book.name</a>---<a href="/book/books/edit/$book.id">Edit</a> -- <a href="/book/books/delete/$book.id">Delete</a></li>
#end
</ul>
#end
我們傳入一個dataProvider對象進來,而這個dataProvider是控制器里傳入進來的,一個專門用來取數據的:
private DataProvider dataProvider;
@Autowired
public BookController(BookDataProvider dataProvider) {
this.dataProvider = dataProvider;
}
@RequestMapping(value = {"", "index.html"}, method = RequestMethod.GET)
public ModelAndView index() {
return new ModelAndView("list", "dataProvider", dataProvider);
}
}
控制器還是一如既往的簡單,我們再來看看cache標簽的實現:
@Override
public String getName() {
return "cache";
}
@Override
public int getType() {
return BLOCK;
}
@Override
public boolean render(InternalContextAdapter context, Writer writer, Node node)
throws IOException, ResourceNotFoundException, ParseErrorException, MethodInvocationException {
Node keyNode = node.jjtGetChild(0);
String cacheKey = (String) keyNode.value(context);
String cacheHtml = cacheHtml = (String) CacheManager.getInstance().get(cacheKey);
if (StringUtils.isEmpty(cacheHtml)) {
Node dataProviderNode = node.jjtGetChild(1);
DataProvider dataProvider = (DataProvider) dataProviderNode.value(context);
Map<String, Object> map = dataProvider.load();
for (String key : map.keySet()) {
context.put(key, map.get(key));
}
Node bodyNode = node.jjtGetChild(3);
Writer tempWriter = new StringWriter();
bodyNode.render(context, tempWriter);
cacheHtml = tempWriter.toString();
CacheManager.getInstance().set(cacheKey, cacheHtml);
}
writer.write(cacheHtml);
return true;
}
}
我們在標簽內部取到外部傳入的dataProvider,它實現了一個接口DataProvider,然后在標簽內部進行數據的查詢。
然后將查詢的數據put到velocity的context中,然后再次render,將render的結果放到緩存。這下好了,控制器里只需要向視圖傳遞一個可以取數據的對象就可以了,cache標簽內部會進行判斷。DataProvider和BookDataProvider的代碼:
Map<String,Object> load();
}
@Service
public class BooksDataProvider implements DataProvider{
private BookDAO bookDAO;
@Autowired
public BooksDataProvider(BookDAO bookDAO){
this.bookDAO = bookDAO;
}
public Map<String, Object> load() {
Map<String,Object> result = new HashMap<String,Object>();
result.put("books",bookDAO.findAll());
return result;
}
}
現在我們要改造的就是對于每個不同的緩存片段寫一個DataProvider的實現,而實際上這個實現原來已經有了:
就是原來控制器內那部分代碼。比如BooksDataProvider實際上就是原來BookController內的代碼。通過這種方式,我們基本上就將一個頁面劃分為很多用cache包圍的小片段了,只要劃分出來了那么你就可以對不同
的片段采用不同的緩存策略。代碼的結構還算清晰。
下面我稍微介紹一下Velocity自定義標簽的使用
Velocity自定義標簽
每個自定義標簽都從Directive派生下來,我們要覆蓋幾個方法。
1、getName,返回一個字符串,這個就是你在velocity模板里使用的那個標簽的名字:#cache。
2、標簽的類型,像我們的cache這種#cache…#end的叫塊級標簽,那么返回的就是一個BLOCK(常量1)。還有一個類型是LINE(2),那就沒有那個#end了。
3、render方法,這是最主要的,你可以覆蓋一些行為。
取外部傳入的參數
那么在標簽內部如何取得外部傳入的參數呢?其實它的行為和xml path的操作方式差不多。在render方法的參數里有一個Node,這個就是標簽自身。我們可以通過node.jjtGetChild(index)來取得各種參數。以0開始,如果是BLOCK類型的標簽,那么最后一個就是標簽包圍的內容了(比如我們的cache標簽)。然后我們可以通過node的value取到參數的值。取值的時候還將context傳入進去了,這說明這個值是可計算的。比如現在有這么一個需求,我們的圖書列表是分頁的,但是只緩存第一頁,后面的不緩存。那我們就期望能傳入
一個表達式,讓cache標簽自己計算一把,如果這個表達式為true則緩存,否則不緩存:
...
#end
Boolean needCache = (Boolean) needCacheNode.value(context);
String cacheHtml = StringUtils.EMPTY;
if (needCache) {
cacheHtml = (tring) CacheManager.getInstance().get(cacheKey);
}
這樣就可以計算出$pageIndex==1這個表達式的結果(當然,$pageIndex這個變量是需要傳入進來的)。
好了,編寫好自定義標簽的代碼我們可以使用了。而并不是我們寫好這代碼往那兒一丟就可以使用了,還需要一個配置環節:
<property name="resourceLoaderPath" value="/WEB-INF/templates/"/>
<property name="velocityProperties">
<props>
<prop key="userdirective">com.yuyijq.web.Cache</prop>
</props>
</property>
</bean>
好了,這么一個利用Velocity的片段緩存就完成了。但是這種方式也存在一些問題,給我們帶來了一些bug。
1、有的時候我們會在片段里set一些變量,然后在這個片段外使用。但是現在使用了cache之后,cache之后的是HTML
文本,這些變量全部消失了,那么片段外也取不到這些變量的值了。那我們就需要仔細搜查Velocity模板,將這些變量的使用全部移動到片段內部。如果是一開始就設計了這個片段緩存還好,我們可以注意這個問題。但問題是現在是項目的中途提出的,系統中velocity模板成千上萬,我們每緩存一個片段就要仔細檢查一番,而且Velocity模板沒有測試(當然可以寫測試,但很麻煩)。這個過程全部靠人肉,所以出bug的幾率會很高。
2、還是一樣,改動比較大,不僅模板,后面的控制器也需要修改。不過貌似也沒什么更好的方法,要使用片段緩存貌似改動是避免不了的。
成熟的方案
片段緩存應該是大型系統里經常采用的,那么就應該有一些成熟的方案。我們為何不尋找那些成熟的方案要重新制造輪子呢。這個方案就是ESI,在下一篇文章中我會介紹結合Varnish和ESI來做片段緩存。