
接手过传统Java Web项目的人应该都有这个感受刚打开一个用JSP写的旧项目时最先让人头大的往往不是业务逻辑而是那一层套一层的目录结构——webapp下面一堆文件夹WEB-INF里面还有lib和classes有时候还要分清哪个是源码路径、哪个是编译输出路径。我一直觉得搞懂JSP项目这套结构比背十个标签语法都重要。因为结构决定了你往哪儿放文件、以什么路径访问、打包之后变成什么样子甚至决定了你排查问题时的第一直觉。这篇内容围绕“JSP结构”这个主题把项目目录、页面内部语法组成、运行时底层机制、常见问题这几层拆开讲一遍适合刚学Java Web的学生也适合要接手老项目、第一次被扔到传统JSP工程里的开发新人。我会尽量用实际经历来说明哪些结构上的坑是文档里不会写的。1. 从IDEA新建JSP项目说起一套目录结构背后的两层逻辑先说一个很多人刚接触时搞混的点你在IDEA里看到的项目目录和Tomcat真正部署运行时的目录是两份不同的结构。很多新手对着IDE里那一堆文件夹发懵就是因为没分清“源代码存放结构”和“运行时部署结构”这两层。1.1 两种常见的项目目录形态传统JSP项目有两种典型创建方式。一种是纯手工搭建的Web项目目录通常是这样的mywebapp/ ├── src/ │ └── com/ │ └── example/ │ └── servlet/ │ └── HelloServlet.java ├── web/ │ ├── index.jsp │ ├── css/ │ ├── js/ │ └── WEB-INF/ │ ├── web.xml │ └── lib/这种结构下web目录是Web根目录所有能通过URL直接访问的资源都放在它下面WEB-INF是受保护目录浏览器不能直接访问。另一种是Maven标准结构我现在的习惯是用这个jsp-demo/ ├── pom.xml ├── src/ │ └── main/ │ ├── java/ │ │ └── com/example/servlet/ │ ├── resources/ │ └── webapp/ │ ├── index.jsp │ └── WEB-INF/ │ ├── web.xml │ └── lib/Maven工程里src/main/webapp相当于传统结构的web目录。用IDEA新建项目时如果你选了Maven骨架再手动补上src/main/webapp/WEB-INF/web.xml就得到一个既规范又能直接用Tomcat跑起来的工程。1.2 WEB-INF目录的职责边界WEB-INF是整套目录结构里最特殊的一个存在。放在里面的web.xml是配置中心lib目录放项目依赖的jar包classes目录放编译后的class文件。关键规则是WEB-INF里面的内容不允许通过浏览器直接URL访问只能由服务端转发forward到JSP页面。这意味着如果你把index.jsp放到WEB-INF里面访问http://localhost:8080/项目名/index.jsp是404必须写一个Servlet或者用request.getRequestDispatcher(/WEB-INF/index.jsp).forward()才能渲染出来。很多新手问“为什么我明明有这文件却访问不了”就是这个结构规则在起作用。反过来说CSS、JS、图片这类静态资源应该放在WEB-INF外面。我接过一个老项目前人把整个css文件夹塞进了WEB-INF结果页面样式全挂改了一个下午才找到原因。记住一条WEB-INF放的是“不想被直接访问的实现细节”不是“所有资源的总仓库”。1.3 打包war之后的目录变化传统JSP项目最终要打成war包部署。war包的内部结构就是部署结构的镜像把web或webapp目录整体压缩后缀改成.war放在Tomcat的webapps目录下Tomcat会自动解压成同名目录。我常用的打包方式是Maven的mvn clean package插件配置里指定warName和输出目录。用IDEA图形界面导出war包当然也行但有个坑IDEA导出时经常把无关配置文件带进去或者Maven依赖没打全部署到服务器上启动报ClassNotFoundException。所以传统JSP项目我强烈建议用Maven打包至少依赖管理是明确的war包内部结构是否完整一目了然。2. JSP页面的内部结构指令、脚本、动作标签一个都不能含糊目录结构是“骨架”那JSP页面本身的语法结构就是“血肉”。一个JSP文件从顶层往下看大致由这几部分组成指令directive、脚本元素scriptlet、表达式、声明、EL表达式、JSTL标签、HTML静态内容。2.1 page指令的每个属性都不是摆设JSP页面开头几乎都会有一行% page %指令但不少人只习惯抄没仔细想过每个属性的作用。以这行为例% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 importjava.util.* isErrorPagefalse errorPageerror.jsp sessiontrue %contentType告诉浏览器响应体的格式和字符集没有它页面会乱码或直接下载文件。pageEncoding告诉JSP编译器当前文件本身是用什么编码保存的。这个属性错了中文注释都会变成乱码。import相当于Java代码里的import语句缺了它页面里直接写List、ArrayList就会编译报错。errorPage和isErrorPage配合使用做错误页跳转。session默认值是true如果页面根本不用session显式设成false可以省掉创建Session的开销。高并发页面这个细节值得注意。贴一个实际中常见的配置组合三处编码统一乱码问题能消掉一大半% page contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 % % request.setCharacterEncoding(UTF-8); response.setCharacterEncoding(UTF-8); %2.2 脚本元素声明、表达式、脚本片段JSP有三种脚本元素写法不同作用域也完全不同%! int count 0; % !-- 声明成员变量/方法属于Servlet类本身 -- % count; % !-- 脚本片段_jspService方法里的代码 -- % count % !-- 表达式输出到响应流 --注意脚本片段里的局部变量每次请求都会重新初始化而声明里的成员变量是多个请求共享的。如果用它做计数器并发下就会出线程安全问题。那行%! int count 0; %翻译成Servlet源码后成为类的字段而% count; %变成_jspService()方法内部的行两者作用域完全不同——这是结构理解上的关键点。我在老项目里见过有人直接用% %写几百行数据查询逻辑页面里塞满Java代码维护难度直线上升。我的建议是新代码尽量不写脚本片段业务逻辑放Servlet或Service层页面只用EL和JSTL做展示。2.3 EL表达式和JSTL另一种结构上的“解耦”EL表达式解决了脚本片段导致的混乱。它通过${}语法从page、request、session、application四个作用域中按顺序查找属性这就是EL的“作用域查找结构”p用户名${sessionScope.user.name}/psessionScope前缀可以明确指定查找范围避免命名冲突。配合JSTL的c:forEach、c:if标签页面结构变得干净得多c:if test${not empty list} ul c:forEach items${list} varitem li${item.title}/li /c:forEach /ul /c:ifJSTL这套标签的本质是把“循环、分支、格式化”这类通用逻辑从Java代码中抽离成可复用标签页面结构上更接近模板引擎。2.4 include动作和include指令的区分JSP里引用公共文件有两种写法一个是动作标签一个是指令% include fileheader.jsp % !-- 指令编译期静态包含 -- jsp:include pageheader.jsp / !-- 动作运行期动态包含 --% include %是在翻译阶段直接把文件内容复制进来相当于把代码粘贴到当前页面两个页面共享变量时要注意冲突。jsp:include则是在运行时把另一个页面的输出拿过来相当于方法调用变量不共享。选哪种结构取决于你是否需要页面间传值传值用动态纯复用用静态。3. 运行时视角JSP为何能被当成Servlet访问理解了页面结构再看运行时结构。JSP本质上是一种被容器翻译成Servlet的模板文件。这种翻译机制决定了它的生命周期、异常表现和性能特征。3.1 生命周期中的“首次访问慢”现象JSP的生命周期包括编译翻译成.java再编译成.class、初始化jspInit、服务_jspService、销毁jspDestroy四个阶段。注意JSP不是启动时就全部编译的而是首次被访问时才翻译编译。所以传统JSP项目一启动访问第一个页面会明显感觉慢那是在编译不是卡死。如果改了JSP文件Tomcat通过文件时间戳判断是否重新编译。这也是为什么老项目改完JSP经常要清理Tomcat的work目录——清掉编译缓存才能确保改动生效。3.2 Translation阶段的结构映射JSP文件里各部分是如何映射到Servlet源码的用表格来说明更直观JSP中的部分翻译后的Servlet位置% page %等指令_jspService方法前、类的成员区%! %声明Servlet类的成员变量/方法% %脚本片段_jspService方法内部代码% %表达式out.print()调用HTML静态内容out.write()写入${}EL表达式PageContext.findAttribute()查找理解了这张表格就明白为什么脚本片段里不能声明方法、为什么表达式可以直接输出、为什么页面静态文本最终也是代码。JSP的“结构”和Servlet的“结构”在这层是一一对应的。3.3 翻车现场响应头Refresh与页面加载后刷新一次热搜里有个说法是“jsp页面让加载完后刷新一次”部分场景下确实需要这么做——比如页面从数据库读数据后希望展示完自动刷新一次获取最新状态。实现方式是利用响应头结构% response.setHeader(Refresh, 1); %或者在HTML的head里写meta http-equivRefresh content1效果是加载完成后隔1秒再刷新一次。要注意这种写法会进入刷新循环一般要加条件判断只有首次加载时才刷新。这也是“页面结构”层面的一个实用细节。3.4 乱码问题的三层去路JSP中文乱码是个老生常谈的问题本质原因是三层编码结构不一致保存文件的编码、编译器读文件的编码、响应输出的编码。排查顺序是文件本身保存为UTF-8IDEA右下角能看到。pageEncoding设为UTF-8保证编译时读对字节。contentType里的charset设为UTF-8保证响应时告知浏览器用UTF-8解码。从Tomcat 8开始默认URI编码是UTF-8但GET请求的参数编码要看server.xml里Connector的URIEncoding配置。我之前遇到过页面显示正常、request.getParameter取出来却是乱码的怪问题最后发现是所有JSP都正常唯独Tomcat的Connector用了ISO-8859-1。改掉配置再重启问题消失——这个坑写在老项目的部署文档里都不一定有人注意到。4. 从结构角度拆解三类高频问题经验上看JSP项目里的疑难杂症最后都追溯到“结构”上——不是目录结构就是页面结构要么就是运行时生成的结构。下面三类问题是我反复遇到的。4.1 路径问题相对路径与上下文路径的纠缠静态资源引用和链接跳转是路径问题高发地。假设项目部署名是jsp-demo部署在Tomcat上后根路径是http://localhost:8080/jsp-demo。如果在JSP里写死了相对路径link relstylesheet hrefcss/style.css当前URL是/jsp-demo/user/list.jsp时浏览器会把CSS解析为/jsp-demo/user/css/style.css404。正确做法是在JSP里动态获取上下文路径% String ctx request.getContextPath(); % link relstylesheet href%ctx%/css/style.css或者直接用ELlink relstylesheet href${pageContext.request.contextPath}/css/style.css这个写法的好处是无论项目部署名怎么改路径都能自动适配。我给老项目做结构重构时第一步就是把所有写死的绝对路径和相对路径统一改成contextPath拼接这一改动直接消灭了一大批资源加载问题。4.2 include嵌套导致的变量作用域混乱老项目喜欢用% include %做公共头部比如header.jsp里定义了一些公共变量。看起来省事但因为静态include是代码级复制一旦header里的变量名和主页面里的变量名撞了直接编译报错。这种错误在IDE里提示不直观经常要看到Tomcat日志里的“duplicate local variable”才发现是include打架。我的建议是公共部分尽量用jsp:include加参数传递或者干脆提取成JSTL的c:import避免变量互相污染。4.3 表单字段与JavaBean的映射结构传统JSP开发中经常做表单提交页面里input nameusername要和后台的User对象的username属性对应上。这里有一个隐含的结构规则表单字段名与JavaBean属性名必须一致Servlet里通过request.getParameter(username)取值然后setUsername赋进去。在热搜词里看到“结构体初始化”“结构体变量定义”这些词C语言的结构体思想和JavaBean的“属性封装”其实是一个思想在不同语言里的表达把相关的数据字段组织成一个整体结构。做JSP开发时可以借鉴这个思想——不要零散地传十几个参数把表单数据映射成对象代码结构会清晰很多User user new User(); user.setUsername(request.getParameter(username)); user.setEmail(request.getParameter(email));如果用了MVC框架这个映射动作还能进一步自动完成。但无论如何页面表单的name、JavaBean的属性、数据库字段这三者保持一致的命名和类型才是后端开发顺畅的关键。5. 实战示例从零搭一个个人信息展示页面理论讲了这么多用一个小案例串一遍。目标是做一个个人信息展示页输入用户名后端Servlet读取数据转发到JSP展示。我会把工程结构、关键代码和操作步骤全部列出来。5.1 工程结构清单jsp-personal-demo/ ├── pom.xml └── src/ └── main/ ├── java/ │ └── com/example/ │ ├── model/ │ │ └── User.java │ └── servlet/ │ └── UserServlet.java └── webapp/ ├── index.jsp ├── showUser.jsp └── WEB-INF/ └── web.xml5.2 关键代码User.java——对应数据库表结构的实体类package com.example.model; public class User { private String username; private String email; private String intro; public String getUsername() { return username; } public void setUsername(String username) { this.username username; } public String getEmail() { return email; } public void setEmail(String email) { this.email email; } public String getIntro() { return intro; } public void setIntro(String intro) { this.intro intro; } }UserServlet.java——处理请求并转发package com.example.servlet; import com.example.model.User; import javax.servlet.ServletException; import javax.servlet.http.*; import java.io.IOException; public class UserServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username request.getParameter(username); request.setCharacterEncoding(UTF-8); response.setCharacterEncoding(UTF-8); User user new User(); user.setUsername(username null ? 匿名用户 : username); user.setEmail(demoexample.com); user.setIntro(一名正在学习Java Web结构的开发者。); request.setAttribute(user, user); request.getRequestDispatcher(/showUser.jsp).forward(request, response); } }web.xml——Servlet映射?xml version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 servlet servlet-nameUserServlet/servlet-name servlet-classcom.example.servlet.UserServlet/servlet-class /servlet servlet-mapping servlet-nameUserServlet/servlet-name url-pattern/user/url-pattern /servlet-mapping welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list /web-appindex.jsp——入口表单% page contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 % !DOCTYPE html html head meta charsetUTF-8 title个人信息展示/title /head body h2输入用户名查询个人信息/h2 form action${pageContext.request.contextPath}/user methodget input typetext nameusername placeholder请输入用户名 button typesubmit查询/button /form /body /htmlshowUser.jsp——展示页面% page contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 % !DOCTYPE html html head meta charsetUTF-8 title个人信息/title /head body h2用户信息/h2 p用户名${user.username}/p p邮箱${user.email}/p p简介${user.intro}/p a href${pageContext.request.contextPath}/index.jsp返回/a /body /html5.3 打包和验证步骤在项目根目录执行mvn clean package生成target/jsp-personal-demo.war将war包拷贝到Tomcat的webapps目录启动Tomcat后访问http://localhost:8080/jsp-personal-demo/输入用户名提交会看到URL变为/jsp-personal-demo/user?usernamexxx页面正常展示。这个流程验证了整套结构Web请求先进ServletServlet构造模型数据后forward到JSPJSP通过EL表达式从request作用域取数据渲染。每一条都建立在正确的目录结构、页面结构、运行时结构之上。5.4 在IDEA里直接调试传统JSP项目如果你要在这个项目上继续开发建议在IDEA里配置本地的Tomcat菜单Run - Edit Configurations点加号选Tomcat Server - Local。在Server页签选择本机Tomcat路径Deployment页签点加号添加war explodedartifact。修改JSP后IDEA默认会做热部署但如果改了web.xml或新增类经常需要Restart。这里有个小经验IDEA的war exploded模式会把webapp目录直接映射给TomcatJSP改动后不用重新打包刷新浏览器就能看到调试效率高很多。我第一次用这个模式时还担心和正式war包结构不一致实际上部署后的结构完全相同只是没有压缩成一个文件而已。写在最后传统JSP项目看起来“土”但它把Web开发的底层结构暴露得清清楚楚——目录结构告诉你怎么组织文件页面结构告诉你怎么混合Java与HTML运行时结构告诉你容器到底做了什么。这些结构性认知换成Spring Boot也好、换成微服务也好底层都是相通的。根据我个人的经验遇到JSP项目别急着喷它老先花半小时把目录结构、web.xml映射、页面编码这三件事搞清楚后面能少踩无数个坑。特别是那些被迫接手老项目的同学保持对这个“老技术”结构的耐心你会发现自己排查问题的思路比很多只会套框架的人清晰得多。如果需要这套结构还可以继续扩展加一个过滤器做统一编码、用JSTL重写页面循环逻辑、把JDBC连接抽成工具类、甚至把数据库表结构自动映射成JavaBean的工具。结构理顺之后这些都是一步步往里填的事情。