Velocity工作原理解析和優化
目錄
在MVC開發模式下,View離不開模板引擎,在Java語言中模板引擎使用得最多是JSP、Velocity和FreeMarker,在MVC程式設計開發模式中,必不可少的一個部分是V的部分。V負責前端的頁面展示,也就是負責生產最終的HTML,V部分通常會對應一個編碼引擎,當前眾多的MVC框架都已經可以將V部分獨立開來,可以與眾多的模板引擎整合。
Velocity總體架構
從程式碼結構上看,Velocity主要分為app、context、runtime和一些輔助util幾個部分。
APP模組
其中app主要封裝了一些介面,暴露給使用者使用。主要有兩個類,分別是Velocity(單例)和VelocityEngine。
前者主要封裝了一些靜態介面,可以直接呼叫,幫助你渲染模板,只要傳給Velocity一個模板和模板中對應的變數值就可以直接渲染。
VelocityEngine類主要是供一些框架開發者呼叫的,它提供了更加複雜的介面供呼叫者選擇,MVC框架中初始化一個VelocityEngine:
以上是Spring MVC建立Velocity模板引擎的VelocityEngine例項的程式碼段,先建立一個VelocityEngine例項,再將配置引數設定到VelocityEngine的Property中,最終呼叫init方法初始化。
Context模組
Context模組主要封裝了模板渲染需要的變數,它的主要作用有兩點:
- 便於與其他框架整合,起到一個介面卡的作用,如MVC框架內部儲存的變數往往在一個Map中,這樣MVC框架就需要將這個Map適配到Velocity的context中。
- Velocity內部做資料隔離,資料進入Velocity的內部的不同模組需要對資料做不同的處理,封裝不同的資料介面有利於模組之間的解耦。
Context類是外部框架需要向Velocity傳輸資料必須實現的介面,具體實現時可以整合抽象類AbstractContext,例如,Spring MVC中直接繼承了VelocityContext,呼叫建構函式建立Velocity需要的資料結構。
另外一個介面InternetEventContext主要是為擴充套件Velocity事件處理準備的資料介面,當你擴充套件了事件處理、需要操作資料時可以實現這個介面,並且處理你需要的資料。
Runtime模組
整個Velocity的核心模組在runtime package下,這裡會將載入的模板解析成JavaCC語法樹,Velocity呼叫mergeTemplate方法時會渲染整棵樹,並輸出最終的渲染結果。
RuntimeInstance類
RuntimeInstance類為整個Velocity渲染提供了一個單例模式,它也是Velocity的一個門面,封裝了渲染模板需要的所有介面,拿到了這個例項就可以完成渲染過程了。它與VelocityEngine不同,VelocityEngine代表了整個Velocity引擎,它不僅包括模板渲染,還包括引數設定及資料的封裝規則,RuntimeInstance僅僅代表一個模板的渲染狀態。
JJTree渲染過程解析
下面是一段Velocity的模板程式碼vm和這段程式碼解析成的語法樹:
Velocity渲染這段程式碼將從根節點ASTproces開始,按照深度優先遍歷演算法開始遍歷整棵樹,遍歷的程式碼如下所示:
如程式碼所示,依次執行當前節點的所有子節點的render方法,每個節點的渲染規則都在render方法中實現,對應到上面的vm程式碼,#foreach節點對應到ASTDirective。這種型別的節點是一個特殊的節點,它可以通過directiveName來表示不同型別的節點,目前ASTDirective已經有多個,如#break、#parse、#include、#define等都是ASTDirective型別的節點。這種型別的節點通常都有一個特點,就是它們的定義類似於一個函式的定義,一個directiveName後面跟著一對括號,括號裡含有引數和一些關鍵詞,如#foreach,directiveName是foreach,括號中的$i是ASTReference型別,in是關鍵詞ASTWord型別,[1 ..10]是一個數組型別ASTIntegerRange,在#foreach和#end之間的所有內容都由ASTBlock表示。
所謂的指令指的就是在頁面上能用一些類似標籤的東西。Velocity預設的指令檔案位置在org/apache/velocity/runtime/defaults/directive.properties。
在這個檔案中定義了一些預設的指令,例如:
directive.1=org.apache.velocity.runtime.directive.Foreach
directive.2=org.apache.velocity.runtime.directive.Include
directive.3=org.apache.velocity.runtime.directive.Parse
directive.4=org.apache.velocity.runtime.directive.Macro
directive.5=org.apache.velocity.runtime.directive.Literal
directive.6=org.apache.velocity.runtime.directive.Evaluate
directive.7=org.apache.velocity.runtime.directive.Break
directive.8=org.apache.velocity.runtime.directive.Define
我們在vm檔案中可以直接使用foreach等指令來讓我們的頁面更加的靈活。
Velocity的語法相對簡單,所以它的語法節點並不是很多,總共有50幾個,它們可以劃分為如下幾種型別。
- 塊節點型別:主要用來表示一個程式碼塊,它們本身並不表示某個具體的語法節點,也不會有什麼渲染規則。這種型別的節點主要由ASTReference、ASTBlock和ASTExpression等組成。
- 擴充套件節點型別:這些節點可以被擴充套件,可以自己去實現,如我們上面提到的#foreach,它就是一個擴充套件型別的ASTDirective節點,我們同樣可以自己再擴充套件一個ASTDirective型別的節點。
- 中間節點型別:位於樹的中間,它的下面有子節點,它的渲染依賴於子節點才能完成,如ASTIfStatement和ASTSetDirective等。
- 葉子節點:它位於樹的葉子上,沒有子節點,這種型別的節點要麼直接輸出值,要麼寫到writer中,如ASTText和ASTTrue等。
Velocity讀取vm模板根據JavaCC語法分析器將不同型別的節點按照上面的幾個型別解析成一個完整的語法樹。
在呼叫render方法之前,Velocity會呼叫整個節點樹上所有節點的init方法來對節點做一些預處理,如變數解析、配置資訊獲取等。這非常類似於Servlet例項化時呼叫init方法。Velocity在載入一個模板時也只會呼叫init方法一次,每次渲染時呼叫render方法就如同呼叫Servlet的service方法一樣。
#set語法
#set語法可以建立一個Velocity的變數,#set語法對應的Velocity語法樹是ASTSetDirective類,翻開這個類的程式碼,可以發現它有兩個子節點:分別是RightHandSide和LeftHandSide,分別代表“=”兩邊的表示式值。與Java語言的賦值操作有點不一樣的是,左邊的LeftHandSide可能是一個變數識別符號,也可能是一個set方法呼叫。變數識別符號很好理解,如前面的#set($var=“偶數”),另外是一個set方法呼叫,如#set($person.name=”junshan”),這實際上相當於Java中person.setName(“junshan”)方法的呼叫。
#set語法如何區分左邊是變數識別符號還是set方法呼叫?看一下ASTSetDirective類的render方法:
從程式碼中可以看到,先取得右邊表示式的值,然後根據左邊是否有子節點判斷是變數識別符號還是呼叫set方法。通過#set語法建立的變數是否有有效範圍,從程式碼中可以看到會將這個變數直接放入context中,所以這個變數在這個vm模板中是一直有效的,它的有效範圍和context也是一致的。所以在vm模板中不管在什麼地方通過#set建立的變數都是一樣的,它對整個模板都是可見的。
Velocity的方法呼叫
Velocity的方法呼叫方式有多種,它和我們熟悉的Java的方法呼叫還是有一些區別之處的,如果你不熟悉,可能會產生一些誤解,下面舉例介紹一下。
Velocity通過ASTReference類來表示一個變數和變數的方法呼叫,ASTReference類如果有子節點,就表示這個變數有方法呼叫,方法呼叫同樣是通過“.”來區分的,每一個點後面會對應一個方法呼叫。ASTReference有兩種型別的子節點,分別是ASTIdentifier和ASTMethod。它們分別代表兩種型別的方法呼叫,其中ASTIdentifier主要表示隱式的“get”和“set”型別的方法呼叫。而ASTMethod表示所有其他型別的方法呼叫,如所有帶括號的方法呼叫都會被解析成ASTMethod型別的節點。
所謂隱式方法呼叫在Velocity中通常有如下幾種。
1.Set型別,如#set($person.name=”junshan”),如下:
- person.setName(“junshan”)
- person.setname(“junshan”)
- person.put(“name”,”junshan”)
2.Get型別,如#set($name=$person.name)中的$person.name,如下:
- person.getName()
- person.getname()
- person.get(“name”)
- person.isname()
- person.isName()
Get&Set反射呼叫
Set 繼承SetExecutor:當Velocity在解析#set($person.name=”junshan”)時,它會找到$person對應的物件,然後建立一個SetPropertyExecutor物件並查詢這個物件是否有setname(String)方法,如果沒有,再查詢setName(String)方法,如果再沒有,那麼再建立MapSetExecutor物件,看看$person對應的物件是不是一個Map。如果是Map,就呼叫Map的put方法,如果不是Map,再建立一個PutExecutor物件,檢查一下$person對應的物件有沒有put(String)方法,如果存在就呼叫物件的put方法。
Get:除去Set型別的方法呼叫,其他的方法呼叫都繼承了AbstractExecutor類,如#set($name=$person.name)中解析$person.name時,建立PropertyExecutor物件封裝可能存在的getname(String)或getName(String)方法。否則建立MapGetExecutor檢查$person變數是否是一個Map物件。如果不是,建立GetExecutor物件檢查$person變數對應的物件是否有get(“Name”)方法。如果還沒有,建立BooleanPropertyExecutor物件並檢查$person變數對應的物件是否有isname()或者isName()方法。找到對應的方法後,將相應的java.lang.reflect.Method物件封裝在對應的封裝物件中。
以上這些查詢順序中,某個方法找到後就直接返回某種型別的Executor物件包裝的Method,然後通過反射呼叫Method的invoke方法。Velocity的反射呼叫是通過Introspector類來完成的,它定義了類物件的方法查詢規則。
顯式呼叫:除去以上對兩種隱式的方法呼叫的封裝外,Velocity還有一種簡單的方法呼叫方式,就是帶有括號的方法呼叫,如$person.setName(“junshan”),這種精確的方法呼叫會直接查詢變數$person對應的物件有沒有setName(String)方法,如果有,會直接返回一個VelMethod物件,這個物件是對通用的方法呼叫的封裝,它可以處理$person對應的物件是陣列型別或靜態類時的情況。陣列的情況如string=newString[]{“a”,”b”,”c”},要取的第二個值在Java中可以通過string[1]來取,但在Velocity中可以通過$string.get(1)取得陣列的第二個值。為何能這樣做呢?可以看一下Velocity中相應的程式碼:
從上面的程式碼中我們可以發現,精確查詢方法的規則是查詢$person對應的物件是否有指定的方法,然後檢查該物件是否是陣列,如果是陣列,把它封裝成List,然後按照ArrayListWrapper類去代理訪問陣列的相應值。如果$person對應的物件是靜態類,可以呼叫其靜態方法。
#if、#elseif和#else語法
#if和#else節點是Velocity中的邏輯判斷節點,它的語法規則幾乎和Java是一樣的,主要的不同點在條件判斷上,如Velocity中判斷#if($express)為true的情況是隻要$express變數的值不為null和false就行,而Java中顯然不能這樣判斷。
除單個變數的值判斷之外,Velocity還支援Java的各種表示式判斷,如“>”、“<”、“==”和邏輯判斷“&&”、“||”等。每一個判斷條件都會對應一個節點類,如“==”對應的類為ASTEQNode,判斷兩個值是否相等的條件為:先取得等號兩邊的值,如果是數字,比較兩個數字的大小是否相等,再判斷兩邊的值是否都是null,都為null則相等,否則其中一個為null,肯定不等;再次就是取這兩個值的toString(),比較這兩個值的字元值是否相等。值得注意的是,Velocity中並不能像Java中那樣判斷兩個變數是否是同一個變數,也就是object1==object2與object1. equals(object2)在Velocity中是一樣的效果。
特別要注意的是,很多人在寫Velocity程式碼時有類似這樣的寫法,如#if("$example.user"== "null")和#if("$example.flag" == "true"),這些寫法都是不正確的,正確的寫法是#if($example.user)和#if($example.flag)。
若要使用 #ifnull() 或 #ifnotnull(), 要使用#ifnull ($foo)這個特性必須在velocity.properties檔案中加入:
userdirective = org.apache.velocity.tools.generic.directive.Ifnull
userdirective = org.apache.velocity.tools.generic.directive.Ifnotnull
如果有多個#elseif節點,Velocity會依次判斷每個子節點,從#if節點的render方法程式碼中我們可以看出,第一個子節點就是#if中的表示式判斷,這個表示式的值為true則執行第二個子節點,第二個子節點就是#if下面的程式碼塊。如果#if中表達式判斷為false,則繼續執行後面的子節點,如果存在其他子節點肯定就是#elseif或者#else節點了,其中任何一個為true將會執行這個節點的render方法並且會直接返回。
#foreach語法
Velocity中的迴圈語法只有這一種,它與Java中的for迴圈的語法糖形式十分類似,如#foreach($child in $person.children) $person.children表示的是一個集合,它可能是一個List集合或者一個數組,而$child表示的是每個從集合中取出的值。從render方法程式碼中可以看出,Velocity首先是取得$person.children的值,然後將這個值封裝成Iterator集合,然後依次取出這個集合中的每一個值,將這個值以$child為變數識別符號放入context中。除此以外需要特別注意的是,Velocity在迴圈時還在context中放入了另外兩個變數,分別是counterName和hasNextName,這兩個變數的名稱分別在配置檔案配置項directive.foreach.counter.name和directive.foreach.iterator.name中定義,它們表示當前的迴圈計數和是否還有下一個值。前者相當於for(int i=1;i<10;i++)中的i值,後者相當於while(it.hasNext())中的it.hasNext()的值,這兩個值在#foreach的迴圈體中都有可能用到。由於elementKey、counterName和hasNextName是在#foreach中臨時建立的,如果當前的context中已經存在這幾個變數,要把原始的變數值儲存起來,以便在這個#foreach執行結束後恢復。如果context中沒有這幾個變數,那麼#foreach執行結束後要刪除它們,這就是程式碼最後部分做的事情,這與我們前面介紹的#set語法沒有範圍限制不同,#foreach中臨時產生的變數只在#foreach中有效。
#parse語法
#parse語法也是Velocity中十分常用的語法,它的作用是可以讓我們對Velocity模板進行模組化,可以將一些重複的模組抽取出來單獨放在一個模板中,然後在其他模板中引入這個重用的模板,這樣可以增加模板的可維護性。而#parse語法就提供了引入一個模板的功能,如#parse(‘head.vm’)引入一個公共頁頭。當然head.vm可以由一個變數來表示。#parse和#foreach一樣都是通過擴充套件節點ASTDirective來解析的,所以#parse和#foreach一樣都共享當前模板執行環境的上下文。雖然#parse是單獨一個模板,但是這個模板中變數的值都在#parse所在的模板中取得。Velocity中的#parse我們可以僅理解為只是將一段vm程式碼放在一個單獨的模板中,其他沒有任何變化。 從程式碼中可以看出執行分為三部分,首先取得#parse(‘head.vm’)中的head.vm的模板名,然後呼叫getTemplate獲取head.vm對應的模板物件,再呼叫該模板對應的整個語法樹的render方法執行渲染。#parse語法的執行和其他的模板的渲染沒有什麼區別,只不過模板渲染時共用了父模板的context和writer物件而已。
事件處理機制
Velocity的事件處理機制所涉及的類在org.apache.velocity.app.event下面, EventHandler是所有類的父介面,EventHandler類有5個子類,分別代表5種不同的事件處理型別。
- ReferenceInsertionEventHandler:表示針對Velocity中變數的事件處理,當Velocity在渲染輸出某個“$”表示的變數時可以對這個變數做修改,如對這個變數的值做安全過濾以防止惡意JS程式碼出現在頁面中等。
- NullSetEventHandler:顧名思義是對#set語法賦值為null時的事件做處理。
- MethodExceptionEventHandler:這個事件是對Velocity在反射執行某個方法呼叫時出錯後,有機會做一些處理,如捕獲異常、控制返回一些特殊值等。
- InvalidReferenceEventHandler:表示Velocity在解析“$”變量出現沒有找到對應的物件時做如何處理。
- IncludeEventHandler:在處理#include和#parse時提供了處理和修改載入外部資源的機會。
Velocity提供的這些事件處理機制也為我們擴充套件Velocity提供了機會,如果你想擴充套件Velocity,必須對它的事件處理機制有很好的理解。
如何呼叫到擴充套件的EventHandler?Velocity提供了兩種方式,Velocity在渲染時遇到符合的事件都會檢查以下的EventCartridge:
- 把你新建立的EventHandler直接加到org.apache.velocity.runtime.RuntimeInstance類的eventCartridge屬性中,直接將自定義的EventHandler通過配置項eventCartridge.classes來設定,Velocity在初始化RuntimeInstance時會解析配置項,然後會例項化EventHandler。
- 把自定義的EventHandler加到自己建立的EventCartridge物件中,然後在渲染時把這個EventCartridge物件通過呼叫attachToContext方法加到context中,但是這個context必須要繼承InternalEventContext介面,因為只有這個接口才提供了attachToContext方法和取得EventCartridge的getEventCartridge方法。動態地設定EventHandler,只要將EventHandler加到渲染時的context中,Velocity在渲染時就能呼叫它。
EventCartridge中儲存了所有的EventHandler,並且EventCartridge把它們分別儲存在5個不同的屬性集合中,分別是referenceHandlers、nullSetHandlers、methodExceptionHandlers、includeHandlers和invalidReferenceHandlers。如何找到EventHandle?Velocity在渲染時分別在兩個地方檢查可能存在的EventHandler,那就是RuntimeInstance物件和渲染時的context物件,這兩個物件在Velocity渲染時隨時都能訪問到。何時被觸發?有一個類EventHandlerUtil,它就負責在合適的事件觸發時呼叫事件處理介面來處理事件。如變數在輸出到頁面之前會呼叫value = EventHandlerUtil.referenceInsert(rsvc, context, literal(), value)來檢查是否有referenceHandlers需要呼叫。其他事件也是類似處理方式。
ps:
擴充套件Velocity的事件處理會涉及對Context的處理,Velocity增加了一個ContextAware介面,如果你實現的EventHandler需要訪問Context,那麼可以繼承這個介面。Velocity在呼叫EventHandler之前會把渲染時的context設定到你的EventHandler中,這樣你就可以在EventHandler中取到context了。如果要訪問RuntimeServices物件,同樣可以繼承RuntimeServicesAware介面。
Velocity還支援另外一種擴充套件方式,就是在渲染某個變數的時候判斷這個變數是不是Renderable類的例項,如果是,將會呼叫這個例項的render( InternalContextAdapter context, Writer writer)方法,這種呼叫是隱式呼叫,也就是不需要在模板中顯式呼叫render()方法。
優化的理論基礎
程式的語言層次結構和這個語言的執行效率形成一對倒立的三角形結構。從圖中可以看出,越是上層的高階語言,它的執行效率往往越低。這很好理解,因為最底層的程式語言只有計算機能明白,與人的思維很不接近,為什麼我們開發出這麼多上層語言,很重要的目的就是對底層的程式做封裝,使得我們開發更方便,很顯然這些經過重重封裝的語言的執行效率肯定比沒有經過封裝的底層程式語言的效率要差很多,否則和硬體相關的驅動程式也不會用C語言或組合語言來實現了。
資料結構減少抽象化
程式的本質是資料結構加上演算法,演算法是過程,而資料結構是載體。程式語言也是同樣的道理,越是高階的程式語言必然資料結構越抽象化,這裡的抽象化是指它們的資料結構與人的思維越接近。有些語言(如Python)的語法規則非常像我們的人語言,即使沒有學過程式設計的人也很容易理解它。這裡所說的資料結構去抽象化是指把需要呼叫底層的介面的程式改由我們自己去實現,減少這個程式的封裝程度,從而達到提升效能的目的,所以並不是改變程式語法。
簡單的程式複雜化
先舉一個例子,我們想從資料庫中去掉一行資料,目前的環境中已經有人提高了一個調資料庫查詢的介面,這個介面的實現使用了iBatis作為資料層呼叫資料庫查詢資料,實際上它封裝了物件與資料欄位的關係對映及管理資料庫連線池等。使用起來很方便,但是它的執行效率是不是比我們直接寫一個簡單的JDBC連線、提交一個SQL語句的效率高呢?很顯然,後面的執行效率更高,拋去其他因素,顯然沒有經過封裝的複雜程式要比簡單的呼叫上層介面效率要高很多。所以我們要做的就是適當地讓我們的程式複雜一點,而不要偷懶,也許這樣我們的程式效率會增加不少。
減少翻譯的代價
我們知道與不同國家的人交流是要通過翻譯的,但是這個翻譯實在是耗時間。程式設計同樣存在翻譯的問題,如我們的編碼問題,美國人的所有字元一個位元組就能全部表示,所以他們的所有字元就是一個位元組,也就是一個ASSCII碼,所以對他們來說不存在字元編碼問題,但是對其他國家的程式設計師來說,不得不面臨一個讓人頭疼的字元編碼問題,需要將位元組與字元之間來回翻譯,而且還很容易出現錯誤。我們要儘量減少這種翻譯,至少在真正與人交流時把一些經常用的詞彙提前就翻譯好,從而在面對面交流時減少需要翻譯的詞彙的數量,從而提升交流效率。
變的轉化為不變
現在的網頁基本上都是動態網頁,但是所謂的動態網頁中仍然有很多靜態的東西,如模板中仍然有很多是HTML程式碼,它們和一些變數共同拼接成一個完整的頁面,但是這些內容從程式設計師寫出來到最終在瀏覽器裡渲染,都是一成不變的。既然是不變的,那麼就可以對它們做一些預處理,如提前將它們編碼或者將它們放到CDN上。另外,儘量把一些變化的內容轉化成不變的內容,如我們可能將一個URL作為一個變數傳給模板去渲染,但是這個URL中真正變化的僅僅是其中的一個引數,整個主體肯定是不會變化的,所以我們仍然可以從變化的內容中分離出一部分作為不變的來處理。這些都是細節,但是當這些細節組合在一起時往往就會帶來讓你意想不到的好的結果。
常用優化技巧
Velocity渲染模板是先把模板解析成一棵語法樹,然後去遍歷這棵樹分別渲染每個節點,知道了它的工作原理,我們就可以根據它的工作機制來優化渲染的速度。既然是遍歷這棵樹來渲染節點的,而且是順序遍歷的,那麼很容易想到有兩種辦法來優化渲染:
- 減少樹的總節點數量。
- 減少渲染耗時的節點數量。
- 改變Velocity的解釋執行,變為編譯執行。
- 方法呼叫的無反射優化
- 字元輸出改成位元組輸出
- 去掉頁面輸出中多餘的非中文空格。我們知道,頁面的HTML輸出中多餘的空格是不會在HTML的展示時有作用的,多個連續的空格最終都只會顯示一個空格的間距,除非你使用“ ”表示空格。雖然多餘的空格並不能影響HTML的頁面展示樣式,但是服務端頁面渲染和網路資料傳輸這些空格和其他字元沒有區別,同樣要做處理,這樣的話,這些空格就會造成時間和空間維度上的浪費,所以完全可以將多個連續的空格合併成一個,從而既減少了字元又不會影響頁面展示。
- 壓縮TAB和換行。同樣的道理,還可以將TAB字符合併成一個,以及將多餘的換行也合併一下,也能減少不少字元。
- 合併相同的資料。在模板中有很多相同資料在迴圈中重複輸出,如類目、商品、選單等,可以將相同的重複內容提取出來合併在CSS中或者用JS來輸出。
- 非同步渲染。將一些靜態內容抽取出來改成非同步渲染,只在使用者確實需要時再向伺服器去請求,也能夠減少很多不必要的資料傳輸。
減少樹的總節點數量
既然一個模板輸出的內容是確定的,那麼這個模板的vm程式碼應該是固定的,減少節點數量必然刪去一部分vm程式碼才能做到?其實並不是這樣的,雖然最終渲染出來的頁面是一樣的,但是vm的寫法卻有很大不同,筆者在檢查vm程式碼時遇到很多不優美的寫法,導致無謂增加了很多不必要的語法節點。如下面一段程式碼:
這段程式碼實際上只是要計算一個值,但是由於不熟悉Velocity的一些語法,寫得很麻煩,其實只要一個表示式就好了,如下:
這樣可以減少很多語法節點。
減少渲染耗時的節點數量
Velocity的方法呼叫是通過反射執行的,顯然反射執行方法是耗時的,那麼又如何減少反射執行的方法呢?這個改進就如同Java中一樣,可以增加一些中間變數來儲存中間值,而減少反射方法的呼叫。如在一個模板中要多次呼叫到$person.name,那麼可以通過#set建立一個變數$name來儲存$person.name這個反射方法的執行結果。如#set($name=$person.name),這樣雖然增加了一個#set節點,但是如果能減少多次反射呼叫仍然是很值得的。
另外,Velocity本身提供了一個#macro語法,它類似於定義一個方法,然後可以呼叫這個方法,但在沒有必要時儘量少用這種語法節點,這些語法節點比較耗時。還有一些大數計算等,最好定義在Java中,通過呼叫Java中的方法可以加快Velocity的執行效率。
解釋執行轉換成編譯執行
也就是將vm模板先編譯成Java類,再去執行這個Java物件,從而渲染出頁面。Sketch模版引擎,主要分為兩個部分:執行時環境和編譯時環境。前者主要用來將模板渲染成HTML,後者主要是把模板編譯成Java類。當請求渲染一個vm模板時,通過呼叫單例RuntimeServer獲取一個模板編譯後的Java物件,然後呼叫這個模板對應的Java物件的render方法渲染出結果。如果是第一次呼叫一個vm模板,Sketch框架將會載入該vm模板,並將這個vm模板編譯成Java,然後例項化該Java類,例項化物件放入RuntimeContext集合中,並根據Context容器中的變數對應的物件值渲染該模板。一個模板將會被多次編譯,這是一個不斷優化的過程。
我們優化Velocity模板的一個目的就是將模板的解釋執行變為編譯執行,從前面的理論分析可知,vm中的語法最終被解釋成一棵語法樹,然後通過執行這棵語法樹來渲染出結果。我們要將它變成編譯執行的目的就是要將簡單的程式複雜化,如一個#if語法在Velocity中會被解釋成一個節點,顯然執行這個#if語法要比真正執行Java中的if語句要複雜很多。雖然表面上只需呼叫一個樹的render方法,但是如果要將這個樹變成真正的Java中的if去執行,這個過程要複雜很多。所以我們要將Velocity的語法翻譯成Java語法,然後生成Java類再去執行這個Java類。理論上Velocity是動態解釋語言而Java是編譯性語言,顯然Java的執行效率更高。
如何將Velocity的語法節點變成Java中對應的語法?實現思路大體如下。
仍然沿用Velocity中將一個vm模板解釋成一棵AST語法樹,但是重新修改這棵樹的渲染規則,我們將重新定義每個語法節點生成對應的Java語法,而不是渲染出結果。在SimpleNode類中重新定義一個generate方法,這個方法將會執行所有子類的generater方法,它會將每個Velocity的語法節點轉化成Java中對應的語法形式。除這個方法外還有value方法和setValue方法,它們分別是獲取這個語法節點的值和設定這個節點的值,而不是輸出。
總之,要將所有的Velocity的語法都翻譯成對應的Java語法,這樣才能將整個vm模板變成一個Java類。那麼整個vm又是如何組織成一個Java類的呢?
example_vm是模板example.vm編譯成的Java類,它繼承了AbstractTemplateInstance類,這個類是編譯後模板的父類,也是遵照設計模板中的模板模式來設計的。這個類定義了模板的初始化和銷燬的方法,同時定義了一個render方法供外部呼叫模板渲染,而TemplateInstance類很顯然是所有模板的介面類,它定義了所有模板對外提供的方法
TemplateConfig類非常重要,它含有一些模板渲染時需要呼叫的輔助方法,如記錄方法呼叫的實際物件型別及方法引數的型別,還有一些出錯處理措施等。_TRACE方法在執行編譯後的模板類時需要記錄下vm模板中被執行的方法的執行引數,_COLLE方法當模板中的變數輸出時可以觸發各種註冊的觸發事件,如變數為空判斷、安全字元轉義等。我們可以發現有個內部類I,這個類只儲存一些變數屬性,用於快取每次模板執行時通過Context容器傳過來的變數的值。
上面vm例子中的#foreach語法被編譯成了一個單獨的方法,這是為什麼呢?因為我們的模板如果非常大,將所有的程式碼都放在一個方法中(如render),這個方法可能會超過64KB,我們知道Java編譯器的方法的最大大小限制是64KB,這個問題在JSP中也會存在,所有JSP中引入了標籤,每個標籤都被編譯成一個方法,也是為了避免方法生成的Java類過長而不能編譯。
ps:上面程式碼中還有兩個地方要注意:一個地方是$exampleDO.getItemList()程式碼被解析成_I.exampleDO).getItemList()方法呼叫(第一次編譯時是通過反射呼叫,多次編譯後通過方法呼叫),也就是將Velocity的動態反射呼叫變成了Java的原生方法呼叫;另外一個地方是將靜態字串解析成byte陣列,頁面的渲染輸出改成了位元組流輸出。
方法呼叫的無反射優化
一個地方是$exampleDO.getItemList()程式碼被解析成_I.exampleDO).getItemList()方法呼叫(第一次編譯時是通過反射呼叫,多次編譯後通過方法呼叫)。
只有當模板真正執行時才會知道$exampleDO變數實際對應的Java物件,才知道這個物件對應的Java類。而要能確定一個方法,不僅要知道這個方法的方法名,還要知道這個方法對應的引數型別。所以在這種情況下要多次執行才能確定每個方法對應的Java物件及方法的引數型別。
第一次編譯時不知道變數的型別,所以所有的方法呼叫都以反射方式執行,$exampleDO.getItemList()的呼叫變成了_TRACE方法呼叫,這個方法有點特殊,它會記錄下這個$exampleDO.getItemList()這次呼叫傳過來的物件context.get("exampleDO")及方法引數new Object[]{},並以這個方法的hash值作為key儲存下來。當第二次編譯時遇到$exampleDO.getItemList()語法節點時將會將這個語法節點解析成(Mode) _I.exampleDO).getItemList()。由於一個模板中一次執行並不能執行到所有的方法,所以一次執行並不能將所有的方法呼叫轉變成反射方式。這種情況下就會多次生成模板對應的Java類及多次編譯。
字元輸出改成位元組輸出
另外一個地方是將靜態字串解析成byte陣列,頁面的渲染輸出改成了位元組流輸出。
靜態字串直接是out.write(_S0),這裡的_S0是一個位元組陣列,而vm模板中是字串,將字串轉成位元組陣列是在這個模板類初始化時完成的。字元的編碼是非常耗時的,如果我們將靜態字串提前編碼好,那麼在最終寫Socket流時就會省去這個編碼時間,從而提高執行效率。從實際的測試來看,這對提升效能很有幫助。另外,從程式碼中還可以發現,如果是變數輸出,呼叫的是out.write(_EVTCK(context,"$str", context.get("str"))),而_EVTCK方法在輸出變數之前檢查是否有事件需要呼叫,如XSS安全檢查、為空檢查等。
與JSP比較
JSP渲染機制
在實際應用中通常用兩種方式呼叫JSP頁面,一種方式是直接通過org.apache.jasper. servlet.JspServlet來呼叫請求的JSP頁面,另一種方式是通過如下方式呼叫:
兩種方式都可以渲染JSP,前一種方式更加方便,只要中配置的路徑符合JspServlet就可以直接渲染,後一種方式更加靈活,不需要特別的配置就行。雖然兩種呼叫方式有所區別,但是最終的JSP渲染原理都是一樣的。下面以一個最簡單的JSP頁面為例看它是如何渲染的:
如上面這個index.jsp頁面,把它放在Tomcat的webapps/examples/jsp目錄下,我們通過第二種方式來呼叫,訪問一個Servlet,然後在這個Servlet中通過RequestDispatcher來渲染這個JSP頁面。呼叫程式碼如下:
從圖中可以看出,ServletContext根據path來找到對應的Servlet,這個對映是在Mapper.map方法中完成的,Mapper的對映有7種規則,這次對映是通過副檔名“.jsp”來找到JspServlet對應的Wrapper的。然後根據這個JspServlet建立ApplicationDispatcher物件。接下來就和呼叫其他Servlet一樣呼叫JspServlet的service方法,由於JspServlet專門處理渲染JSP頁面,所以這個Servlet會根據請求的JSP檔名將這個JSP包裝成JspServletWrapper物件。JSP在執行渲染時會被編譯成一個Java類,而這個Java類實際上也是一個Servlet,那麼JSP檔案又是如何被編譯成Servlet的呢?這個Servlet到底是什麼樣子的?每一個Servlet在Tomcat中都被包裝成一個最底層的Wrapper容器,那麼每一個JSP頁面最終都會被編譯成一個對應的Servlet,這個Servlet在Tomcat容器中就是對應的JspServletWrapper。
HttpJspBase類是所有JSP編譯成Java的基類,這個類也繼承了HttpServlet類、實現了HttpJspPage介面,HttpJspBase的service方法會呼叫子類的_jspService方法。被編譯成的Java類的_jspService方法會生成多個變數:pageContext、application、config、session、out和傳進來的request、response,顯然這些變數我們都可以直接引用,它們也被稱為JSP的內建變數。對比一下JSP頁面和生成的Java類可以發現,頁面的所有內容都被放在_jspService方法中,其中頁面直接輸出的HTML程式碼被翻譯成out.write輸出,頁面中的動態“<%%>”包裹的Java程式碼直接寫到_jspService方法中的相應位置,而“<%=%>”被翻譯成out.print輸出。
我們從JspServlet的service方法開始看一下index.jsp是怎麼被翻譯成index_jsp類的,首先建立一個JspServletWrapper物件,然後建立編譯環境類JspCompilationContext,這個類儲存了編譯JSP檔案需要的所有資源,包括動態編譯Java檔案的編譯器。在建立JspServletWrapper物件之前會首先根據jspUri路徑檢查JspRuntimeContext這個JSP執行環境的集合中對應的JspServletWrapper物件是否已經存在。在JDTCompiler呼叫generateJava方法時會生產JSP對應的Java檔案,將JSP檔案翻譯成Java類是通過ParserController類完成的,它將JSP檔案按照JSP的語法規則解析成一個個節點,然後遍歷這些節點來生成最終的Java檔案。具體的解析規則可以檢視這個類的註釋。翻譯成Java類後,JDTCompiler再將這個類編譯成class檔案,然後建立物件並初始化這個類,接下來就是呼叫這個類的service方法,完成最後的渲染。下圖這個過程的時序圖。
Velocity與JSP
從上面的JSP渲染機制我們可以看出JSP檔案渲染其實和Velocity的渲染機制很不一樣,JSP檔案實際上執行的是JSP對應的Java類,簡單地說就是將JSP的HTML轉化成out.write輸出,而JSP中的Java程式碼直接複製到翻譯後的Java類中。最終執行的是翻譯後的Java類,而Velocity是按照語法規則解析成一棵語法樹,然後執行這棵語法樹來渲染出結果。所以它們有如下這些區別。
- 執行方式不一樣:JSP是編譯執行,而Velocity是解釋執行。如果JSP檔案被修改了,那麼對應的Java類也會被重新編譯,而Velocity卻不需要,只是會重新生成一棵語法樹。
- 執行效率不同:從兩者的執行方式不同可以看出,它們的執行效率不一樣,從理論上來說,編譯執行的效率明顯好於解釋執行,一個很明顯的例子在JSP中方法呼叫是直接執行的,而Velocity的方法呼叫是反射執行的,JSP的效率會明顯好於Velocity。當然如果JSP中有語法JSTL,語法標籤的執行要看該標籤的實現複雜度。
- 需要的環境支援不一樣:JSP的執行必須要有Servlet的執行環境,也就是需要ServletContext、HttpServletRequest和HttpServletResponse類。而要渲染Velocity完全不需要其他環境類的支援,直接給定Velocity模板就可以渲染出結果。所以Velocity不只應用在Servlet環境中。