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
| 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) { 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 {
@ExceptionHandler(BusinessException.class) public ResponseEntity<Result<Void>> handleBusinessException(BusinessException ex) { log.error("业务异常: {}", ex.getMessage(), ex); 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
| import org.springframework.web.bind.MethodArgumentNotValidException; import java.util.stream.Collectors;
@Slf4j @RestControllerAdvice(basePackages = "com.example.springbootdemo.controller") public class GlobalExceptionHandler {
@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
|
@Slf4j @RestControllerAdvice(basePackages = "com.example.springbootdemo.controller") public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class) public ResponseEntity<Result<Void>> handleUnknownException(Exception ex) { log.error("系统未知异常: {}", ex.getMessage(), ex); 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 body
中 user_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 1 | URL 2 | 是否同源 | 原因 |
---|
http://example.com/page.html | http://example.com/api/data | 是 | 协议、域名、端口(默认80)都相同 |
http://example.com | https://example.com | 否 | 协议不同 (http vs https) |
http://www.example.com | http://api.example.com | 否 | 域名不同 (www vs api) |
http://example.com | http://example.com:8080 | 否 | 端口不同 (默认80 vs 8080) |
在我们的场景中,前端应用运行在 http://localhost:5173
,而后端 API 运行在 http://localhost:8080
,因为端口不同,所以它们是不同源的。因此,浏览器出于安全考虑,默认禁止了这次请求。
2. CORS (跨域资源共享)
CORS 是一种 W3C 标准,它允许服务器在响应头中添加一些特殊的 Access-Control-*
字段,从而“告诉”浏览器,我允许来自指定不同源的请求访问我的资源。
当浏览器发起一个跨域的“非简单请求”(例如,PUT
, DELETE
,或者带有自定义请求头的 POST
)时,它会自动先发送一个 OPTIONS
方法的预检请求 到服务器。服务器需要在响应中明确告知浏览器,它允许哪些源、哪些 HTTP 方法、哪些请求头进行跨域访问。浏览器验证通过后,才会发送真正的业务请求。
虽然我们可以在每个 @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) .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
| pnpm create vite cors-test-app --template vue
cd cors-test-app
pnpm install
|
2. 安装核心依赖
我们需要为项目添加 Tailwind CSS
, DaisyUI
用于美化界面,以及 axios
用于发起 HTTP 请求。
1 2 3 4 5 6 7 8
| pnpm add -D tailwindcss @tailwindcss/vite
pnpm add -D daisyui
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. 运行与验证
- 确保您的 Spring Boot 后端应用正在运行。
- 在前端项目
cors-test-app
的根目录下,执行启动命令: - Vite 通常会启动一个运行在
5173
端口的开发服务器。请在浏览器中打开它提供的地址(如 http://localhost:5173
)。 - 点击页面上的 “获取用户列表” 按钮。
验证结果:
- 成功: 如果您能看到用户列表被成功加载并显示在表格中,那么恭喜您,后端的全局跨域配置已完全生效!
- 失败: 如果您看到红色的错误提示,并且在浏览器控制台(按 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
|
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) { }
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) {
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("文件名不能为空"); } String originalFilename = file.getOriginalFilename(); String extName = FileUtil.extName(originalFilename); String uniqueId = IdUtil.fastSimpleUUID(); String newFileName = uniqueId + "." + extName; File uploadPath = new File(uploadDir); if (!uploadPath.exists()) { uploadPath.mkdir(); } File destFile = new File(uploadPath, newFileName); 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>
|
运行验证
- 重启 后端 Spring Boot 应用和前端 Vite 应用。
- 验证:
- 页面现在显示文件上传和文件列表两个模块。
- 文件列表会自动加载
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
等,在我们第四章的文件下载功能中,它就扮演了重要角色。
非常清晰。最后一个问题:当一个请求进来,比如请求头 Accept
是 application/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. 客户端的“诉求”:Accept
与 Content-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 的内部决策流程。

场景: 客户端发起 GET /users/1
请求,其请求头中包含 Accept: application/json
。
- 请求路由:
DispatcherServlet
接收到请求,并通过 HandlerMapping
找到 UserController
的 getUserById
方法。 - 方法执行:
getUserById
方法被调用,执行完毕后,返回了一个 UserVO
对象。 - 触发内容协商: 因为
UserController
被 @RestController
注解(包含了 @ResponseBody
),Spring MVC 知道需要将这个 UserVO
对象写入 HTTP 响应体。此时,内容协商机制正式启动。 - 遍历转换器: Spring MVC 获取到内部注册的所有
HttpMessageConverter
实例列表(这个列表是有优先级的,通常 MappingJackson2HttpMessageConverter
优先级较高)。 - “能力”检测 (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
。
- 锁定并执行: Spring MVC 找到了第一个“中标”的转换器——
MappingJackson2HttpMessageConverter
,于是停止遍历。它立即调用该转换器的 write()
方法,将 UserVO
对象和 MediaType
传入。write()
方法内部再调用 Jackson 库,将 UserVO
对象序列化为 JSON 字符串,并写入 HTTP 响应的输出流。 - 响应完成: 最终,客户端收到了一个
Content-Type
为 application/json
、响应体为用户 JSON 数据的 HTTP 响应。
对于 @RequestBody 的处理流程也是完全类似的,只不过 Spring MVC 依据的是请求头中的 Content-Type,并调用转换器的 canRead() 和 read() 方法。