Tomcat 源码解析一JSP编译器Jasper-佛怒火莲(下)

  没有办法CSDN博客只能写20万字左右,这里接着上一篇博客继续分析 。

  用在线对比工具比对比,发现class字节码中有2处增加了内容,有一处由01 修改 成了02 , 先来看增加的16进制转化为字符串后是什么 。
  先来看 01 00 14 53 6F 75 72 63 65 44 65 62 75 67 45 78 74 65 6E 73 69 6F 6E
  先来看常量池中UTF8常量结构

CONSTANT_Utf8_info {
    u1 tag;
    u2 length;
    u1 bytes[length];
}

  在常量池中第一个字节表示当前常量所属类型,之前我写了一个项目,叫自己手写虚拟机,项目中有一段下面截图中的代码,如第一个字节是1 ,表示utf8类型,tag = 7 ,则表示class类型, 10 为方法引用等
在这里插入图片描述
自己手写虚拟机博客
自己动手写Java虚拟机 (Java核心技术系列)_java版
还有一篇
Java字节码文件结构剖析(一) 关于字节码解析的,如果不懂,可以去看看这两篇博客 。

  再来看 01 00 14 53 6F 75 72 63 65 44 65 62 75 67 45 78 74 65 6E 73 69 6F 6E , 01 表示当前常量池 utf8类型, u2 表示后面两个字节表示utf8字符串的长度,00 14 从16进制转化为10进制为1 * 16 + 4 = 20,也就是向后取20个字节为53 6F 75 72 63 65 44 65 62 75 67 45 78 74 65 6E 73 69 6F 6E,刚好转化为字符串类型为SourceDebugExtension。
在这里插入图片描述
在这里插入图片描述
  其他的,自己去查吧,从字节码的修改来看,是在常量池中添加了一个utf8的常量,并且常量的内容为SourceDebugExtension。

  接下来,看第二段增加的内容,00 FB 00 00 00 82 53 4D 41 50 0A 69 6E 64 65 78 5F 6A 73 70 2E 6A 61 76 61 0A 4A 53 50 0A 2A 53 20 4A 53 50 0A 2A 46 0A 2B 20 30 20 69 6E 64 65 78 2E 6A 73 70 0A 69 6E 64 65 78 2E 6A 73 70 0A 2A 4C 0A 31 2C 39 3A 39 33 0A 39 3A 31 30 31 0A 31 31 2C 31 30 3A 31 30 33 0A 32 30 3A 31 31 33 0A 32 31 2C 32 3A 31 31 34 2C 33 0A 32 33 2C 35 3A 31 32 30 0A 32 37 3A 31 35 30 2C 39 0A 32 37 3A 31 32 36 0A 2A 45 0A, 既然常量池中加了SourceDebugExtension常量,那推测上面这段字节码和SourceDebugExtension属性有关,查一下他的结构 。
在这里插入图片描述
  00 FB表示指向常量池的索引,转化为10进制为251
  00 00 00 82 后面4个字节表示attribute_length 转化为10进制为130 。
  53 4D 41 50 0A 69 6E 64 65 78 5F 6A 73 70 2E 6A 61 76 61 0A 4A 53 50 0A 2A 53 20 4A 53 50 0A 2A 46 0A 2B 20 30 20 69 6E 64 65 78 2E 6A 73 70 0A 69 6E 64 65 78 2E 6A 73 70 0A 2A 4C 0A 31 2C 39 3A 39 33 0A 39 3A 31 30 31 0A 31 31 2C 31 30 3A 31 30 33 0A 32 30 3A 31 31 33 0A 32 31 2C 32 3A 31 31 34 2C 33 0A 32 33 2C 35 3A 31 32 30 0A 32 37 3A 31 35 30 2C 39 0A 32 37 3A 31 32 36 0A 2A 45 0A 其余的转化为字符串为
SMAP
index_jsp.java
JSP
*S JSP
*F

  • 0 index.jsp
    index.jsp
    *L
    1,9:93
    9:101
    11,10:103
    20:113
    21,2:114,3
    23,5:120
    27:150,9
    27:126
    *E
    在这里插入图片描述
      现在应该比较明白了吧。 那下面红框里的这一段00 F9 00 00 00 02 00 FA 字节码又表示的是什么呢?
    在这里插入图片描述

  之前我们见过SourceFile的属性结构如下。
在这里插入图片描述
  00 F9 :F9 指向常量池的索引,转化为10进制为249
  00 00 00 02 : 属性长度占4个字节 2 ,因此向后读取两个字节。
  00 FA :指向常量池中的索引 250
  有人会说,你说得好听的,但是怎么来证明呢? 我们用jclasslib bytecode viewer打印修改过的class文件 。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
  是不是和我们之前的分析一模一样,当然啦,上面是对字节码的添加做的事情,下面来分析对字节码从01 修改为02 又是什么意思呢?还是借助于之前手动写java虚拟机的代码来看。
在这里插入图片描述
  因此进入readAttributes()方法 。
在这里插入图片描述
  java字节码规定,在读取属性表之前,有2个字节标识当前属性表长度,也就是原文件的00 01 和 修改后00 02表示属性表长度,显然之前只有SourceFile属性,现在增加了SourceDebugExtension属性信息,所以需要将原来的长度由1 修改为2 。
在这里插入图片描述
  到这里,我们来总结一下 static void install(File classFile, byte[] smap) 到底对字节码做了哪些事情 。

  1. 在常量池中增加Utf8的SourceDebugExtension常量。
  2. 修改属性表长度
  3. 在属性表中添加SourceDebugExtension属性。
  4. 当然常量池的个数也做了修改,这里就不分析了。 一样的道理 。

  从结果来看,tomcat对原字节码做了以上修改,接下来看代码如何实现,不过在看这段代码之前,继续回顾一下class文件的字节码结构 。

ClassFile {
    u4             magic;
    u2             minor_version;
    u2             major_version;
    u2             constant_pool_count;
    cp_info        constant_pool[constant_pool_count-1];
    u2             access_flags;
    u2             this_class;
    u2             super_class;
    u2             interfaces_count;
    u2             interfaces[interfaces_count];
    u2             fields_count;
    field_info     fields[fields_count];
    u2             methods_count;
    method_info    methods[methods_count];
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

  有了字节码结构,再来看源码就清淅多了。

void addSDE() throws UnsupportedEncodingException, IOException {
	// 我们知道前面4个字节是魔数,2 个字节是次版本号,2个字节是主版本号,
	// 这里主要是复制前面8个字节  
    copy(4 + 2 + 2);
    int constantPoolCountPos = genPos;
    // 再读取两个字节,为常量池个数  
    int constantPoolCount = readU2();
    if (log.isDebugEnabled())
        log.debug("constant pool count: " + constantPoolCount);
    // 将常量池个数以字节码形式写到gen的byte数组中
    writeU2(constantPoolCount);

    // copy old constant pool return index of SDE symbol, if found
    // 复制常量池,如果返回值sdeIndex > 0 ,则表示常量池中已经有SourceDebugExtension
    // 的UTF8常量
    sdeIndex = copyConstantPool(constantPoolCount);
    if (sdeIndex < 0) {
        // if "SourceDebugExtension" symbol not there add it
        writeUtf8ForSDE();

        // increment the constantPoolCount
        sdeIndex = constantPoolCount;
        ++constantPoolCount;
        // 修改常量池个数constantPoolCountPos = constantPoolCountPos + 1  
        randomAccessWriteU2(constantPoolCountPos, constantPoolCount);

        if (log.isDebugEnabled())
            log.debug("SourceDebugExtension not found, installed at: " + sdeIndex);
    } else {
        if (log.isDebugEnabled())
            log.debug("SourceDebugExtension found at: " + sdeIndex);
    }
    // access, this, super
    // access_flags 2 个字节指向常量池索引,当前类的访问标识
    // this_class 2个字节指向当前类
    // super_class 2 个字节指向父类,这些都不需要修改,直接复制即可
    copy(2 + 2 + 2); 
    //2 个字节,表示当前类实现接口数
    int interfaceCount = readU2();
    // 将接口数以2个字节写到新byte[]数组中
    writeU2(interfaceCount);
    if (log.isDebugEnabled())
        log.debug("interfaceCount: " + interfaceCount);
    // 因为接口也是指向常量池中的索引
    // interfaces[interfaces_count] ,而指向常量池的索引占两个字节
    // 因此最终需要复制的字节数 = 接口数 * 2 
    copy(interfaceCount * 2);
    copyMembers(); // 复制类中所有field属性
    copyMembers(); // 复制类中所有的方法表 
    int attrCountPos = genPos;
    // 获取属性个数 attributes_count ,当然属性个数占两个字节
    int attrCount = readU2();
    writeU2(attrCount);
    if (log.isDebugEnabled())
        log.debug("class attrCount: " + attrCount);
    // copy the class attributes, return true if SDE attr found (not copied)
    // 如果属性中没有 SourceDebugExtension,则增加个数 
    if (!copyAttrs(attrCount)) {
        // 修改attributes_count = attributes_count  + 1 
        ++attrCount;
        // 将修改的值写到attributes_count 对应的字节码位置 
        randomAccessWriteU2(attrCountPos, attrCount);
        if (log.isDebugEnabled())
            log.debug("class attrCount incremented");
    }
    // sdeIndex 表示SourceDebugExtension的UTF8所在常量池中索引 ,将smap解析得到的行号对应关系写到字节码中
    writeAttrForSDE(sdeIndex);
}

  下面来看复制常量池方法 ,不过在看常量池结构之前先来看常量池中每种类型及其含义,以及每种类型所点字节。
在这里插入图片描述

int copyConstantPool(int constantPoolCount)
    throws UnsupportedEncodingException, IOException {
    int sdeIndex = -1;
    // copy const pool index zero not in class file
    for (int i = 1; i < constantPoolCount; ++i) {
        int tag = readU1();
        writeU1(tag);
        switch (tag) {
            case 7 :  // Class
            case 8 :  // String
            case 16 : // MethodType
                if (log.isDebugEnabled())
                    log.debug(i + " copying 2 bytes");
                copy(2);
                break;
            case 15 : // MethodHandle
                if (log.isDebugEnabled())
                    log.debug(i + " copying 3 bytes");
                copy(3);
                break;
            case 9 :  // Field
            case 10 : // Method
            case 11 : // InterfaceMethod
            case 3 :  // Integer
            case 4 :  // Float
            case 12 : // NameAndType
            case 18 : // InvokeDynamic
                if (log.isDebugEnabled())
                    log.debug(i + " copying 4 bytes");
                copy(4);
                break;
            case 5 : // Long
            case 6 : // Double
                if (log.isDebugEnabled())
                    log.debug(i + " copying 8 bytes");
                copy(8);
                i++;
                break;
            case 1 : // Utf8
                int len = readU2();
                writeU2(len);
                byte[] utf8 = readBytes(len);
                String str = new String(utf8, "UTF-8");
                if (log.isDebugEnabled())
                    log.debug(i + " read class attr -- '" + str + "'");
                if (str.equals("SourceDebugExtension")) {
                    sdeIndex = i;
                }
                writeBytes(utf8);
                break;
            default :
                throw new IOException("unexpected tag: " + tag);
        }
    }
    return sdeIndex;
}

  简单分析一下,如CONSTANT_Long_info,CONSTANT_Double_info类型结构为
{
  tag u1
  bytes u8
}
  为什么复制字节码时是copy(8);而不是copy(9)呢,大家注意
int tag = readU1();
writeU1(tag);
这两行代码,tag = readU1(); 读取到tag标识当前常量属于常量池中哪种类型,并且将tag写到新的byte[] 数组中 ,因此后面只需要拷贝8个字节,并且有一行代码 if (str.equals(“SourceDebugExtension”)) 判断,如果当前UTF8类型是SourceDebugExtension,则不需要向常量池中写入UTF8的SourceDebugExtension类型了。 并将UTF8类型是SourceDebugExtension 常量的索引返回,方便写入attributeInfo时引用此索引 。
  如果常量池中没有UTF8类型是SourceDebugExtension 常量,则调用writeUtf8ForSDE()向常量池中写入。

void writeUtf8ForSDE() {
    int len = "SourceDebugExtension".length();
    writeU1(1); // 1 表示是UTF8常量
    writeU2(len); // 2个字节表示常量字符串的长度
    // 将 "SourceDebugExtension"字符串以16进制写入新byte[]数组中
    for (int i = 0; i < len; ++i) {
        writeU1("SourceDebugExtension".charAt(i));
    }
}

  修改常量池个数方法。从pos位置开始,将val转化为两个字节,从pos位置写入,再恢复之前的写入位置。

void randomAccessWriteU2(int pos, int val) {
	// 保存之前的位置
    int savePos = genPos;
    genPos = pos;
    // 修改pos位置2个字节的内容
    writeU2(val);
    // 恢复成之前的位置
    genPos = savePos;
}

  将smap解析得到的jsp行号与生成java代码的行号对应关系写到字节码中。

void writeAttrForSDE(int index) {
	// 2 个字节指向常量池中SourceDebugExtension 
    writeU2(index);
    // 用4个字节存储smap信息
    writeU4(sdeAttr.length);
    // 将smap中信息存储到新的byte[]数组中
    for (int i = 0; i < sdeAttr.length; ++i) {
        writeU1(sdeAttr[i]);
    }
}

  下面这个方法中需要注意一点,当attribute_info中有SourceDebugExtension时,是不会复制之前的SourceDebugExtension信息的,因此attrCount是不需要++,直接将新的SourceDebugExtension替代掉之前的SourceDebugExtension即可,这也是attrCount 不需要++,直接调用writeAttrForSDE()就可以的原因。

boolean copyAttrs(int attrCount) {
    boolean sdeFound = false;
    for (int i = 0; i < attrCount; ++i) {
        int nameIndex = readU2();
        // don't write old SDE
        if (nameIndex == sdeIndex) {
            sdeFound = true;
            if (log.isDebugEnabled())
                log.debug("SDE attr found");
        } else {
            writeU2(nameIndex); // name
            int len = readU4();
            writeU4(len);
            copy(len);
            if (log.isDebugEnabled())
                log.debug("attr len: " + len);
        }
    }
    return sdeFound;
}

  我相信到这里,大家肯定已经明白smap是怎样被加到字节码中去了,那SourceDebugExtension加到字节码中有什么用呢?看了
《Java虚拟机规范.Java SE 8版》的第101页,也没有看到如何使用。
在这里插入图片描述
  难道是在代码抛出异常时,定位异常用的不?来看一个例子。
在这里插入图片描述
  生成的index_jsp.java的代码是
在这里插入图片描述
  再看_jspx_page_context是个什么对象。

public void _jspService(final javax.servlet.http.HttpServletRequest request, final javax.servlet.http.HttpServletResponse response)
      throws java.io.IOException, javax.servlet.ServletException {

  final javax.servlet.jsp.PageContext pageContext;
  javax.servlet.http.HttpSession session = null;
  final javax.servlet.ServletContext application;
  final javax.servlet.ServletConfig config;
  javax.servlet.jsp.JspWriter out = null;
  final java.lang.Object page = this;
  javax.servlet.jsp.JspWriter _jspx_out = null;
  javax.servlet.jsp.PageContext _jspx_page_context = null;


  try {
    response.setContentType("text/html;charset=UTF-8");
    pageContext = _jspxFactory.getPageContext(this, request, response,
              null, true, 8192, true);
    _jspx_page_context = pageContext;
    application = pageContext.getServletContext();
    config = pageContext.getServletConfig();
    session = pageContext.getSession();
    out = pageContext.getOut();
    _jspx_out = out;

    ...

  从生成的jsp代码中得知,_jspx_page_context = _jspxFactory.getPageContext(this, request, response,
null, true, 8192, true);,那_jspxFactory又是怎样来的呢?从生成的jsp文件中得知。

private static final javax.servlet.jsp.JspFactory _jspxFactory =
          javax.servlet.jsp.JspFactory.getDefaultFactory();

  进入JspFactory类

public static void setDefaultFactory(JspFactory deflt) {
    JspFactory.deflt = deflt;
}

public static JspFactory getDefaultFactory() {
    return deflt;
}

  发现_jspxFactory最终又来源于setDefaultFactory()方法 ,查找一下,发现setDefaultFactory()方法又在JspRuntimeContext的静态初始化方法中调用了。

static {
    JspFactoryImpl factory = new JspFactoryImpl();
    SecurityClassLoad.securityClassLoad(factory.getClass().getClassLoader());
    if( System.getSecurityManager() != null ) {
        String basePackage = "org.apache.jasper.";
        try {
            	... 
        } catch (ClassNotFoundException ex) {
            throw new IllegalStateException(ex);
        }
    }

    JspFactory.setDefaultFactory(factory);
}

  最终得到_jspxFactory实际上是JspFactoryImpl,而进入其getPageContext()方法 。

public PageContext getPageContext(Servlet servlet, ServletRequest request,
        ServletResponse response, String errorPageURL, boolean needsSession,
        int bufferSize, boolean autoflush) {

    if( Constants.IS_SECURITY_ENABLED ) {
        PrivilegedGetPageContext dp = new PrivilegedGetPageContext(
                this, servlet, request, response, errorPageURL,
                needsSession, bufferSize, autoflush);
        return AccessController.doPrivileged(dp);
    } else {
        return internalGetPageContext(servlet, request, response,
                errorPageURL, needsSession,
                bufferSize, autoflush);
    }
}


private PageContext internalGetPageContext(Servlet servlet, ServletRequest request,
        ServletResponse response, String errorPageURL, boolean needsSession,
        int bufferSize, boolean autoflush) {
    try {
        PageContext pc;
        if (USE_POOL) {
            PageContextPool pool = localPool.get();
            if (pool == null) {
                pool = new PageContextPool();
                localPool.set(pool);
            }
            pc = pool.get();
            if (pc == null) {
                pc = new PageContextImpl();
            }
        } else {
            pc = new PageContextImpl();
        }
        pc.initialize(servlet, request, response, errorPageURL,
                needsSession, bufferSize, autoflush);
        return pc;
    } catch (Throwable ex) {
        ExceptionUtils.handleThrowable(ex);
        if (ex instanceof RuntimeException) {
            throw (RuntimeException) ex;
        }
        log.fatal("Exception initializing page context", ex);
        return null;
    }
}

  最终得到PageContext实际上是PageContextImpl对象,而其private void doHandlePageException(Throwable t)方法如下 。

private void doHandlePageException(Throwable t) throws IOException,
        ServletException {

    if (errorPageURL != null && !errorPageURL.equals("")) {

        /*
         * Set request attributes. Do not set the
         * javax.servlet.error.exception attribute here (instead, set in the
         * generated servlet code for the error page) in order to prevent
         * the ErrorReportValve, which is invoked as part of forwarding the
         * request to the error page, from throwing it if the response has
         * not been committed (the response will have been committed if the
         * error page is a JSP page).
         */
        request.setAttribute(PageContext.EXCEPTION, t);
        request.setAttribute(RequestDispatcher.ERROR_STATUS_CODE,
                Integer.valueOf(HttpServletResponse.SC_INTERNAL_SERVER_ERROR));
        request.setAttribute(RequestDispatcher.ERROR_REQUEST_URI,
                ((HttpServletRequest) request).getRequestURI());
        request.setAttribute(RequestDispatcher.ERROR_SERVLET_NAME,
                config.getServletName());
        try {
            forward(errorPageURL);
        } catch (IllegalStateException ise) {
            include(errorPageURL);
        }

        // The error page could be inside an include.

        Object newException =
                request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);

        // t==null means the attribute was not set.
        if ((newException != null) && (newException == t)) {
            request.removeAttribute(RequestDispatcher.ERROR_EXCEPTION);
        }

        // now clear the error code - to prevent double handling.
        request.removeAttribute(RequestDispatcher.ERROR_STATUS_CODE);
        request.removeAttribute(RequestDispatcher.ERROR_REQUEST_URI);
        request.removeAttribute(RequestDispatcher.ERROR_SERVLET_NAME);
        request.removeAttribute(PageContext.EXCEPTION);

    } else {
        // Otherwise throw the exception wrapped inside a ServletException.
        // Set the exception as the root cause in the ServletException
        // to get a stack trace for the real problem
        if (t instanceof IOException)
            throw (IOException) t;
        if (t instanceof ServletException)
            throw (ServletException) t;
        if (t instanceof RuntimeException)
            throw (RuntimeException) t;

        Throwable rootCause = null;
        if (t instanceof JspException) {
            rootCause = ((JspException) t).getCause();
        } else if (t instanceof ELException) {
            rootCause = ((ELException) t).getCause();
        }

        if (rootCause != null) {
            throw new ServletException(t.getClass().getName() + ": "
                    + t.getMessage(), rootCause);
        }

        throw new ServletException(t);
    }
}

  因为没有配置错误页面,因此最终进入上面的加粗代码 ,抛出java.lang.ArithmeticException: / by zero 异常。 在这里插入图片描述
  在servlet.service()方法调用时,抛出了java.lang.ArithmeticException: / by zero 异常,最终被handleJspException()方法捕获到。
在这里插入图片描述
  进入 handleJspException(Exception ex)方法 。

protected JasperException handleJspException(Exception ex) {
    try {
        Throwable realException = ex;
        if (ex instanceof ServletException) {
            realException = ((ServletException) ex).getRootCause();
        }

        // 获取到异常堆栈
        StackTraceElement[] frames = realException.getStackTrace();
        StackTraceElement jspFrame = null;

        for (int i=0; i<frames.length; ++i) {
        	// 遍历所有的异常堆栈,如果栈中的类名与servlet中类名一样,则所在的栈帧的行号就是生成_jsp.java文件抛出异常所在的java行号
            if ( frames[i].getClassName().equals(this.getServlet().getClass().getName()) ) {
                jspFrame = frames[i];
                break;
            }
        }


        if (jspFrame == null ||
                this.ctxt.getCompiler().getPageNodes() == null) {
            // If we couldn't find a frame in the stack trace corresponding
            // to the generated servlet class or we don't have a copy of the
            // parsed JSP to hand, we can't really add anything
            return new JasperException(ex);
        }
		// 获取抛出异常的java 行号
        int javaLineNumber = jspFrame.getLineNumber();
        // 创建异常详细信息
        JavacErrorDetail detail = ErrorDispatcher.createJavacError(
                jspFrame.getMethodName(),
                this.ctxt.getCompiler().getPageNodes(),
                null,
                javaLineNumber,
                ctxt);

        // If the line number is less than one we couldn't find out
        // where in the JSP things went wrong
        int jspLineNumber = detail.getJspBeginLineNumber();
        if (jspLineNumber < 1) {
            throw new JasperException(ex);
        }

        if (options.getDisplaySourceFragment()) {
            return new JasperException(Localizer.getMessage
                    ("jsp.exception", detail.getJspFileName(),
                            "" + jspLineNumber) + Constants.NEWLINE +
                            Constants.NEWLINE + detail.getJspExtract() +
                            Constants.NEWLINE + Constants.NEWLINE +
                            "Stacktrace:", ex);

        }

        return new JasperException(Localizer.getMessage
                ("jsp.exception", detail.getJspFileName(),
                        "" + jspLineNumber), ex);
    } catch (Exception je) {
        // If anything goes wrong, just revert to the original behaviour
        if (ex instanceof JasperException) {
            return (JasperException) ex;
        }
        return new JasperException(ex);
    }
}

在这里插入图片描述

  如何得到异常的详细信息呢?进入createJavacError()方法 。

public static JavacErrorDetail createJavacError(String fname,
        Node.Nodes page, StringBuilder errMsgBuf, int lineNum,
        JspCompilationContext ctxt) throws JasperException {
    JavacErrorDetail javacError;
    // Attempt to map javac error line number to line in JSP page
    ErrorVisitor errVisitor = new ErrorVisitor(lineNum);
    page.visit(errVisitor);
    
	// 得到抛出异常的Node
    Node errNode = errVisitor.getJspSourceNode();
    
    if ((errNode != null) && (errNode.getStart() != null)) {
        // If this is a scriplet node then there is a one to one mapping
        // between JSP lines and Java lines
        if (errVisitor.getJspSourceNode() instanceof Node.Scriptlet ||
                errVisitor.getJspSourceNode() instanceof Node.Declaration) {
            javacError = new JavacErrorDetail(
                    fname,
                    lineNum,
                    errNode.getStart().getFile(),
                    // 通过java代码抛出异常的行号计算出jsp抛出异常行号
                    errNode.getStart().getLineNumber() + lineNum -
                        errVisitor.getJspSourceNode().getBeginJavaLine(),
                    errMsgBuf,
                    ctxt);
        } else {
            javacError = new JavacErrorDetail(
                    fname,
                    lineNum,
                    errNode.getStart().getFile(),
                    errNode.getStart().getLineNumber(),
                    errMsgBuf,
                    ctxt);
        }
    } else {
        /*
         * javac error line number cannot be mapped to JSP page
         * line number. For example, this is the case if a
         * scriptlet is missing a closing brace, which causes
         * havoc with the try-catch-finally block that the code
         * generator places around all generated code: As a result
         * of this, the javac error line numbers will be outside
         * the range of begin and end java line numbers that were
         * generated for the scriptlet, and therefore cannot be
         * mapped to the start line number of the scriptlet in the
         * JSP page.
         * Include just the javac error info in the error detail.
         */
        javacError = new JavacErrorDetail(
                fname,
                lineNum,
                errMsgBuf);
    }
    return javacError;
}

  先通过调用ErrorVisitor的访问者模式,得到抛出异常的Node,我们之前不是分析过,jsp最终被解析成一个一个Node,接下来看ErrorVisitor内部做了哪些事情 。

static class ErrorVisitor extends Node.Visitor {

    // Java source line number to be mapped
    private int lineNum;

    /*
     * JSP node whose Java source code range in the generated servlet
     * contains the Java source line number to be mapped
     */
    Node found;

    /*
     * Constructor.
     *
     * @param lineNum Source line number in the generated servlet code
     */
    public ErrorVisitor(int lineNum) {
        this.lineNum = lineNum;
    }

    @Override
    public void doVisit(Node n) throws JasperException {
        if ((lineNum >= n.getBeginJavaLine())
                && (lineNum < n.getEndJavaLine())) {
            found = n;
        }
    }

    /*
     * Gets the JSP node to which the source line number in the generated
     * servlet code was mapped.
     *
     * @return JSP node to which the source line number in the generated
     * servlet code was mapped
     */
    public Node getJspSourceNode() {
        return found;
    }
}

  上面代码重点就是加粗代码,如果抛出异常的行号在Node的java代码开始行和结束行之间,那么抛出异常的地方肯定在这个Node中。在JavacErrorDetail对象创建之前,有一个变量 errNode.getStart().getLineNumber() + lineNum -
errVisitor.getJspSourceNode().getBeginJavaLine(),这个是什么意思呢? 如Node节点的java开始行为102 , 对应的jsp 代码是第10行,而java 代码抛出异常的行号是109 ,那么jsp中抛出异常的行号是多少呢? 不就是109 - 102 + 10 = 17 不?因此这个变量就是计算jsp抛出异常的代码行号。
  接下来进入JavacErrorDetail的构造函数 。

public JavacErrorDetail(String javaFileName,
        int javaLineNum,
        String jspFileName,
        int jspBeginLineNum,
        StringBuilder errMsg,
        JspCompilationContext ctxt) {

    this(javaFileName, javaLineNum, errMsg);
    this.jspFileName = jspFileName;
    this.jspBeginLineNum = jspBeginLineNum;

    if (jspBeginLineNum > 0 && ctxt != null) {
        InputStream is = null;
        FileInputStream  fis = null;

        try {
            // Read both files in, so we can inspect them
            JarFile jarFile = null;
            JarResource tagJarResource = ctxt.getTagFileJarResource();
            if (tagJarResource != null) {
                jarFile = tagJarResource.getJarFile();
            }

            is = JspUtil.getInputStream(jspFileName, jarFile, ctxt, null);
			// 读取jsp文件
            String[] jspLines = readFile(is);

            fis = new FileInputStream(ctxt.getServletJavaFileName());
            // 读取生成的java文件
            String[] javaLines = readFile(fis);

            if (jspLines.length < jspBeginLineNum) {
                // Avoid ArrayIndexOutOfBoundsException
                // Probably bug 48498 but could be some other cause
                jspExtract = Localizer.getMessage("jsp.error.bug48498");
                return;
            }

            // If the line contains the opening of a multi-line scriptlet
            // block, then the JSP line number we got back is probably
            // faulty.  Scan forward to match the java line...
            // 正常情况都是 
            // <% 
            // int a = 0;
            // int b = 1 / 0 ;
            // %>
            // 但如果出现
            // 
            // int a = 0 ;
            // %>
            // <% int b = 1/0;
            // 时, 需要对Node对应的jsp代码行重新遍历,直到jsp和java行号相等,重新更新
            // jspBeginLineNum 行号 
            if (jspLines[jspBeginLineNum-1].lastIndexOf("<%") >
                jspLines[jspBeginLineNum-1].lastIndexOf("%>")) {
                String javaLine = javaLines[javaLineNum-1].trim();

                for (int i=jspBeginLineNum-1; i<jspLines.length; i++) {
                    if (jspLines[i].indexOf(javaLine) != -1) {
                        // Update jsp line number
                        this.jspBeginLineNum = i+1;
                        break;
                    }
                }
            }

            // copy out a fragment of JSP to display to the user
            StringBuilder fragment = new StringBuilder(1024);
            // 打印出抛出异常行号 -4 到 抛出异常行号 + 2 之间的jsp 代码
            // 更加有利于开发者定位问题
            int startIndex = Math.max(0, this.jspBeginLineNum-1-3);
            int endIndex = Math.min(
                    jspLines.length-1, this.jspBeginLineNum-1+3);

            for (int i=startIndex;i<=endIndex; ++i) {
            	// jsp 对应的行号, 但这里 i + 1 ,实际上打印的是 抛出异常代码行号 -3  ~ + 3之间的jsp代码
                fragment.append(i+1);
                fragment.append(": ");
                // jsp对应行号 jsp 代码 
                fragment.append(jspLines[i]);
                fragment.append(Constants.NEWLINE);
            }
            jspExtract = fragment.toString();
        } catch (JasperException je) {
            // Exception is never thrown - ignore
        } catch (IOException ioe) {
            // Can't read files - ignore
        } finally {
            if (is != null) {
                try {
                    is.close();
                } catch (IOException ioe) {
                    // Ignore
                }
            }
            if (fis != null) {
                try {
                    fis.close();
                } catch (IOException ioe) {
                    // Ignore
                }
            }
        }
    }
}

  关于jsp抛出异常的细节部分都已经分析过了。 最终通过JasperException异常抛出到前端页面 。

return new JasperException(Localizer.getMessage
        "jsp.exception", detail.getJspFileName(),
               "" + jspLineNumber) + Constants.NEWLINE +
               Constants.NEWLINE + detail.getJspExtract() +
               Constants.NEWLINE + Constants.NEWLINE +
               "Stacktrace:", ex);

在这里插入图片描述

  我们分析了那么多,终于知道当jsp文件抛出异常时,tomcat是如何处理,但遗憾的是,依然不知道SourceDebugExtension怎样用。

在这里插入图片描述
  从网上资料显示 SourceDebugExtension属性用于存储额外的代码调试信息。 典型的场景是在进行JSP文件调试时, 没法经过Java堆栈来定位到JSP文件的行号。 可能是用来做调试用的吧。关于smap的分析到这里了,接下来,使是生成的jsp代码编译的分析 。

16.2.2 JDT Compiler编译器

  通过JSP编译器编译后,生成了对应的Java文件,接下来, 要把Java文件编译成Class文件,对于 这部分,完全没有必要重复造轮子,常见的优秀编译工具有Eclispe JDT Java编译器和Ant编译器,Tomcat 其实同时支持两个编译器, 通过配置可以选择,而默认使用Eclipse JDT 编译器。
  通过调用这些现在的编译器的API ,就可以方便的实现对Java 文件的编译,由于两个编译器的功能一样,因此就选 默认的Eclipse JDT 编译器,下面看如何用Eclipse JDT 编译器编译java文件 。
  Eclipse JDT 提供了Compiler 类用于编译,它的构造函数比较复杂,如下所示 ,其实,实现自定义的构造函数包含的参数即基本完成了编译工作 。

public Compiler {
   INameEnvironment environment ;
   IErrorHandlingPolicy policy ;
   CompilerOptions options ;
   final ICompilerRequestor requestor ,
   IProblemFactory problemFactory ;
}
  为了方便说明,直接给出一个简单的编译实现,如下图所示 。

/**
 * JDT class compiler. This compiler will load source dependencies from the
 * context classloader, reducing dramatically disk access during
 * the compilation process.
 *
 * @author Cocoon2
 * @author Remy Maucherat
 */
public class JDTCompiler extends org.apache.jasper.compiler.Compiler {

    private static final String JDT_JAVA_9_VERSION;

    static {
        // The constant for Java 9 changed between 4.6 and 4.7 in a way that is
        // not backwards compatible. Need to figure out which version is in use
        // so the correct constant value is used.

        String jdtJava9Version = null;

        Class<?> clazz = CompilerOptions.class;

        for (Field field : clazz.getFields()) {
            if ("VERSION_9".equals(field.getName())) {
                // 4.7 onwards: CompilerOptions.VERSION_9
                jdtJava9Version = "9";
                break;
            }
        }

        if (jdtJava9Version == null) {
            // 4.6 and earlier: CompilerOptions.VERSION_1_9
            jdtJava9Version = "1.9";
        }

        JDT_JAVA_9_VERSION = jdtJava9Version;
    }

    private final Log log = LogFactory.getLog(JDTCompiler.class); // must not be static

    /**
     * Compile the servlet from .java file to .class file
     */
    @Override
    protected void generateClass(String[] smap)
        throws FileNotFoundException, JasperException, Exception {

        long t1 = 0;
        if (log.isDebugEnabled()) {
            t1 = System.currentTimeMillis();
        }

        final String sourceFile = ctxt.getServletJavaFileName();
        final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
        String packageName = ctxt.getServletPackageName();
        final String targetClassName =
            ((packageName.length() != 0) ? (packageName + ".") : "")
                    + ctxt.getServletClassName();
        final ClassLoader classLoader = ctxt.getJspLoader();
        String[] fileNames = new String[] {sourceFile};
        String[] classNames = new String[] {targetClassName};
        final ArrayList<JavacErrorDetail> problemList =
            new ArrayList<JavacErrorDetail>();

        class CompilationUnit implements ICompilationUnit {

            private final String className;
            private final String sourceFile;

            CompilationUnit(String sourceFile, String className) {
                this.className = className;
                this.sourceFile = sourceFile;
            }

            @Override
            public char[] getFileName() {
                return sourceFile.toCharArray();
            }

            @Override
            public char[] getContents() {
                char[] result = null;
                FileInputStream is = null;
                InputStreamReader isr = null;
                Reader reader = null;
                try {
                    is = new FileInputStream(sourceFile);
                    isr = new InputStreamReader(is,
                            ctxt.getOptions().getJavaEncoding());
                    reader = new BufferedReader(isr);
                    char[] chars = new char[8192];
                    StringBuilder buf = new StringBuilder();
                    int count;
                    while ((count = reader.read(chars, 0,
                                                chars.length)) > 0) {
                        buf.append(chars, 0, count);
                    }
                    result = new char[buf.length()];
                    buf.getChars(0, result.length, result, 0);
                } catch (IOException e) {
                    log.error("Compilation error", e);
                } finally {
                    if (reader != null) {
                        try {
                            reader.close();
                        } catch (IOException ioe) {/*Ignore*/}
                    }
                    if (isr != null) {
                        try {
                            isr.close();
                        } catch (IOException ioe) {/*Ignore*/}
                    }
                    if (is != null) {
                        try {
                            is.close();
                        } catch (IOException exc) {/*Ignore*/}
                    }
                }
                return result;
            }

            @Override
            public char[] getMainTypeName() {
                int dot = className.lastIndexOf('.');
                if (dot > 0) {
                    return className.substring(dot + 1).toCharArray();
                }
                return className.toCharArray();
            }

            @Override
            public char[][] getPackageName() {
                StringTokenizer izer =
                    new StringTokenizer(className, ".");
                char[][] result = new char[izer.countTokens()-1][];
                for (int i = 0; i < result.length; i++) {
                    String tok = izer.nextToken();
                    result[i] = tok.toCharArray();
                }
                return result;
            }

            @Override
            public boolean ignoreOptionalProblems() {
                return false;
            }
        }

        final INameEnvironment env = new INameEnvironment() {

                @Override
                public NameEnvironmentAnswer
                    findType(char[][] compoundTypeName) {
                    StringBuilder result = new StringBuilder();
                    String sep = "";
                    for (int i = 0; i < compoundTypeName.length; i++) {
                        result.append(sep);
                        result.append(compoundTypeName[i]);
                        sep = ".";
                    }
                    return findType(result.toString());
                }

                @Override
                public NameEnvironmentAnswer
                    findType(char[] typeName,
                             char[][] packageName) {
                    StringBuilder result = new StringBuilder();
                    String sep = "";
                    for (int i = 0; i < packageName.length; i++) {
                        result.append(sep);
                        result.append(packageName[i]);
                        sep = ".";
                    }
                    result.append(sep);
                    result.append(typeName);
                    return findType(result.toString());
                }

                private NameEnvironmentAnswer findType(String className) {

                    InputStream is = null;
                    try {
                        if (className.equals(targetClassName)) {
                            ICompilationUnit compilationUnit =
                                new CompilationUnit(sourceFile, className);
                            return
                                new NameEnvironmentAnswer(compilationUnit, null);
                        }
                        String resourceName =
                            className.replace('.', '/') + ".class";
                        is = classLoader.getResourceAsStream(resourceName);
                        if (is != null) {
                            byte[] classBytes;
                            byte[] buf = new byte[8192];
                            ByteArrayOutputStream baos =
                                new ByteArrayOutputStream(buf.length);
                            int count;
                            while ((count = is.read(buf, 0, buf.length)) > 0) {
                                baos.write(buf, 0, count);
                            }
                            baos.flush();
                            classBytes = baos.toByteArray();
                            char[] fileName = className.toCharArray();
                            ClassFileReader classFileReader =
                                new ClassFileReader(classBytes, fileName,
                                                    true);
                            return
                                new NameEnvironmentAnswer(classFileReader, null);
                        }
                    } catch (IOException exc) {
                        log.error("Compilation error", exc);
                    } catch (org.eclipse.jdt.internal.compiler.classfmt.ClassFormatException exc) {
                        log.error("Compilation error", exc);
                    } finally {
                        if (is != null) {
                            try {
                                is.close();
                            } catch (IOException exc) {
                                // Ignore
                            }
                        }
                    }
                    return null;
                }

                private boolean isPackage(String result) {
                    if (result.equals(targetClassName)) {
                        return false;
                    }
                    String resourceName = result.replace('.', '/') + ".class";
                    InputStream is = null;
                    try {
                        is = classLoader.getResourceAsStream(resourceName);
                        return is == null;
                    } finally {
                        if (is != null) {
                            try {
                                is.close();
                            } catch (IOException e) {
                            }
                        }
                    }
                }

                @Override
                public boolean isPackage(char[][] parentPackageName,
                                         char[] packageName) {
                    StringBuilder result = new StringBuilder();
                    String sep = "";
                    if (parentPackageName != null) {
                        for (int i = 0; i < parentPackageName.length; i++) {
                            result.append(sep);
                            result.append(parentPackageName[i]);
                            sep = ".";
                        }
                    }
                    if (Character.isUpperCase(packageName[0])) {
                        if (!isPackage(result.toString())) {
                            return false;
                        }
                    }
                    result.append(sep);
                    result.append(packageName);
                    return isPackage(result.toString());
                }

                @Override
                public void cleanup() {
                }

            };

        final IErrorHandlingPolicy policy =
            DefaultErrorHandlingPolicies.proceedWithAllProblems();

        final Map<String,String> settings = new HashMap<String,String>();
        settings.put(CompilerOptions.OPTION_LineNumberAttribute,
                     CompilerOptions.GENERATE);
        settings.put(CompilerOptions.OPTION_SourceFileAttribute,
                     CompilerOptions.GENERATE);
        settings.put(CompilerOptions.OPTION_ReportDeprecation,
                     CompilerOptions.IGNORE);
        if (ctxt.getOptions().getJavaEncoding() != null) {
            settings.put(CompilerOptions.OPTION_Encoding,
                    ctxt.getOptions().getJavaEncoding());
        }
        if (ctxt.getOptions().getClassDebugInfo()) {
            settings.put(CompilerOptions.OPTION_LocalVariableAttribute,
                         CompilerOptions.GENERATE);
        }

        // Source JVM
        if(ctxt.getOptions().getCompilerSourceVM() != null) {
            String opt = ctxt.getOptions().getCompilerSourceVM();
            if(opt.equals("1.1")) {
                settings.put(CompilerOptions.OPTION_Source,
                             CompilerOptions.VERSION_1_1);
            } else if(opt.equals("1.2")) {
                settings.put(CompilerOptions.OPTION_Source,
                             CompilerOptions.VERSION_1_2);
            } else if(opt.equals("1.3")) {
                settings.put(CompilerOptions.OPTION_Source,
                             CompilerOptions.VERSION_1_3);
			... 
            } else {
                log.warn(Localizer.getMessage("jsp.warning.unknown.sourceVM", opt));
                settings.put(CompilerOptions.OPTION_Source,
                        CompilerOptions.VERSION_1_6);
            }
        } else {
            // Default to 1.6
            settings.put(CompilerOptions.OPTION_Source,
                    CompilerOptions.VERSION_1_6);
        }

        // Target JVM
        if(ctxt.getOptions().getCompilerTargetVM() != null) {
            String opt = ctxt.getOptions().getCompilerTargetVM();
            
                settings.put(CompilerOptions.OPTION_Compliance,
                             JDT_JAVA_9_VERSION);
            } else if(opt.equals("10")) {
                // Constant not available in latest ECJ version that runs on
                // Java 6.
                // This is checked against the actual version below.
                settings.put(CompilerOptions.OPTION_TargetPlatform, "10");
                settings.put(CompilerOptions.OPTION_Compliance, "10");
            } else if(opt.equals("11")) {
                // Constant not available in latest ECJ version that runs on
                // Java 6.
                // This is checked against the actual version below.
                settings.put(CompilerOptions.OPTION_TargetPlatform, "11");
                settings.put(CompilerOptions.OPTION_Compliance, "11");
            } else if(opt.equals("12")) {
                // Constant not available in latest available ECJ version.
                // May be supported in a snapshot build.
                // This is checked against the actual version below.
                settings.put(CompilerOptions.OPTION_TargetPlatform, "12");
                settings.put(CompilerOptions.OPTION_Compliance, "12");
            } else if(opt.equals("13")) {
                // Constant not available in latest available ECJ version.
                // May be supported in a snapshot build.
                // This is checked against the actual version below.
                settings.put(CompilerOptions.OPTION_TargetPlatform, "13");
                settings.put(CompilerOptions.OPTION_Compliance, "13");
            } else {
                log.warn(Localizer.getMessage("jsp.warning.unknown.targetVM", opt));
                settings.put(CompilerOptions.OPTION_TargetPlatform,
                        CompilerOptions.VERSION_1_6);
            }
        } else {
            // Default to 1.6
            settings.put(CompilerOptions.OPTION_TargetPlatform,
            		... 
                    CompilerOptions.VERSION_1_6);
            settings.put(CompilerOptions.OPTION_Compliance,
                    CompilerOptions.VERSION_1_6);
        }

        final IProblemFactory problemFactory =
            new DefaultProblemFactory(Locale.getDefault());

        final ICompilerRequestor requestor = new ICompilerRequestor() {
                @Override
                public void acceptResult(CompilationResult result) {
                    try {
                        if (result.hasProblems()) {
                            IProblem[] problems = result.getProblems();
                            for (int i = 0; i < problems.length; i++) {
                                IProblem problem = problems[i];
                                if (problem.isError()) {
                                    String name =
                                        new String(problems[i].getOriginatingFileName());
                                    try {
                                        problemList.add(ErrorDispatcher.createJavacError
                                                (name, pageNodes, new StringBuilder(problem.getMessage()),
                                                        problem.getSourceLineNumber(), ctxt));
                                    } catch (JasperException e) {
                                        log.error("Error visiting node", e);
                                    }
                                }
                            }
                        }
                        if (problemList.isEmpty()) {
                            ClassFile[] classFiles = result.getClassFiles();
                            for (int i = 0; i < classFiles.length; i++) {
                                ClassFile classFile = classFiles[i];
                                char[][] compoundName =
                                    classFile.getCompoundName();
                                StringBuilder classFileName = new StringBuilder(outputDir).append('/');
                                for (int j = 0;
                                     j < compoundName.length; j++) {
                                    if(j > 0) {
                                        classFileName.append('/');
                                    }
                                    classFileName.append(compoundName[j]);
                                }
                                byte[] bytes = classFile.getBytes();
                                classFileName.append(".class");
                                FileOutputStream fout = null;
                                BufferedOutputStream bos = null;
                                try {
                                    fout = new FileOutputStream(classFileName.toString());
                                    bos = new BufferedOutputStream(fout);
                                    bos.write(bytes);
                                } finally {
                                    if (bos != null) {
                                        try {
                                            bos.close();
                                        } catch (IOException e) {
                                        }
                                    }
                                }
                            }
                        }
                    } catch (IOException exc) {
                        log.error("Compilation error", exc);
                    }
                }
            };

        ICompilationUnit[] compilationUnits =
            new ICompilationUnit[classNames.length];
        for (int i = 0; i < compilationUnits.length; i++) {
            String className = classNames[i];
            compilationUnits[i] = new CompilationUnit(fileNames[i], className);
        }
        CompilerOptions cOptions = new CompilerOptions(settings);

        // Check source/target JDK versions as the newest versions are allowed
        // in Tomcat configuration but may not be supported by the ECJ version
        // being used.
        String requestedSource = ctxt.getOptions().getCompilerSourceVM();
        if (requestedSource != null) {
            String actualSource = CompilerOptions.versionFromJdkLevel(cOptions.sourceLevel);
            if (!requestedSource.equals(actualSource)) {
                log.warn(Localizer.getMessage("jsp.warning.unsupported.sourceVM", requestedSource, actualSource));
            }
        }
        String requestedTarget = ctxt.getOptions().getCompilerTargetVM();
        if (requestedTarget != null) {
            String actualTarget = CompilerOptions.versionFromJdkLevel(cOptions.targetJDK);
            if (!requestedTarget.equals(actualTarget)) {
                log.warn(Localizer.getMessage("jsp.warning.unsupported.targetVM", requestedTarget, actualTarget));
            }
        }

        cOptions.parseLiteralExpressionsAsConstants = true;
        Compiler compiler = new Compiler(env,
                                         policy,
                                         cOptions,
                                         requestor,
                                         problemFactory);
        compiler.compile(compilationUnits);

        if (!ctxt.keepGenerated()) {
            File javaFile = new File(ctxt.getServletJavaFileName());
            javaFile.delete();
        }

        if (!problemList.isEmpty()) {
            JavacErrorDetail[] jeds =
                problemList.toArray(new JavacErrorDetail[0]);
            errDispatcher.javacError(jeds);
        }

        if( log.isDebugEnabled() ) {
            long t2=System.currentTimeMillis();
            log.debug("Compiled " + ctxt.getServletJavaFileName() + " "
                      + (t2-t1) + "ms");
        }

        if (ctxt.isPrototypeMode()) {
            return;
        }

        // JSR45 Support
        if (! options.isSmapSuppressed()) {
            SmapUtil.installSmap(smap);
        }

    }


}

  为了有助于理解,我们根据构造函数的参数依次看它的作用。

  • INameEnvironment 接口,它需要实现的主要方法是findType和isPackage ,FindType有助于JDT 找到相应的Java源文件或者Class字节码,根据传进来的包名和类名去寻找,例如,传入了java.lang.String或org.apache.jsp.HelloWorld_jsp ,则分别找到JDK自带的String字节码及Tomcat 中编译的HelloWorld_jsp.java文件,接着,按要求封装这些对象,返回JDT 规定的NameEnvironmentAnswer对象,而isPackage则提供是否是包的判断 。
  • IErrorHandlingPolicy 接口, 用于描述错误策略, 可直接使用DefaultErrorHandlingPolicies.exitOnFirstError(),如果表示第一个错误,就退出编译。
  • CompilerOptions对象,指定编译时的一些参数,例如,这里指定的编译的java版本为1.7
  • ICompilerRequestor接口,它只有一个acceptResult方法,这个方法用于处理编译后的结果,如果包含了错误信息,则抛出异常, 否则,把编译成功的字节码写到指定的路径的HelloWorld_jsp.class文件中,即生成字节码 。
  • IProblemFactory接口, 主要用于控制编译错误的信息格式 。

  所有Compiler构造函数需要的参数对象都已经准备后, 传入这些参数后创建一个Compiler对象,然后调用compile方法即可对指定的Java文件进行编译,这里完成了HelloWorld_jsp.java的编译, 结果生成了HelloWorld_jsp.class字节码, 实际上,Tomcat中基本上也这样使用了JDT 实现Servlet的编译,但它使用的某些策略可能不同,例如,使用DefaultErrorHandlingPolicies.proceedWithAllProblems()作为错误策略。

  至此,我们已经清楚Tomcat 对JSP编译处理的整个过程了。 它首先根据JSP语法解析生成类似xxx.java的Servlet ,然后再通过Eclipse JDT 对xxx.java编译,最后生成 JVM 能识别的Class字节码 。

  首先,需要一个后台执行线程,Tomcat 中有专门的一条线程负责处理不同的容器的后台任务,要在不同的容器中执行某些后台任务 , 只需要重写backgroudProcess方法即可实现, 由于JSPServlet对应于Wrapper 级别,因此要在StandardWrapper重写backgroundProcess,它会调用实现了PeriodicEventListener接口的Servlet ,其中,JspServlet就实现了PeriodicEventListener 接口,此接口只有一个periodicEvent方法,具体的检测逻辑在此方法中实现即可。
在这里插入图片描述

  其次,判断重新编译的根据是什么?重新编译就是再次把JSP变成java再变成Class,而触发这个动作的条件就是,当我们修改JSP文件后, 或者JSP文件引入的资源被修改后, 所以最好判断的依据就是某个JSP或资源的最后修改时间-LastModified属性, 正常顺序是JSP经过编译后生成的Class文件,把此class文件的LastModified 属性值决定是否重新编译,重新编译后,JSP 与Class 文件的LastModified属性再次设置为相同的值,对于引入的资源,内存中维护了上次编译时引入的资源的LastModified属性再次设置相同的值,不断的获取引入的资源LastModified属性并与内存中对应的LastModified属性进行比较,同样可以很容易的判断是否需要重新编译 。

  最后,对于本地和远程资源分别如何检测? 对于本地资源来说,使用java.io.File类可以很方便的实现对某JSP文件或其他文件的LastModified属性读取,对于远程资源,比如jar包,为了方便处理Jar 包含的属性,使用java.net.URL可以很方便的操作,它包含了很多协议,它包含了很多的协议,例如 ,常见的Jar , File , Ftp 等协议,使用它相当的方便 。

  URL includeUrl = new URL(“jar:http://hostname/third.jar!/”);
  URLConnection iuc = includeUrl.openConnection();
  long includeLastModified = ((JarURLConnection)iuc).getJarEntry().getTime();

  如前所述, 只需要三步可以远程对远程Jar 包的读取出最后修改时间,当然,URL 还支持本地文件资源的读取,所以,它是很好的资源读取抽象对象, Tomcat 中对引入的资源的管理都使用URL作为操作对象 。
  本节探讨了Jasper自动检测机制的实现, 自动检测机制给我们开发带来了很好的检验,我们不必修改了JSP 后自动执行编译操作,而由Tomcat 通过Jasper帮我们检测编译操作。

  关于编译代码这一块,我不想说太多,代码我也不太懂,为了更方便理解编译这一块的原理,我们来看一个例子。

public class MyJDTCompiler {

    private static final String JDT_JAVA_9_VERSION;

    static {
        // The constant for Java 9 changed between 4.6 and 4.7 in a way that is
        // not backwards compatible. Need to figure out which version is in use
        // so the correct constant value is used.

        String jdtJava9Version = null;

        Class<?> clazz = CompilerOptions.class;

        for (Field field : clazz.getFields()) {
            if ("VERSION_9".equals(field.getName())) {
                // 4.7 onwards: CompilerOptions.VERSION_9
                jdtJava9Version = "9";
                break;
            }
        }
        if (jdtJava9Version == null) {
            // 4.6 and earlier: CompilerOptions.VERSION_1_9
            jdtJava9Version = "1.9";
        }

        JDT_JAVA_9_VERSION = jdtJava9Version;
    }

    private final Log log = LogFactory.getLog(MyJDTCompiler.class); // must not be static

    /**
     * Compile the servlet from .java file to .class file
     */
    public void generateClass( final String sourceFile ,final String outputDir,  String packageName
    , String outclassName ,  final ClassLoader classLoader  )
        throws FileNotFoundException, JasperException, Exception {

        long t1 = 0;
        if (log.isDebugEnabled()) {
            t1 = System.currentTimeMillis();
        }


        final String targetClassName =
            ((packageName.length() != 0) ? (packageName + ".") : "")
                    + outclassName;
        String[] fileNames = new String[] {sourceFile};
        String[] classNames = new String[] {targetClassName};

        final ArrayList<JavacErrorDetail> problemList =
            new ArrayList<JavacErrorDetail>();

        class CompilationUnit implements ICompilationUnit {

            private final String className;
            private final String sourceFile;

            CompilationUnit(String sourceFile, String className) {
                this.className = className;
                this.sourceFile = sourceFile;
            }

            @Override
            public char[] getFileName() {
                return sourceFile.toCharArray();
            }

            @Override
            public char[] getContents() {
                char[] result = null;
                FileInputStream is = null;
                InputStreamReader isr = null;
                Reader reader = null;
                try {
                    is = new FileInputStream(sourceFile);
                    isr = new InputStreamReader(is,                            "UTF-8");
                    reader = new BufferedReader(isr);
                    char[] chars = new char[8192];
                    StringBuilder buf = new StringBuilder();
                    int count;
                    while ((count = reader.read(chars, 0,
                                                chars.length)) > 0) {
                        buf.append(chars, 0, count);
                    }
                    result = new char[buf.length()];
                    buf.getChars(0, result.length, result, 0);
                } catch (IOException e) {
                    log.error("Compilation error", e);
                } finally {
                    if (reader != null) {
                        try {
                            reader.close();
                        } catch (IOException ioe) {/*Ignore*/}
                    }
                    if (isr != null) {
                        try {
                            isr.close();
                        } catch (IOException ioe) {/*Ignore*/}
                    }
                    if (is != null) {
                        try {
                            is.close();
                        } catch (IOException exc) {/*Ignore*/}
                    }
                }
                return result;
            }

            @Override
            public char[] getMainTypeName() {
                int dot = className.lastIndexOf('.');
                if (dot > 0) {
                    return className.substring(dot + 1).toCharArray();
                }
                return className.toCharArray();
            }

            @Override
            public char[][] getPackageName() {
                StringTokenizer izer =
                    new StringTokenizer(className, ".");
                char[][] result = new char[izer.countTokens()-1][];
                for (int i = 0; i < result.length; i++) {
                    String tok = izer.nextToken();
                    result[i] = tok.toCharArray();
                }
                return result;
            }

            @Override
            public boolean ignoreOptionalProblems() {
                return false;
            }
        }

        final INameEnvironment env = new INameEnvironment() {

                @Override
                public NameEnvironmentAnswer
                    findType(char[][] compoundTypeName) {
                    StringBuilder result = new StringBuilder();
                    String sep = "";
                    for (int i = 0; i < compoundTypeName.length; i++) {
                        result.append(sep);
                        result.append(compoundTypeName[i]);
                        sep = ".";
                    }
                    return findType(result.toString());
                }

                @Override
                public NameEnvironmentAnswer
                    findType(char[] typeName,
                             char[][] packageName) {
                    StringBuilder result = new StringBuilder();
                    String sep = "";
                    for (int i = 0; i < packageName.length; i++) {
                        result.append(sep);
                        result.append(packageName[i]);
                        sep = ".";
                    }
                    result.append(sep);
                    result.append(typeName);
                    return findType(result.toString());
                }

                private NameEnvironmentAnswer findType(String className) {

                    InputStream is = null;
                    try {
                        if (className.equals(targetClassName)) {
                            ICompilationUnit compilationUnit =
                                new CompilationUnit(sourceFile, className);
                            return
                                new NameEnvironmentAnswer(compilationUnit, null);
                        }
                        String resourceName =
                            className.replace('.', '/') + ".class";
                        is = classLoader.getResourceAsStream(resourceName);
                        if (is != null) {
                            byte[] classBytes;
                            byte[] buf = new byte[8192];
                            ByteArrayOutputStream baos =
                                new ByteArrayOutputStream(buf.length);
                            int count;
                            while ((count = is.read(buf, 0, buf.length)) > 0) {
                                baos.write(buf, 0, count);
                            }
                            baos.flush();
                            classBytes = baos.toByteArray();
                            char[] fileName = className.toCharArray();
                            ClassFileReader classFileReader =
                                new ClassFileReader(classBytes, fileName,
                                                    true);
                            return
                                new NameEnvironmentAnswer(classFileReader, null);
                        }
                    } catch (IOException exc) {
                        log.error("Compilation error", exc);
                    } catch (org.eclipse.jdt.internal.compiler.classfmt.ClassFormatException exc) {
                        log.error("Compilation error", exc);
                    } finally {
                        if (is != null) {
                            try {
                                is.close();
                            } catch (IOException exc) {
                                // Ignore
                            }
                        }
                    }
                    return null;
                }

                private boolean isPackage(String result) {
                    if (result.equals(targetClassName)) {
                        return false;
                    }
                    String resourceName = result.replace('.', '/') + ".class";
                    InputStream is = null;
                    try {
                        is = classLoader.getResourceAsStream(resourceName);
                        return is == null;
                    } finally {
                        if (is != null) {
                            try {
                                is.close();
                            } catch (IOException e) {
                            }
                        }
                    }
                }

                @Override
                public boolean isPackage(char[][] parentPackageName,
                                         char[] packageName) {
                    StringBuilder result = new StringBuilder();
                    String sep = "";
                    if (parentPackageName != null) {
                        for (int i = 0; i < parentPackageName.length; i++) {
                            result.append(sep);
                            result.append(parentPackageName[i]);
                            sep = ".";
                        }
                    }
                    if (Character.isUpperCase(packageName[0])) {
                        if (!isPackage(result.toString())) {
                            return false;
                        }
                    }
                    result.append(sep);
                    result.append(packageName);
                    return isPackage(result.toString());
                }

                @Override
                public void cleanup() {
                }

            };

        final IErrorHandlingPolicy policy =
            DefaultErrorHandlingPolicies.proceedWithAllProblems();

        final Map<String,String> settings = new HashMap<String,String>();
        settings.put(CompilerOptions.OPTION_LineNumberAttribute,
                     CompilerOptions.GENERATE);
        settings.put(CompilerOptions.OPTION_SourceFileAttribute,
                     CompilerOptions.GENERATE);
        settings.put(CompilerOptions.OPTION_ReportDeprecation,
                     CompilerOptions.IGNORE);

        settings.put(CompilerOptions.OPTION_Encoding, "UTF-8");

        settings.put(CompilerOptions.OPTION_LocalVariableAttribute,
                CompilerOptions.GENERATE);

        settings.put(CompilerOptions.OPTION_Source,
                CompilerOptions.VERSION_1_8);

        settings.put(CompilerOptions.OPTION_TargetPlatform,
                CompilerOptions.VERSION_1_8);
        settings.put(CompilerOptions.OPTION_Compliance,
                CompilerOptions.VERSION_1_8);


        final IProblemFactory problemFactory =
            new DefaultProblemFactory(Locale.getDefault());

        final ICompilerRequestor requestor = new ICompilerRequestor() {
                @Override
                public void acceptResult(CompilationResult result) {
                    try {
                        if (result.hasProblems()) {
                            IProblem[] problems = result.getProblems();
                            for (int i = 0; i < problems.length; i++) {
                                IProblem problem = problems[i];
                                if (problem.isError()) {
                                    String name =
                                        new String(problems[i].getOriginatingFileName());
                                    try {
                                        problemList.add(null);
                                    } catch (Exception e) {
                                        log.error("Error visiting node", e);
                                    }
                                }
                            }
                        }
                        if (problemList.isEmpty()) {
                            ClassFile[] classFiles = result.getClassFiles();
                            for (int i = 0; i < classFiles.length; i++) {
                                ClassFile classFile = classFiles[i];
                                char[][] compoundName =
                                    classFile.getCompoundName();
                                StringBuilder classFileName = new StringBuilder(outputDir).append('/');
                                for (int j = 0;
                                     j < compoundName.length; j++) {
                                    if(j > 0) {
                                        classFileName.append('/');
                                    }
                                    classFileName.append(compoundName[j]);
                                }
                                byte[] bytes = classFile.getBytes();
                                classFileName.append(".class");
                                FileOutputStream fout = null;
                                BufferedOutputStream bos = null;
                                try {
                                    fout = new FileOutputStream(classFileName.toString());
                                    bos = new BufferedOutputStream(fout);
                                    bos.write(bytes);
                                } finally {
                                    if (bos != null) {
                                        try {
                                            bos.close();
                                        } catch (IOException e) {
                                        }
                                    }
                                }
                            }
                        }
                    } catch (IOException exc) {
                        log.error("Compilation error", exc);
                    }
                }
            };

        ICompilationUnit[] compilationUnits =
            new ICompilationUnit[classNames.length];
        for (int i = 0; i < compilationUnits.length; i++) {
            String className = classNames[i];
            compilationUnits[i] = new CompilationUnit(fileNames[i], className);
        }
        CompilerOptions cOptions = new CompilerOptions(settings);


        cOptions.parseLiteralExpressionsAsConstants = true;
        Compiler compiler = new Compiler(env,
                                         policy,
                                         cOptions,
                                         requestor,
                                         problemFactory);


        compiler.compile(compilationUnits);


    }


}

  上面加粗代码的调用,就能将.java文件编译成.class文件。 而修改了generateClass()方法传入了4个参数 ,public void generateClass( final String sourceFile ,final String outputDir, String packageName
, String outclassName , final ClassLoader classLoader ),这4个参数分别是

  • sourceFile :源.java文件名称及路径
  • outputDir :生成的class文件目录
  • packageName : .java文件包名
  • ReflictInvoke :源文件名称
  • classLoader :类加载器

  接下来看一个例子。

public class MyCompilerTest {

    public static void main(String[] args) throws Exception {
        String content = "package com.luban.compilerx;\n" +
                "\n" +
                "\n" +
                "\n" +
                "\n" +
                "public class ReflictInvoke extends BaseInvoke {\n" +
                "\n" +
                "    public void invoke(){\n" +
                "        System.out.println(\"ReflictInvoke调用11111111\");\n" +
                "    }\n" +
                "}\n";

        // 如果文件不存在 ,则创建文件
        File file = new File("/Users/quyixiao/gitlab/tomcat/output/production/tomcat/com/luban/compilerx");
        if (!file.exists()) {
            file.mkdirs();
        }
        String sorceFile = "/Users/quyixiao/gitlab/tomcat/output/production/tomcat/com/luban/compilerx/ReflictInvoke.java";
        FileOutputStream fos = new FileOutputStream(sorceFile);
        Writer out = new OutputStreamWriter(fos, "UTF-8");
        out.write(content);
        out.close();
        fos.close();


        String outputDir = "/Users/quyixiao/gitlab/tomcat/output/production/tomcat";
        String packageName = "com.luban.compilerx";
        String targetClassName = "ReflictInvoke";

        MyJDTCompiler myJDTCompiler = new MyJDTCompiler();
        ClassLoader classLoader = MyCompilerTest.class.getClassLoader();
        myJDTCompiler.generateClass(sorceFile, outputDir, packageName, targetClassName, classLoader);

        Class<?> clazz = classLoader.loadClass("com.luban.compilerx.ReflictInvoke");

        BaseInvoke c = (BaseInvoke) clazz.newInstance();
        c.invoke();
    }

}

  这个例子的原理很简单,就是想动态的创建一个类ReflictInvoke

package com.luban.compilerx;




public class ReflictInvoke extends BaseInvoke {

    public void invoke(){
        System.out.println("ReflictInvoke调用11111111");
    }
}


  调用compiler.compile(compilationUnits);将java文件编译为.class文件,再通过Class<?> clazz = classLoader.loadClass(“com.luban.compilerx.ReflictInvoke”); 加载到虚拟机,再实例化它,最终调用其invoke()方法,这门技术,我觉得很牛逼,这样,我们可以在运行时创建一个类,并调用它,很多的框架都用到了这项技术,如Spring的CGLIB 代理,不就是运行时动态的修改原来类的代码结构,再编译它,最终再加载到虚拟机,替换掉原来的类,话不多说,看一下执行效果 。
在这里插入图片描述

  看到没有,简单的一个类,就实现了java文件动态生成,编译,及使用。
  之前已经分析到jsp文件的编译了, 接下来,看jsp如何获取实例对象 。

public void service(HttpServletRequest request,
                    HttpServletResponse response,
                    boolean precompile)
        throws ServletException, IOException, FileNotFoundException {

    Servlet servlet;

    try {

        if (ctxt.isRemoved()) {
            throw new FileNotFoundException(jspUri);
        }

        if ((available > 0L) && (available < Long.MAX_VALUE)) {
            if (available > System.currentTimeMillis()) {
                response.setDateHeader("Retry-After", available);
                response.sendError
                    (HttpServletResponse.SC_SERVICE_UNAVAILABLE,
                     Localizer.getMessage("jsp.error.unavailable"));
                return;
            }

            // Wait period has expired. Reset.
            available = 0;
        }

        /*
         * (1) Compile
         *  将jsp文件编译成servlet
         */
        if (options.getDevelopment() || mustCompile) {
            synchronized (this) {
                if (options.getDevelopment() || mustCompile) {
                    // The following sets reload to true, if necessary
                    ctxt.compile();
                    mustCompile = false;
                }
            }
        } else {
            if (compileException != null) {
                // Throw cached compilation exception
                throw compileException;
            }
        }

        /*
         * (2) (Re)load servlet class file
         * 生成Servlet文件对应的对象实例
         */
        servlet = getServlet();

        // If a page is to be precompiled only, return.
        // 一个页面仅仅只需要编译
        if (precompile) {
            return;
        }

    } catch (ServletException ex) {
        if (options.getDevelopment()) {
            throw handleJspException(ex);
        }
        throw ex;
    } catch (FileNotFoundException fnfe) {
        // File has been removed. Let caller handle this.
        throw fnfe;
    } catch (IOException ex) {
        if (options.getDevelopment()) {
            throw handleJspException(ex);
        }
        throw ex;
    } catch (IllegalStateException ex) {
        if (options.getDevelopment()) {
            throw handleJspException(ex);
        }
        throw ex;
    } catch (Exception ex) {
        if (options.getDevelopment()) {
            throw handleJspException(ex);
        }
        throw new JasperException(ex);
    }

    try {

        /*
         * (3) Handle limitation of number of loaded Jsps
         */
        if (unloadAllowed) {  // 是否要卸载jsp-servlet
            synchronized(this) {
                if (unloadByCount) {
                    // 如果配置了限制的数量,则表示ctxt.getRuntimeContext()中只能容纳固定的jsw
                    // 那么如果超过了限制则将队尾jsw移除掉
                    // 当然,就算没有配置限制的数量,background线程会定时执行,将超过jspIdleTimeout时间的移除掉
                    if (unloadHandle == null) {
                        unloadHandle = ctxt.getRuntimeContext().push(this);
                    } else if (lastUsageTime < ctxt.getRuntimeContext().getLastJspQueueUpdate()) {
                        // lastUsageTime表示当前jsw上次使用时间
                        // ctxt.getRuntimeContext().getLastJspQueueUpdate()这个时间会由background线程定时更新一次
                        // 如果lastUsageTime 大于 ctxt.getRuntimeContext().getLastJspQueueUpdate()不需要做什么操作

                        // 第一种情况
                        // 1. jsw被访问
                        // 2. background线程执行
                        // 3. jsw再次被访问
                        // 4. 符合当前条件,jsw被移动至队首

                        // 第二种情况
                        // 1. background线程执行
                        // 2. jsw第一次被访问
                        // 3. 不符合条件,而此时应该符合unloadHandle == null

                        // 将最近访问的jsw移动至队首
                        ctxt.getRuntimeContext().makeYoungest(unloadHandle); // 将unloadHandle移到队首
                        lastUsageTime = System.currentTimeMillis();
                    }
                } else {
                    // 更新最近使用的时间
                    if (lastUsageTime < ctxt.getRuntimeContext().getLastJspQueueUpdate()) {
                        lastUsageTime = System.currentTimeMillis();
                    }
                }
            }
        }
        /*
         * (4) Service request
         */
        if (servlet instanceof SingleThreadModel) {
           // sync on the wrapper so that the freshness
           // of the page is determined right before servicing
           synchronized (this) {
               servlet.service(request, response);
            }
        } else {
            servlet.service(request, response);
        }
    } catch (UnavailableException ex) {
        String includeRequestUri = (String)
            request.getAttribute(RequestDispatcher.INCLUDE_REQUEST_URI);
        if (includeRequestUri != null) {
            // This file was included. Throw an exception as
            // a response.sendError() will be ignored by the
            // servlet engine.
            throw ex;
        }

        int unavailableSeconds = ex.getUnavailableSeconds();
        if (unavailableSeconds <= 0) {
            unavailableSeconds = 60;        // Arbitrary default
        }
        available = System.currentTimeMillis() +
            (unavailableSeconds * 1000L);
        response.sendError
            (HttpServletResponse.SC_SERVICE_UNAVAILABLE,
             ex.getMessage());
    } catch (ServletException ex) {
        if(options.getDevelopment()) {
            throw handleJspException(ex);
        }
        throw ex;
    } catch (IOException ex) {
        if (options.getDevelopment()) {
            throw new IOException(handleJspException(ex).getMessage(), ex);
        }
        throw ex;
    } catch (IllegalStateException ex) {
        if(options.getDevelopment()) {
            throw handleJspException(ex);
        }
        throw ex;
    } catch (Exception ex) {
        if(options.getDevelopment()) {
            throw handleJspException(ex);
        }
        throw new JasperException(ex);
    }
}

  生成的_jsp.java 又是如何调用及执行的呢?重点就是上面两行加粗代码 ,先来看servlet = getServlet(); 这一行代码 。

public Servlet getServlet() throws ServletException {
    /*
     * DCL on 'reload' requires that 'reload' be volatile
     * (this also forces a read memory barrier, ensuring the new servlet
     * object is read consistently).
     *
     * When running in non development mode with a checkInterval it is
     * possible (see BZ 62603) for a race condition to cause failures
     * if a Servlet or tag is reloaded while a compile check is running
     */
    if (getReloadInternal() || theServlet == null) {
        synchronized (this) {
            // Synchronizing on jsw enables simultaneous loading
            // of different pages, but not the same page.
            if (getReloadInternal() || theServlet == null) {
                // This is to maintain the original protocol.
                destroy();

                final Servlet servlet;

                try {
                    InstanceManager instanceManager = InstanceManagerFactory.getInstanceManager(config);
                    // 实例化对象
                    servlet = (Servlet) instanceManager.newInstance(ctxt.getFQCN(), ctxt.getJspLoader());
                } catch (Exception e) {
                    Throwable t = ExceptionUtils
                            .unwrapInvocationTargetException(e);
                    ExceptionUtils.handleThrowable(t);
                    throw new JasperException(t);
                }

                servlet.init(config);

                if (theServlet != null) {
                    ctxt.getRuntimeContext().incrementJspReloadCount();
                }

                theServlet = servlet;
                reload = false;
                // Volatile 'reload' forces in order write of 'theServlet' and new servlet object
            }
        }
    }
    return theServlet;
}


public Object newInstance(final String className, final ClassLoader classLoader)
        throws IllegalAccessException, NamingException, InvocationTargetException,
        InstantiationException, ClassNotFoundException, IllegalArgumentException,
        NoSuchMethodException, SecurityException {
    // loadClass()方法加载类
    Class<?> clazz = classLoader.loadClass(className);
    return newInstance(clazz.getDeclaredConstructor().newInstance(), clazz);
}

  上面代码原理还是很简单就,先加载我们生成的index_jsp.class类,先调用其init()方法。

public final void init(ServletConfig config)
    throws ServletException
{
    super.init(config);
    jspInit();
    _jspInit();
}

  看到没有,调用_jspInit()方法,不就是生成index_jsp.java的_jspInit() 方法不?
在这里插入图片描述
  同理,来看 servlet.service(request, response);方法调用,最终也是调用了_jspService(request,response);
在这里插入图片描述
  不就对应index_jsp.java 的 _jspService()方法不?如下图所示 。
在这里插入图片描述

总结:

  文章分享到这里也告一段落了, 因为篇幅过长,所以分为Tomcat 源码解析一JSP编译器Jasper-佛怒火莲(上) , Tomcat 源码解析一JSP编译器Jasper-佛怒火莲(下)两部分,前部分主要讲jsp 解析相关内容,后部分主要讲tomcat如何修改字节码,编译,及获取生成类实例。 可以涉及到的内容及范围也比较广,当然,你觉得关于jsp相关源码就完了吗?肯定没有,如果你想了解EL表达式如何解析,表达式替换,还需要看Tomcat 源码解析一EL表达式源码解析 这篇博客 ,内部细节实现也是很复杂的,当然,我也尽量的将所见到的源码分析清楚,希望这对大家有所帮助。由于我比较喜欢斗破苍穹,因此后面的博客中,每一篇博客可能以斗破苍穹中的一种斗技作为后缀, 话不多说,就到这里了,下一篇博客见。

  本文相关的源码地址

https://github.com/quyixiao/tomcat.git
https://github.com/quyixiao/JspReader.git

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值