j2ee規范中對jsp的編譯有個規范:第一步,先編譯出來一個xml文件, 第二部再從這個xml文件編譯為一個java文件
例如: test.jsp
<%!
int a = 1;
private String sayHello(){return "hello";}
%>
<%
int a = 1;
%>
<h1>Hello World</h1>
第一步,先編譯為一個xml文件,結果如下
<jsp:declare>
int a = 1;
private String sayHello(){return "hello";}
</jsp:declare>
<jsp:scriptlet>
int a = 1;
</jsp:scriptlet>
<h1>Hello World</h1>
第三步,再編譯為一個java文件, 大致結果如下
public class _xxx_test{
int a = 1;
private String sayHello(){return "hello";}
public void _jspService(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException{
JspWriter out = xxxx.getWriter();
int a = 1;
out.write("<h1>Hello World</h1>");
}
}
從中可以看出編譯過程, 編譯器依次讀入文本, 遇到<%@就認為這是個jsp指令, 指令是對編譯和執行這個jsp生效的.
當遇到<%!它的時候就認為這是個聲明, 其中的內容會直接生成為類的類屬性或者類方法, 這個看裡面是怎麼寫的,
例如: int a = 1; 就認為這是個類屬性.
當遇到<%它的時候就認為這是個腳本, 會被放置到默認的方法裡面的.
以上是jsp的編譯過程, 還沒有說對標簽怎麼編譯, 後面再說.
有個問題, 當編譯器遇到<%的時候,會依次讀入後續內容直到遇到%>, 如果裡面的java代碼裡面包含了個字符串,這個字符串的內容是%>,怎麼辦?
我知道的是像tomcat是不會處理這種情況的,也就是說jsp的編譯器並不做語法檢查, 只解析字符串, 上面的這種情況編譯出來的結果就是錯的了,下一步再編譯為class
文件的時候就會報未結束的字符常量. 例如:
<%
String s = "test%>"
%>
編譯出來的結果大致如下:
public class _xxx_test{
public void _jspService(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException{
JspWriter out = xxxx.getWriter();
String s = "test
out.write("\r\n");
}
}
j2ee規范還定義了jsp可以使用xml語法編寫, 因為jsp是先編譯為xml, 其實<%也是先編譯成了<jsp:scriptlet>因此下面的兩個文件是等效的:
文件1:
<%
int a = 1;
%>
文件2:
<jsp:scriptlet>int a = 1;</jsp:scriptlet>
不過對於規范,不同的容器在實現的時候並不一定會按照規范來做,我知道的是tomcat是按照這個來做的,並且我記得在tomcat的早期版本中還能在work目錄中找到對應的xml文件.
但是websphere是不支持的,不知道現在的版本支不支持, resin好像也不支持, 也就是說在websphere中, <%必須寫成<%, 不能用<jsp:script>
websphere並沒有先編譯為xml, 再編譯為java
以上的編譯過程對於編碼來說是很簡單的,如果不編譯為xml文件,它簡單到只用正則就能搞定.
EL表達式
對於el表達式的支持也很簡單, 遇到${, 就開始讀入, 直到遇到}, 將其中的內容生成為一個表達式對象, 直接調用該表達式的write方法即可, 例如:
abc${user.name}123
編譯結果大致如下:
public class _xxx_test{
public void _jspService(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException{
JspWriter out = xxxx.getWriter();
ExprEnv exprEnv = xxx.create();
out.write("abc");
org.xxx.xxx.Expr _expr_xxx = xxx.createExpr("${user.name}");
_expr_xxx.write(out, exprEnv);
out.write("123\r\n");
}
}
不同的容器在實現的時候有所不同, 例如resin, 會將所有的表達式編譯為類的靜態變量, 以提升性能. 因為一個jsp頁面一旦寫好, 表達式的數目和內容是確定的,
因此是可以編譯為靜態變量的.
為什麼要編譯為調用表達式的write方法, 而不是編譯為out.write(_expr_xxx.getValue()), 我認為其中一個原因是為了表達式做null處理,\
任何一個表達式如果返回會空, 那麼寫到頁面上都應該是"", 而不應該是"null"
out.write默認會將null對象轉為"null"字符串寫入, 如果編譯為out.write(_expr_xxx.getValue()),
就得 out.write((_expr_xxx.getValue() != null ? _expr_xxx.getValue() : ""));
很顯然這樣是影響性能的, 因為如果返回結果不為null的話對表達式可能會計算兩次.
如果不這樣做,就需要重新定義變量, 為了變量不沖突,每個地方編譯器都要生成一個新的變量名, 導致最終生成的文件較大.
tag編譯
對tag的編譯略微麻煩,但也不復雜,這需要對源文件做html解析,但是跟一個完整的html解析器比起來,對tag的解析相對來說簡單多了
只需要在遇到'<'字符的時候讀出來節點名,然後在當前應用支持的標簽庫中去查找對應的標簽類, 如果沒查到,就按照上面的繼續編譯為out.write("<");
否則, 讀入所有的屬性, 創建一個標簽實例, 然後根據定義的屬性和標簽中定義的屬性,依次調用對應的setter方法, 例如:
<c:if test="${user.name == 'tom'}"><h1>a</h1></c:if>
編譯結果大致為:
Expr expr_0 = xxx.createExpr("${user.name == 'tom'}");
Tag _tag_0 = new xxx.xxx.IfTag();
_tag_0.setter(...);
int _tag_flag_0 = _tag_0.doStartTag();
if(_tag_flag_0 != SKIP_BODY)
{
while(true)
{
// doInitBody, doBody等
_tag_flag_0 = _tag_0.doEndTag();
// doAfterBody等
if(_tag_flag_0 != EVAL_BODY_AGAIN)
{
break;
}
}
}
上面是一個標簽運行的標准流程, 事實上對於不同的容器,編譯結果區別很大,例如resin, 實際編譯結果大致如下:
Expr expr_0 = xxx.createExpr("${user.name == 'tom'}");
if(expr_0.getBoolean())
{
}
很簡單的編譯結果, 對於j2ee核心標簽庫的支持除了forEach編譯為了循環之外,其他的一律編譯成了很簡單的代碼,都沒有使用循環.
這一點可能是為了減小編譯結果,並且提升性能。
因為對於大部分標簽來說實在沒有必要按照標准的tag執行流程來編譯, 對於核心標簽庫中定義的標簽因為行為很明確,所以可以簡化編譯結果.
tomcat對於標簽的編譯, 采用的是每個標簽都編譯為一個方法, 並且采用的是do...while結構. resin則都編譯在_jspService方法內.
標簽的結束, 在編譯標簽的過程中,如何知道標簽結束了呢?一個很簡單的想法是,如果遇到開始標簽,就一直讀入,直到遇到結束標簽,很顯然這樣是行不通的。
因為標簽有嵌套,如果遇到嵌套標簽怎麼辦?按照上面的流程接著讀啊,讀到子標簽結束, 再然後呢? 稍微懂點數據結構的話,就很容易了,用棧。
同樣的問題,大致的解決思路都是一樣的, 比如計算器, 比如html,xml解析器, 都可以這麼做, 對於html解析器,我將會寫另外一篇文章專門說明.
先建立一個棧, 當遇到一個標簽的時候,就先把它壓入棧, 元素內容根據需要自己定義, 我們暫時假定結構如下:
class TagInfo{
String nodeName; // 節點的名稱
Map<String, String> attributes; // 節點屬性 例如: test: ${user.name == 'tom'}
Map<String, String> variables; // 當前標簽可能需要用到的變量列表, 例如 flagName: _flag_0, exprName: expr_0等
}
注意是把TagInfo壓入棧
當遇到一個結束標簽的時候, 取得結束標簽的nodeName, 然後從棧彈出一個元素, 如果tagInfo.nodeName == nodeName, 那麼生成該標簽結束的代碼
對於標簽的標准流程來說,只需要生成如下的代碼就可以了:
// out.write("<h1>1</h1>");
// 這之前的代碼可能都是out.write之類的
// _tag_flag_0之類的變量都從tagInfo獲取
_tag_flag_0 = _tag_0.doEndTag();
// doAfterBody等
if(_tag_flag_0 != EVAL_BODY_AGAIN)
{
break;
}
}
}
如果當前nodeName != tagInfo.nodeName那麼就繼續彈, 直到找到一個對應的標簽, 其實這種情況只是容錯處理,
實際上頁面最後運行出來的結果跟jsp編寫者的預期是不一致的.
如果一直到棧底都沒找到,那就拋異常吧。
對於棧來說,很多時候不需要pop, 只需要查看一下棧頂是否符合要求,符合的時候才pop, 否則先pop, 不符合還得push, 很麻煩
所以棧最好提供一個peek函數, 傳入一個int, 默認是棧頂, 根據參數決定返回當前棧的那個元素, 這樣比較方便
最後, 在jsp中,規范規定, 所有以_jsp開頭的變量都不能使用, 這是留給API或者容器用的.
上面是對jsp編譯過程的一個分析,對於j2ee規范定義的部分,我沒有看過原文,是從一些java書上看的一些零散的東西, 更多的是
看一些容器編譯出來的java源文件分析和猜測的,可能很多地方的想法跟j2ee規范定義的不一致,有興趣的可以在java官網找一下
規范原文看看。
06年的時候,我曾經用java實現過一套類似於tomcat的容器,當然功能弱多了, 只支持一些基本的功能,能跑jsp和servlet, 不支持el和tag.
更要命的是當時剛工作,對於一個代碼量較大的項目的控制能力很差,寫到最後覺得架構上很力不從心,勉勉強強能把jsp和servlet跑起來之後就沒有再繼續了。
當時還不了解socket的nio, socket的io用的是阻塞io, 線程也沒有用線程池,每次都是new一個新線程,性能很差。
有興趣的可以參考我的另外幾篇文章,用java實現反向代理, 其中的代碼是當年代碼中的一部分.
說一說js版的jstl吧
js版的jstl基本上是按照我上面分析的來實現的, 支持腳本, 支持el, 支持tag, 支持自定義tag.
為了性能的考慮,對tag的編譯借鑒了resin的思路, 對於標准標簽不按照標准流程編譯, 而是精簡編譯.
還是出於性能的考慮,編譯過程省略了中間一步,也就是不先編譯為xml, 而是直接編譯為js源文件.
因為如果編譯過程產生xml, 對於大文件來說就要在內存中再產生一份xml的內容, 然後再次編譯為js文件
中間需要兩次編譯,耗內存還耗資源.
對el的支持,采用了一個偷懶的方法. 例如abc{user.name}123這樣的代碼, 在jstl的實現中,需要寫成: abc{this.user.name}123
只要是pageContext中的屬性都需要加上this;
這跟實現有關, 對el如何計算是很麻煩的, 需要寫一個解釋器, 否則簡單的解析對於復雜的表達式就無能為力了.
例如${user.name}很容易計算出來結果, 但是對於${myfun1(user.name) + myfun2('test') + myfun3('test')}這樣的表達式
或者是%{user.age > 100 * 2}就比較麻煩了, 沒有一個解釋器基本搞不定.
我一開始考慮用eval, 但是eval在某些環境中性能較差, 而且編譯出來的結果裡面如果有很多el就會調用很多次
更重要的是用eval也無法實現, 例如, eval("user.name + '123'"); 在全局中根本沒有user這個對象
但是如果都加上this, 那麼eval就可以了
但是絕對不能用eval, eval的開銷太大.
寫個解釋器不現實,也沒必要,為了支持表達式,用一個解釋型的語言再寫個解釋器,不太劃算。
最後采用了一個折中的辦法,就是pageContext中的對象, 在el中都加this, 也就是說el中的所有的this都指向pageContext
對於每一個表達式都生成一個表達式對象,這點和j2ee中的定義保持一致. 另外會生成一個函數, 例如:
abc${this.user.name}123
最後的編譯結果大致如下:
new (function(){
this.handle = function(pageContext){
var out = this.getWriter();
out.write("abc");
this._expr_0.write(out, pageContext);
out.write("123");
})();
// 這裡是編譯過程產生的所有的表達式對象
this._expr_0 = new Expression("_expr_0", "this.user.name");
// 這裡記錄了編譯過程產生的所有的表達式對象的引用
this.exprPool = [
this._expr_0
];
// 這個地方對關鍵,是所有的表達式函數, 目前為null, 在第一次運行的時候才會被編譯
this.exprList = null;
第一次運行的時候,會檢查this.exprList是否為空, 如果為空,編譯所有的表達式, 編譯結果如下:
this.exprList = new (function(){
this._expr_0 = function(){
// 最終this._expr_0函數會被放到pageContext中, 這就是為什麼要用this的原因
return ( this.user.name );
}
})();
this.exprList指向的是一個新的對象, 這裡必須是個對象才行。
下一步,運行期:
scriptlet.execute(context);
context由調用者傳入, 可以是一個純粹的json對象. scriptlet.execute方法如下:
// scriptlet指向了第一次編譯返回的對象
// scriptlet在new的時候創建了execute方法
scriptlet.execute = function(context){
var pageContext = PageContextFactory.create(this, context, this.exprList);
this.handle(pageContext);
};
在PageContextFactory.create方法裡面會對context包裝, 創建一個新的對象,並把context的所有屬性賦給新的pageContext
然後再把exprList包含的所有的函數賦值給新的pageContext, 這樣pageContext就擁有了context的所有屬性和scriptlet運行所
需要的所有的表達式函數, 表達式中的this指向的是pageContext, 這就是el中為什麼要用this的原因.