简介:这份文档面向正在使用SpringBoot与Vue前后端分离架构的Java Web开发者,尤其是需要将项目部署到Windows环境下Tomcat服务器的技术人员。内容围绕无Nginx场景下的部署流程展开,涵盖后端WAR包改造、前端dist资源整合、Tomcat的server.xml配置调整,以及刷新页面404错误的web.xml处理方案,并延伸至调用第三方API时的跨域配置思路。资源包共1个doc文件,大小约72KB,以文字步骤与配置代码片段为主,便于对照操作。目前已有1450人学习下载,适合具备一定SpringBoot与Vue基础、希望快速完成Tomcat部署并排查常见问题的开发者参考,可帮助读者理清前后端资源在Tomcat中的路径映射关系,减少部署过程中的试错成本。
1. 把 SpringBoot + Vue 塞进 Tomcat:为什么前后端分离项目还要走这步
很多人第一次听到“Tomcat 部署 SpringBoot + Vue”,第一反应是:SpringBoot 不是自带内嵌 Tomcat 吗,Vue 打完包不是扔 Nginx 就完事了吗,为什么还要把两个东西合起来塞进一个外置 Tomcat?这个问题我在实际交付里被问过不下十次。真实场景是这样的:甲方运维体系里只有一套 Tomcat 集群,安全策略、日志采集、端口开放、证书挂载全部围绕它做完了,你临时申请一个 Nginx 或者新开一个内嵌端口,流程能拖两周。这时候把 Vue 的静态产物和 SpringBoot 的接口打进同一个 war,丢进既有 Tomcat,是最省事的落地路径。
这篇文章讲的就是这条路径:怎么把 SpringBoot 从 jar 改成 war、Vue 的 dist 放哪、前端路由和后端接口怎么不打架、Tomcat 版本和 JDK 怎么配、以及上线后 404 和刷新白屏到底出在哪。适合手上只有 Tomcat 可用、又不想把前后端拆成两套部署体系的开发者。下面按我实际改过的一个模拟项目 X 的流程走一遍,参数和坑都写清楚。
2. SpringBoot 改 war 与 Tomcat 版本选型:先定容器再动代码
2.1 为什么外置 Tomcat 部署必须把 jar 改成 war
SpringBoot 默认打成可执行 jar,靠spring-boot-starter-web里内嵌的 Tomcat 启动,main方法一跑容器就起来了。但外置 Tomcat 的启动入口是它自己的Bootstrap,它不认识 SpringBoot 的main,只认 Servlet 规范里的ServletContainerInitializer和WebApplicationInitializer。所以要让外置 Tomcat 接管,必须做两件事:把打包方式改成 war,并让启动类继承SpringBootServletInitializer重写configure。这不是可选项,是硬性前提。
常见做法是在pom.xml里把spring-boot-starter-web的内嵌 Tomcat 标记为provided,这样打出来的 war 里不会带 Tomcat 的 jar,避免和外置容器的类冲突。我一般还会顺手把spring-boot-maven-plugin的mainClass保留,方便本地还能用 jar 方式调试。
<!-- pom.xml 关键片段 --> <packaging>war</packaging> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <!-- 关键:外置 Tomcat 提供,打包时排除,防止类冲突 --> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <!-- provided 表示编译期可见、运行期由容器提供 --> <scope>provided</scope> </dependency> </dependencies>逻辑说明:exclusions把内嵌 Tomcat 从 starter-web 里摘掉,再用一条独立的provided依赖补回来。这样编译时能用到 Servlet API,运行时由外置 Tomcat 提供实现。参数上唯一要注意的是scope必须是provided,写成compile就会把 Tomcat 的 jar 打进 war,部署时大概率报ClassNotFoundException或者LinkageError。
2.2 启动类改造:SpringBootServletInitializer 的写法
启动类改造只有几行,但写错位置会导致 Tomcat 启动时找不到 Spring 上下文,表现为访问任何接口都是 404,日志里却看不到明显异常。
@SpringBootApplication public class DemoApplication extends SpringBootServletInitializer { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { // 外置 Tomcat 通过这个入口加载 Spring 上下文 return builder.sources(DemoApplication.class); } }逻辑说明:SpringBootServletInitializer实现了WebApplicationInitializer,Tomcat 启动时会扫描到它并调用configure。sources里传启动类,等价于内嵌模式下的SpringApplication.run。参数上没什么可调的,但要注意如果项目里有多个@Configuration入口,别在这里重复注册,否则会出现 Bean 重复定义的启动失败。
2.3 Tomcat 版本和 JDK 的对应关系
版本选错是部署阶段最隐蔽的坑。Tomcat 9 对应 Servlet 4.0,Tomcat 10 开始把javax.servlet换成了jakarta.servlet,这个包名变更会让所有基于javax的 SpringBoot 2.x 项目直接启动失败。我一般按下面这张表来定:
| Tomcat 版本 | Servlet 规范 | 包名 | 适配的 SpringBoot |
|---|---|---|---|
| Tomcat 8.5 | Servlet 3.1 | javax | SpringBoot 1.x / 2.x |
| Tomcat 9 | Servlet 4.0 | javax | SpringBoot 2.x |
| Tomcat 10.0 | Servlet 5.0 | jakarta | SpringBoot 2.7+ 需迁移 |
| Tomcat 10.1 | Servlet 6.0 | jakarta | SpringBoot 3.x |
选型建议:如果项目是 SpringBoot 2.x,直接锁 Tomcat 9,别碰 Tomcat 10。如果非要用 Tomcat 10,要么把 SpringBoot 升到 3.x,要么做javax到jakarta的包名迁移,工作量不小。JDK 方面,Tomcat 9 建议 JDK 8 或 11,Tomcat 10.1 建议 JDK 17。我见过有人在 JDK 17 上跑 Tomcat 9 加 SpringBoot 2.3,编译能过,运行时报模块访问异常,最后降回 JDK 11 才稳。
3. Vue 打包产物与 SpringBoot 静态资源整合:路径和路由是重灾区
3.1 Vue 的 dist 到底放哪:三种放法和各自代价
Vue 打完包是一堆静态文件,要让它被 SpringBoot 的 war 一起带进 Tomcat,常见有三种放法。第一种是放进src/main/resources/static,打包时自动进WEB-INF/classes/static,SpringBoot 默认就会把它当静态资源目录。第二种是放进src/main/webapp,打包后位于 war 根目录,Tomcat 默认的default servlet能直接服务。第三种是单独配ResourceHandler指向外部目录,适合静态文件频繁更新、不想重新打包的场景。
我一般选第一种,因为路径可控、和 SpringBoot 的静态资源机制天然契合。但要注意static目录下的文件会被 SpringBoot 的ResourceHttpRequestHandler接管,如果前端路由用了 history 模式,刷新非根路径会 404,这个后面单独讲。
# Vue 项目打包,输出到 dist npm run build # 把 dist 内容拷进 SpringBoot 的 static 目录 # 注意是拷 dist 里面的内容,不是拷 dist 这个文件夹本身 cp -r dist/* /path/to/springboot-project/src/main/resources/static/逻辑说明:npm run build的产物默认在dist,里面通常有index.html、js/、css/、img/等。拷的时候要拷内容,如果拷成static/dist/index.html,访问路径就变成了/dist/index.html,和预期不符。参数上,Vue 的vue.config.js里publicPath要设成./或/,设成/dist/会导致所有资源引用路径多一层,页面白屏。
3.2 前端路由 history 模式与后端接口的路径冲突
Vue Router 默认是 hash 模式,URL 带#,刷新不会打到后端,所以不会 404。但很多人为了 URL 好看改成 history 模式,这时候刷新/user/list这种路径,浏览器会直接向 Tomcat 请求/user/list,Tomcat 找不到对应资源就返回 404。解决办法是在后端加一个转发规则,把所有非接口、非静态资源的请求转发到index.html。
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 静态资源走默认的 classpath:/static/ registry.addResourceHandler("/**") .addResourceLocations("classpath:/static/"); } @Override public void addViewControllers(ViewControllerRegistry registry) { // 把非 /api 开头的无后缀路径转发到 index.html,解决 history 刷新 404 registry.addViewController("/{path:[^\\.]*}") .setViewName("forward:/index.html"); } }逻辑说明:addViewControllers里的正则[^\\.]*表示匹配不含点的路径,这样/user/list会被转发到index.html,而/js/app.js这种带点的静态资源不会被误转发。参数上,如果接口路径也用了无后缀形式,比如/api/user,要确保它不被这条规则拦截,通常做法是接口统一加/api前缀,然后在正则里排除,或者把接口路径放在更具体的@RequestMapping里,Spring 的匹配优先级会先命中控制器。
3.3 接口前缀与跨域:同源部署后 CORS 配置要清理
前后端分离开发时,Vue 的devServer配了 proxy,后端也开了 CORS。但合并到同一个 war 部署后,前端和后端同源了,原来的 CORS 配置反而可能引发问题,比如响应头里出现重复的Access-Control-Allow-Origin,某些浏览器会直接拒绝。我一般在上线前把@CrossOrigin注解和全局 CORS 配置都去掉,接口前缀统一成/api,前端 axios 的baseURL也改成/api。
// 前端 axios 配置,同源部署后 baseURL 用相对路径 const service = axios.create({ baseURL: '/api', // 不再写完整域名,避免跨域 timeout: 10000 })逻辑说明:baseURL用/api是相对路径,请求会打到当前域名下的/api,和 war 的 context path 拼接后就是最终地址。参数上要注意,如果 Tomcat 里 war 的 context path 不是根路径,比如/demo,那baseURL要么写成/demo/api,要么用./api相对当前路径。我一般建议 war 包名和 context path 保持一致,减少心智负担。
4. 部署实操:从打 war 到 Tomcat 跑起来的完整命令链
4.1 打 war 包并检查产物结构
打包命令本身简单,但产物结构必须检查,否则部署后各种找不到类、找不到资源。
# 在 SpringBoot 项目根目录执行 mvn clean package -DskipTests # 检查 war 包结构,确认静态资源和类都在正确位置 unzip -l target/demo-0.0.1-SNAPSHOT.war | head -30逻辑说明:-DskipTests跳过测试加快打包,正式发版建议保留测试。unzip -l列出 war 内容,重点看三处:WEB-INF/classes/static/index.html是否存在,WEB-INF/lib/下有没有 Tomcat 的 jar(有就说明provided没生效),WEB-INF/classes/下启动类是否编译进去。参数上,war 包名就是默认的 context path,demo-0.0.1-SNAPSHOT.war部署后访问路径是/demo-0.0.1-SNAPSHOT/,嫌长可以在pom.xml的finalName里改成demo。
4.2 部署到 Tomcat 并验证启动
把 war 丢进 Tomcat 的webapps目录,启动后看日志。这一步的排查信息全在日志里,别急着开浏览器。
# 拷贝 war 到 Tomcat webapps cp target/demo.war $TOMCAT_HOME/webapps/ # 启动 Tomcat(Linux 下) $TOMCAT_HOME/bin/startup.sh # 实时看启动日志,重点找 Started 和 Exception tail -f $TOMCAT_HOME/logs/catalina.out逻辑说明:Tomcat 启动时会自动解压 war 到同名目录,日志里出现Spring Boot的 banner 和Started DemoApplication说明 Spring 上下文加载成功。如果只看到 Tomcat 启动但没看到 Spring 日志,多半是SpringBootServletInitializer没生效。参数上,catalina.out是主日志,localhost.<日期>.log里会有更细的 Servlet 容器异常,两个都要看。
4.3 验证静态资源和接口是否都能访问
启动成功后,按顺序验证:先访问根路径看index.html是否返回,再访问一个静态资源看是否 200,最后访问一个接口看数据是否正常。
# 验证首页 curl -I http://localhost:8080/demo/ # 验证静态资源 curl -I http://localhost:8080/demo/js/app.js # 验证接口 curl http://localhost:8080/demo/api/user/list逻辑说明:-I只看响应头,200说明资源可达,404说明路径不对,500说明后端异常。接口返回数据说明 Spring 上下文和数据库连接都正常。参数上,如果首页返回 404 但静态资源 200,说明index.html没被正确转发,检查addViewControllers配置;如果接口 404 但首页正常,检查@RequestMapping的路径和 context path 是否拼接正确。
5. 避坑与排查:Tomcat 部署前后端项目的五个高频翻车点
5.1 启动报 javax.servlet 相关 ClassNotFound
现象:Tomcat 启动日志里出现java.lang.NoClassDefFoundError: javax/servlet/ServletContext或类似异常,应用无法启动。原因:war 包里打进了 Tomcat 的 jar,和外置容器的类加载器冲突,或者 Tomcat 10 用了jakarta包名而项目还是javax。解决:检查pom.xml里spring-boot-starter-tomcat的scope是否为provided,用unzip -l确认 war 里没有tomcat-embed-core;如果是 Tomcat 10,降回 Tomcat 9 或迁移包名。
5.2 页面白屏但接口正常
现象:访问首页返回 200,但浏览器一片空白,控制台报资源 404。原因:Vue 的publicPath配错,打包后index.html里引用的 js/css 路径多了或少了层级。解决:检查vue.config.js的publicPath,同源部署一般设./或/,重新打包后看index.html里的<script src>路径是否和实际访问路径一致。
5.3 刷新非根路径 404
现象:首页能打开,点导航也能跳,但按 F5 刷新/user/list就 404。原因:history 模式下浏览器直接请求该路径,Tomcat 找不到对应资源。解决:加addViewControllers转发规则,把无后缀路径转发到index.html,同时确保接口路径不被误转发。
5.4 接口返回 404 但本地 jar 模式正常
现象:本地用java -jar跑一切正常,部署到 Tomcat 后接口全 404。原因:war 部署后 context path 变了,前端请求路径没跟着改,或者server.servlet.context-path配置和外置 Tomcat 的 context path 冲突。解决:确认 war 包名和访问路径,前端baseURL加上 context path;如果application.yml里配了context-path,外置 Tomcat 下这个配置不生效,要以 war 包名为准。
5.5 静态资源缓存导致更新不生效
现象:重新部署后页面还是旧版本,清浏览器缓存才好。原因:Tomcat 对静态资源默认开了缓存,或者 Vue 打包的文件名没带 hash。解决:Vue 打包默认会给 js/css 加 hash,如果没加,在vue.config.js里配filenameHashing: true;Tomcat 侧可以在web.xml里给静态资源加Cache-Control头,或者用版本号查询参数强制刷新。
6. 进阶技巧:用 context path 隔离多环境与灰度验证
前面讲的都是单 war 部署,实际项目里经常遇到同一台 Tomcat 要跑多个环境,或者要做灰度验证。这时候 context path 的用法就值得单独说一下。Tomcat 的 context path 由 war 包名决定,demo.war对应/demo,demo-test.war对应/demo-test。利用这个机制,可以把测试环境和生产环境部署在同一台 Tomcat 上,前端打包时通过环境变量注入不同的baseURL,一套代码出两个 war。
# 前端按环境打包,注入不同的 API 前缀 # .env.test 里写 VUE_APP_BASE_API=/demo-test/api # .env.prod 里写 VUE_APP_BASE_API=/demo/api npm run build -- --mode test mv dist demo-test cp -r demo-test $TOMCAT_HOME/webapps/demo-test/逻辑说明:Vue CLI 支持--mode指定环境文件,VUE_APP_前缀的变量会注入到前端代码里。打包后把dist重命名成和 war 包名一致的目录,Tomcat 会把它当成一个独立的 context。参数上要注意,两个环境的 war 包名不能相同,否则后部署的会覆盖先部署的;数据库连接等后端配置通过application-{profile}.yml区分,启动时用-Dspring.profiles.active指定。
灰度验证的思路类似:新版本部署成demo-gray.war,前端灰度入口指向/demo-gray/api,验证通过后再把demo.war替换掉。这样不用停机,也不用改 Tomcat 配置。我一般会在灰度 war 的application.yml里把日志级别调成 DEBUG,方便观察新代码的行为,验证完再改回 INFO。
最后说个我自己的习惯:每次改完pom.xml或vue.config.js,一定先本地mvn clean package打一次 war,用unzip -l确认结构,再往 Tomcat 上丢。这个动作花不了一分钟,但能挡掉八成部署阶段的玄学问题。另一个习惯是 Tomcat 的catalina.out永远开着tail -f,启动失败的第一现场全在那里,比事后翻日志快得多。这套流程我用了几年,从 SpringBoot 2.1 到 2.7、Tomcat 8.5 到 9 都跑通过,版本升级时主要盯包名和 scope 两个点,基本不会翻车。希望帮到你。
本文还有配套的精品资源,点击获取