简介:围绕 SpringBoot 与 Vue 前后端分离项目在 Tomcat 上的部署落地,这份文档面向需要把开发成果发布到 Windows 服务器运行的 Java Web 开发者、运维人员及课程实训读者,聚焦后端打包、前端静态资源托管、访问路径配置和页面刷新异常等常见问题。压缩包共 1 个文件,为 doc 文档,约 72KB,内容以部署流程说明、关键配置片段和问题排查提示为主,便于按环节对照查阅。文档覆盖打包方式调整、Vue 构建产物放置、Tomcat 的上下文配置、错误页处理,以及调用百度地图等第三方接口时的跨域配置思路,能帮助读者理解前后端分离项目在传统容器中的发布要点与排错方向。已有 1458 人学习,适合具备 SpringBoot、Vue 和 Tomcat 基础、希望减少部署踩坑时间的读者参考。
1. 一个只有 Tomcat 的 Windows 环境,怎么把前后端塞进去
接到过这么一个活:内网几台 Windows Server,运维给的说法很干脆,机器上跑着一堆老系统,装 nginx 要提工单、开端口要审批、装容器想都别想,现成能用并且有人会重启的,就一个 Tomcat。项目本身是 springboot + vue 前后端分离,按理说该 jar 包一挂、nginx 一配就完事,结果环境把这条路堵死了。
所以这份文档解决的就是这么个具体问题:不借助 nginx,把后端打成 war 丢进 webapps,把 vue 的 dist 静态资源也丢进去,让 tomcat 同时托管页面和接口。好处很实在——页面和接口同域同端口,前端写相对路径就能调通,代理和跨域那一圈麻烦直接省掉。适合手里有现成前后端分离项目、部署环境受限、又不想把技术栈推倒重来的开发者。
2. 部署链路拆解:war 包和 dist 在 webapps 里怎么各管一段
2.1 一次页面加载背后的两次请求分发
先把链路走通,后面配错的时候才知道该查哪一段。浏览器访问http://ip:8080/,Tomcat 的 Connector 收到请求,交给 Host 处理,Host 下面挂着一个path="/"的 Context,它的 docBase 指向 dist 目录。因为路径是根,Tomcat 默认的 DefaultServlet 会去找 dist 下的index.html,找到就返回,页面框架起来了,剩下的路由交给 Vue Router 在浏览器里接管。
页面渲染完,代码里发起/prod-api/system/user/list这种请求,这时命中的是另一个 Context——就是 webapps 下面那个prod-api.war解压出来的目录,war 的名字天然就是 Context path。请求进到 war 内部,被 SpringBoot 注册的DispatcherServlet接住,再按@RequestMapping分发到 Controller。整个过程里,浏览器看到的 origin 始终是http://ip:8080,协议、域名、端口三样全一样,同源策略根本不会触发。
这就是不用 nginx 也能跑通的核心原因:不是绕过了跨域,是从一开始就不存在跨域。理解这一点,后面所有配置项的意义就清楚了——Context 配的是「页面从哪来」,war 名配的是「接口从哪来」,两者必须在同一个 Host、同一个端口下。
2.2 目录落位与服务端口安排
落位其实就三样东西,但每一样的细节都能让人栽跟头。我一般部署完先dir一下 webapps,确认结构跟预期一致再启动,比事后看日志高效得多。
| 位置 | 内容 | 说明 |
|---|---|---|
webapps/prod-api.war | 后端 war 包 | 文件名即 Context path,改名就等于改接口前缀 |
webapps/prod-api/ | Tomcat 自动解压目录 | 每次替换 war 前建议先删掉,避免残留旧 class |
webapps/dist/ | Vue 打包产物 | index.html必须直接在这一层,不能多套一层 |
端口在conf/server.xml的 Connector 上改,把这台机器对外开放的端口写进去就行。这里有个 Windows 特有的坑:改成 80 端口看着很美,但 80 常被系统里的其他 Web 服务占着,而且绑定 1024 以下端口在某些环境下需要额外权限,启动日志里会直接抛BindException。我一般先用 8080 跑通,确认业务没问题再去谈端口的事,把变量拆开验证。
另外一个容易被忽略的点是unpackWARs。默认是 true,Tomcat 会把 war 解压成同名目录再加载,改配置文件、换静态资源都方便;如果设成 false,war 直接内存加载,改一个字符都得重新打包。部署调试阶段务必保持 true,等稳定了再考虑要不要动。
2.3 path 与接口前缀的对应关系
把映射关系列清楚,配错了对着表一查就知道错在哪。
| 浏览器地址 | 谁在处理 | 落到什么 |
|---|---|---|
http://ip:8080/ | Contextpath="/" | webapps/dist/index.html |
http://ip:8080/static/js/app.xxx.js | Contextpath="/" | dist 下的静态资源文件 |
http://ip:8080/prod-api/user/list | Context/prod-api | war 内的 Controller |
http://ip:8080/system/user | Contextpath="/" | 磁盘上没这个文件,404 |
最后一行是后面所有 404 问题的总根源:Vue Router 用的是 history 模式时,用户在页面里点跳转没问题,因为路由是 JS 拦下来的,但一按 F5,浏览器真的会拿/system/user去问服务器要文件,服务器当然没有。这个问题在第 4 章和第 5 章还会反复出现,先记住它的成因。
3. 后端改造:把 springboot 项目从 jar 调成能在外部 Tomcat 跑的 war
3.1 pom.xml:打包方式、内嵌容器作用域和产物名
默认的 SpringBoot 工程打出来是 fat jar,里面自带一个内嵌 Tomcat,直接扔进 webapps 是不认的——外置 Tomcat 需要的是标准 war 结构,WEB-INF/web.xml(或者 Servlet 3.0+ 的注解扫描)加上WEB-INF/classes、WEB-INF/lib。所以 pom 里至少有三处要动。
<!-- pom.xml 关键片段 --> <packaging>war</packaging> <build> <!-- 产物名直接定成 prod-api,省得打完包再手工改名 --> <finalName>prod-api</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> <dependencies> <!-- 关键:把内嵌 Tomcat 标成 provided,运行期由外置 Tomcat 提供 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency> </dependencies><packaging>从 jar 改 war 是最基础的一步,改完 Maven 才知道要按 war 的生命周期走war:war插件。finalName定成prod-api是个小技巧,这样打出来的就是target/prod-api.war,拷进 webapps 不用改名,接口前缀天然就是/prod-api,前后端约定的路径一次对齐。
provided这个作用域值得多说两句。它的意思是编译期需要、运行期由容器提供,打 war 时不会把这个依赖塞进WEB-INF/lib。不设它的话,war 包里会同时存在内嵌 Tomcat 的 jar 和外置 Tomcat 的类,两边版本一旦对不上,启动时就是一堆NoSuchMethodError或者ClassNotFoundException,日志刷得满屏红,但报的类名跟你写的业务代码毫无关系,第一次遇到很容易怀疑人生。
提示:SpringBoot 2.x 内嵌的是 Tomcat 9(Servlet 4.0,
javax.servlet包名),SpringBoot 3.x 换成了 Tomcat 10+(Servlet 5.0,jakarta.servlet)。外置 Tomcat 的大版本必须和后端对齐,Boot 3 往 Tomcat 9 里扔,启动直接报类找不到,这不是配置问题,是方言不通。
3.2 启动类继承 SpringBootServletInitializer
光改 pom 还不够,启动类也得改。标准 SpringBoot 的入口是main方法里SpringApplication.run,但部署到外置 Tomcat 时,容器不会去调你的 main——它是通过 Servlet 3.0 的ServletContainerInitializer机制扫描META-INF/services,找到 Spring 的实现类,然后回调SpringBootServletInitializer的configure方法。所以启动类得继承它并重写。
@SpringBootApplication public class ProdApiApplication extends SpringBootServletInitializer { /** * 外置 Tomcat 启动时走这里。 * 等价于 main 方法里的 SpringApplication.run,只是入口换了。 */ @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(ProdApiApplication.class); } /** * 内嵌 Tomcat 启动(本地开发、main 方法直接跑)时走这里。 * 两个入口并存,本地开发和服务器部署用同一份代码。 */ public static void main(String[] args) { SpringApplication.run(ProdApiApplication.class, args); } }这段代码的要点在于两个入口并存。configure负责外置容器,main负责本地 IDE 里直接跑,两者都保留,开发的时候不用改代码,部署的时候也不用改代码,靠运行环境自动选择。我见过有人把 main 删了,结果本地调试只能每次打包扔 Tomcat,一轮下来多花十几分钟,纯粹自找麻烦。
另外注意builder.sources(...)里的类必须是被@SpringBootApplication标注的那个,写错成别的配置类会出现「启动成功但一个 Bean 都没有」的诡异现象,表现是接口全 404,日志却干干净净。
3.3 打包、校验与丢进 webapps
改完这两处就可以打包了。构建命令本身没什么好说的,重点是产物要验一眼,别急着拷。
# 清理后重新打包,跳过测试省时间(正式发版建议别跳) mvn clean package -DskipTests # 看产物结构:应该能看到 WEB-INF/classes 和 WEB-INF/lib unzip -l target/prod-api.war | head -30验产物这一步是血泪经验。曾经有一次打完包直接拷上去,Tomcat 起了半天报ClassNotFoundException: javax.servlet.Filter,排查半小时才发现 pom 里provided加错了位置,war 里根本没有 servlet-api。用unzip -l看一眼,WEB-INF/lib下面该有的业务依赖在不在、不该有的内嵌 Tomcat 在不在,十秒钟的事,能省半小时。
拷过去之后的动作顺序也有讲究。如果 webapps 下面已经有旧的prod-api目录,先把它删掉再放新 war——Tomcat 判断是否重新解压的依据是 war 的时间戳,但残留的旧目录里可能留着上次的配置文件或者编译产物,混在一起排查起来极其难受。
# Windows 下的操作,假设 Tomcat 装在 D:\apps\tomcat-9.0 copy target\prod-api.war D:\apps\tomcat-9.0\webapps\ rd /s /q D:\apps\tomcat-9.0\webapps\prod-api删目录要在 Tomcat 停掉的时候做,运行中删会被文件占用卡住。启动之后留意日志里有没有Deployment of web application archive ... has finished in xxx ms,出现这行基本就说明 war 被正常识别了。
3.4 把接口前缀和前端约定对齐
war 的名字决定了 Context path,也就决定了接口前缀。prod-api.war意味着所有后端接口都在/prod-api下面,这个信息必须传到前端去。
最容易出事的是application.yml里那个server.servlet.context-path。内嵌 Tomcat 跑的时候,配个/api很常见,但打成 war 扔进外置 Tomcat 后,war 名已经贡献了一层前缀,配置文件再贡献一层,实际路径就变成了/prod-api/api/user/list,前端按/prod-api/user/list去请求,必然 404。
server: port: 8080 servlet: # 部署到外置 Tomcat 时这一项必须注释掉,前缀由 war 包名决定 # context-path: /api处理方式很简单:部署前把这一行注释掉,让 war 名成为唯一的前缀来源。如果项目里有多处依赖这个前缀(比如 Swagger 的 basePath、拦截器的 excludePathPatterns),要一起改,别只改一处。我的习惯是在application-prod.yml这类部署专用配置里显式写一行注释说明「前缀由 war 名提供」,下次接手的人一眼就懂。
4. 前端构建与静态资源落位:vue 的 dist 目录怎么放、路径怎么写
4.1 publicPath、路由模式和接口 baseURL
Vue 打包出来的index.html里,js 和 css 的引用路径是构建时写死的,由publicPath决定。这个值配错,表现就是页面一片空白,控制台里一堆GET http://ip:8080/js/app.js 404。
// vue.config.js module.exports = { // 部署在根路径(Context path="/")时用 '/', // 如果整个前端挂在 /web 这种子路径下,这里要同步改成 '/web/' publicPath: '/', outputDir: 'dist', assetsDir: 'static', productionSourceMap: false, devServer: { port: 8081, proxy: { // 开发期把 /prod-api 代理到本地后端,路径和生产保持一致 '/prod-api': { target: 'http://localhost:8080', changeOrigin: true } } } }publicPath设成'/'的前提是 Context 的 path 也是/,两边是一对绑定关系,改一个必须改另一个。devServer.proxy那段是为了让开发环境的请求路径和生产完全一致,都走/prod-api,这样代码里axios.defaults.baseURL = '/prod-api'写一次就够了,不用在打包前手工切换地址。
路由模式这块有两个选择,各有代价。history 模式地址干净,http://ip:8080/system/user这种 URL 看着舒服,但刷新会 404,得靠后面的 web.xml 兜底;hash 模式地址带个#,http://ip:8080/#/system/user,刷新永远不会 404,因为#后面的内容浏览器不会发给服务器。内网系统、对 URL 美观没要求的场景,我一般直接上 hash,省掉一整类问题;如果甲方明确要求地址不能带井号,再走 history + 兜底那条路。
4.2 dist 落位与 server.xml 的 Context 配置逐字段说明
打包命令npm run build跑完,产物在dist目录下。把整个 dist 目录复制到 webapps 里,注意是「dist 目录本身」,不是目录里面的内容——复制完的路径应该是webapps/dist/index.html。
# 前端构建 npm run build # 复制到 Tomcat(Windows 下也可以用资源管理器直接拖) xcopy /E /I /Y dist D:\apps\tomcat-9.0\webapps\dist然后是conf/server.xml里的 Context 配置。这一步是整个部署里最关键、也最容易漏的一环。加在<Host>节点内部、</Host>之前。
<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true"> <!-- path:浏览器访问的路径,"/" 表示根路径直接访问 ip:端口 --> <!-- docBase:静态资源所在目录,可以写绝对路径,也可以写相对 webapps 的目录名 --> <!-- reloadable:改 class 自动重载,调试期开、生产期关 --> <!-- crossContext:允许同一 Host 下的应用互访 ServletContext --> <Context path="/" docBase="dist" reloadable="true" crossContext="true" /> </Host>四个属性逐一说明。path决定用户在地址栏里敲什么,写/就是http://ip:8080/直接进首页;写/web就得访问http://ip:8080/web/,同时前端的publicPath也要跟着改成/web/。docBase写相对路径dist时,是相对 Host 的 appBase(也就是 webapps)解析的,写D:\apps\tomcat-9.0\webapps\dist这种绝对路径也行,好处是不受 appBase 变更影响,坏处是换台机器就得改。
reloadable="true"在调试阶段很香,改了后端 class 文件 Tomcat 会自己重新加载,不用重启;但它是有代价的,Tomcat 会起一个后台线程定期扫描 class 变化,生产环境开着纯属浪费资源,而且偶尔会出现加载到一半的诡异状态。上线前记得关掉。
crossContext="true"允许同一个 Host 下的应用通过ServletContext.getContext()拿到彼此的上下文。前后端在同一个 Tomcat 里时,这个配置能避免一类跨上下文取资源失败的问题,开着的成本很低,一般顺手就带上。
注意:如果 webapps 下还留着 Tomcat 自带的 ROOT 目录,它会和
path="/"的 Context 抢同一个路径,表现是访问首页出来的是 Tomcat 的欢迎页而不是你的 Vue 页面。部署前把 ROOT 目录删掉或者改个名。
改完 server.xml 要重启 Tomcat 才生效,而且这个文件改坏了整个容器都起不来,改之前先备份一份是我的固定动作。
4.3 刷新 404 与 WEB-INF/web.xml 的回退方案
history 模式下按 F5 得到 404,前面说过成因:浏览器真的去请求/system/user这个路径,磁盘上没有对应文件,Tomcat 的 DefaultServlet 找不到就返回 404。解决办法是给这个 Context 配一个错误页,让它把所有 404 都重定向回index.html,页面重新加载后再由 Vue Router 根据地址栏的路径渲染对应组件。
具体做法是在 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 统一回退到 index.html,交给前端路由处理 --> <error-page> <error-code>404</error-code> <location>/index.html</location> </error-page> </web-app>metadata-complete="true"告诉容器这个 web.xml 已经是完整描述,不用再去扫描注解,启动能快一点。error-page那段是核心,error-code指定拦截哪个状态码,location指定跳转目标,写/index.html是相对当前 Context 根的路径。
这个方案有个副作用要提前知道:它拦截的是这个 Context 下所有的 404,包括静态资源缺失。如果index.html引用的某个 js 文件名写错了,请求返回的不是 404 而是index.html的内容,浏览器解析 JS 时抛的是语法错误,排查方向会被带偏。所以看到「控制台报 JS 语法错误」的时候,先打开 Network 看那个请求的响应体是不是 HTML,八成就是这个原因。
还有一种情况:如果后端接口也部署在同一个 Context 下(比如把 war 直接解压合并进 dist),那后端返回的 404 JSON 也会被这条规则吞掉,接口调试时看不到真实的错误信息。这时候就得把前后端的 Context 分开,保持 war 独立,就是第 2 章那张表里的结构。
4.4 多环境打包的注意点
如果项目要同时在开发机、测试机、生产机上跑,接口地址别写死在代码里。用.env.production这类文件控制,打包时自动注入。
# .env.production # 生产环境接口前缀,与 war 包名保持一致 VUE_APP_BASE_API=/prod-api代码里统一用process.env.VUE_APP_BASE_API取,这样换环境只改这个文件、重新 build,不用去翻request.js。我见过打完包手工改 dist 里 js 文件的做法,一次两次还行,第三次就忘了改,上线之后接口全打到测试环境,排查起来绕一大圈。
5. 排查清单:部署到 Tomcat 之后最容易翻车的 5 个现场
5.1 登录能进,进去之后一片空白
现象:登录页正常,账号密码提交成功,跳转之后白屏,控制台没有明显报错,或者只有一条路由相关的警告。
原因:Context 的path="/"没配,或者前端publicPath与实际访问路径不一致。前者导致页面里的相对路径资源全部解析到错误的位置,后者导致index.html里的 js 引用地址带着错误的前缀。原文里提到「不添加 Context 配置,登录是可以的但登陆之后是空白页」,根子就在这里——登录页可能是直接访问的,路由跳转之后的页面依赖的资源路径对不上。
解决:打开浏览器 Network,筛 JS,看请求地址是不是 404;对不上就回头改vue.config.js的publicPath和 server.xml 的path,两边保持一致后重新打包、重启。
5.2 页面能开,一按 F5 就 404
现象:站内点击跳转一切正常,只要刷新当前页面,立刻变成 Tomcat 的 404 页面。
原因:Vue Router 用了 history 模式,地址栏里的/system/user被浏览器当成真实路径发给服务器,磁盘上没有这个文件或目录。
解决:按 4.3 的做法在 dist 下加WEB-INF/web.xml配 404 回退;或者干脆把路由模式改成 hash,从根上避开这个问题。两条路选一条,别混着来。
5.3 启动日志满屏红,报的类名完全没见过
现象:Tomcat 启动时抛ClassNotFoundException或NoSuchMethodError,报的类是 Spring 内部的,跟业务代码无关。
原因:两个方向。一是spring-boot-starter-tomcat没标provided,war 里同时存在内嵌容器和外置容器的类,版本冲突;二是 SpringBoot 大版本和外置 Tomcat 大版本不匹配,Boot 3 用jakarta.*,Tomcat 9 只认javax.*。
解决:先看 pom 里provided加没加,再看两边版本对不对得上。对照表很简单——Boot 2.x 配 Tomcat 9,Boot 3.x 配 Tomcat 10 及以上,没有中间地带。
5.4 接口路径里出现了两层前缀
现象:Network 里看到的请求是/prod-api/prod-api/user/list,返回 404。
原因:application.yml里的server.servlet.context-path没注释掉,war 名贡献了一层,配置文件又贡献了一层。
解决:注释掉 context-path,让 war 名成为唯一前缀来源,重新打包。顺手检查一下 Swagger 配置、拦截器路径、静态资源映射里有没有硬编码这个前缀的地方,一起改掉。
5.5 调第三方服务时浏览器报跨域
现象:后端用 Postman 直连第三方接口一切正常,页面里调就报 CORS 错误,控制台提示缺少Access-Control-Allow-Origin。
原因:跨域是浏览器施加的限制,Postman 这类工具不做同源检查,所以服务端测不出问题。第三方服务(比如地图、短信、OCR 这类开放平台)通常只对白名单里的域名放行,本地或内网地址不在名单上就会被拦。
解决:按顺序试三招。先去第三方控制台把当前访问域名加进白名单,这是最正规的做法;如果对方只支持固定域名、内网地址加不进去,就在后端加一层转发,前端调自己的/prod-api/proxy/xxx,后端用RestTemplate或WebClient去请求第三方再返回,浏览器看到的始终是同源请求;本地开发阶段临时用devServer.proxy代理过去,但这条只解决开发环境,上线还是得靠前两招。
6. 二次开发:把手工搬运变成可重复的交付流程
上面这套流程跑通一次之后,最该做的不是庆祝,是把它脚本化。手工拷 war、手工删目录、手工重启,做一次两次没问题,做到第五次一定会在某个环节手滑——要么忘了删旧目录,要么 war 拷到一半 Tomcat 还在跑,文件被占用。我的做法是写一个 bat,把「停服务、清旧包、拷新包、起服务」四步固定下来。
@echo off setlocal set WAR=target\prod-api.war set CATALINA_HOME=D:\apps\tomcat-9.0 set SERVICE=Tomcat9 if not exist "%WAR%" ( echo [ERROR] 没找到 war 包,先执行 mvn clean package exit /b 1 ) :: 停服务,避免文件占用 net stop %SERVICE% :: 清掉旧 war 和解压目录,防止残留 class 干扰 del /q "%CATALINA_HOME%\webapps\prod-api.war" rmdir /s /q "%CATALINA_HOME%\webapps\prod-api" :: 拷新包,Tomcat 启动时自动解压 copy "%WAR%" "%CATALINA_HOME%\webapps\prod-api.war" net start %SERVICE% echo [OK] 部署完成,访问 http://127.0.0.1:8080/这个脚本里,SERVICE变量要求 Tomcat 是以 Windows 服务方式注册的,用startup.bat手动起的那种用不了net stop,得改成shutdown.bat加超时等待。服务方式的好处是开机自启、日志统一,内网环境基本都这么装,值得花十分钟配一次。
往前再走一步,是把 server.xml 里那段 Context 挪出来。直接改conf/server.xml的风险在于,这个文件承载着整个容器的配置,改错一个标签整个 Tomcat 都起不来;而且某些版本的 Tomcat 在重启时会对 server.xml 做规范化处理,手工加的 Context 有被覆盖的可能。更稳的做法是在conf/Catalina/localhost/下建一个ROOT.xml,内容就是那个 Context 节点,文件名对应 path(/对应ROOT)。这样配置集中、互不干扰,出问题删掉这个文件就能回退。
再往后就是部署粒度的取舍。如果内网机器上要同时跑好几个项目,path="/"只能给一个,其余项目就得挂子路径,比如/web-a、/web-b,对应的publicPath和 war 名都要跟着调整。这时候建议把「项目名、war 名、Context path、publicPath」这四个值做成一张对照表贴在部署文档里,改一个牵动四个,靠脑子记迟早出错。
最后提一个反向的思路。这套方案的本质是用 Tomcat 一个容器兜住前后端,适合环境受限的场景;但如果哪天运维松口了、端口可以自由申请,其实把后端改回 jar 内嵌 Tomcat、再用 winsw 之类的工具注册成 Windows 服务,前端继续由 Tomcat 托管静态资源,反而是更轻的组合——少一层 war 打包改造,启动类不用继承SpringBootServletInitializer,日志也更好找。改造方向不是只有「往 Tomcat 里塞」一条,看清约束条件再选路。
从那以后我每次交付这类部署,都会先在一台干净的虚拟机上从零走一遍完整流程——不是在自己那台配了一堆环境变量的开发机上验证,是真正的干净系统。因为踩过的坑里,至少有一半是「我机器上好好的」造成的。希望帮到你。
本文还有配套的精品资源,点击获取