12-[高级特性] 全局处理与特殊场景

4. [高级特性] 全局处理与特殊场景

摘要: 一个健壮的 API 不仅要能正确处理成功的情况,更要能优雅地应对各种异常。在本章,我们将为项目引入全局异常处理机制,解决之前章节中遗留的“错误响应不统一”的问题。同时,我们还会处理前后端分离架构中常见的跨域(CORS)问题,并为项目增加文件上传下载这一实用的高级功能。

4.1. 全局异常处理:@RestControllerAdvice

4.1.1. 痛点回顾与核心技术

在之前的章节中,我们的 API 在遇到错误时,会暴露两个典型的问题:

  • 业务异常返回 500:当我们在 Service 层检测到“用户名已存在”并抛出 IllegalArgumentException 时,前端收到的是一个笼统的 500 Internal Server Error,这既不准确,也没有清晰地告诉前端失败的原因。
  • 参数校验返回默认格式:当 @Validated 校验失败时,前端收到的虽然是 400 Bad Request,但其 JSON 结构是 Spring Boot 默认的,与我们精心设计的 Result<T> 格式完全不符。

这两个问题都指向了同一个需求:我们需要一个全局的、统一的机制来捕获所有 Controller 抛出的异常,并按照我们自己的 Result<T> 格式,将它们转换为对前端友好的、标准化的响应。

Spring MVC 为此提供了一套极其优雅的组合拳:@RestControllerAdvice@ExceptionHandler

  • @RestControllerAdvice: 将一个类声明为全局控制器增强器,它会“监听”所有 @RestController 中抛出的异常。
  • @ExceptionHandler: 在 @RestControllerAdvice 类中的方法上使用,声明该方法是用于处理特定类型的异常。

4.1.2. 实战:创建全局异常处理器

我们将创建一个 GlobalExceptionHandler,并在其中一步步地添加针对不同异常的处理逻辑。

1. 捕获自定义业务异常

首先,我们来处理像“用户名已存在”这类由我们的业务逻辑主动抛出的异常。

第一步:创建自定义业务异常
一个良好的实践是定义一个自己的 BusinessException,用于封装所有业务层面的错误。

文件路径: src/main/java/com/example/springbootdemo/exception/BusinessException.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.springbootdemo.exception;

import com.example.springbootdemo.common.ResultCode;
import lombok.Getter;

@Getter
public class BusinessException extends RuntimeException {

private final ResultCode resultCode;

public BusinessException(ResultCode resultCode) {
super(resultCode.getMessage());
this.resultCode = resultCode;
}
}

第二步:重构 Service 层以抛出新异常
修改 UserServiceImpl,当用户名已存在时,抛出我们自定义的 BusinessException

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java (修改)

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
// ... imports ...
import com.example.springbootdemo.common.ResultCode;
import com.example.springbootdemo.exception.BusinessException;
import com.example.springbootdemo.entity.User;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;


@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

@Override
public Long saveUser(UserEditDTO dto) {
// 业务校验:检查用户名是否已存在
User existingUser = userMapper.selectOne(new QueryWrapper<User>().lambda().eq(User::getUsername, dto.getUsername()));
if (existingUser != null) {
// 如果用户已存在,抛出自定义业务异常
throw new BusinessException(ResultCode.UserAlreadyExists);
}
// ... 后续代码不变 ...
}


@Override
public void updateUser(UserEditDTO dto) {
// 1. 业务校验:断言要修改的用户必须存在
User user = userMapper.selectById(dto.getId());
if (user == null) {
throw new BusinessException(ResultCode.UserNotFound);
}

}


// ... 其他方法 ...
}

第三步:创建全局异常处理器并添加业务异常处理逻辑

文件路径: src/main/java/com/example/springbootdemo/advice/GlobalExceptionHandler.java (新增文件)

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
package com.example.springbootdemo.advice;

import com.example.springbootdemo.common.Result;
import com.example.springbootdemo.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice(basePackages = "com.example.springbootdemo.controller")
public class GlobalExceptionHandler {

/**
* 专门处理我们自定义的 BusinessException
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Result<Void>> handleBusinessException(BusinessException ex) {
log.error("业务异常: {}", ex.getMessage(), ex);
// 对于业务异常,我们通常认为这是客户端的“错误”请求,所以返回 400 Bad Request
// 响应体中包含我们预定义的业务错误码和消息
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(Result.error(ex.getResultCode()));
}
}

2. 美化参数校验异常

接下来,我们在同一个处理器中,增加对 @Validated 校验失败异常的处理。

文件路径: src/main/java/com/example/springbootdemo/advice/GlobalExceptionHandler.java (修改)

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
// ... imports ...
import org.springframework.web.bind.MethodArgumentNotValidException;
import java.util.stream.Collectors;


@Slf4j
@RestControllerAdvice(basePackages = "com.example.springbootdemo.controller")
public class GlobalExceptionHandler {

// ... 已有的 handleBusinessException 方法 ...

/**
* 专门处理由 @Validated 注解触发的参数校验异常
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Result<Void>> handleValidationException(MethodArgumentNotValidException ex) {
// 从异常中获取所有校验失败的信息,并拼接成一个字符串
String message = ex.getBindingResult().getFieldErrors().stream()
.map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
.collect(Collectors.joining("; "));

log.warn("参数校验失败: {}", message);

return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(Result.error(message));
}
}

3. 捕获未知系统异常

最后,我们需要一个“兜底”方案,来处理所有未预料到的服务器内部错误。

文件路径: src/main/java/com/example/springbootdemo/advice/GlobalExceptionHandler.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ... imports ...

@Slf4j
@RestControllerAdvice(basePackages = "com.example.springbootdemo.controller")
public class GlobalExceptionHandler {

// ... 已有的两个 ExceptionHandler ...

/**
* 兜底处理所有其他未被捕获的异常
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<Result<Void>> handleUnknownException(Exception ex) {
log.error("系统未知异常: {}", ex.getMessage(), ex);
// 对于未知的系统异常,我们返回 500 Internal Server Error
// 并使用一个通用的、模糊的错误提示,避免泄露服务器内部细节
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Result.error("系统异常,请联系管理员"));
}
}

4.1.3. 回归测试:验证统一错误响应

重启应用,并使用 SpringDoc 重新测试我们之前遇到的所有错误场景。

测试用例 1:新增已存在的用户

  • 操作: 调用 POST /users 接口,Request body 中使用一个已存在的用户名。
  • 验证: 响应码为 400 Bad Request,响应体为:
    1
    2
    3
    4
    5
    {
    "code": 1002,
    "message": "用户已存在",
    "data": null
    }

测试用例 2:新增用户时参数校验失败

  • 操作: 调用 POST /users 接口,Request bodyuser_name 字段为空字符串。
  • 验证: 响应码为 400 Bad Request,响应体为:
    1
    2
    3
    4
    5
    {
    "code": 500,
    "message": "username: 用户名不能为空",
    "data": null
    }

通过创建 GlobalExceptionHandler,我们成功地将所有异常处理逻辑集中到了一个地方。现在,无论我们的 API 遇到业务异常、参数校验异常还是未知的系统异常,都能向前端返回统一、规范、友好的 Result 响应。这极大地提升了 API 的健壮性和专业性。


4.2. 跨域配置:CORS (Cross-Origin Resource Sharing)

随着我们的后端 API 功能日益完善,前端同事已经准备好对接我们的接口了。然而,当他们在自己的开发环境(例如 http://localhost:5173)中尝试调用我们部署在 http://localhost:8080 上的 API 时,浏览器的控制台无情地报出了一个经典错误:

1
2
Access to fetch at 'http://localhost:8080/users' from origin 
'http://localhost:5173' has been blocked by CORS policy...

这就是前后端分离开发中几乎必然会遇到的“拦路虎”——跨域问题

4.2.1. 理论:浏览器的同源策略与 CORS 工作原理

1. 同源策略

这是浏览器的一个核心安全机制。它规定,一个源(origin)的网页脚本,只能访问与其同源的资源,而不能访问不同源的资源。

什么是“源”?
一个源由协议 (protocol)域名 (domain)端口 (port) 三者共同定义。只有当这三者完全相同时,两个 URL 才被认为是同源的。

URL 1URL 2是否同源原因
http://example.com/page.htmlhttp://example.com/api/data协议、域名、端口(默认80)都相同
http://example.comhttps://example.com协议不同 (http vs https)
http://www.example.comhttp://api.example.com域名不同 (www vs api)
http://example.comhttp://example.com:8080端口不同 (默认80 vs 8080)

在我们的场景中,前端应用运行在 http://localhost:5173,而后端 API 运行在 http://localhost:8080,因为端口不同,所以它们是不同源的。因此,浏览器出于安全考虑,默认禁止了这次请求。

2. CORS (跨域资源共享)

CORS 是一种 W3C 标准,它允许服务器在响应头中添加一些特殊的 Access-Control-* 字段,从而“告诉”浏览器,我允许来自指定不同源的请求访问我的资源。

当浏览器发起一个跨域的“非简单请求”(例如,PUT, DELETE,或者带有自定义请求头的 POST)时,它会自动先发送一个 OPTIONS 方法的预检请求 到服务器。服务器需要在响应中明确告知浏览器,它允许哪些源、哪些 HTTP 方法、哪些请求头进行跨域访问。浏览器验证通过后,才会发送真正的业务请求。


4.2.2. 实践:通过 WebMvcConfigurer 全局配置 CORS

虽然我们可以在每个 @RestController 或每个 @RequestMapping 上使用 @CrossOrigin 注解来单独开启跨域,但这会导致配置分散,难以维护。最佳实践是进行全局 CORS 配置

我们将创建一个 WebConfig 配置类,通过实现 WebMvcConfigurer 接口来一站式地解决整个应用的跨域问题。

文件路径: src/main/java/com/example/springbootdemo/config/WebConfig.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.springbootdemo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 对所有请求路径生效
.allowedOrigins("http://localhost:5173") // 允许来自指定源的请求
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 允许的方法
.allowCredentials(true) // 允许发送 Cookie
.maxAge(3600); // 预检请求的有效期,单位为秒
}
}

代码解析:

  • addMapping("/**"): /** 表示此 CORS 配置将应用于我们应用中的所有 API 接口。
  • .allowedOrigins("http://localhost:5173"): 这是最核心的配置,它明确地告诉浏览器,我只允许来自 http://localhost:5173 这个源的跨域请求。在生产环境中,您应该将其替换为您的前端应用的实际域名。
  • .allowedMethods(...): 允许跨域的 HTTP 方法列表。
  • .allowCredentials(true): 是否允许客户端在跨域请求中携带凭证信息(如 Cookies)。
  • .maxAge(3600): 设置预检请求(Preflight Request)的缓存时间,单位为秒。在此时间内,浏览器对相同的跨域请求将不再发送预检请求。

4.2.3. 前端验证:构建 Vue3 应用测试 CORS

为了验证我们后端的 CORS 配置是否成功,我们将快速搭建一个基于 Vite + Vue 3 的前端应用,并使用 axios 库来尝试调用我们部署在 8080 端口的 /users 接口。

1. 环境与项目创建

在开始之前,请确保您的开发环境满足以下要求:

  • Node.js: 16.0 或更高版本
  • 包管理器: 本教程使用 pnpm

如果尚未安装 pnpm,可以通过 npm install -g pnpm 命令进行全局安装。

现在,我们来创建前端项目:

1
2
3
4
5
6
7
8
# 1. 使用 Vite 创建一个新的 Vue 3 项目
pnpm create vite cors-test-app --template vue

# 2. 进入项目目录
cd cors-test-app

# 3. 安装项目依赖
pnpm install

2. 安装核心依赖

我们需要为项目添加 Tailwind CSS, DaisyUI 用于美化界面,以及 axios 用于发起 HTTP 请求。

1
2
3
4
5
6
7
8
# 1. 安装 Tailwind CSS 及其 Vite 插件
pnpm add -D tailwindcss @tailwindcss/vite

# 2. 安装 DaisyUI
pnpm add -D daisyui

# 3. 安装 axios
pnpm add axios

3. 项目配置

第一步:配置 Vite (vite.config.js)
编辑项目根目录下的 vite.config.js 文件,引入 Tailwind CSS 插件。

文件路径: cors-test-app/vite.config.js (修改)

1
2
3
4
5
6
7
8
9
10
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite' // 导入插件

export default defineConfig({
plugins: [
vue(),
tailwindcss(), // 使用插件
],
})

第二步:创建并引入 CSS (style.css & main.js)
src 目录下创建 style.css 文件。

文件路径: cors-test-app/src/style.css (新增文件)

1
2
@import "tailwindcss";
@plugin "daisyui";

然后,在 main.js 中引入这个样式文件。

文件路径: cors-test-app/src/main.js (修改)

1
2
3
4
5
import { createApp } from 'vue'
import App from './App.vue'
import './style.css' // 确保引入了样式文件

createApp(App).mount('#app')

4. 编写接口调用代码

现在,我们来修改 App.vue,添加一个按钮,点击后调用后端的 /users 接口并展示数据。

文件路径: cors-test-app/src/App.vue (修改)

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<script setup>
import { ref } from 'vue'
import axios from 'axios'

const users = ref([])
const loading = ref(false)
const error = ref(null)

const fetchUsers = async () => {
loading.value = true
error.value = null
users.value = []
try {
// 调用我们部署在 8080 端口的后端 API
const response = await axios.get('http://localhost:8080/users?pageNo=1&pageSize=5')

users.value = response.data.data
} catch (err) {
console.error('API 调用失败:', err)
error.value = '获取用户数据失败!请检查浏览器控制台(F12)以确认是否为 CORS 错误。'
} finally {
loading.value = false
}
}
</script>

<template>
<div class="container mx-auto p-8">
<h1 class="text-3xl font-bold mb-6">后端 CORS 配置验证</h1>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="card-actions justify-start">
<button class="btn btn-primary" @click="fetchUsers" :disabled="loading">
<span v-if="loading" class="loading loading-spinner"></span>
{{ loading ? '正在加载...' : '获取用户列表 (GET /users)' }}
</button>
</div>

<div v-if="error" class="alert alert-error mt-4">
<span>{{ error }}</span>
</div>

<div v-if="users.length > 0" class="overflow-x-auto mt-4">
<table class="table w-full table-zebra">
<thead>
<tr>
<th>ID</th>
<th>用户名 (user_name)</th>
<th>状态</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<th>{{ user.id }}</th>
<td>{{ user.user_name }}</td>
<td>{{ user.statusText }}</td>
<td>{{ user.createTime }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>

5. 运行与验证

  1. 确保您的 Spring Boot 后端应用正在运行
  2. 在前端项目 cors-test-app 的根目录下,执行启动命令:
    1
    pnpm dev
  3. Vite 通常会启动一个运行在 5173 端口的开发服务器。请在浏览器中打开它提供的地址(如 http://localhost:5173)。
  4. 点击页面上的 “获取用户列表” 按钮。

验证结果:

  • 成功: 如果您能看到用户列表被成功加载并显示在表格中,那么恭喜您,后端的全局跨域配置已完全生效!
  • 失败: 如果您看到红色的错误提示,并且在浏览器控制台(按 F12 打开)中看到了关于 CORS policy 的错误信息,请回到 4.2.2 节检查您的后端 WebConfig.java 配置是否正确,并确保前端应用的运行端口(5173)与后端配置中 .allowedOrigins() 的值一致。

4.3. 文件处理:上传、下载与静态资源访问

4.3.1. 后端:配置与静态资源映射

1. 添加配置 (YAML 格式)

首先,我们在 application.yml 中启用文件上传并定义存储目录。

文件路径: src/main/resources/application.yml (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
# ... spring.datasource ...

# Servlet Multipart Settings
servlet:
multipart:
enabled: true
max-file-size: 2MB
max-request-size: 10MB

# 自定义属性
app:
upload:
dir: D:/springboot-uploads/ # 文件最终存储物理路径

请将 app.upload.dir 的值修改为您计算机上一个真实存在的、用于存放上传文件的目录路径,并确保路径以 / 结尾。

2. 配置静态资源映射

接下来,我们配置一个 URL 路径(如 /springboot-uploads/**)直接映射到我们的物理存储目录,这是最高效的文件预览方式。

文件路径: src/main/java/com/example/springbootdemo/config/WebConfig.java (修改)

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
package com.example.springbootdemo.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

@Value("${app.upload.dir}")
private String uploadDir;

@Override
public void addCorsMappings(CorsRegistry registry) {
// ... 已有的 CORS 配置 ...
}

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
/**
* 配置静态资源映射
* addResourceHandler("/springboot-uploads/**"): 对外暴露的访问路径
* addResourceLocations("file:" + uploadDir): 文件存放的物理路径
*/
registry.addResourceHandler("/springboot-uploads/**")
.addResourceLocations("file:" + uploadDir);
}
}

4.3.2. 后端:FileController 功能实现

FileController 将负责三个核心功能:处理上传、提供文件列表、处理强制下载请求。

文件路径: src/main/java/com/example/springbootdemo/controller/FileController.java (新增文件)

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
package com.example.springbootdemo.controller;


import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import com.example.springbootdemo.common.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

@Tag(name = "文件管理", description = "提供文件上传下载接口")
@RestController
@RequestMapping("/files")
public class FileController {
@Value("${app.upload.dir}")
private String uploadDir;

@Operation(summary = "文件上传")
@PostMapping("/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
if (file.isEmpty()) {
return Result.error("文件名不能为空");
}
// 1.获取文件的原始文件名
String originalFilename = file.getOriginalFilename();
// 2.获取文件的扩展名 例如:.jpg
String extName = FileUtil.extName(originalFilename);
// 3.生成一个随机ID
String uniqueId = IdUtil.fastSimpleUUID();
// 4.拼接新文件名 id.jpg
String newFileName = uniqueId + "." + extName;
// 5.创建一个文件对象 读取到我们预设置的文件夹中
File uploadPath = new File(uploadDir);
if (!uploadPath.exists()) {
// 如果文件夹不存在,创建文件夹
uploadPath.mkdir();
}
// 6.将我们的文件对象写入到我们预设置的文件夹中
File destFile = new File(uploadPath, newFileName);
// 7.将文件转移到我们预设置的文件夹中
file.transferTo(destFile);
return Result.success(newFileName);
}

@Operation(summary = "获取文件列表")
@GetMapping("/list")
public Result<List<String>> listFiles() {
File uploadDirFile = new File(uploadDir);
File[] files = uploadDirFile.listFiles();
if (files == null) {
return Result.success(Collections.emptyList());
}
List<String> filenames = Arrays.stream(files).map(File::getName).toList();
return Result.success(filenames);
}
@Operation(summary = "文件下载(强制附件)")
@GetMapping("/download/{filename}")
public ResponseEntity<Resource> download(@PathVariable String filename) throws IOException {
// 构建文件路径
File file = new File(uploadDir, filename);

// 检查文件是否存在
if (!file.exists()) {
return ResponseEntity.notFound().build();
}

// 创建文件资源
Resource resource = new FileSystemResource(file);

// 设置响应头
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"");
headers.add(HttpHeaders.CONTENT_TYPE, "application/octet-stream");

return ResponseEntity.ok()
.headers(headers)
.body(resource);
}

}

4.3.3. 前端联动:组件化实现文件管理

现在我们来构建前端界面,将所有功能整合在一起。

1. 文件上传组件 (FileUpload.vue)

文件路径: cors-test-app/src/components/FileUpload.vue (新增文件)

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<script setup>
import { ref } from 'vue'
import axios from 'axios'
const selectedFile = ref(null)
const uploadResult = ref(null)
const isUploading = ref(false)

// 定义组件可以发出的事件
const emit = defineEmits(['upload-success'])


const handleFileChange = (event) => {
selectedFile.value = event.target.files[0]
uploadResult.value = null
}

const handleUpload = async () => {
if (!selectedFile.value) {
alert('请选择要上传的文件')
return
}

isUploading.value = true

// FormData 对象:用于将文件和其他数据打包成一个整体,以便发送给后端
const formData = new FormData()
formData.append('file', selectedFile.value)

try {
const response = await axios.post('http://localhost:8080/files/upload', formData)
uploadResult.value = response.data

if (response.data.code === 200) {
emit('upload-success', response.data.data)
}
} catch (error) {
console.error('上传失败:', error)
} finally {
isUploading.value = false
}
}



</script>


<template>
<div class="card bg-base-100 shadow-xl mt-8">
<div class="card-body">
<h2 class="card-title">文件上传</h2>
<div class="form-control w-full max-w-xs">
<input type="file" @change="handleFileChange" class="file-input file-input-bordered w-full max-w-xs" />
</div>
<div class="card-actions justify-start mt-4">
<button class="btn btn-secondary" @click="handleUpload" :disabled="isUploading">
<span v-if="isUploading" class="loading loading-spinner"></span>
{{ isUploading ? '正在上传...' : '上传文件' }}
</button>
</div>
</div>
</div>
</template>

2. 文件列表组件 (FileList.vue)

文件路径: cors-test-app/src/components/FileList.vue (新增文件)

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'

const fileList = ref([])
const isLoading = ref(false)

// 获取文件列表
const fetchFileList = async () => {
isLoading.value = true

try {
const response = await axios.get('http://localhost:8080/files/list')
if (response.data.code === 200) {
fileList.value = response.data.data || []
console.log(fileList.value)
}
} catch (err) {
console.error('获取文件列表失败:', err)
} finally {
isLoading.value = false
}
}

// 下载文件
const downloadFile = (filename) => {
const downloadUrl = `http://localhost:8080/files/download/${filename}`
const link = document.createElement('a')
link.href = downloadUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}

// 组件挂载时获取文件列表
onMounted(() => {
fetchFileList()
})

// 暴露刷新方法给父组件
defineExpose({
refresh: fetchFileList
})
</script>

<template>
<div class="card bg-base-100 shadow-xl mt-8">
<div class="card-body">
<h2 class="card-title mb-4">文件列表</h2>

<!-- 加载状态 -->
<div v-if="isLoading" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>

<!-- 文件列表 -->
<div v-else-if="fileList.length > 0" class="space-y-2">
<div v-for="filename in fileList" :key="filename"
class="flex items-center justify-between p-3 bg-base-200 rounded-lg">
<div class="font-medium">{{ filename }}</div>
<button class="btn btn-primary btn-sm" @click="downloadFile(filename)">
下载
</button>
</div>
</div>

<!-- 空状态 -->
<div v-else class="text-center py-8 text-base-content/60">
<div class="text-4xl mb-2">📁</div>
<div>暂无文件</div>
</div>
</div>
</div>
</template>

3. 主应用重构 (App.vue)

App.vue 负责组合这两个组件,并实现它们之间的联动。

文件路径: cors-test-app/src/App.vue (修改)

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
<script setup>
import { ref } from 'vue'
import FileUpload from './components/FileUpload.vue'
import FileList from './components/FileList.vue'

const fileListRef = ref(null)

const handleUploadSuccess = (filename) => {
console.log('父组件收到上传成功事件,文件名:', filename)
// 上传成功后刷新文件列表
if (fileListRef.value) {
fileListRef.value.refresh()
}
}
</script>

<template>
<div class="container mx-auto p-8">
<h1 class="text-3xl font-bold mb-6 text-center">文件管理系统</h1>

<!-- 文件上传组件 -->
<FileUpload @upload-success="handleUploadSuccess" />

<!-- 文件列表组件 -->
<FileList ref="fileListRef" />
</div>
</template>

运行验证

  1. 重启 后端 Spring Boot 应用和前端 Vite 应用。
  2. 验证:
    • 页面现在显示文件上传和文件列表两个模块。
    • 文件列表会自动加载 D:/springboot-uploads/ 目录下的所有文件。
    • 点击“预览”可以在新标签页打开图片(由 WebConfig 的静态资源映射处理)。
    • 点击“下载”会直接下载文件(由 FileController/download 接口处理)。
    • 联动测试:上传一个新文件,成功后,文件列表会自动刷新并显示出刚刚上传的文件。

功能闭环:通过后端 Controller 逻辑、WebConfig 静态资源映射和前端组件化的协同工作,我们实现了一套功能完整、体验良好的文件管理功能,并深刻理解了不同场景下的文件处理策略。


5. [幕后探秘] HttpMessageConverter 工作原理

摘要: 在前面的实战中,我们已经看到了 @RequestBody@ResponseBody 的强大威力,它们似乎能“魔法般”地在 JSON 和 Java 对象之间进行自动转换。本章,我们将扮演一次侦探,深入 Spring MVC 的内部,揭开这个“魔法”的秘密——彻底搞懂其背后真正的功臣 HttpMessageConverter (HTTP 消息转换器) 的核心工作原理。


5.1. 核心面试题:揭秘 @RequestBody 与 @ResponseBody

在面试中,HttpMessageConverter 是一个非常高频的考点,因为它直接关系到 Spring MVC 的核心工作原理。本节,我们将通过一场模拟面试,来彻底揭开它神秘的面纱。

面试官深度剖析:HttpMessageConverter
今天 上午 10:00

在 Spring MVC 项目中,我们经常在 Controller 方法的参数上使用 @RequestBody,或者在类上使用 @RestController。当一个 JSON 请求过来,或者一个方法返回一个 Java 对象时,Spring MVC 究竟在背后做了什么,能实现这种自动转换?

面试官您好。这个自动化转换的核心,在于 Spring MVC 的 HttpMessageConverter (HTTP 消息转换器) 机制。它是一个策略接口,专门负责在 HTTP 请求体/响应体和 Java 对象之间进行序列化与反序列化。

很好。那你能具体讲讲这个 HttpMessageConverter 接口吗?它通过什么方法来判断自己是否能处理一个请求,又是通过什么方法来执行转换的?

当然。HttpMessageConverter 接口主要通过两对核心方法来工作:

第一对是 canRead()canWrite(),用于能力检测。Spring MVC 会用它们来询问转换器能否处理特定的 Java 类型(如 UserVO.class)和媒体类型(如 application/json)。

第二对是 read()write(),用于执行转换。一旦能力检测通过,就会调用这两个方法进行真正的读写操作。read() 负责将请求体转换为 Java 对象(@RequestBody 的工作),write() 负责将 Java 对象转换为响应体(@ResponseBody 的工作)。

明白了。那在一个典型的 Spring Boot Web 应用中,都注册了哪些常见的 HttpMessageConverter 实现呢?我们最常用的 JSON 转换是由哪个来完成的?

Spring Boot 会根据类路径上的依赖,自动配置一个转换器“家族”。其中最重要的几个包括:

StringHttpMessageConverter: 负责处理纯 String 类型的转换,比如我们第一章的 HelloController

FormHttpMessageConverter: 负责处理 application/x-www-form-urlencoded 类型的表单数据。

MappingJackson2HttpMessageConverter: 这是我们的绝对主力。只要项目中存在 Jackson 库,它就会被注册,并负责所有 application/json 相关的转换。我们项目中所有的 DTO/VO 与 JSON 之间的转换,都是它的功劳。

此外,还有处理二进制数据的 ByteArrayHttpMessageConverter 等,在我们第四章的文件下载功能中,它就扮演了重要角色。

非常清晰。最后一个问题:当一个请求进来,比如请求头 Acceptapplication/json,而 Controller 方法返回了一个 UserVO 对象,Spring MVC 是如何从这么多转换器中,精确地选择 MappingJackson2HttpMessageConverter 来工作的呢?

这是一个很好的问题,它涉及到了 Spring MVC 的内容协商 (Content Negotiation) 机制。简单来说,Spring MVC 会遍历所有已注册的转换器,结合客户端请求的 Accept 头和 Controller 方法返回的 Java 对象类型,调用每个转换器的 canWrite() 方法进行“招标”。在这个场景下,MappingJackson2HttpMessageConverter 会“中标”,于是框架就最终调用它的 write() 方法来完成工作。关于这个流程的更多细节,我们可以在下一节深入探讨。


5.2 工作流程:内容协商

在上一节的模拟面试中,我们提到了一个关键机制——内容协商。正是这个机制,使得 Spring MVC 能够智能地从众多 HttpMessageConverter 中,挑选出最合适的一个来处理当前的请求。

“协商”一词非常形象,它指的是客户端(浏览器、App等)和服务器端(我们的 Spring MVC 应用)之间,就资源的表现形式(即数据格式,如 JSON、XML、HTML 等)进行“商议”的过程。

1. 客户端的“诉求”:AcceptContent-Type 请求头

客户端通过两个核心的 HTTP 请求头来表达自己的“诉求”:

  • Accept: 用于响应协商。它告诉服务器:“我能理解以下几种格式的数据,请你按照这个列表的优先级,返回我能处理的一种。”
    • 例如: Accept: application/json, application/xml;q=0.9, */*;q=0.8
    • 这表示:我最希望得到 json 格式的数据。如果不行,xml 也勉强可以(权重q=0.9)。如果前两者都没有,那就随便给我一种你有的格式吧(权重q=0.8)。
  • Content-Type: 用于请求协商。它告诉服务器:“我这次发送给你的请求体,是这种格式的数据,请你按照这个格式来解析。”
    • 例如: Content-Type: application/json
    • 这表示:我用 @RequestBody 发送过来的数据是一个 JSON 字符串。

2. 服务器的“决策”:HttpMessageConverter 的选择流程

现在,我们以一个完整的响应过程为例,详细拆解 Spring MVC 的内部决策流程。

mermaid-diagram-2025-08-18-113035

场景: 客户端发起 GET /users/1 请求,其请求头中包含 Accept: application/json

  1. 请求路由: DispatcherServlet 接收到请求,并通过 HandlerMapping 找到 UserControllergetUserById 方法。
  2. 方法执行: getUserById 方法被调用,执行完毕后,返回了一个 UserVO 对象。
  3. 触发内容协商: 因为 UserController@RestController 注解(包含了 @ResponseBody),Spring MVC 知道需要将这个 UserVO 对象写入 HTTP 响应体。此时,内容协商机制正式启动。
  4. 遍历转换器: Spring MVC 获取到内部注册的所有 HttpMessageConverter 实例列表(这个列表是有优先级的,通常 MappingJackson2HttpMessageConverter 优先级较高)。
  5. “能力”检测 (canWrite): Spring MVC 开始从头到尾遍历这个列表,对每一个转换器进行“垂询”:
    • 问询 ByteArrayHttpMessageConverter: 调用其 canWrite(UserVO.class, MediaType.APPLICATION_JSON) 方法。它一看,自己只处理 byte[],处理不了 UserVO,于是返回 false
    • 问询 StringHttpMessageConverter: 调用其 canWrite(UserVO.class, MediaType.APPLICATION_JSON) 方法。它一看,自己只处理 String,处理不了 UserVO,也返回 false
    • 问询 MappingJackson2HttpMessageConverter: 调用其 canWrite(UserVO.class, MediaType.APPLICATION_JSON) 方法。它内部逻辑判断:“首先,我能处理任意的 POJO(UserVO 满足);其次,我支持 application/json 这个媒体类型。太棒了!” 于是,它返回 true
  6. 锁定并执行: Spring MVC 找到了第一个“中标”的转换器——MappingJackson2HttpMessageConverter,于是停止遍历。它立即调用该转换器的 write() 方法,将 UserVO 对象和 MediaType 传入。write() 方法内部再调用 Jackson 库,将 UserVO 对象序列化为 JSON 字符串,并写入 HTTP 响应的输出流。
  7. 响应完成: 最终,客户端收到了一个 Content-Typeapplication/json、响应体为用户 JSON 数据的 HTTP 响应。

对于 @RequestBody 的处理流程也是完全类似的,只不过 Spring MVC 依据的是请求头中的 Content-Type,并调用转换器的 canRead() 和 read() 方法。