1. 为什么现在还有人用 JSP 配外部 Tomcat先把结论摆在前面Spring Boot 整合 JSP 并部署到外部 Tomcat在 2024 年依然有它的生存空间但它的适用场景比很多人想象的要窄得多。我见过不少团队在这个组合上反复折腾最后要么是历史遗留系统迁移要么是甲方明确要求交付一个可以丢进已有 Tomcat 容器里的 war 包。如果你正好卡在这两种场景里那这篇内容就是写给你的。先说清楚这个组合到底解决什么问题。Spring Boot 默认推荐的是 Thymeleaf、FreeMarker 这类模板引擎配合内嵌 Tomcat 打成可执行 jar 直接跑。但 JSP 有个特殊性它需要在 Servlet 容器里被编译成 Servlet而 Spring Boot 的内嵌 Tomcat 对 JSP 的支持一直是个能用但不推荐的状态。官方文档里写得很直白内嵌容器对 JSP 的支持有诸多限制尤其是打成可执行 jar 之后JSP 文件的定位会出问题。所以当你必须用 JSP 时标准做法就是打成 war 包部署到外部 Tomcat。这里有个很多人一开始没搞明白的点Spring Boot 项目打 war 包和打 jar 包启动类的写法是不一样的。打 jar 包时主类需要继承SpringBootServletInitializer吗不需要。但打 war 包部署到外部容器时主类必须继承SpringBootServletInitializer并重写configure方法。原因在于外部 Tomcat 启动时是通过 Servlet 容器的 SPI 机制来加载应用的它不会去调用你的main方法而是通过SpringBootServletInitializer来引导 Spring 的上下文。这个机制如果不理解你会遇到war 包部署上去访问 404日志里啥也没有这种让人抓狂的情况。再聊聊适用人群。如果你是刚接触 Spring Boot 的新手我建议你先别碰这个组合老老实实用 Thymeleaf 打 jar 包把 Spring Boot 的核心概念吃透。但如果你是维护老系统的开发者手上有一堆 JSP 页面和自定义标签库迁移成本太高那这套方案就是你的现实选择。还有一种情况是运维环境受限公司统一用外部 Tomcat 做容器管理不允许每个应用自带内嵌容器那你也得走这条路。我个人的经验是这个组合的坑主要集中在三个地方依赖配置、打包方式、以及 JSP 文件的存放位置。这三个地方任何一个出问题表现都是页面出不来但根因完全不同。接下来我会把每个环节拆开讲包括我踩过的具体坑和排查思路。2. 依赖配置里那几个必须改的地方2.1 打包方式与 parent 的取舍第一步是把pom.xml里的打包方式从 jar 改成 war。这个改动看起来简单但它会连锁影响很多默认行为。比如 Spring Boot 的 Maven 插件在检测到 war 打包时会自动把内嵌容器相关的依赖标记为provided这样打出来的 war 包就不会把 Tomcat 的 jar 包塞进去避免和外部 Tomcat 的类冲突。packagingwar/packaging关于 parent我建议保留spring-boot-starter-parent。有些教程会说不用 parent自己管理依赖版本但那样你得手动处理一大堆版本兼容问题得不偿失。parent 帮你搞定了依赖版本仲裁尤其是 Spring 生态内部各组件的版本对齐这个价值很大。2.2 spring-boot-starter-web 与 tomcat 的 provided 处理核心依赖是spring-boot-starter-web它默认会带入内嵌 Tomcat。打 war 包时你需要把内嵌 Tomcat 的依赖排除掉或者标记为 provided。有两种做法第一种是直接在spring-boot-starter-web里排除spring-boot-starter-tomcatdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId exclusions exclusion groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId /exclusion /exclusions /dependency第二种是保留依赖但加provided作用域dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId scopeprovided/scope /dependency我实测下来更推荐第二种。原因是provided作用域在编译和测试阶段仍然可用你在本地写单元测试或者用SpringBootTest跑集成测试时内嵌容器还能正常工作不需要额外配置。而第一种排除法会导致本地测试时没有 Servlet 容器测试跑不起来。这个细节很多教程不会提但实际开发中差别很大。2.3 JSP 相关的两个依赖不能少JSP 本身需要两个依赖tomcat-embed-jasper和jstl。注意tomcat-embed-jasper也要标provided因为外部 Tomcat 自带 Jasper 编译器。dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-jasper/artifactId scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId /dependency这里有个版本坑要提醒如果你用的是 Spring Boot 2.x 配 JDK 8jstl用javax.servlet坐标没问题。但如果你上了 Spring Boot 3.x 配 JDK 17整个 Servlet API 从javax.servlet迁移到了jakarta.servletJSTL 的坐标也变成了jakarta.servlet.jsp.jstl。这个迁移不是改个版本号就完事JSP 页面里的标签库 URI 也可能需要调整。我见过有人升级 Spring Boot 版本后 JSP 页面直接报 500排查半天才发现是命名空间的问题。提示如果你的项目还在用 Spring Boot 2.x不要轻易为了用新版本而升级到 3.xJSP 相关的迁移成本比纯 API 项目高得多。2.4 一个容易被忽略的依赖冲突外部 Tomcat 自带 EL 表达式实现比如 Tomcat 9 自带tomcat-embed-el或者容器级别的 EL 实现。如果你的项目里通过其他依赖间接引入了不同版本的 EL 实现部署到外部 Tomcat 后可能出现NoSuchMethodError或者表达式解析异常。排查方法是看 war 包的WEB-INF/lib目录下有没有 EL 相关的 jar如果有考虑排除掉让容器提供。我遇到过一次项目里引入了某个报表库它间接带了一个老版本的 EL 实现本地用内嵌 Tomcat 跑没问题部署到外部 Tomcat 就报表达式解析错误。最后是在pom.xml里把这个传递依赖排除掉才解决。这种问题排查起来很费时间因为错误信息往往指向 JSP 页面本身而不是依赖冲突。3. 启动类改造与 JSP 视图解析器的配置3.1 SpringBootServletInitializer 到底做了什么前面提到打 war 包时启动类要继承SpringBootServletInitializer。这个类的作用是让 Spring Boot 应用能够被 Servlet 容器引导启动。它的核心逻辑是实现了WebApplicationInitializer接口Servlet 3.0 规范规定容器启动时会扫描这个接口的实现类并调用其onStartup方法。SpringBootServletInitializer在onStartup里创建了 Spring 的WebApplicationContext从而完成整个应用的初始化。SpringBootApplication public class Application extends SpringBootServletInitializer { Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(Application.class); } public static void main(String[] args) { SpringApplication.run(Application.class, args); } }注意main方法可以保留这样你本地开发时还能用内嵌容器跑。configure方法返回的 builder 指定了配置源通常就是启动类本身。如果你有多个配置类可以在这里一起注册。3.2 application.properties 里的视图解析配置JSP 的视图解析需要在配置文件里指定前缀和后缀spring.mvc.view.prefix/WEB-INF/jsp/ spring.mvc.view.suffix.jsp这里的前缀路径/WEB-INF/jsp/是相对于 war 包根目录的。也就是说你的 JSP 文件要放在src/main/webapp/WEB-INF/jsp/目录下。为什么放WEB-INF下面因为WEB-INF目录对外部访问是不可见的只能通过服务器内部转发访问这样能防止用户直接通过 URL 访问 JSP 文件绕过 Controller 的逻辑。这是个安全实践不是随便定的。我见过有人把 JSP 放在src/main/webapp/jsp/下然后配置前缀为/jsp/本地跑没问题但这样用户可以直接访问http://host/jsp/xxx.jsp如果 JSP 里有敏感逻辑就麻烦了。所以养成放WEB-INF下的习惯。3.3 静态资源与 JSP 的路径冲突Spring Boot 默认的静态资源路径是classpath:/static/、classpath:/public/等。当你打 war 包时src/main/webapp/下的内容会被打到 war 根目录。如果你在webapp下放了静态资源比如 css、js它们的访问路径和 Spring Boot 默认的静态资源处理可能会有冲突。我的建议是JSP 放WEB-INF/jsp/静态资源统一放src/main/resources/static/让 Spring Boot 的默认机制处理。这样职责清晰不会出现路径混乱。如果你非要把静态资源放webapp下那要注意 Spring Boot 的ResourceHttpRequestHandler可能不会去扫描 war 根目录需要额外配置。3.4 本地开发时的一个实用技巧本地开发时如果每次改 JSP 都要重启应用效率太低。可以在application.properties里开启 JSP 的热加载server.servlet.jsp.init-parameters.developmenttrue这个配置会让 Jasper 编译器在检测到 JSP 文件变化时重新编译。注意这个配置只适合开发环境生产环境不要开因为每次请求都检查文件变化会有性能开销。另外如果你用的是 IDEA还需要确保 Update classes and resources 的配置正确否则改了 JSP 也不会生效。4. 打包部署到外部 Tomcat 的完整链路4.1 war 包结构长什么样执行mvn clean package之后你会得到一个 war 包。解压后大致结构是这样的myapp.war ├── META-INF/ ├── WEB-INF/ │ ├── classes/ (编译后的 class 文件) │ ├── lib/ (依赖 jar不含 provided 的) │ ├── jsp/ (你的 JSP 文件) │ └── web.xml (可选Servlet 3.0 可以没有) └── static/ (如果静态资源在 webapp 下)关键点WEB-INF/lib里不应该有tomcat-embed-*.jar因为它们是 provided 的。如果你发现 war 包里还有这些 jar说明依赖配置有问题部署到外部 Tomcat 后可能因为类加载冲突导致各种诡异错误。4.2 部署到 Tomcat 的两种方式第一种是直接把 war 包丢到 Tomcat 的webapps目录下启动 Tomcat 会自动解压部署。访问路径是http://host:port/war包名/。如果你想去掉路径里的 war 包名可以把 war 包重命名为ROOT.war这样访问路径就是根路径。第二种是通过 Tomcat 管理界面或者配置文件指定部署。这种方式适合需要自定义 context path 或者配置数据源的场景。在conf/server.xml里加一个Context标签Context path/myapp docBase/path/to/myapp.war reloadablefalse /reloadablefalse是生产环境的标配开启 reloadable 会让 Tomcat 监控 class 文件变化有性能损耗。4.3 部署后 404 的排查顺序这是最常见的坑。war 包部署上去Tomcat 启动日志没有报错但访问页面就是 404。我总结了一个排查顺序排查点检查方法常见原因war 包是否解压看 webapps 目录下有没有对应的文件夹Tomcat 没权限或部署失败启动类是否继承 ServletInitializer看代码忘记继承Spring 上下文没启动JSP 文件位置看 war 包内 WEB-INF/jsp 下有没有文件文件放错目录或 Maven 没打包进去视图解析器配置看 application.properties前缀后缀写错Controller 映射看日志里有没有 RequestMapping 注册组件扫描路径不对我遇到最多的是第三种JSP 文件没被打进 war 包。原因是 Maven 默认只打包src/main/webapp下的内容如果你把 JSP 放在了别的地方比如src/main/resources下那就不会被打进去。所以 JSP 必须放src/main/webapp/WEB-INF/jsp/。4.4 日志配置的一个坑外部 Tomcat 有自己的日志系统Spring Boot 的日志配置比如logging.file.name在 war 部署模式下可能不生效因为日志系统的初始化时机和 jar 模式不同。如果你发现日志没输出到指定文件检查一下 Tomcat 的conf/logging.properties以及项目里有没有logback.xml或log4j2.xml被正确加载。我的做法是在 war 部署模式下把日志配置尽量简化依赖 Tomcat 的日志输出到logs/catalina.out然后在应用层面用 AOP 或者拦截器记录关键业务日志。这样虽然不够优雅但排查问题时至少能看到东西。5. 那些让我熬夜的坑和对应的解法5.1 JSP 页面里的 EL 表达式不解析现象是页面上直接显示了${user.name}这样的原始文本而不是解析后的值。原因通常是isELIgnored配置问题。在 Servlet 2.3 及之前的规范里EL 默认是忽略的需要显式开启。虽然现在 Tomcat 版本都支持 EL但如果你在web.xml里声明了老版本的 Servlet 规范或者 JSP 页面的page指令里设置了isELIgnoredtrue就会出现这个问题。解法是检查web.xml的版本声明确保是 3.0 或更高。如果没有web.xml检查 JSP 页面头部的page指令。另外如果你用了 JSTL 标签库确保taglib指令的 URI 正确Spring Boot 2.x 用http://java.sun.com/jsp/jstl/core3.x 用jakarta.tags.core。5.2 静态资源 404 但 JSP 正常这个现象说明 Spring MVC 的 DispatcherServlet 工作正常但静态资源的请求没有被正确处理。在外部 Tomcat 部署时静态资源的路径处理和内嵌容器有差异。默认情况下Spring Boot 会注册一个ResourceHttpRequestHandler来处理/static/**等路径但如果你的 DispatcherServlet 映射是/它会拦截所有请求包括静态资源。解法是在配置类里加上资源处理映射Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/static/**) .addResourceLocations(classpath:/static/); } }或者更简单的方式确保你的静态资源放在src/main/resources/static/下并且没有自定义的 Servlet 映射覆盖默认行为。5.3 中文乱码问题JSP 页面的中文乱码是个老生常谈的问题但在 war 部署模式下有额外的坑。首先确保 JSP 页面头部有% page contentTypetext/html;charsetUTF-8 languagejava %其次在application.properties里配置server.servlet.encoding.charsetUTF-8 server.servlet.encoding.enabledtrue server.servlet.encoding.forcetrue但注意server.servlet.encoding这组配置在外部 Tomcat 部署时可能不生效因为编码过滤器是 Spring Boot 自动配置注册的而外部容器的过滤器链初始化时机不同。如果配置不生效你需要在web.xml里手动注册CharacterEncodingFilter或者用 Tomcat 的conf/server.xml里的URIEncoding配置。我遇到过一次GET 请求的中文参数乱码POST 请求正常。原因是 Tomcat 的connector默认 URI 编码不是 UTF-8。在server.xml的Connector标签里加URIEncodingUTF-8解决。5.4 热部署与类加载冲突开发阶段用spring-boot-devtools做热部署很方便但在 war 部署模式下devtools 会和外部 Tomcat 的类加载器打架。表现是应用启动后行为异常或者 devtools 的 restart 类加载器导致ClassCastException。解法很简单打生产 war 包时确保 devtools 依赖是optional或者provided不要打进 war 包。在pom.xml里dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-devtools/artifactId optionaltrue/optional /dependencyoptional的依赖不会被传递也不会被打进 war 包。这个细节很多人不注意结果生产环境出现莫名其妙的类加载问题。5.5 Tomcat 版本与 Servlet 规范的匹配外部 Tomcat 的版本决定了它支持的 Servlet 规范版本。Tomcat 9 支持 Servlet 4.0Tomcat 10 支持 Servlet 5.0jakarta 命名空间。如果你的 Spring Boot 版本和 Tomcat 版本不匹配会出现ClassNotFoundException或者NoSuchMethodError。匹配关系大致是Spring Boot 版本Servlet 命名空间推荐 Tomcat 版本2.xjavax.servletTomcat 9.x3.xjakarta.servletTomcat 10.x如果你拿 Spring Boot 3.x 的项目部署到 Tomcat 9会因为命名空间不匹配直接启动失败。反过来Spring Boot 2.x 部署到 Tomcat 10 也会有问题。所以部署前先确认版本匹配。6. 关于这套方案的一些个人体会说实话Spring Boot 整合 JSP 加外部 Tomcat 这套组合技术本身不复杂但细节特别多而且很多问题在本地开发环境不会暴露只有部署到外部容器才出现。我的建议是如果你确定要走这条路尽早搭建一个和产线一致的 Tomcat 环境做验证不要等到上线前才第一次部署。另外JSP 作为视图层技术它的生态活跃度确实在下降。新项目如果没有任何历史包袱我真心建议用 Thymeleaf 或者前后端分离的方案。但如果是维护老系统这套方案能让你在不重写视图层的前提下享受 Spring Boot 的自动配置和依赖管理性价比还是很高的。最后分享一个我常用的调试技巧当 war 包部署后行为异常时在 Tomcat 的catalina.out里搜索 Spring Boot 或者你的启动类名确认 Spring 上下文是否真的启动了。如果日志里完全没有 Spring 相关的输出那问题一定出在启动类或者web.xml配置上而不是 JSP 或 Controller 层面。这个判断能帮你快速缩小排查范围省下大量时间。
阅读完成 · 觉得有帮助?