欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

jsp编译过程

程序员文章站 2022-04-15 13:07:29
j2ee规范中对jsp的编译有个规范:第一步,先编译出来一个xml文件, 第二部再从这个xml文件编译为一个java文件 例如: test.jsp   &n...

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>
    &lt;h1&gt;Hello World&lt;/h1&gt;
第三步,再编译为一个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的原因.