Maven把Spring Boot和React打包到同一应用

  1. 背景
  2. 创建Spring Boot项目
  3. 创建React项目模块
  4. 前后端联调
  5. 打包React进Spring Boot
  6. Maven打包前自动编译React
  7. 测试war文件
  8. 修复React Router刷新页面后的404错误

背景

一般在中大型企业中不会使用Spring Boot内置的Tomcat或其他服务器,而是打包成war文件部署到企业级Web容器中(JBoss、WebLogic、WebSphere、Liberty……),因为内置的Tomcat容器性能弱或者不符合企业的规范。打包的时候我们可以把npm build生成的前端文件放到Spring Boot的static文件夹,最后打包成一个war文件。

这篇文章有以下目标:

  1. 在Intellij IDEA中创建Spring Boot和React项目
  2. 前后端联调,配置Proxy解决跨域问题
  3. 把Spring Boot和React打包到war文件
  4. 修复刷新页面后的404错误

创建Spring Boot项目

创建后我们发现与内置Tomcat的应用有以下2处不同:

  1. 在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>
  2. 新增了一个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:3000http://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个步骤:

  1. 运行npm run build把React编译到web/build/
  2. 复制web/build/下全部文件到src/main/resources/static/
  3. 运行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 to run npm commands -->
<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 to copy built files to static folder in WAR -->
<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>
<!-- WAR projects put static resources in webapp directory -->
<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 web
npm 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>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

修改src/App.js,使用路由定义2个路径和2个组件:

  1. 根路径/ 对应React组件Home
  2. /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页面显示正常。

本文完