简介:面向SpringBoot与Vue前后端分离项目开发者的一份Tomcat部署文档,适合需要将本地联调完成的应用迁移到Windows服务器、又暂不引入Nginx的团队参考。压缩包内仅含1个doc文件,大小约72KB,以文字说明集中整理后端WAR包改造、前端dist资源放置、Tomcat的server.xml中Context路径配置、刷新404处理及web.xml错误页设置等关键环节,便于按章节查阅和团队传阅。文档还涉及调用百度地图等第三方API时的跨域配置思路,并对登录后空白页、刷新报错、跨上下文访问等常见现象给出排查方向,适合作为部署前的检查清单与问题对照材料。目前已有1458人学习,内容偏实操记录与个人经验整理,适合初中级Java Web开发者、运维人员及课程实训场景快速理解传统Tomcat部署前后端分离项目的完整脉络与易错点。
1. 部署没报错就成功的情况很少:war 丢进去 404、登录后白屏、刷新又 404
前后端分离的项目在本机跑得好好的,一挪到 Windows + Tomcat 上,常见的问题就集中爆发了:war 包扔进 webapps 访问 404、能登录但进去一片空白、路由点一下刷新又 404。这三个现象在搜索结果里能找到一堆互相矛盾的答案,有人让你改 server.xml,有人让你加 web.xml,还有人劝你干脆换成 hash 路由。我见过最折腾的一次,几个人对着同一份部署文档改了两天,最后发现是 war 包名和 webapps 下的解压目录对不上。这篇笔记就沿着这条链路,把 vue tomcat 部署 springboot 过程中每个报错拆开:它为什么发生、用日志和浏览器怎么确认、改哪一行能解决,以及下次怎么少走一遍。适合手里只有一台 Windows 服务器和一个 Tomcat、又要接手老项目部署的开发者。
2. war 包丢进 webapps 却 404:从 pom 到 context 路径的完整链路
把 SpringBoot 项目改成 war 部署到外部 Tomcat,看起来只是改一行<packaging>,实际上有三处必须同时成立:依赖范围对了、启动类被容器接管了、war 名和访问路径对上了。任何一处漏掉,表现都是同一个——页面 404,日志里却风平浪静,这是最容易让人怀疑人生的地方。
2.1 只把 packaging 改成 war 还不够:ServletInitializer 与 provided 依赖
先把 pom 改对。很多人只改打包方式,结果 war 包能部署、Tomcat 也能起来,但所有接口全是 404,因为 Spring 的 DispatcherServlet 压根没注册到外部容器里。同时内嵌 Tomcat 的依赖必须调成 provided,否则 war 包里带一份 Tomcat,外部容器再加载一份,类冲突是迟早的事。
<!-- pom.xml:打包方式 + 容器依赖范围 --> <packaging>war</packaging> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 关键一步:内嵌 Tomcat 只在编译和本地调试时可见,不打进 war --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency>provided的含义是「编译期和测试期可用,运行期由环境提供」。外部 Tomcat 就是那个环境,它自带 servlet 容器,所以 war 里不需要再带一份。副作用是本地直接java -jar跑不起来了,常见做法是在 IDE 里跑 main 方法(IDE 的运行时 classpath 仍然包含 provided 依赖),或者临时切回默认 scope 调试。改完依赖,还要补一个初始化类,把 Spring 容器的启动权交给外部容器。
// 名字随意,继承 SpringBootServletInitializer 即可 public class TomcatServletInitializer extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { // 指向原来的启动类,让外部 Tomcat 触发 Spring 容器初始化 return builder.sources(Application.class); } }这个类的原理不复杂:外部 Tomcat 启动时会通过 SPI 找到SpringServletContainerInitializer,再由它回调所有WebApplicationInitializer的实现。SpringBootServletInitializer就是这个接口的实现,它会替你创建AnnotationConfigWebApplicationContext并注册 DispatcherServlet。少了它,war 只是个普通的空壳 webapp,访问任何接口都是 404,但 Tomcat 的 catalina 日志里不会报任何错,这是最典型的「没报错不等于部署成功」。
打包命令建议加跳过测试,很多项目在服务器上构建时因为测试用例要连数据库直接失败。
# 在项目根目录执行,跳过测试避免构建期连库失败 mvn clean package -DskipTests # 确认产物名字和大小,war 里不该出现 tomcat-embed 的 jar ls -lh target/*.war unzip -l target/prod-api.war | grep -i tomcat如果unzip -l里还能看到tomcat-embed-core,说明provided没生效,大概率是父 pom 或别的 starter 又把它传递进来了,用mvn dependency:tree -Dincludes=org.apache.tomcat.embed定位来源再排除。
2.2 启动日志没报错但页面 404:war 名、端口、context 三处对齐
第二个高频原因是访问路径没对上。war 包放进 webapps 之后,Tomcat 默认用文件名作为 context path:prod-api.war对应的访问前缀就是/prod-api,接口地址是http://ip:8080/prod-api/xxx,不是http://ip:8080/xxx。文档里让你把 war 重命名为prod-api,本质上就是在定这个前缀。
这里有个特别容易翻车的点:在外部容器部署模式下,application.yml里的server.port和server.servlet.context-path全部失效。端口由 Tomcat 的conf/server.xml里 Connector 的port决定,context path 由 war 包名决定。我见过有人在 yml 里写server.port=9090,然后对着 8080 一直刷 404,查了两小时配置。判断方法很直接:看 Tomcat 启动日志最后几行有没有Deployment of web application archive [...] has finished in [x] ms,以及Server startup in [x] milliseconds,再对照localhost_access_log里的实际请求路径。
改完包名以后还有一件事必须做——清干净旧产物。Tomcat 的autoDeploy会同时看prod-api.war和它解压出来的prod-api/目录,如果只替换 war 不删目录,很可能跑的还是旧代码。
# 停掉 Tomcat 之后执行,三个目录都要清 rm -rf webapps/prod-api webapps/prod-api.war rm -rf work/Catalina/localhost/prod-api # 再放新 war 启动,强制重新解压work目录存的是 Tomcat 编译后的 JSP 和会话相关缓存,删掉不影响业务数据,但会清掉所有会话,登录态需要重新来一次。生产环境操作前先确认没人正在用。
2.3 内嵌 Tomcat 与容器 Tomcat 打架的几种典型表现
依赖没调成 provided 时,报错通常长这样:java.lang.ClassCastException、java.lang.NoSuchMethodError、NoClassDefFoundError: javax/servlet/ServletContext,或者应用被初始化了两次、日志里出现两个不同版本的 Tomcat 版本号。根因是类加载器隔离被打破,同一个类被容器和 war 各加载一份,instanceof判断直接失效。
还有一类更隐蔽的:Servlet API 的包名不匹配。SpringBoot 3.x 起全面切到jakarta.servlet.*,对应的容器要求 Tomcat 10 及以上;把这样的 war 丢进 Tomcat 8.5 或 9,启动时就报NoClassDefFoundError: javax/servlet/http/HttpServlet或ClassNotFoundException: jakarta.servlet.ServletContext。这不是配置问题,是版本不匹配,只能二选一:降框架版本到 2.7.x,或者把 Tomcat 升到 10。别在这上面浪费时间去改 server.xml,那是南辕北辙。
判断顺序我一般是这样:先看logs/catalina.out里的第一个异常(不是最后一个,最后一个往往是级联失败),再看 war 包里有没有内嵌容器,最后核对 Tomcat 主版本和 SpringBoot 主版本的对应关系。三步之内基本能定位。
3. 登录能进但一片空白:Vue history 路由撞上 Tomcat 默认站点
白屏是这类部署里最让人迷惑的现象:登录接口明明通了,token 也存下了,路由跳转之后页面就是一片白,控制台有时连红色报错都没有。它和 404 不是一回事,但根因往往在同一个地方——服务端不认识前端路由。
3.1 先分清是白屏还是 404:Network 面板三秒判定
打开浏览器开发者工具的 Network 面板,勾上 Disable cache 再刷新,看两件事:文档请求返回的状态码,以及js、css这些静态资源是不是 200。
如果是 404,且请求路径长得像/dashboard、/user/list这种业务路由,说明前端用的是 history 模式,浏览器把路由地址原样发给了 Tomcat,Tomcat 按物理目录找不到对应的文件夹,直接 404。如果文档请求是 200、HTML 也返回了,但static/js/index.xxxx.js是 404,那是资源基准路径(publicPath)的问题,页面骨架在,脚本没加载,同样是一片白。还有一种情况:所有资源都 200,但 Vue 的 router 匹配不到任何路由,控制台会出现No match found for location with path "/xxx"的警告,这说明前端路由的 base 配置和服务端部署路径没对上。
我一般还会顺手看一眼 Elements 面板,如果#app里是空的,说明 Vue 挂载失败或路由没渲染;如果#app里内容在、只是样式全丢,那就是 css 路径错了。这个区分很关键,三种情况对应的修法完全不同。
3.2 server.xml 里 Context 的写法与三个参数的真实含义
history 模式的正解是让 Tomcat 在找不到物理路径时,把请求交给index.html处理。文档里给的做法是在conf/server.xml的Host节点内加一个Context,把前端dist目录挂成根应用:
<!-- conf/server.xml:Host 节点内部 --> <Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true"> <!-- 前端 dist 挂在根路径;后端 war 用 prod-api 前缀 --> <Context path="" docBase="D:/deploy/dist" reloadable="false" crossContext="true" /> </Host>三个属性值得说清楚。path是 URL 前缀,写空字符串表示根上下文,访问http://ip:8080/就是这个应用;写成/在多数版本也能访问,但部分 Tomcat 会对根上下文的写法给出告警,空字符串更稳。docBase指向前端构建产物所在的物理目录,可以是绝对路径,也可以是相对webapps的路径。reloadable控制类文件变化时是否自动重载,开发环境设true方便,生产环境建议false,因为它会周期性扫描 class 文件,有额外开销。至于crossContext,它的作用是允许通过ServletContext.getContext()访问同一虚拟主机下其他应用的上下文,跟路由白屏没有直接关系——这个属性在原始文档里也是照抄过来的,属于典型的「加了没坏处,但也没解决问题」的配置,真正的解药是让 404 回退到index.html,那是下一章的事。
还有个细节:server.xml只在启动时读取一次,改完必须重启 Tomcat,热改不生效。另外从 Tomcat 8 开始,官方更推荐把 Context 拆到conf/Catalina/localhost/下按应用名建 xml 文件,避免server.xml被反复改动。写在server.xml里依然能用,只是升级或迁移时容易丢,我自己维护的项目一律用独立 xml 文件。
3.3 docBase 写绝对路径还是相对路径:一个换机器就崩的细节
绝对路径最直观,D:/deploy/dist指哪打哪,但它绑死了机器和目录结构,换台服务器就要改配置。相对路径是相对webapps目录的,比如dist就表示webapps/dist,迁移时只要目录结构一致就能直接跑。
代价是相对路径依赖启动目录,如果 Tomcat 被注册成 Windows 服务或者用快捷方式启动,工作目录可能不是bin,相对路径就会解析到别的地方去,表现为「昨天还好好的,今天起来就 404」。我踩过这个坑,后来统一成绝对路径,并且在部署脚本里把路径写死成一个变量,改配置只改一处。
Windows 下路径分隔符也容易出问题。server.xml是 XML,反斜杠是转义字符,docBase="D:\deploy\dist"会被解析成奇怪的路径。两种写法都安全:用正斜杠D:/deploy/dist,或者双反斜杠D:\\deploy\\dist。路径里带空格或中文时,务必用英文目录,Tomcat 对非 ASCII 路径的处理在不同版本上并不一致。
配好之后验证也简单:重启 Tomcat,在浏览器直接访问http://ip:8080/,能看到登录页说明 Context 生效;再访问一个不存在的路由地址,如果还是 404,说明回退规则没配上,接着往下走。
4. 刷新页面就 404:WEB-INF/web.xml 兜底与静态资源路径
Context 配好之后,从首页点进各个菜单都正常,但只要在任意一个内页按 F5,立刻 404。这不是配置没生效,而是刷新这个动作把请求直接发给了服务端,而服务端确实没有这个物理路径。
4.1 error-page 404 回退到 index.html:这段 web.xml 到底做了什么
解决办法是在dist目录下建一个WEB-INF文件夹,里面放web.xml,声明 404 错误页指向index.html:
<!-- dist/WEB-INF/web.xml:404 统一回退到首页 --> <?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1" metadata-complete="true"> <display-name>Router for Tomcat</display-name> <error-page> <error-code>404</error-code> <location>/index.html</location> </error-page> </web-app>原理是 Tomcat 的ErrorReportValve在处理响应时,如果发现状态码是 404,会去查当前 Web 应用的error-page配置,命中就内部转发到location指定的资源。/index.html是相对当前 context 根的绝对路径,所以前端挂在根上下文时写/index.html就行;如果前端挂在/prod-api下,就写/prod-api/index.html。
顺带说一句,WEB-INF目录的存在本身还有一层意义:Tomcat 判断一个目录是不是 Web 应用,看的就是有没有WEB-INF。dist里加了WEB-INF/web.xml,它才是一个完整的 webapp 目录,docBase指过去才能按应用方式加载。metadata-complete="true"的作用是告诉容器不用扫描注解,纯静态站点没有注解可扫,加上能省一点启动时间。
配完记得重启 Tomcat,并且确认改的是部署目录下的那份WEB-INF,不是构建产物源目录里已经过期的副本——重新npm run build会清空dist,这个文件会被删掉。常见做法是把它放到public/WEB-INF/web.xml,让构建时自动带进dist。
4.2 静态资源 404:base、publicPath 与 dist 的目录层级
回退配好之后还有一种顽固的 404:HTML 回来了,但页面还是白,Network 里static/js/app.xxxx.js是红色。原因在于index.html里引用资源用的是相对路径或错的基准路径。
如果路由是/user/list,浏览器会以/user/为基准去解析./static/js/app.js,实际请求变成/user/static/js/app.js,当然 404。修法是在构建配置里把publicPath定成绝对路径:
// vue.config.js module.exports = { // 部署在根路径用 '/';部署在子路径(如 /prod-api/)必须换成 '/prod-api/' publicPath: '/', outputDir: 'dist', // 生产环境不生成 source map,减小 dist 体积 productionSourceMap: false, devServer: { port: 8081 } }publicPath决定index.html中所有资源引用的前缀。前端挂根路径就写/,挂在/prod-api/子路径就写/prod-api/,结尾的斜杠不能省。这个值配错的表现非常典型:首页能开,一点菜单就白,因为深层路由下的相对路径解析全歪了。
还有一个更省事的选项——把 vue-router 切到 hash 模式。#后面的部分不会发给服务端,刷新永远不会 404,也就不需要web.xml兜底。代价是 URL 里带#,如果对方对地址栏形态有要求,那就只能老老实实配 history + 回退。
4.3 接口 404 与 /prod-api 前缀:axios baseURL 与后端 context-path 对齐
前端页面正常之后,下一个报错通常来自接口:登录点不动,Network 里POST /prod-api/login返回 404,或者返回的是index.html的内容(因为 404 被回退成首页了),前端 JSON 解析失败报Unexpected token '<'。这个报错很有辨识度——只要解析报错里出现<,基本可以确定请求打到了 HTML 上。
根因是请求路径拼错。前端在根上下文/,后端 war 是prod-api,接口的真实地址是/prod-api/login。axios 的baseURL必须与之对齐:
// request.js:接口前缀要和 war 包名保持一致 const service = axios.create({ baseURL: process.env.NODE_ENV === 'production' ? '/prod-api' : '/api', timeout: 10000 })这里baseURL结尾不能带斜杠,请求路径开头要带斜杠,否则会拼成/prod-api//login,某些容器会返回 404。开发环境用/api配合 devServer 的 proxy 转发到本地后端,生产环境直接用 war 名,这样一套代码两边都能跑。如果后端在application.yml里还配了server.servlet.context-path,注意它在外部 Tomcat 下不生效,真正生效的是 war 名,别把两者混着算。
5. 常见问题排查:从端口占用到第三方 API 跨域的六条现场记录
下面这几条是我在不同项目里反复遇到的,每条都按「现象 → 原因 → 解决」写,遇到类似症状可以直接对号入座。
5.1 部署阶段的三条:旧目录、端口、没重启
第一条:war 换了新的,跑起来还是旧代码。现象是明明重新打包上传了,页面上的改动一点没生效,日志里也没有重新部署的记录。原因是 webapps 下同时存在旧 war 和旧的解压目录,Tomcat 的 autoDeploy 判定不需要重新解压。解决方式是停服后删掉webapps/prod-api、webapps/prod-api.war、work/Catalina/localhost/prod-api三处,再放新包启动,日志里出现Deployment of web application archive才算真的换了。
第二条:启动直接失败,日志报java.net.BindException: Address already in use: bind。现象是 Tomcat 窗口一闪而过或者卡在启动阶段。原因是 8080 端口被别的进程占了,常见的是上一次的 Tomcat 没退干净,或者本地还跑着一个前端 devServer。
# Windows 下查占用并强制结束 netstat -ano | findstr :8080 taskkill /PID 12345 /Fnetstat输出最后一列就是 PID,拿到直接 kill。如果杀不掉,多半是权限不够,用管理员身份开命令行。另一个选择是改server.xml里 Connector 的port,但改完记得同步前端接口地址和防火墙规则。
第三条:改了 server.xml 完全没反应。现象是 Context 加进去了,访问还是 404,配置像是没读。原因是 Tomcat 只在启动时解析server.xml,运行期修改无效;还有一种可能是改错了文件,目录里躺着server.xml.bak、server.xml.20230101之类的备份,编辑器打开的是备份。解决方式是确认改的是conf/server.xml,保存后完整重启(不是 reload),再看启动日志里有没有 XML 解析错误——格式写错时 Tomcat 会直接启动失败,而不是忽略配置。
5.2 联调阶段的三条:跨域、白名单、Cookie 路径
第四条:调用第三方 API 报 CORS 错误。现象是控制台出现Access to XMLHttpRequest at 'https://third-party.example.com/xxx' from origin 'http://192.168.1.10:8080' has been blocked by CORS policy,接口在 Postman 里却是通的。原因是 CORS 是浏览器强制的同源策略,Postman 不发预检、不校验响应头,所以看起来正常。第三方服务没有把你的新域名或 IP 端口加进允许列表,浏览器就直接拦了。
解决思路两条。优先走后端代理,让请求从服务器发出,天然没有同源限制:
@RestController @RequestMapping("/api/proxy") public class ThirdPartyProxyController { private final RestTemplate restTemplate = new RestTemplate(); @GetMapping("/geocode") public String geocode(@RequestParam String address) { // 前端只调自己的域名,第三方请求由后端发起,绕开浏览器同源策略 String url = "https://third-party.example.com/geocode?addr=" + address; return restTemplate.getForObject(url, String.class); } }RestTemplate在这里只做转发,超时和重试要显式配置,默认没有超时会导致线程堆积。如果确实需要浏览器直连,那就得在服务端加 CORS 响应头:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration cfg = new CorsConfiguration(); // 只放行自己前端所在的实际地址,不要图省事写 "*" cfg.addAllowedOrigin("http://192.168.1.10:8080"); cfg.addAllowedHeader("*"); cfg.addAllowedMethod("*"); // 需要携带 Cookie 时必须为 true,此时 Origin 不能是 "*" cfg.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", cfg); return new CorsFilter(source); } }这里有个组合限制:allowCredentials(true)和allowedOrigin("*")不能同时使用,浏览器会直接拒绝。上线前把 Origin 写死成实际域名,别用通配。
第五条:第三方服务返回域名或 Referer 校验失败。现象是请求发出去了,返回 403 或者一个INVALID_USER_DOMAIN之类的错误码,地图不显示、组件空白。原因是这类服务通常要求把调用方域名加进控制台白名单,而你现在用的是IP:端口,或者刚换的新域名还没登记。解决方式是登录对应平台把新地址加进白名单,并把 Referer 策略调整为允许发送;如果对方只认备案域名,那就只能用后端代理,把请求收敛到自己的域名下。
第六条:登录成功但后续接口全 401。现象是登录接口返回了 token 或写了 Cookie,紧接着的请求却提示未授权。原因是 Cookie 的path被设成了/prod-api,而前端页面在根路径/,浏览器按路径匹配不会带上这个 Cookie。解决方式是在服务端把 Cookie 的 path 设为/,或者干脆改用 Header 里带 token 的方案,避开 Cookie 路径和SameSite这一堆边界情况。前后端分离项目里,后者更省心。
6. 快速定位:五分钟判断问题出在打包、Tomcat 还是前端路由
排这类故障,最怕的是没有顺序地乱试。我的习惯是先做一次分层判定,把范围从「整个系统」压到「某一层」,再往下钻。
第一步看 Tomcat 有没有真的把应用起来。打开logs/catalina.out,搜索Server startup in和Deployment of web application archive,两条都在说明容器层没问题;只有前者没有后者,说明 war 没被识别,回去查包名和 webapps 目录权限。
第二步用 curl 直接打后端接口,绕开浏览器:
# 直接验证后端是否可用,不走前端 curl -i http://127.0.0.1:8080/prod-api/actuator/health # 验证前端静态页是否被正确返回 curl -i http://127.0.0.1:8080/index.html接口通、页面不通,问题在前端;两个都不通,问题在容器或路径。
第三步用浏览器 Network 面板区分白屏类型,对照下表:
| 现象 | 先看哪里 | 快速动作 |
|---|---|---|
| 访问根路径直接 404 | webapps 下有没有解压目录 | 看 catalina.out 的部署日志 |
| 登录后白屏,js/css 报 404 | publicPath 与 Context 的 path | 改成绝对路径重新构建 |
| 内页刷新 404 | dist 下有没有 WEB-INF/web.xml | 补文件并重启 Tomcat |
| 接口 404 且返回内容是 HTML | axios baseURL 与 war 名 | 对齐/prod-api前缀 |
| 控制台 CORS 报错 | 请求目标是不是第三方域名 | 后端代理或加白名单 |
| 启动报 NoClassDefFoundError | SpringBoot 与 Tomcat 主版本 | 对齐 jakarta / javax |
第四步才是改配置。改完必须重启 Tomcat 再验一次,别在浏览器里反复刷新等它自己生效——server.xml和web.xml都不是热加载的。
血泪经验是:这套流程里最容易跳过的是第一步,大家习惯直接盯着浏览器报错改前端,结果改了半天发现 war 根本没部署上去。从那以后我每次部署完,都强制先跑一遍curl接口和curl首页这两条命令,确认服务端这一层是干净的,再去动前端配置。这份部署文档里把 pom 改造、war 命名、Context 配置、web.xml 兜底和跨域处理这几步都串起来了,配合上面的排查顺序用,能省掉大部分来回试错的时间。希望帮到你。
本文还有配套的精品资源,点击获取