
先说个写JavaWeb很容易遇到的事学完Servlet之后你可能信心满满地想用纯Servlet写一个页面结果写了一半人就麻了。输出一行HTML需要写一句out.println(html)输出一个表格要循环拼字符串前端改了样式Java代码里的HTML片段也得跟着改。页面越写越大Java代码和HTML标签缠在一起调试时一个引号套错整个响应直接崩掉。这个痛点经历过的人都懂。而JSPJava Server Pages就是用来解决这个问题的——JavaWeb开发系列写到第六篇终于轮到它了。这篇不打算按教科书套路把%、%、%!这些语法背一遍就完事。我更想讲清楚三件事JSP到底解决了什么问题、它和Servlet之间究竟是什么关系、以及实际写项目时哪些用法会反复用到、哪些坑踩一次就能记一辈子。这篇内容适合刚学完Servlet、正处于“会写接口但不会写页面”阶段的JavaWeb初学者也适合那些用过JSP但一直没想明白它底层是怎么跑起来的同学。1. 从Servlet拼HTML的噩梦说起JSP到底解决了什么问题1.1 纯Servlet输出页面到底有多痛苦先看一段代码想象一下用Servlet输出一个“学生信息管理”的页面protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType(text/html;charsetUTF-8); PrintWriter out response.getWriter(); out.println(!DOCTYPE html); out.println(html); out.println(head); out.println(meta charset\UTF-8\); out.println(title学生信息管理系统/title); out.println(/head); out.println(body); out.println(h1学生列表/h1); out.println(table border\1\); out.println(trthID/thth姓名/thth成绩/th/tr); for (Student s : studentList) { out.println(tr); out.println(td s.getId() /td); out.println(td s.getName() /td); out.println(td s.getScore() /td); out.println(/tr); } out.println(/table); out.println(/body); out.println(/html); }这个代码能跑但它有几个很致命的问题第一改样式等于改Java代码。设计师把表格的border改成1px solid #ccc你就得打开Servlet源码找到那一行字符串小心翼翼地改改错了引号位置整个页面直接白屏。第二代码可读性极差。Java逻辑和HTML标签混在一个方法里任何一个人接手这种代码都会想跑路。第三开发效率低。写完Java代码要编译、要重启容器、要重新部署才能看效果。改一个标题都要走一遍这个流程心态很容易崩。1.2 JSP的设计初衷把页面从Java代码里解放出来JSP的思路和Servlet正好相反它本质上是一个HTML页面但允许在里面嵌入Java代码片段。写出来的页面长这样% page contentTypetext/html;charsetUTF-8 languagejava % !DOCTYPE html html head meta charsetUTF-8 title学生信息管理系统/title /head body h1学生列表/h1 table border1 trthID/thth姓名/thth成绩/th/tr % ListStudent studentList (ListStudent) request.getAttribute(studentList); for (Student s : studentList) { % tr td% s.getId() %/td td% s.getName() %/td td% s.getScore() %/td /tr % } % /table /body /html对比一下感受差异页面结构一眼能看懂HTML是HTMLJava只负责其中动态变化的部分。前端同事拿到这个文件改样式直接改不用关心Java代码后端同事要改逻辑也只盯% %里面的部分。这正是JSP的核心定位让JSP专注做表现层View让Servlet专注做控制层Controller。Servlet接收请求、处理业务逻辑、准备好数据然后把数据交给JSP去渲染成HTML页面。这种分工后来演化成了经典的Model 2 / MVC模式是JavaWeb项目最基本的架构形态。顺便说一句JSP最初的设计灵感其实来自对PHP、ASP这类“能在HTML里直接写服务端代码”的脚本语言的反击。Sun公司希望Java生态也能提供一种类似体验的动态页面技术同时又不想丢掉Java的类型安全和跨平台优势于是就把“JSP页面翻译成Servlet”这条路走到底了。2. JSP不是一门新语言它本质上就是一个Servlet2.1 Tomcat在后台是怎么处理JSP的“JSP就是一个Servlet”这句话很多教材都写过但真正理解的人不多。我当年学的时候也疑惑xxx.jsp只是个文本文件连.java都不是它凭什么就是个Servlet了关键在于Tomcat更准确地说是Jasper引擎会在背后做一层翻译。当你第一次在浏览器请求一个JSP页面时Tomcat做了这些事情找到对应的.jsp文件用Jasper引擎把它翻译成一个.java源文件这个类会继承org.apache.jasper.runtime.HttpJspBase通过javac把.java编译成.class文件加载这个class创建实例调用它的_jspService()方法把_jspService()方法产生的响应返回给浏览器。所以你看经过Tomcat这层翻译JSP最终还是变成一个Servlet。翻译后的文件放在哪以Tomcat 9为例默认在Tomcat安装目录下的work/Catalina/localhost/你的应用名/org/apache/jsp/目录里。如果你用IDEA开发时用的是Tomcat也能在控制台日志里看到Jasper翻译和编译的路径信息。举个真实例子你在JSP里写的这段代码% s.getName() %Tomcat翻译成Java之后大致是这样out.print(s.getName());而你写的普通HTML片段h1学生列表/h1翻译之后大致是out.write(h1学生列表/h1\r\n);我们在JSP里写的所有东西最终都会变成Java代码里的字符串输出。所以JSP从来没有“绕过”Servlet它就是Servlet的另一种表达形式。2.2 九大内置对象在Servlet代码里对应什么很多人一开始学JSP会被“内置对象”这个说法搞懵为什么我在JSP里直接用request、response、session不用定义就能用原因很简单因为这些变量是Tomcat在生成的_jspService()方法开头就替你写好的。翻译后的方法签名长这样public void _jspService(final HttpServletRequest request, final HttpServletResponse response) throws java.io.IOException, ServletException { // 以下变量由容器自动注入 final PageContext pageContext; HttpSession session null; final ServletContext application; final ServletConfig config; JspWriter out null; final java.lang.Object page this; // ... 初始化代码 }也就是说JSP页面里的Java代码本质上是写在_jspService()方法内部的所以才能无障碍访问这些变量。九大内置对象和Servlet里的对应关系我用一张表整理清楚了内置对象类型在传统Servlet中的等价物requestHttpServletRequestservice方法入参responseHttpServletResponseservice方法入参sessionHttpSessionrequest.getSession()applicationServletContextgetServletContext()configServletConfiggetServletConfig()outJspWriterresponse.getWriter()pageContextPageContext没有直接对应页面级上下文pageObjectthis当前Servlet实例exceptionThrowable仅在isErrorPagetrue时可用这张表值得你收藏起来。很多时候你写JSP觉得“玄学”本质是没搞清这些对象从哪来。一旦明白了它们就是在_jspService()方法里预定义好的局部变量一切就都顺了。2.3 为什么JSP不能重写init和destroy方法这里有个容易被忽略的细节。JSP页面翻译后的Servlet类会覆盖init()和destroy()方法名字叫_jspInit()和_jspDestroy()但_jspService()方法是不能被覆盖的它是final的。所以你在JSP里用%! %声明的成员变量和方法会变成翻译后Servlet类的成员。而你在页面里写的那些脚本片段% %则是塞进_jspService()方法体里。这就导致一个很关键的结论JSP里声明的方法和普通Java方法没什么区别但JSP里写的脚本代码相当于写在了一个大方法里面不是类级别的东西。很多初学者会在JSP里写这样的代码% int count 0; count; // 每次访问页面都重新执行count永远不会累加 %如果你以为声明在% %里的变量是“全局的”那结果一定出乎你意料——因为每次请求都会调用_jspService()方法方法里的局部变量每次都会重新初始化。如果你想让它跨请求保存状态得用%! %声明成员变量或者更靠谱的做法是放在session或application里。3. JSP基础语法哪些在实战中反复用到3.1 三种脚本元素别混为一谈JSP脚本元素分三种作用和使用场景完全不同。很多新手一上来就把它们搞混写出来的代码报错还找不到原因。逐一过一遍。声明Declaration%! %声明的是JSP翻译后的Servlet类的成员。可以声明方法、成员变量但注意它不能直接调用request、response这些内置对象因为那些是_jspService()方法内的局部变量。%! private String formatScore(double score) { return score 60 ? 及格 : 不及格; } %实际项目中%! %用得很少。稍微复杂点的逻辑都应该放到Java类里而不是塞进JSP页面。如果你发现自己在一个JSP里写了一堆方法停下来想想是不是设计出了问题。脚本段Scriptlet% %写在_jspService()方法体内的Java代码。可以定义局部变量、写if/for循环、调用外部Java类全部没问题。因为它在方法内部所以能直接使用所有内置对象。% // 从request中取出之前setAttribute的数据 ListStudent list (ListStudent) request.getAttribute(studentList); if (list ! null !list.isEmpty()) { % ul % for (Student s : list) { % li% s.getName() %/li % } % /ul % } %这种写法在早期的JavaWeb项目里很常见但是维护起来真的很痛苦。一个页面几百行一半是HTML一半是Java逻辑可读性极差。所以现在的主流实践是脚本段只用于做简单的控制流不要写复杂业务逻辑。真要写业务放Servlet、放Service不要放JSP。表达式Expression% %等价于out.print(...)把你写的内容输出到页面上。注意结尾一定不能加分号这是新手的经典报错点。% student.getName() %翻译成Java之后就是out.write(String.valueOf(student.getName()));在EL表达式普及之前% %是JSP页面输出动态数据的主要方式。现在虽然有了更优雅的${}写法但% %在维护老项目、或者需要输出Java方法返回值时仍然会用到该认识还是得认识。3.2 page指令里的属性每一个都很关键JSP指令是写给容器看的“配置信息”最常用的是page指令。它的属性非常多但真正在实战中频繁打交道的就这几个属性作用常用取值pageEncoding指定JSP文件本身的保存编码UTF-8contentType设置响应类型和字符集text/html;charsetUTF-8import导入Java类java.util.Listsession是否创建/使用sessiontrue/falseisELIgnored是否忽略EL表达式false推荐errorPage本页面出现异常时跳转的页面error.jspisErrorPage声明本页面是错误处理页面true时可用exception对象extends指定翻译后的父类一般不用别乱改写JSP页面时第一行的page指令建议完整写上这样一行% page contentTypetext/html;charsetUTF-8 languagejava pageEncodingUTF-8 %languagejava其实是默认值写不写都行。但pageEncoding和contentType最好都写上并且都用UTF-8这是避免中文乱码的第一道防线。3.3 include指令和jsp:include动作两个“包含”不是一回事JSP里做“页面复用”最常见的方式是把公共的头尾、导航栏抽出来然后在需要的页面里包含进来。这个“包含”有两种写法很多人在这里掉过坑。静态包含% include fileheader.jsp %这种包含是“翻译期”的。Tomcat在翻译JSP时会把被包含的文件内容原封不动地复制到当前页面里也就是说最终生成的Servlet只有一个。两个页面合在一起编译如果两边都定义了同名的id属性或变量会冲突报错。合并后静态包含能访问当前页面的变量。动态包含jsp:include pageheader.jsp /这种包含是“运行时”的。它会把请求转发给被包含的JSP由它自己生成内容再把响应内容嵌入当前页面。两个页面各自独立编译成Servlet互不干扰。因为发生了两次请求被包含页面不能访问当前页面的局部变量。什么时候用哪个我个人的经验如果被包含的内容和当前页面共享大量数据比如都在同一个request作用域里取值用静态包含如果被包含模块相对独立用动态包含。从CPU开销来看静态包含更省因为没有第二次请求和页面自行编译的开销。但从解耦角度看动态包含更灵活。老项目里头尾导航通常用静态包含因为内容固定而且能直接使用页面里的变量。4. 一个完整的页面传值展示链路从Servlet到JSP再到浏览器4.1 先来搭一个用户信息展示的场景我见过太多初学者学JSP时只盯着“JSP页面怎么写”却忽略了JSP的数据是从哪来的。这里用一个非常贴合实际的项目场景个人信息展示页面把Servlet和JSP串起来跑一遍。先定义一个JavaBean——学生信息实体类package com.demo.entity; public class Student { private int id; private String name; private int age; private String major; // 无参构造getter/setter略 }然后是Servlet它的职责是接收请求、组装数据、转发给JSPpackage com.demo.servlet; import com.demo.entity.Student; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.ArrayList; import java.util.List; WebServlet(/student/list) public class StudentListServlet extends HttpServlet { Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 模拟从数据库查询学生列表 ListStudent studentList new ArrayList(); studentList.add(new Student(1, 张三, 20, 计算机科学与技术)); studentList.add(new Student(2, 李四, 21, 软件工程)); studentList.add(new Student(3, 王五, 22, 网络工程)); // 把数据放入request作用域 request.setAttribute(studentList, studentList); // 转发到JSP页面 request.getRequestDispatcher(/jsp/studentList.jsp).forward(request, response); } }最后是JSP页面它在webapp/jsp/studentList.jsp% page contentTypetext/html;charsetUTF-8 languagejava pageEncodingUTF-8 % !DOCTYPE html html head meta charsetUTF-8 title学生信息列表/title /head body h1学生信息列表/h1 table border1 cellpadding8 cellspacing0 tr thID/th th姓名/th th年龄/th th专业/th /tr % ListStudent list (ListStudent) request.getAttribute(studentList); if (list ! null) { for (Student s : list) { % tr td% s.getId() %/td td% s.getName() %/td td% s.getAge() %/td td% s.getMajor() %/td /tr % } } % /table /body /html浏览器访问http://localhost:8080/你的应用名/student/listTomcat会调用ServletServlet往request里塞数据然后转发给JSP渲染。这就是最经典的“Servlet取数 JSP展示”协作流程。4.2 request.setAttribute和request.getParameter的区别这个点非常基础但几乎每年都会看到新手踩坑。很多人一拿到request对象就懵setAttribute和getParameter到底有什么区别简单记法getParameter是用来接收客户端通过URL或表单提交的数据的这些数据本是字符串是“请求参数”setAttribute是服务端在请求处理过程中自己往request对象里挂数据的是“请求属性”。// 接收浏览器传来的参数比如 ?id1 或者表单里的namexxx String id request.getParameter(id); // 服务端自己把处理结果挂到request上以便JSP使用 request.setAttribute(studentList, studentList);JSP页面里想拿参数用${param.id}想拿属性用${studentList}UML上的数据传递口径完全不同。实际项目里最常见的报错场景就是Servlet里用setAttribute(studentList, list)存了数据JSP里却写了request.getParameter(studentList)去取取出来永远是null。另外注意用forward转发到JSP时request对象是同一个所以setAttribute设置的数据在JSP里能取到。但如果是response.sendRedirect()重定向那是浏览器重新发起了一个新的请求原来的request对象就没了setAttribute的数据自然也就丢了。这也能解释为什么转发forward可以在页面间带数据而重定向redirect带不了。4.3 用EL表达式和JSTL替换脚本段上面那个JSP示例里我们用了% %和% %脚本虽然能跑但页面里的一大堆Java标签实在谈不上优雅。JavaWeb进入JSP 2.0时代以后官方推荐的方案是EL表达式 JSTL标签库。EL表达式最简单的形式是${}直接在页面里取作用域中的数据。上面的% s.getId() %可以改写成${student.id}。它还有自动处理空值的能力变量不存在时不报错直接显示空白比脚本段落友好太多。不过EL只能做“取数”和简单运算遇到循环、判断就得靠JSTL。JSTL里有几个核心标签是JavaWeb项目必备的% page contentTypetext/html;charsetUTF-8 languagejava pageEncodingUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % !DOCTYPE html html head meta charsetUTF-8 title学生信息列表/title /head body h1学生信息列表/h1 table border1 cellpadding8 cellspacing0 tr thID/th th姓名/th th年龄/th th专业/th /tr c:forEach items${studentList} vars tr td${s.id}/td td${s.name}/td td${s.age}/td td${s.major}/td /tr /c:forEach /table /body /html用c:forEach遍历列表用${s.xxx}取JavaBean属性页面里干干净净。这是现代JavaWeb项目里JSP页面的标准长相。如果你学完JSP直接进入Spring MVC你会发现Thymeleaf等模板引擎的思路也和这个大同小异——先取数据再通过模板语法渲染。5. 实际项目里最常见的JSP坑与排查方法5.1 中文乱码问题的三条链路中文乱码可能是JavaWeb初学者遇到最多的“玄学问题”了。实际上乱码不玄关键在于搞清楚数据经历的三个阶段每个阶段都有一次编码/解码。第一段JSP页面文件的保存编码。页面里的中文是以什么编码保存到磁盘上的。如果IDE里文件编码是GBK但你pageEncoding写的是UTF-8页面上的中文就会乱。解决方案IDE全局文件编码设为UTF-8JSP头部pageEncodingUTF-8两处对齐。第二段Tomcat向浏览器输出响应时的编码。由contentTypetext/html;charsetUTF-8控制响应头里会带上Content-Type: text/html;charsetUTF-8浏览器按这个编码解析页面。这段设置错了页面里的中文显示成“锟斤拷”UTF-8字节被GBK解码或“”解码不了的占位符。第三段浏览器提交表单数据时Tomcat接收请求参数的编码。这是很多人容易漏的地方。POST提交的表单数据Tomcat默认按ISO-8859-1解码一旦有中文到了Servlet里就是乱码。所以Servlet里需要加一行request.setCharacterEncoding(UTF-8);注意这行必须在request.getParameter()之前调用否则无效。GET请求的URL参数编码处理方式又不一样需要修改Tomcat的server.xml里的URIEncodingUTF-8或者在代码里new String(param.getBytes(ISO-8859-1), UTF-8)重新解码比较脏。把这三条链路捋顺了中文乱码基本就能根治。5.2 JSP编译后的class文件到底保存到哪里了这个点我在带新人时几乎每次都要解释一遍。很多同学写了一个JSP改了代码刷新页面发现还是旧的就开始怀疑人生。其实只要理解了Tomcat对JSP的处理流程就明白问题出在哪了。当你第一次访问JSP时Tomcat的Jasper引擎会把.jsp翻译成.java源文件再编译成.class。这些文件默认在Tomcat的work目录下apache-tomcat-9.0.x/work/Catalina/localhost/你的应用名/org/apache/jsp/打开这个目录你能看到类似student_005flist_jsp.java这样的文件Tomcat会把点号转义里面就是JSP翻译后的完整Servlet源码。当你修改了JSP文件Tomcat检测到文件时间戳变了下次访问时会重新翻译、编译。但如果部署时没把最新的.jsp文件拷过去或者IDE没有重新部署那Tomcat用的还是旧的.jsp自然不生效。在IDEA里排查这类问题最快的办法是Build - Rebuild Project然后重新部署到Tomcat。如果还是不行手动到work目录下把对应jsp的.java和.class文件删掉让Tomcat强制重新翻译编译。我在项目里就碰到过一次改了JSP怎么都不生效最后发现是IDEA的部署配置里忘了勾选“更新资源”选项热部署只更新了类文件没更新JSP。这个问题很隐蔽建议你把这篇内容收藏一下以后遇到“JSP不生效”可以直接按这个思路排查。另外有时候我们需要直接看JSP翻译后的Java代码来排查问题。比如你怀疑某个EL表达式是不是写错了或者某个标签是不是没有生效直接去work目录打开对应的_jsp.java文件搜索相关字符串一眼就能看出问题所在。这比盲猜高效得多。5.3 页面路径问题为什么${pageContext.request.contextPath}成了标配JavaWeb项目部署到Tomcat后访问路径一般是http://localhost:8080/应用名/资源路径这个“应用名”就构成了一个上下文路径contextPath。问题在于这个上下文路径在不同机器、不同环境上是可能变的。你今天部署成/myapp明天生产环境可能叫/app。如果你在JSP里写死了相对路径比如href/css/style.css那么当应用路径不是根路径时浏览器会去http://localhost:8080/css/style.css找资源找不到页面样式全丢。这是新手特别容易踩的坑。解决办法是在页面里用EL拼出带上下文路径的URLlink relstylesheet href${pageContext.request.contextPath}/css/style.css script src${pageContext.request.contextPath}/js/jquery.min.js/script a href${pageContext.request.contextPath}/student/list学生列表/a${pageContext.request.contextPath}会动态解析成当前应用的上下文路径这样无论部署在哪个环境路径都不会错。进入Spring MVC时代后很多模板引擎也有类似的语法道理是一样的。5.4 “屏蔽JSP离开页面提示”其实是浏览器的事不是JSP的事很多人写JSP时想做一个效果用户点了页面上的某个按钮要离开当前页面时弹出一个确认框问“确定要离开吗”。于是去搜“屏蔽jsp离开页面提示”搜到一堆答案看到onbeforeunload事件才明白这根本不是JSP的语法能力。beforeunload是浏览器提供的一个原生JavaScript事件在窗口、文档或其资源即将卸载时触发。它和JSP没有直接关系只是因为写页面时用的是JSP文件初学的人下意识觉得这是JSP提供的能力。你能在JSP里用onbeforeunload本质上是因为JSP最终输出的是HTML和JavaScript并不是JSP本身有这个东西。这个误区的背后其实是一个很实际的问题初学者经常分不清“服务端技术”和“浏览器技术”的边界。JSP的所有能力都在服务端它在请求到达时执行负责生成HTML字符串。而页面加载完成后的所有交互——点按钮、弹窗、离开提示——都是浏览器里的JavaScript和责任范围。理清这个边界以后学前端框架时就不会再把onclick、onchange这些属性往服务端技术上靠了。6. JSP不是银弹什么场景应该果断放弃它6.1 前后端分离时代JSP的处境有些尴尬现在的开发环境和十几年前完全不同了。越来越多的项目走前后端分离的路子后端只出JSON接口前端用Vue、React等框架做SPA单页应用。在这种架构下JSP的存在感确实被削弱了。因为SPA只需要一个静态的HTML入口文件页面内容全部通过JavaScript动态渲染JSP的作用范围被压缩得很小。但JSP远没有到“彻底淘汰”的地步。我在实际项目里遇到过不少情况JSP依然是合理选择老系统的维护和改造。很多银行、政务、企业内部系统都是十多年前用JSP写的还在稳定运行。你不需要把它们全部重写能读懂JSP、能在上面做增量开发就是生产力。快速原型和后台管理系统。如果一个系统只有几个人用要求快速上线、服务端渲染JSP Servlet的组合其实非常省事部署也简单。SEO有要求的场景。搜索引擎对动态渲染的SPA页面抓取能力有限而JSP是服务端直接输出HTML天然对SEO友好。虽然在现代前端技术里可以搞SSR但JSP作为最传统的服务端渲染方案依然在某些业务场景有价值。所以在学的时候别抱着“学这个是不是过时了”的心态。技术的价值取决于你用它解决什么问题而不在于它新不新。6.2 学完JSP之后的路应该怎么走如果你现在刚把JSP基础学完下面的路线可以做参考第一步把JSP和Servlet配合起来用自己写一个完整的小项目比如学生信息管理系统实现登录、列表查询、新增、删除、修改。这个阶段不要用任何框架就用纯JSP Servlet JDBC MySQL把整个请求-响应的流转过程彻底跑通。第二步引入EL和JSTL把JSP页面里的Java脚本替换掉体会一下数据展示层的模板化写法同时也让页面更干净。第三步学一下Filter和Listener它们在JavaWeb里承担请求过滤和监听的作用很多框架的底层都基于它们实现。第四步再去学Spring MVC甚至Spring Boot。有了前面的基础你会发现注解、控制器、模板引擎这些新知识都是“老朋友换了个马甲”。没有JSP打底直接冲框架很容易浮在表面遇到问题不知道从何排查。从我个人的实际体会来说这些年带过不少新人凡是跳过JSP直接学Spring Boot的对“请求怎么进来数据怎么流转页面怎么输出”这件事往往是模糊的——当然也能写代码但一旦偏离框架的默认用法就抓瞎。反倒是沉下心来把JSP和Servlet啃过一遍的人后面学什么都快。JavaWeb这套体系里JSP可能不是最时髦的但它绝对是理解整个服务端渲染和MVC架构最好的入门窗口。