☰
JSP结构详解:从Servlet规范到Tomcat部署与工程实践
2026/9/30 12:35:17 网站建设 项目流程

一说到“JSP 结构”,很多刚接触 JavaWeb 的同学脑子里蹦出来的可能就是一堆 jsp 文件扔在 webapp 目录下,能跑就行。但等你真正参与项目、或者自己从零搭一个传统 JavaWeb 工程时,才会发现“结构”这两个字的分量——它不只是文件夹怎么摆的问题,而是决定了你的项目能不能被 Tomcat 正确识别、能不能顺利打包部署、后期维护时别人能不能一眼看懂你的代码。

我最早用 IDEA 新建 JSP 项目时也犯过迷糊,明明照着教程敲了代码,启动 Tomcat 后却一直 404。后来才明白,问题不是代码写错了,而是目录结构没按 Servlet 规范来。这篇就以“JSP 结构”为主线,把传统 JSP 项目的目录规范、JSP 页面本身的组成结构、不同规模项目的结构设计,以及从传统 JSP 过渡到 Spring Boot 后的目录变迁,一次讲透。内容偏实战,适合刚入门 JavaWeb 的初学者,也适合那些一直在用 IDE 自动生成项目、但从未深究过“为什么是这个结构”的开发者。

1. 项目整体设计:JSP 结构到底在解决什么问题

1.1 规范的本质:让容器找到该找的东西

JSP 项目的目录结构,本质上不是某个框架拍脑袋定的,而是 Servlet 容器(最典型的就是 Tomcat)在启动和运行时的“约定”。容器会按照固定路径去寻找 web.xml、class 文件、依赖 jar 包,你去改了它的默认查找路径,容器就找不到你的东西,表现就是 404 或者 500。

打个比方:Tomcat 就像一个按图索骥的快递员,它只认固定的几个投放点。webapp 根目录是“前台收件处”,WEB-INF/classes 是“内部仓库”,WEB-INF/lib 是“工具间”。你把 class 文件放到了前台,快递员根本不会去那儿翻,它只认 WEB-INF/classes。这就是为什么很多人把编译后的 .class 文件随手放在项目根目录,结果 Tomcat 一启动就一脸懵。

一个标准的传统 JSP 项目目录长这样:

project-root ├── pom.xml # Maven 构建配置(如果用 Maven 管理) └── src └── main ├── java # Java 源码,存放 Servlet、Filter、JavaBean │ └── com │ └── example │ ├── servlet │ │ └── UserServlet.java │ ├── service │ │ └── UserService.java │ └── dao │ └── UserDao.java ├── resources # 配置文件,如 db.properties、log4j.properties └── webapp # Web 根目录,部署后就是应用的根路径 ├── index.jsp ├── css │ └── style.css ├── js │ └── main.js ├── images ├── WEB-INF # 受保护目录,外部无法通过 URL 直接访问 │ ├── web.xml # 部署描述符,配置 Servlet 映射、欢迎页面等 │ ├── classes # 编译后的 .class 文件(Maven 构建时自动输出) │ └── lib # 第三方依赖 jar 包(Maven 构建时自动收集) └── views # 存放 JSP 页面(很多项目习惯放这里) ├── user │ └── list.jsp └── common └── header.jsp

必须说清楚:src/main/java、src/main/resources、pom.xml 这些是 Maven 约定的“源码目录”,它们是构建阶段的输入;而 webapp 目录是“部署单元”的根,构建完成之后,整个 webapp 目录加上 classes、lib 会被打包成 war 包,这个 war 包才是 Tomcat 真正认识的东西。有的老项目没用 Maven,直接把 java 源码编译后的 class 文件手动扔进 WEB-INF/classes,jar 包手动扔进 WEB-INF/lib,也可以跑,只是管理起来比较痛苦。

1.2 WEB-INF 的“保护”属性是很多人忽略的重点

WEB-INF 这个目录有个特殊属性:它是 Servlet 规范钦定的受保护目录。部署之后,浏览器直接访问http://localhost:8080/你的应用/WEB-INF/web.xml是拿不到内容的,容器会直接拒绝。如果 JSP 页面放在 WEB-INF 下,比如WEB-INF/views/user/list.jsp,你也不能通过在浏览器地址栏输入路径的方式访问到它,必须通过 Servlet 或控制器 forward 过去。

这一点恰恰是早期 JSP 项目做访问控制的核心手段——把 JSP 页面藏在 WEB-INF 下,所有请求先经过 Servlet 处理,再由 Servlet 转发到具体的 JSP 视图。这样可以避免用户绕过 Servlet 直接打开 JSP,带来逻辑校验被绕过、页面数据不完整等问题。后来 Spring MVC 的 InternalResourceViewResolver 默认的 prefix 就是/WEB-INF/views/,正是这个思路的延续。

我见过不少新手把 web.xml 或数据库配置文件直接放在 webapp 根目录下,结果部署上线后配置信息被人直接 URL 访问拉走,数据库账号密码一览无余。正确做法就是:所有需要保护的文件一律进 WEB-INF,静态资源(css、js、图片)才放在 webapp 根目录下。

2. 核心细节解析:JSP 页面本身的“内部结构”

2.1 一个 JSP 页面由四类元素构成

如果你把一个 JSP 页面拆开来看,它其实是由四类元素拼起来的:指令(directive)、脚本元素(scripting elements)、动作(actions / JSTL 标签)、模板文本(template text,也就是普通的 HTML)。

指令以<%@ %>开头,常见的有 page、include、taglib 三个。page 指令管的是页面属性:语言、编码、导入的包、是否开启 session、errorPage 配置等。include 指令是静态包含,也就是编译时直接把被包含的文件内容复制进来,相当于代码合并。taglib 指令则用于引入 JSTL 或自定义标签库。

脚本元素包括声明(<%! %>)、表达式(<%= %>)、脚本片段(<% %>)和注释(<%-- --%>)。声明是定义成员变量或方法;表达式是直接向输出流打印结果;脚本片段就是 Java 代码块,可以写循环、判断、逻辑。不过现在写 JSP 已经非常不推荐用脚本元素了——用 JSTL + EL 替代是主流,脚本元素越多,页面越难维护。

动作标签方面,JSP 标准动作(如 jsp:include、jsp:forward、jsp:useBean)在实际开发中逐渐被 JSTL 标签库取代。JSTL 提供了 c:if、c:forEach、c:choose 等逻辑标签,配合 EL 表达式(${user.name})使用,能让 JSP 页面像模板一样干净。

2.2 九个隐含对象:JSP 结构里最核心的“基础设施”

JSP 页面为什么能直接用 request、response、session、out 这些变量而不需要手动创建?因为 Servlet 容器在把 JSP 翻译成 Servlet(Java 类)时,自动在_jspService()方法里声明了九个隐含对象。这九兄弟是 JSP 结构的底层支柱:

隐含对象类型作用域典型用途
requestHttpServletRequest一次请求获取请求参数、请求头、转发
responseHttpServletResponse一次请求设置响应头、重定向、写响应
pageContextPageContext当前页面访问其他所有隐含对象,管理页面属性
sessionHttpSession一次会话存登录状态、用户信息
applicationServletContext整个应用存全局配置、计数、缓存
outJspWriter当前页面向响应输出文本
configServletConfig当前页面获取 Servlet 初始化参数
pageObject当前页面等价于 this,很少用
exceptionThrowable仅 errorPage 页面捕获异常信息

理解了这些隐含对象的作用域关系,你就能明白为什么 Servlet 里设置request.setAttribute("user", user)后,JSP 页面里可以直接用 `$(req})——EL 表达式的取值顺序就是从 page、request、session、application 四个作用域依次查找的。很多人遇到“页面取不到值”的问题,十有八九是 setAttribute 和 getAttribute 的作用域不一致,比如在 session 里存了却从 request 里取。

2.3 JSP 的执行过程:结构决定了行为

JSP 之所以叫“Java Server Pages”,是因为它本质上就是一个被包装过的 Servlet。当 Tomcat 第一次收到对某个 JSP 页面的请求时,JSP 引擎(Tomcat 内置的 Jasper)会经历这么几个步骤:

  1. 把 .jsp 文件翻译成 .java 文件(一个继承 HttpServlet 的类)。
  2. 把 .java 文件编译成 .class 文件。
  3. 加载并实例化该类,调用其_jspService()方法处理请求。
  4. 后续请求直接复用这个 Servlet 实例,直到 JSP 文件被修改触发重新翻译。

这个翻译产物在哪里?正常情况下它会被存放在 Tomcat 的work目录下。如果你在 IDEA 里跑了一次项目,然后去 Tomcat 的work/Catalina/localhost/你的应用/org/apache/jsp目录下翻,就能看到index_jsp.java和index_jsp.class。如果你好奇 JSP 的本质,直接打开这个 .java 文件看看——你会发现你写的<% %>代码被塞进了_jspService()方法,你写的 HTML 被一行行用out.write(...)包裹。

这种“先翻译、再编译、后执行”的机制,也解释了为什么 JSP 的首次访问总是比较慢,之后会变快——因为翻译和编译只发生在第一次。所以如果你改了 JSP 文件部署到生产环境,Tomcat 会自动检测文件时间戳变化并重新翻译,但如果你改的是 Java 类,那就必须重新编译并重启容器,否则新代码不会生效。

3. 实操过程:从零搭一个规范的 JSP 项目

3.1 在 IDEA 中新建传统 JSP 项目的完整流程

用 IDEA 新建传统的 JSP 项目(非 Spring Boot),我建议走 Maven 骨架的方式,这样目录结构从一开始就是规范的。操作上大致是:

  1. IDEA 里选择 New Project,选择 Maven,勾选Create from archetype,选org.apache.maven.archetypes:maven-archetype-webapp骨架。
  2. 填写 GroupId(一般是反写的公司域名,比如 com.example)、ArtifactId(项目名)。
  3. 生成后你会发现默认目录极简:只有一个src/main/webapp/index.jsp和src/main/webapp/WEB-INF/web.xml。
  4. 手动补上src/main/java和src/main/resources两个目录(IDEA 里右键 New Directory 直接新建),否则 Maven 不会把它当源码目录。
  5. 在pom.xml里补齐依赖:servlet-api、jsp-api(scope 为 provided,因为 Tomcat 自带),如果要用 JSTL 再加 jstl 依赖。
  6. 配置 Tomcat:Run → Edit Configurations → 加一个 Tomcat Server → Local,Deployment 里把项目的 war exploded 包加上,Application context 填/或/你的应用名。

这里有个细节很多人踩坑:如果你直接新建的是 IDEA 的“Java Enterprise”项目,IDEA 会帮你生成完整的目录结构,包括 src/main/java、src/main/resources、src/main/webapp,还帮你下载 Tomcat 依赖。这种方式省事,但生成的 web.xml 可能是 Servlet 4.0 的 schema 版本,如果你用 Servlet 2.5 的语法去写,启动时会报 schema 解析错误。

3.2 war 包结构与部署要点

传统 JSP 项目的最终交付物是 war 包。war 包的内部结构就是前面说的 webapp 目录结构加 WEB-INF/classes、WEB-INF/lib。在 pom.xml 里配好 maven-war-plugin 后,执行mvn clean package,target 目录下就会生成项目名.war。

把 war 包丢进 Tomcat 的 webapps 目录,启动 Tomcat 后它会自动解压并部署。这里有两个问题值得提前知道:

第一,war 包部署时的“应用上下文”默认就是 war 包的文件名。如果你的包叫demo.war,访问路径就是http://localhost:8080/demo/;想改成根路径,可以把 war 包改成ROOT.war,或者用 Tomcat 的docBase配置虚拟目录。

第二,IDEA 里开发时一般用 exploded 方式(解压目录)部署,这样 JSP 和静态资源改完刷新就能生效,不用重新打包。但千万别把 exploded 目录当成生产部署方式——生产环境必须是 war 包整体部署,否则你的目录权限、软链、日志路径处理都会出幺蛾子。

很多人问“nginx 支持 JSP 吗”,答案是:nginx 本身完全不支持。nginx 是静态资源服务器和反向代理,它没有 JSP 翻译引擎,它处理不了 .jsp 的请求。正确的做法是 nginx 做前端入口,把包含 .jsp 的请求通过proxy_pass转发给后端的 Tomcat,Tomcat 处理完把生成的 HTML 返回,nginx 再回给浏览器。配置大概长这样:

server { listen 80; server_name example.com; # 静态资源直接由 nginx 处理,减轻 Tomcat 压力 location /static/ { alias /opt/myapp/static/; expires 7d; } # JSP 动态请求转发给 Tomcat location ~ \.jsp$ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 默认转发(如果你把 JSP 藏在 WEB-INF 下,这里转发给 Tomcat) location / { proxy_pass http://127.0.0.1:8080; } }

3.3 页面内部结构的一个可参考模板

下面给一个用了 JSTL + EL 的干净页面结构示例。注意我把 Java 脚本元素全去掉了,逻辑通过 Servlet 预处理,页面只负责显示。

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>用户列表</title> <link rel="stylesheet" href="${pageContext.request.contextPath}/css/style.css"> </head> <body> <%@ include file="/views/common/header.jsp" %> <div class="container"> <h2>用户列表</h2> <c:if test="${empty userList}"> <p>暂无用户数据</p> </c:if> <c:forEach items="${userList}" var="user"> <div class="user-item"> <span>${user.name}</span> <span>${user.email}</span> </div> </c:forEach> </div> <%@ include file="/views/common/footer.jsp" %> </body> </html>

这里有两个重点:${pageContext.request.contextPath}是动态获取应用上下文路径的推荐方式,这样项目无论部署在/demo还是/ROOT,静态资源路径都不会写死。第二个是<%@ include %>静态包含,它是在编译期就完成内容合并的,所以 header.jsp 里定义的变量在本页面也能直接用,但这也意味着 header 和主页面共用同一个命名空间,注意不要跟主页面里的变量重名。

4. 常见问题与排查技巧实录

4.1 部署后 404、500、中文乱码,怎么快速定位

问题一:启动 Tomcat 后访问项目首页 404。排查顺序一般是:先看 Tomcat 启动日志有没有报错,特别是 Context 初始化阶段是否失败;再确认访问路径是否带了正确的应用上下文(localhost:8080/项目名/而不是localhost:8080/);最后看 welcome-file 配置对不对。如果你的欢迎页写的是index.html,但项目里只有index.jsp,Tomcat 找不到index.html就会 404,把 web.xml 里的welcome-file改成index.jsp就好。

问题二:JSP 里的中文全是乱码。这基本是编码不一致造成的。你要保证四个环节统一为 UTF-8:JSP 文件本身的编码(<%@ page contentType="text/html;charset=UTF-8" %>)、浏览器解析时的编码(同样由 page 指令的 contentType 控制)、Servlet 或 Filter 里 request 和 response 的编码设置、数据库连接的 URL 编码参数。如果是 POST 请求乱码,在 Filter 里request.setCharacterEncoding("UTF-8")必须放在读取请求参数之前;如果是 GET 请求乱码,还要在 Tomcat 的server.xml里给 Connector 加URIEncoding="UTF-8"。

问题三:修改了 JSP 文件但浏览器不生效。优先检查浏览器缓存,强制刷新一下(Ctrl + F5)。然后再看 Tomcat 是否真的检测到了文件变化——IDEA 里如果用 exploded 方式部署且开了Build project automatically,一般会即时生效,但如果你的文件是手动复制到 target 目录的,Tomcat 对 JSP 文件的时间戳很敏感,复制过去时时间戳没有变化,它就不会重新翻译。最稳妥的方式是重新 build。

4.2 传统 JSP 项目改造成 Spring Boot 时的“结构迁移”

这部分其实是很多老项目的必经之路。传统 JSP 项目要迁移到 Spring Boot,目录结构会发生比较明显的变化,我用一张表来对照:

传统 JSP 项目Spring Boot 项目变化说明
src/main/javasrc/main/java基本不变,放 Spring Boot 启动类和业务代码
src/main/resourcessrc/main/resources从放 properties 变为可以放模板、静态资源
src/main/webapp/WEB-INF/views 下的 jspsrc/main/resources/templates 下(如果用了模板引擎)Spring Boot 官方推荐 Thymeleaf,但也可以继续用 JSP
WEB-INF/lib 手动管理 jarMaven 管理依赖,fat jar 内置不再需要手动拷贝 jar 到 lib 目录
web.xml配置类或用 application.properties 声明Servlet、Filter、Listener 通过注解或配置类注册
war 包部署到 Tomcat可打成可执行 jar 内嵌 Tomcat,也可打 war 外置 Tomcat注意 Spring Boot 打 war 需要继承 SpringBootServletInitializer

如果把 JSP 迁到 Spring Boot 内部,需要特别注意:Spring Boot 默认不支持 JSP,必须在 pom.xml 里加两个依赖:tomcat-embed-jasper(用于内嵌 Tomcat 解析 JSP)和jstl(如果要用 JSTL 标签)。同时要在application.properties里配置:

spring.mvc.view.prefix=/WEB-INF/views/ spring.mvc.view.suffix=.jsp

这里的/WEB-INF/views/路径是针对src/main/webapp目录下的 JSP 文件的。也就是说即使用了 Spring Boot,JSP 文件依然要放在src/main/webapp下,而不是resources/templates下——这是很多人在迁移时犯的第一个错。

4.3 关于 JSP 结构的个人经验总结

从我这些年做过和接手过的项目来看,真正把 JSP 项目做得好维护的团队,都有几个共同习惯。第一,JSP 页面里尽量不写 Java 代码;第二,公共头尾、导航栏统一用 include 拆出去;第三,所有 JSP 页面放在 WEB-INF/views 下按模块分子目录,而不是平铺在 webapp 根目录;第四,Servlet 或 Controller 只负责数据处理和页面跳转,不要在 Servlet 里拼 HTML。

另外还有一个小技巧:多个 JSP 页面需要共享的 Java 工具方法或数据,可以写在application作用域里。在 ServletContextListener 里启动时初始化一次,比如站点配置信息、国际化资源等,然后用 EL${applicationScope.siteName}在任意页面取用,避免每次请求都去查数据库或读配置文件。

最后提醒一下做毕设和课设的同学:JSP 不是过时的技术,它的理念——前后端模版混合、服务端渲染、按 Servlet 规范组织工程——至今仍然体现在很多现代框架里,把 JSP 项目的目录结构和页面结构摸透了,你再去学 Spring MVC、Thymeleaf,会发现很多东西都是相通的。但如果你是今天刚刚开始学 JavaWeb,我建议你依然要花半天时间手写一次 JSP 项目,亲自看一次翻译后的 .java 文件,亲手把 war 包丢进 Tomcat——这个过程积累的体感,比看多少遍框架文档都有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询