简介:面向Java Web开发者与运维人员的一份Tomcat部署实践文档,聚焦SpringBoot+Vue前后端分离项目在Windows环境下不依赖Nginx、直接由Tomcat承载的落地场景,适合已具备基础框架使用经验、需要完成上线交付的中初级开发者参考。压缩包内仅含1个doc文件,约72KB,篇幅精炼,以文字讲解配合配置片段的方式组织,涵盖后端改造war包、前端dist资源放置、server.xml中Context节点配置、刷新页面404的web.xml兜底处理,以及调用百度地图等第三方接口时的CORS跨域配置等关键环节。目前已有1458人学习。读者可对照文档完成一次完整部署演练,并借助其中的报错现象与对应解决办法积累排错经验,减少上线阶段反复调试的时间成本。
1. 登录进去一片白、F5 一下直接 404:这套部署最磨人的两个瞬间
Windows 服务器上装个 Tomcat,后端 SpringBoot 打成 war 丢进 webapps,前端 Vue 打包出的 dist 也丢进去,不装 Nginx,一个容器顶到底——这是大量企业内网系统和小型门户的默认部署形态。流程满打满算五步,真上手的人基本都卡在同一类瞬间:账号密码输完,跳转看着成功了,页面却整片空白;或者好不容易点开菜单,手一抖按了 F5,浏览器当场甩出一个 404。这两个现象看着像同一个 bug,根因却隔着好几层:一个卡在 Tomcat 的 Context 映射上,一个卡在 dist 目录里缺失的 WEB-INF/web.xml 上,还有一个藏在 pom 打包方式里,等你重启容器才炸出来。下面按我自己踩坑的顺序,把每一步的报错、根因和改法摊开讲。
2. war 包扔进 webapps 起不来:jar 改 war 时漏掉的三处改动
把<packaging>jar</packaging>改成war,很多人以为这就完事了,打包复制进 webapps,重启 Tomcat,然后日志里开始刷红。SpringBoot 默认跑的是内嵌容器模式,java -jar那套和外部 Tomcat 那套的运行链路完全不是一回事,前者容器是应用的一部分,后者应用是容器的一部分。这一步改不干净,后面所有配置都是白搭。
2.1 只改 packaging,启动就报 Failed to start component
最常见的报错长这样,出现在logs/catalina.out或者当天的localhost.YYYY-MM-DD.log里:
SEVERE [localhost-startStop-1] org.apache.catalina.core.ContainerBase.addChildInternal ContainerBase.addChild: start: org.apache.catalina.LifecycleException: Failed to start component [StandardEngine[Catalina].StandardHost[localhost].StandardContext[/prod-api]]往下翻通常还能看到NoClassDefFoundError、ClassNotFoundException,或者干脆是内嵌 Tomcat 的类和你外部 Tomcat 的类打起来了。根因只有一个:war 包里还躺着一整套内嵌 Tomcat 的 jar,容器加载时两套 Servlet 实现撞在一起,谁都别想启动。
改法是把内嵌容器声明成provided,让它在编译期可见、打包时被排除,运行时用外部 Tomcat 提供的那一份:
<!-- pom.xml --> <packaging>war</packaging> <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> <scope>provided</scope> </dependency>scope取provided是关键,它告诉 Maven「这东西运行时由环境提供」,不会进WEB-INF/lib。如果只写 exclusions 不补 provided 依赖,编译阶段会直接报javax.servlet包找不到。
第二处改动在启动类。外部容器启动时不会走main方法,需要由容器回调 SpringBoot 的初始化逻辑,所以启动类必须继承SpringBootServletInitializer并重写configure:
@SpringBootApplication public class ProdApiApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { // 告诉外部容器:从哪个类开始加载 Spring 上下文 return builder.sources(ProdApiApplication.class); } public static void main(String[] args) { SpringApplication.run(ProdApiApplication.class, args); } }漏掉这一步的典型表现不是启动失败,而是启动成功了但接口全 404——因为容器起了,Spring 上下文压根没加载,DispatcherServlet没注册。这种「看着正常其实没起来」的情况最容易误导人,日志里干干净净,你会开始怀疑前端配错了。
第三处容易被忽略:application.yml里如果配了server.port,在外部容器模式下不生效,端口由 Tomcat 的 Connector 决定。反过来,如果配了server.servlet.context-path,它会和 Tomcat 的 Context path 叠加,最终访问路径变成两层,/prod-api/prod-api/xxx这种鬼东西就是这么来的。部署到外部容器时,这一项建议直接注释掉,路径交给 Tomcat 管。
2.2 包名即路径:prod-api.war 复制过去之后,访问地址怎么变
Maven 打出来的包名默认取artifactId和version,大概率是prod-api-1.0.0.war。直接丢进 webapps,Tomcat 自动解压出的目录名就是prod-api-1.0.0,访问路径也随之变成/prod-api-1.0.0,前端那边写死的请求前缀全部对不上,接口一片 404。
所以复制的时候要顺手改名,去掉版本号后缀:
# 构建,跳过测试加快速度 mvn clean package -Dmaven.test.skip=true # 产物在 target 下,复制到 webapps 并改名 copy target\prod-api-1.0.0.war D:\tomcat\webapps\prod-api.war包名prod-api决定了这个应用的 context path 就是/prod-api,后端接口的基础地址随之变成http://ip:端口/prod-api/接口路径。这个约定要在前端打包之前就定下来,因为 Vue 那边的请求基址要么写成相对路径,要么写死这个前缀,打包之后再改就得重新构建一次。
还有一个血泪经验:Tomcat 会自动把 war 解压成同名文件夹,下次你更新 war 包重新复制进去,如果旧的解压目录还在、时间戳又比 war 新,Tomcat 可能直接跳过解压,跑的还是上一版代码。稳妥的做法是每次部署前先停容器,删掉 webapps 下的旧目录和 war,再复制新的,然后清一遍work目录。这一步在开发机上无所谓,在生产机上能省掉大量「我明明改了」的扯皮。
2.3 打包完先自检:war 里到底装了什么
不要等部署上去再猜。war 包本质是个 zip,用解压工具打开看一眼WEB-INF/lib下有没有tomcat-embed-*.jar,有就说明 provided 没配对;再看WEB-INF/classes下有没有你的启动类和application.yml,没有就是资源过滤配置有问题。
命令行也能确认:
# 列出 war 包内 lib 目录内容,检查是否残留内嵌 tomcat jar tf target\prod-api-1.0.0.war | findstr "tomcat-embed" # 正常情况下应该没有任何输出这个自检动作花不到十秒,但能挡掉一大半「部署上去才发现」的返工。war 包干净了,再往下谈路径和路由才有意义。
3. 登录成功却白屏:Context 没配和配错,表现出来是两种病
前端 dist 复制进 webapps 之后,如果直接访问http://ip:端口/,你看到的很可能是 Tomcat 的默认欢迎页,而不是自己的登录页。原因是根路径/上跑的还是容器自带的 ROOT 应用,你的 dist 目录在 webapps 下只是一个普通文件夹,Tomcat 不会自动把它当站点根目录。有人会把 dist 里的东西全拷进webapps/ROOT覆盖掉,能跑,但一旦升级 Tomcat 或者换机器就全乱套。
3.1 server.xml 里那行 Context 到底改了哪个环节
标准做法是让 Tomcat 知道「访问根路径时,去 dist 目录找文件」。打开conf/server.xml,在<Host>节点内部追加一个<Context>:
<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true"> <!-- 把根路径 / 映射到前端 dist 目录 --> <Context path="" docBase="D:/tomcat/webapps/dist" reloadable="true" crossContext="true" /> </Host>几个参数的实际含义,比抄代码本身重要:
| 参数 | 作用 | 写错会怎样 |
|---|---|---|
path | 映射的 URL 前缀,空串代表根路径/ | 写成/dist则要访问ip:端口/dist才出页面 |
docBase | 静态资源所在目录,绝对路径或相对 webapps 的路径 | 路径写错,访问根路径直接 404 |
reloadable | 检测WEB-INF/classes变化后自动重载 | 生产环境开着会额外占 CPU,但调试期方便 |
crossContext | 允许同一容器内不同应用通过getContext()互相访问 | 跟浏览器跨域毫无关系,见 3.3 |
端口也在同一个文件里改,找到<Connector port="8080" ...>,把8080换成你要用的端口。改完必须重启 Tomcat,server.xml不支持热加载。
顺带提一个更稳妥的写法:直接改server.xml有个隐患,Tomcat 版本升级时这个文件常常被覆盖,辛苦配的 Context 就没了。我一般会在conf/Catalina/localhost/下新建一个ROOT.xml,内容只写<Context docBase="..." />,效果一样,且升级时不会被冲掉。前提是webapps/ROOT目录先清掉或者删掉,否则两个根上下文会冲突。
3.2 空白页的真正来源:前端路由和静态目录对不上
原文里提到「不配置 Context,登录可以但登录后空白」,我当时的猜测是前端路由导致的,这个判断方向是对的,但根因要说准。
Vue 打包出来的dist里只有一个index.html和一堆静态资源。前端路由如果用 history 模式(createWebHistory),地址栏会出现/login、/dashboard这种路径,这些路径在服务器上不存在对应文件。页面首次加载时浏览器请求的是http://ip:端口/,能拿到index.html,前端路由接管后靠 JS 跳转,所以「点菜单」一切正常。但登录后跳转的那一下,如果index.html压根没被正确返回,或者返回的是 Tomcat 默认页,表现就是白屏——DOM 是空的,控制台可能还有一堆静态资源 404。
所以白屏排查的第一步不是看 Vue 代码,而是打开 F12 的 Network,看那个文档请求(/或者/login)返回的状态码和内容。返回 200 且内容是自己的index.html,问题在前端路由或 publicPath;返回 404 或者返回了 Tomcat 的猫头页面,问题在 Context 配置。
还有一处经常一起翻车:vue.config.js里的publicPath。如果应用最终部署在根路径,保持默认/即可;如果部署在/dist这类子路径下,必须改成对应前缀,否则index.html里引用的/js/app.xxx.js会去根路径找,全部 404,页面白得干干净净。
// vue.config.js module.exports = { // 部署在根路径时保持 '/',部署在子路径时改成 '/子路径/' publicPath: '/', outputDir: 'dist', productionSourceMap: false }publicPath改错的典型症状是:HTML 能打开,Network 里 JS、CSS 全红,控制台报Unexpected token '<'——因为服务器把所有找不到的路径都返回了index.html,浏览器拿 HTML 当 JS 解析。
3.3 别把 crossContext 当成跨域开关
这个坑值得单独拎出来说。crossContext="true"在原文里被写在了解决跨域的位置旁边,很容易让人误以为它是干这个的。实际上它控制的是 Servlet 容器内部的行为:允不允许 A 应用通过ServletContext.getContext("/B")拿到 B 应用的上下文对象。它跟浏览器发起 HTTP 请求时的同源策略、跟Access-Control-Allow-Origin响应头,一点关系都没有。
真正的前端跨域问题,浏览器控制台会明确写出has been blocked by CORS policy,那是 HTTP 层的策略,改server.xml再多次也不会消失。这一点放在下一章展开。
crossContext在这个部署形态下的实际价值其实很有限,除非你确实有多个 webapp 需要共享数据。保持默认false更安全,开着反而可能带来类加载器层面的干扰。
4. 刷新就 404、调第三方接口报 CORS:两块最容易被漏掉的兜底配置
到这一步,页面能出来了,登录也能跳转了,剩下两个报错会在接下来半小时里轮流折磨你。它们分属两个完全不同的层面,但都发生在「本地开发一切正常,部署上去就炸」的语境里,所以经常被混为一谈。
4.1 为什么点菜单正常、按 F5 就 404
现象很稳定:首页打开正常,点侧边栏菜单切换页面正常,只要在任意一个子路由上按 F5,或者把地址复制到新标签页打开,立刻 404。
原因前面提过一半——history 模式下,/order/list这个路径在服务器磁盘上根本不存在。点菜单时不发文档请求,是 JS 在改地址栏;按 F5 时浏览器真的向服务器要/order/list这个资源,Tomcat 的 DefaultServlet 在 dist 目录里找不到它,返回 404。
解决办法是给这个 Context 加一个错误页兜底:凡是 404,统一返回index.html,剩下的路由匹配交给前端 JS 处理。这就是原文要求在dist下新建WEB-INF文件夹并放web.xml的原因。
<?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> <!-- 任何 404 都回退到首页,由前端路由接管 --> <error-page> <error-code>404</error-code> <location>/index.html</location> </error-page> </web-app>metadata-complete="true"表示这个应用完全靠web.xml描述,容器不必去扫描注解。对一个纯静态目录来说,这能省掉启动时的扫描开销,也避免了扫描过程中冒出一些莫名其妙的告警。
<location>写/index.html是相对当前 Context 根路径的,因为 Context 的path是空串,它正好指向 dist 下的index.html。如果 Context 配成了/dist,这里就得跟着调整。
这里有个版本兼容的坑:上面这份web.xml用的是javaee命名空间和 3.1 版本,Tomcat 8、9 认这个。到了 Tomcat 10 之后,命名空间整体换成了jakarta,直接抄这份文件会被解析器拒绝,启动时报The required element 'web-app' is missing一类的错误。换容器版本时,这份文件要跟着改,别以为它是永久有效的。
4.2 error-page 的副作用:接口 404 也会被喂回一段 HTML
这个坑我踩过一次,排查了很久。配置完 error-page 之后,刷新问题解决了,但某天前端开始报Unexpected token '<', "<!DOCTYPE "... is not valid JSON。
原因很直接:<error-page>是按状态码匹配的,不区分请求的是页面还是接口。前端请求/prod-api/user/info,后端因为某个原因返回了 404,Tomcat 一看是 404,照样把index.html返回给前端。前端 axios 拿到的响应 Content-Type 是text/html,直接JSON.parse,当场报错。更迷惑的是,Network 面板里这个请求的状态码还是 200,因为错误页替换之后状态码被改写了。
几种处理方式,按推荐程度排:
第一种,让后端对所有接口异常返回统一的 JSON 结构,状态码保持在 200 或者业务约定的码值,不让框架层产生 404。SpringBoot 里用@RestControllerAdvice全局捕获异常即可,这样根本不会走到 Tomcat 的错误页逻辑。
第二种,前端在拦截器里判断响应的Content-Type,不是application/json就直接提示「服务异常」,而不是硬解析。这属于兜底,不能当主方案。
第三种,前端接口统一走一个固定前缀(比如/prod-api),Tomcat 侧不用 error-page 处理接口路径。但这需要容器支持按路径细分的错误页配置,web.xml的<location>做不到条件判断,所以真要做只能在应用层拦住。
实践下来第一条最省事,接口和页面在错误处理上本来就该分开,硬用同一个 404 兜底迟早出事。
4.3 第三方接口报 CORS:Access-Control-Allow-Origin 该怎么读
前端要调外部服务(比如某地图开放平台的接口、某短信服务的 JS SDK),本地开发时通过 webpack 的 devServer 代理绕过去了,部署到 Tomcat 之后没有代理层,浏览器直接发起跨域请求,控制台立刻报:
Access to XMLHttpRequest at 'https://third-party.example.com/api/geo' from origin 'http://192.168.1.10:8080' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.先把这句话读准:报错的主体是浏览器,被拦的是响应,原因是对方服务器没返回Access-Control-Allow-Origin头。也就是说,你能不能调这个接口,决定权在对方服务器,不在你的 Tomcat。这时候去改server.xml、去加crossContext、去翻 Tomcat 文档,全是白费功夫。
能动的只有三条路:
其一,后端做一层转发。前端调自己的/proxy/geo,SpringBoot 在服务端发起 HTTP 请求去拿数据再返回。服务端之间不受同源策略约束,这条路最稳,也最不影响第三方服务的鉴权设计。
@RestController @RequestMapping("/proxy") public class ThirdPartyProxyController { private final RestTemplate restTemplate = new RestTemplate(); // 前端只调自家接口,由服务端转发到第三方,绕开浏览器同源策略 @GetMapping("/geo") public String geo(@RequestParam String address) { String url = "https://third-party.example.com/api/geo?address=" + address + "&ak=" + accessKey; return restTemplate.getForObject(url, String.class); } }注意密钥放在服务端配置里,别写死在前端打包产物中,否则打开 F12 就能看到。转发接口本身也要做参数校验和频率限制,不然它就变成了一个公开的代理入口。
其二,对方接口支持 JSONP。只对 GET 有效,且需要对方配合返回回调包装,现在的新接口基本不用了。
其三,如果对方明确允许跨域,只是预检请求没过,那就检查你的请求是不是带了自定义头或者 Cookie。带 Cookie 的跨域请求要求对方Access-Control-Allow-Origin不能是*,必须是具体域名,同时Access-Control-Allow-Credentials: true。这一条经常出现在「配置了还是不行」的场景里。
至于在 SpringBoot 里给自己后端的接口配 CORS,写法是这样:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") // 只对自家 /api 下的接口生效 .allowedOriginPatterns("*") // 生产环境建议写具体域名 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这份配置解决的是「别人调你的接口」的跨域,和你调第三方接口是反方向的两件事。很多人把这两个方向搞混,配了半天发现没用,就是因为方向反了。
5. 五个反复踩的坑:从旧代码不生效到接口返回一段 HTML
这一节把前面散落的经验集中成清单,每条都按「现象 → 原因 → 解决」写,遇到对应症状可以直接对号入座。
5.1 改了代码重新部署,跑的还是旧版本
现象:war 包替换了,Tomcat 重启了,页面上还是老逻辑,日志里也没有报错。
原因:Tomcat 发现 webapps 下已经存在同名解压目录,且目录的最后修改时间比新 war 包还新,就会跳过解压;另外work/Catalina/localhost/下缓存了编译后的 JSP 和相关类,某些情况下也会造成残留。
解决:停容器,删掉webapps/prod-api整个目录、删掉webapps/prod-api.war,再复制新包,然后清掉work/Catalina/localhost/prod-api。养成「先删后拷」的习惯,可以省掉大量自我怀疑的时间。
5.2 启动报 XML 解析错误,定位到 server.xml 某一行
现象:改完server.xml重启,容器直接起不来,日志里是SAXParseException或者The content of element type "Host" must match ...。
原因:<Context>插错位置了,塞到了<Host>外面;或者docBase路径里带了反斜杠没转义;或者路径里含空格、中文,XML 解析时被截断。
解决:确认<Context>必须写在<Host>开始标签和结束标签之间。Windows 路径统一用正斜杠D:/tomcat/webapps/dist,或者用双反斜杠转义。路径里有空格的话,先换个无空格目录,别在这上面耗。
5.3 前端请求全红,地址指向 localhost
现象:服务器上打开页面,Network 面板里所有接口请求都发往http://localhost:8080,全部失败。
原因:前端代码里把 axios 的baseURL写死成了开发环境地址,打包时没换。
解决:改成一个相对路径的前缀,或者用环境变量在构建时注入。打包命令里区分环境,别靠手工改代码。
// 用相对路径,跟随当前页面所在域名和端口 const request = axios.create({ baseURL: '/prod-api', // 与 war 包名保持一致 timeout: 10000 })相对于写死绝对地址,相对路径在换机器、换端口时不需要重新构建,这是最省心的做法。
5.4 页面能开,但所有静态资源 404
现象:index.html打开正常,标题也有,但页面样式全丢、控制台报一堆 JS 加载失败。
原因:publicPath配成了子路径,实际却部署在根路径;或者 Context 的docBase指错了目录,index.html是从别处兜底拿到的。
解决:看一眼 Network 里 JS 请求的完整 URL,对比实际部署位置,把vue.config.js的publicPath和 Tomcat 的 Contextpath对齐。这两个值是一对,必须同时改。
5.5 刷新 404 修好了,接口却报 JSON 解析失败
现象:控制台出现Unexpected token '<', "<!DOCTYPE "... is not valid JSON。
原因:web.xml里的<error-page>把后端接口返回的 404 也重写成了index.html,前端拿到的是 HTML。
解决:接口异常统一在应用层用全局异常处理器返回 JSON,不要依赖容器错误页;前端请求拦截器再加一层Content-Type判断兜底。具体细节见 4.2。
6. 三步定位:先分清是后端没起来、静态资源没找对,还是路由没兜住
遇到这套部署下的任何报错,我现在的顺序是固定的三步,基本能在五分钟内把范围缩到一个具体层。
第一步,看容器日志确认应用是否真的启动了。Windows 下进 Tomcat 目录,直接翻当天的日志:
# 过滤严重级别和异常关键字,快速定位启动失败点 findstr /C:"SEVERE" /C:"Exception" /C:"Failed to start" logs\localhost.*.log如果这里出现StandardContext[/prod-api]相关的启动失败,问题在 war 包本身,回头看第 2 章的三处改动;如果日志干净、只有正常的启动信息,说明应用起来了,往下走。
第二步,用浏览器 Network 面板区分「文档请求」和「接口请求」。点开那个失败的请求,看三样东西:状态码、Content-Type、Response 前几十个字符。返回的是 HTML 说明被错误页兜走了;返回 404 且 Content-Type 是text/html,说明是路由没兜住或者路径不对;返回 404 且是application/json,那是后端接口真没匹配上。这三者的修复方向完全不同。
第三步,绕过前端直接打接口。浏览器地址栏敲http://ip:端口/prod-api/你的接口路径,能返回 JSON 说明后端链路通,问题在前端配置;返回 404 说明后端没起来或者路径前缀不对;返回 500 就直接看日志里的堆栈。这一步能把「前端问题」和「后端问题」彻底切开,避免在错误的方向上改半天。
把常见报错和对应的定位位置整理成一张表,排查时对着看:
| 现象 | 先看哪里 | 大概率根因 |
|---|---|---|
| 启动日志 SEVERE,Context 启动失败 | catalina.out / localhost 日志 | war 包内嵌 tomcat 未排除,或启动类未继承初始化类 |
| 根路径显示 Tomcat 默认页 | server.xml 的 Context 配置 | docBase路径错,或 Context 未生效 |
| 刷新子路由 404 | 浏览器 Network 文档请求 | dist 下缺WEB-INF/web.xml的 404 兜底 |
| 接口返回 HTML 导致 JSON 解析失败 | Response 的 Content-Type | error-page 误伤了接口 404 |
| 控制台 blocked by CORS policy | 报错里的origin和对方域名 | 第三方接口未开放跨域,需服务端转发 |
| 改了代码不生效 | webapps 与 work 目录 | 旧解压目录残留,Tomcat 跳过解压 |
这套流程跑顺之后,你会发现绝大多数「玄学白屏」其实都能归类到上面六行里,剩下的才是真正需要读源码的疑难杂症。从那以后我每次部署前都强制走一遍固定动作:确认 war 包里没有tomcat-embed、确认 webapps 下的旧目录已清空、确认 dist 下有WEB-INF/web.xml、确认publicPath和 Context 的path一致。这四个检查点各花十秒,能挡掉后面几小时的返工。这套流程连同server.xml片段、web.xml全文和一份逐项勾选的部署检查清单,我整理成了一份可直接对照操作的文档,遇到上面任何一个报错时翻到对应章节照着改就行。希望帮到你。
本文还有配套的精品资源,点击获取