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模塊主要封裝了模板渲染需要的變量,它的主要作用有兩點:

  1. 便於與其他框架集成,起到一個適配器的作用,如MVC框架內部保存的變量往往在一個Map中,這樣MVC框架就需要將這個Map適配到Velocity的context中。
  2. 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幾個,它們可以劃分爲如下幾種類型。

  1. 塊節點類型:主要用來表示一個代碼塊,它們本身並不表示某個具體的語法節點,也不會有什麼渲染規則。這種類型的節點主要由ASTReference、ASTBlock和ASTExpression等組成。
  2. 擴展節點類型:這些節點可以被擴展,可以自己去實現,如我們上面提到的#foreach,它就是一個擴展類型的ASTDirective節點,我們同樣可以自己再擴展一個ASTDirective類型的節點。
  3. 中間節點類型:位於樹的中間,它的下面有子節點,它的渲染依賴於子節點才能完成,如ASTIfStatement和ASTSetDirective等。
  4. 葉子節點:它位於樹的葉子上,沒有子節點,這種類型的節點要麼直接輸出值,要麼寫到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有兩種類型的子節點,分別是ASTIdentifierASTMethod。它們分別代表兩種類型的方法調用,其中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種不同的事件處理類型。

  1. ReferenceInsertionEventHandler:表示針對Velocity中變量的事件處理,當Velocity在渲染輸出某個“$”表示的變量時可以對這個變量做修改,如對這個變量的值做安全過濾以防止惡意JS代碼出現在頁面中等。
  2. NullSetEventHandler:顧名思義是對#set語法賦值爲null時的事件做處理。
  3. MethodExceptionEventHandler:這個事件是對Velocity在反射執行某個方法調用時出錯後,有機會做一些處理,如捕獲異常、控制返回一些特殊值等。
  4. InvalidReferenceEventHandler:表示Velocity在解析“$”變量出現沒有找到對應的對象時做如何處理。
  5. IncludeEventHandler:在處理#include和#parse時提供了處理和修改加載外部資源的機會。

Velocity提供的這些事件處理機制也爲我們擴展Velocity提供了機會,如果你想擴展Velocity,必須對它的事件處理機制有很好的理解。

如何調用到擴展的EventHandler?Velocity提供了兩種方式,Velocity在渲染時遇到符合的事件都會檢查以下的EventCartridge:

  1. 把你新創建的EventHandler直接加到org.apache.velocity.runtime.RuntimeInstance類的eventCartridge屬性中,直接將自定義的EventHandler通過配置項eventCartridge.classes來設置,Velocity在初始化RuntimeInstance時會解析配置項,然後會實例化EventHandler。
  2. 把自定義的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渲染模板是先把模板解析成一棵語法樹,然後去遍歷這棵樹分別渲染每個節點,知道了它的工作原理,我們就可以根據它的工作機制來優化渲染的速度。既然是遍歷這棵樹來渲染節點的,而且是順序遍歷的,那麼很容易想到有兩種辦法來優化渲染:

  1. 減少樹的總節點數量。
  2. 減少渲染耗時的節點數量。
  3. 改變Velocity的解釋執行,變爲編譯執行。
  4. 方法調用的無反射優化
  5. 字符輸出改成字節輸出
  6. 去掉頁面輸出中多餘的非中文空格。我們知道,頁面的HTML輸出中多餘的空格是不會在HTML的展示時有作用的,多個連續的空格最終都只會顯示一個空格的間距,除非你使用“ ”表示空格。雖然多餘的空格並不能影響HTML的頁面展示樣式,但是服務端頁面渲染和網絡數據傳輸這些空格和其他字符沒有區別,同樣要做處理,這樣的話,這些空格就會造成時間和空間維度上的浪費,所以完全可以將多個連續的空格合併成一個,從而既減少了字符又不會影響頁面展示。
  7. 壓縮TAB和換行。同樣的道理,還可以將TAB字符合併成一個,以及將多餘的換行也合併一下,也能減少不少字符。
  8. 合併相同的數據。在模板中有很多相同數據在循環中重複輸出,如類目、商品、菜單等,可以將相同的重複內容提取出來合併在CSS中或者用JS來輸出。
  9. 異步渲染。將一些靜態內容抽取出來改成異步渲染,只在用戶確實需要時再向服務器去請求,也能夠減少很多不必要的數據傳輸。

減少樹的總節點數量

既然一個模板輸出的內容是確定的,那麼這個模板的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是按照語法規則解析成一棵語法樹,然後執行這棵語法樹來渲染出結果。所以它們有如下這些區別。

  1. 執行方式不一樣:JSP是編譯執行,而Velocity是解釋執行。如果JSP文件被修改了,那麼對應的Java類也會被重新編譯,而Velocity卻不需要,只是會重新生成一棵語法樹
  2. 執行效率不同:從兩者的執行方式不同可以看出,它們的執行效率不一樣,從理論上來說,編譯執行的效率明顯好於解釋執行,一個很明顯的例子在JSP中方法調用是直接執行的,而Velocity的方法調用是反射執行的,JSP的效率會明顯好於Velocity。當然如果JSP中有語法JSTL,語法標籤的執行要看該標籤的實現複雜度。
  3. 需要的環境支持不一樣:JSP的執行必須要有Servlet的運行環境,也就是需要ServletContext、HttpServletRequest和HttpServletResponse類。而要渲染Velocity完全不需要其他環境類的支持,直接給定Velocity模板就可以渲染出結果。所以Velocity不只應用在Servlet環境中。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章