Maven把Spring Boot和React打包到同一应用
背景 创建Spring Boot项目 创建React项目模块 前后端联调 打包React进Spring Boot Maven打包前自动编译React 测试war文件 修复React Router刷新页面后的404错误
背景 一般在中大型企业中不会使用Spring Boot内置的Tomcat或其他服务器,而是打包成war文件部署到企业级Web容器中(JBoss、WebLogic、WebSphere、Liberty……),因为内置的Tomcat容器性能弱或者不符合企业的规范。打包的时候我们可以把npm build生成的前端文件放到Spring Boot的static文件夹,最后打包成一个war文件。
这篇文章有以下目标:
在Intellij IDEA中创建Spring Boot和React项目
前后端联调,配置Proxy解决跨域问题
把Spring Boot和React打包到war文件
修复刷新页面后的404错误
创建Spring Boot项目
创建后我们发现与内置Tomcat的应用有以下2处不同:
在pom.xml中,输出文件格式是war而不是jar,新增了tomcat的依赖,scope是provided,作用是在编译阶段引用这个依赖,但并不把这个依赖打包进war文件,因为将要部署war文件的外部Web容器会提供这个依赖。使用tomcat依赖并不意味着生成的war文件只能部署在Tomcat中,我们在开发阶段只是使用这个依赖包含的Servlet API。1 2 3 4 5 6 7 8 9 <packaging > war</packaging > ... <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-tomcat</artifactId > <scope > provided</scope > </dependency >
新增了一个ServletInitializer.java文件,ServletInitializer是Web容器从war启动Spring Boot的入口,PokemonApplication是jar启动Spring Boot进而启动内置Tomcat的入口,在war文件中并不从PokemonApplication启动Spring Boot,所以删掉也无妨。1 2 3 4 5 6 7 8 9 10 11 12 13 package org.chris.pokemon;import org.springframework.boot.builder.SpringApplicationBuilder;import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;public class ServletInitializer extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure (SpringApplicationBuilder application) { return application.sources(PokemonApplication.class); } }
通常IntelliJ IDEA 已经创建好了artifact,类型是web exploded,没有就手动创建一个。
在开发阶段选用轻量级的Tomcat,启动速度快。在Edit Configurations…创建Tomcat Server选Local,把artifact添加进Deployment选项卡。
注意:要把下面Application Context中的/pokemon_war_exploded改为/,否则你的根路径是http://localhost:8080/pokemon_war_exploded ,而不是http://localhost:8080/ 。
如果有多个应用在同一个Tomcat中可以用不同的context区分,但我们只有一个就使用/。
在Server面板On ‘Update’ Action和On frame deactivation中都选则Update classes and resources,用于修改代码后自动更新,不用重启Tomcat也能看到变化。注意:热更新代码只在修改方法内部逻辑时成功,如果修改了class结构(比如增删改了类、字段和方法,修改方法参数数量和类型等),则热更新失败,必须重启Tomcat。 在After lunch中可以设置Tomcat启动成功后自动打开你喜欢的浏览器。
启动。如果看到Spring在控制台输出信息就启动成功了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 . ____ _ __ _ _ /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ \\/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____| .__|_| |_|_| |_\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot :: (v3.3.4) 2024-10-13T10:38:33.587+08:00 INFO 9147 --- [pokemon] [on(2)-127.0.0.1] org.chris.pokemon.ServletInitializer : Starting ServletInitializer v0.0.1-SNAPSHOT using Java 23 with PID 9147 (/Users/chris/IdeaProjects/pokemon/target/pokemon-0.0.1-SNAPSHOT/WEB-INF/classes started by chris in /Users/chris/Work/IdeaProjects/libs/apache-tomcat-10.0.12/bin) 2024-10-13T10:38:33.590+08:00 INFO 9147 --- [pokemon] [on(2)-127.0.0.1] org.chris.pokemon.ServletInitializer : No active profile set, falling back to 1 default profile: "default" 2024-10-13T10:38:34.504+08:00 INFO 9147 --- [pokemon] [on(2)-127.0.0.1] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 856 ms 2024-10-13T10:38:35.191+08:00 INFO 9147 --- [pokemon] [on(2)-127.0.0.1] org.chris.pokemon.ServletInitializer : Started ServletInitializer in 2.063 seconds (process running for 4.649) [2024-10-13 10:38:35,232] Artifact pokemon:war exploded: Artifact is deployed successfully [2024-10-13 10:38:35,233] Artifact pokemon:war exploded: Deploy took 3,575 milliseconds 2024-10-13T10:38:35.751+08:00 INFO 9147 --- [pokemon] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet' 2024-10-13T10:38:35.752+08:00 INFO 9147 --- [pokemon] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms
如果只看到如下信息,说明Tomcat部署artifact成功,但Spring Boot没有启动
1 2 [2024-10-13 10:40:45,583] Artifact pokemon:war exploded: Artifact is deployed successfully [2024-10-13 10:40:45,583] Artifact pokemon:war exploded: Deploy took 1,221 milliseconds
在Tomcat Catalina Log选项卡你会看到下面的提示
1 13-Oct-2024 10:45:44.580 WARNING [RMI TCP Connection(2)-127.0.0.1] org.apache.tomcat.util.descriptor.web.WebXml.setVersion Unknown version string [5.0]. Default version will be used.
这个错误发生的原因是低版本的tomcat不支持高版本的Spring Boot。我使用Tomcat 8.5.76部署Spring Boot 3.3.4就遇到这个问题,把Tomcat改为10.0.12就可以启动Spring Boot了。
创建React项目模块 在Project Structure的Module点击New Module,选择React项目,名字为web。
点击OK后等待一会儿 (通常非常慢),可以去喝杯咖啡。
React项目创建后会建立web文件夹,在Edit Configurations…添加npm,Command选择start命令,点击OK后启动。 幸运的话会在控制台看到如下信息,自动调用浏览器打开http://localhost:3000
1 2 3 4 5 6 7 8 9 10 11 Compiled successfully! You can now view web in the browser. Local: http://localhost:3000 On Your Network: http://192.168.31.194:3000 Note that the development build is not optimized. To create a production build, use npm run build. webpack compiled successfully
前后端联调 创建domain包,在下面创建Pokemon的class
1 2 3 4 5 6 7 8 9 10 11 12 package org.chris.pokemon.domain;import lombok.Builder;import lombok.Data;@Data @AllArgsConstructor public class Pokemon { private String name; private String type; }
创建controller包,在下面创建PokemonController的class
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 package org.chris.pokemon.controller;import org.chris.pokemon.domain.Pokemon;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.util.ArrayList;import java.util.List;@RestController @RequestMapping("/api/pokemon") public class PokemonController { @GetMapping("/list") public ResponseEntity<?> list() { List<Pokemon> pokemonList = new ArrayList <>(); pokemonList.add(new Pokemon ("Charmander" , "Fire" )); pokemonList.add(new Pokemon ("Squirtle" , "Water" )); return ResponseEntity.ok(pokemonList); } }
启动后打开http://localhost:8080/api/pokemon/list 得到下面响应
1 2 3 4 5 6 7 8 9 10 [ { "name" : "Charmander" , "type" : "Fire" } , { "name" : "Squirtle" , "type" : "Water" } ]
把web/src/App.js改成以下代码,尝试获取后端数据并展示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import './App.css' ;import {useEffect, useState} from "react" ;function App ( ) { const [pokemonList, setPokemonList] = useState ([]); useEffect (() => { fetch ('http://localhost:8080/api/pokemon/list' ) .then (response => response.json ()) .then (data => setPokemonList (data)); }, []); return ( <div > <div > Pokemon List:</div > <ol > {pokemonList.map(pokemon => <li key ={pokemon.name} > {pokemon.name} ({pokemon.type})</li > )} </ol > </div > ); } export default App ;
刷新React页面,不幸地失败了。打开浏览器的开发者工具,发现Console有报错信息。
打开Network看到获取数据时有CORS错误:
这是怎么回事?原来浏览器的安全策略不允许跨域访问,http://localhost:3000 和 http://localhost:8080 属于不同的域,跨域访问有很多解决方案,这个项目最简单的方式就是把请求转发给http://localhost:8080 , 这样我们只需要访问http://localhost:3000 就好了。
在web/src/创建setupProxy.js文件,代码的作用是把所有/api开头的请求转发到http://localhost:8080
1 2 3 4 5 6 7 8 9 10 const {createProxyMiddleware} = require ('http-proxy-middleware' )module .exports = function (app ) { app.use ( '/api' , createProxyMiddleware ({ target : 'http://localhost:8080' , changeOrigin : true }) ); }
再把App.js中发往http://localhost:8080/api/pokemon/list 的请求改为发往 /api/pokemon/list 即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import './App.css' ;import {useEffect, useState} from "react" ;function App ( ) { const [pokemonList, setPokemonList] = useState ([]); useEffect (() => { fetch ('/api/pokemon/list' ) .then (response => response.json ()) .then (data => setPokemonList (data)); }, []); return ( <div > <div > Pokemon List:</div > <ol > {pokemonList.map(pokemon => <li key ={pokemon.name} > {pokemon.name} ({pokemon.type})</li > )} </ol > </div > ); } export default App ;
重启npm然后刷新页面,我们成功获取到了数据。在Network面板中我们发现请求数据的接口地址是http://localhost:3000/api/pokemon/list , Proxy帮我们把请求转发给了http://localhost:8080/api/pokemon/list
打包React进Spring Boot 在Edit Configurations…添加npm run build配置。Command选run,scripts选build。
添加后运行build,npm会把React编译后的文件放在web/build文件夹。把build下的全部文件(不包括build文件夹)放在Http Server上就可以运行React应用了。但是我们并不想在生产环境部署2台Web服务器,后端Web容器也有响应静态资源的能力,所以可以把React文件放在Spring Boot中一起管理。
复制web/build/下全部文件到src/main/resources/static/。重启Tomcat打开http://localhost:8080 发现React运行正常,并且也成功获取到了数据。
点击Maven面板中Lifecycle的package可以把Spring Boot打包成war文件,在target下面可以找到pokemon-0.0.1-SNAPSHOT.war,把这个war文件放到生产环境中的Web容器就可以运行了。
打包需要以下3个步骤:
运行npm run build把React编译到web/build/
复制web/build/下全部文件到src/main/resources/static/
运行Maven的package命令生成war文件
为什么不把这3个步骤自动化,让Maven在package的时候自动执行前面2步呢?
Maven打包前自动编译React 为了让Maven运行npm run build命令以及复制编译好的前端文件,我们需要增加2个Maven插件,exec-maven-plugin用于运行命令,maven-resources-plugin用于复制文件。在pom.xml中的project → build → plugins 节点下增加下面2个plugin标签。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 <plugin > <groupId > org.codehaus.mojo</groupId > <artifactId > exec-maven-plugin</artifactId > <version > 3.0.0</version > <executions > <execution > <id > exec-npm-run-build</id > <phase > generate-resources</phase > <goals > <goal > exec</goal > </goals > <configuration > <executable > npm</executable > <arguments > <argument > run</argument > <argument > build</argument > </arguments > <workingDirectory > ${project.basedir}/web</workingDirectory > </configuration > </execution > </executions > </plugin > <plugin > <artifactId > maven-resources-plugin</artifactId > <version > 3.2.0</version > <executions > <execution > <id > copy-resources</id > <phase > process-resources</phase > <goals > <goal > copy-resources</goal > </goals > <configuration > <outputDirectory > ${project.build.directory}/classes/static</outputDirectory > <resources > <resource > <directory > ${project.basedir}/web/build/</directory > <includes > <include > **/*</include > </includes > </resource > </resources > </configuration > </execution > </executions > </plugin >
清空上一步复制到src/main/resources/static/的前端文件,因为插件帮我们把前端文件复制到target/classes/static/下并打包,并不需要先复制到src/main/resources/static/。
为了防止残留文件导致异常,通常会先点击Maven面板中Lifecycle下的clean清理target文件夹,然后点package,稍等片刻我们可以在target中找到war文件。把war文件复制一份,改后缀名为.zip,用压缩软件打开,我们看到React文件已经复制到war文件里的WEB-INF/classes/static/了。
测试war文件 在Edit Configurations…选中Tomcat,点击上面的复制按钮Copy Configuration,复制一份Tomcat配置,名称后面加Test,在Deployment选项卡中删掉web exploded的artifact,添加External Source…,选中target目录下的war文件,并把Application context改为/。 保存后运行,war文件成功运行。
修复React Router刷新页面后的404错误 这个问题在你本地开启npm和Tomcat时并不会出现,但是当你打包成war放到Web容器中时,用户点击了链接,网址变成了非根路径/后,在浏览器点击刷新后会出现404错误。我们先来重现这个错误。
在Intellij IDEA的Terminal面板中执行下面命令,先切换到web目录,然后安装react-router和react-router-dom。注意:npm命令一定要在有package.json的目录下运行。
1 2 cd webnpm install --save react-router-dom
修改web/src/index.js,增加BrowserRouter标签:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import React from 'react' ;import ReactDOM from 'react-dom/client' ;import './index.css' ;import App from './App' ;import reportWebVitals from './reportWebVitals' ;import { BrowserRouter } from "react-router-dom" ;const root = ReactDOM .createRoot (document .getElementById ('root' ));root.render ( <React.StrictMode > <BrowserRouter > <App /> </BrowserRouter > </React.StrictMode > ); reportWebVitals ();
修改src/App.js,使用路由定义2个路径和2个组件:
根路径/ 对应React组件Home
/about 对应React组件About
实际开发中可以把Home和About放在不同文件,使用export导出,App只放路由。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 import './App.css' ;import {useEffect, useState} from "react" ;import {Routes , Route , Link } from "react-router-dom" ;function Home ( ) { const [pokemonList, setPokemonList] = useState ([]); useEffect (() => { fetch ('/api/pokemon/list' ) .then (response => response.json ()) .then (data => setPokemonList (data)); }, []); return ( <div > <div > <Link to ="/about" > About</Link > </div > <div > Pokemon List:</div > <ol > {pokemonList.map(pokemon => <li key ={pokemon.name} > {pokemon.name} ({pokemon.type})</li > )} </ol > </div > ); } function About ( ) { return <div > Field Guide of Pokemon</div > } function App ( ) { return <Routes > <Route path ="/" element ={ <Home /> }/> <Route path ="about" element ={ <About /> }/> </Routes > } export default App ;
打开http://localhost:3000/ 显示主页还有一个About链接。点击About后打开新的页面,发现网址变成了http://localhost:3000/about 。刷新About页面也是正常显示。
现在我们打包成war,部署到Tomcat Test中启动,在About页面刷新浏览器,发现报了404错误。
这是怎么回事?
React是单页面应用 (Single Page Application),浏览器自始至终都只加载了index.html一个页面,用户看到的内容及变化都是JavaScript修改index.html的DOM完成的。与服务器通信是通过JSON完成,并在浏览器本地渲染新的组件展示。
我们打开http://localhost:8080/ 时,向Tomcat请求了路径/的资源,Tomcat默认给了static/中的index.html,正是React的初始页面,内容是空白页,React加载JavaScript渲染Home组件显示内容。当你点击About链接,React Router移除了Home组件,显示了About组件的内容,并修改了网址,但是没有向Tomcat请求新的页面。
点击浏览器的刷新按钮后,浏览器向服务器请求了路径为/about的资源。但是服务器并没有在Controller定义/about,在static/也没有名为about的静态资源。于是返回了404错误。
在Spring MVC中,响应资源有优先级,首先寻找Controller的动态资源,其次寻找static/下的静态资源,如果没有找到静态资源则返回404 错误。
那我们可以在没有找到静态资源后默认返回index.html,React Router会根据浏览器网址渲染对应的页面。
为了实现这个逻辑,需要在Spring MVC中增加ResourceHandlers。
在application.properties中添加一行,指定静态资源位置
1 spring.web.resources.static-locations =classpath:/static/
创建configuration包,再创建WebMvcConfiguration类,改为下面的代码,任何没有找到资源的路径都会返回index.html。后端再也不会返回404错误,未找到资源的路径可以交由前端React Router处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 package org.chris.pokemon.configuration;import org.springframework.beans.factory.annotation.Value;import org.springframework.context.annotation.Configuration;import org.springframework.core.io.Resource;import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;import org.springframework.web.servlet.resource.PathResourceResolver;import java.io.IOException;@Configuration public class WebMvcConfiguration implements WebMvcConfigurer { @Value("${spring.web.resources.static-locations}") private String staticLocations; @Override public void addResourceHandlers (ResourceHandlerRegistry registry) { registry.addResourceHandler("/**" ) .addResourceLocations(staticLocations) .resourceChain(true ) .addResolver(new PathResourceResolver () { @Override protected Resource getResource (String resourcePath, Resource location) throws IOException { Resource resource = super .getResource(resourcePath, location); return resource == null ? super .getResource("index.html" , location) : resource; } }); } }
重新打包和部署,刷新About页面后没有报404错误,About页面显示正常。
本文完