文件上传与下载
1. 概述
文件上传与下载是 Web 应用中最常见的需求之一。Spring MVC 从 3.0 开始通过 MultipartResolver 策略接口提供了对文件上传的原生支持;从 3.1 开始引入 StandardServletMultipartResolver 以利用 Servlet 3.0 原生的 Part API;同时 Spring 框架提供了 Resource 体系来简化文件的流式下载。
本文从 MultipartResolver 体系出发,覆盖配置对比、大文件处理、流式下载、断点续传、OSS 直传签名,最后以一个完整的内容管理系统大文件分片上传实战串联所有知识点。
2. MultipartResolver 体系
2.1 接口定义
MultipartResolver 定义在 org.springframework.web.multipart 包下,是 Spring MVC 处理文件上传的核心策略接口:
public interface MultipartResolver {
/**
* 判断当前请求是否为 multipart 请求(Content-Type 以 multipart/ 开头)
*/
boolean isMultipart(HttpServletRequest request);
/**
* 将普通 HttpServletRequest 解析为 MultipartHttpServletRequest,
* 从中可以获取 MultipartFile 对象
*/
MultipartHttpServletRequest resolveMultipart(HttpServletRequest request) throws MultipartException;
/**
* 清理上传过程中创建的临时资源(如临时文件)
*/
void cleanupMultipart(MultipartHttpServletRequest request);
}2.2 MultipartFile 抽象
resolveMultipart() 返回的 MultipartHttpServletRequest 通过 getFile(String name) 和 getFileMap() 方法提供对 MultipartFile 的访问:
public interface MultipartFile {
String getName(); // 表单字段名
String getOriginalFilename(); // 客户端原始文件名
String getContentType(); // 文件 MIME 类型
boolean isEmpty(); // 是否为空
long getSize(); // 文件大小(字节)
byte[] getBytes() throws IOException; // 获取文件字节数组
InputStream getInputStream() throws IOException; // 获取输入流
void transferTo(File dest) throws IOException, IllegalStateException; // 保存到目标文件
}transferTo() 方法在不同解析器下有不同实现——CommonsMultipartFile 使用 Apache Commons FileUpload 的 FileItem.write(),而 StandardMultipartFile 使用 Java NIO 的 Files.copy()。
2.3 解析器定位与初始化
DispatcherServlet 中维护了一个 MultipartResolver 引用:
// DispatcherServlet 初始化阶段
private void initMultipartResolver(ApplicationContext context) {
try {
this.multipartResolver = context.getBean(MULTIPART_RESOLVER_BEAN_NAME, MultipartResolver.class);
} catch (NoSuchBeanDefinitionException ex) {
// 未配置则不启用 multipart 支持
this.multipartResolver = null;
}
}关键点:Bean 名称必须为 multipartResolver,否则 DispatcherServlet 无法自动检测。
在 doDispatch() 请求处理流程中,如果检测到 multipart 请求,会先调用 multipartResolver.resolveMultipart() 包装请求:
// DispatcherServlet.doDispatch() 片段
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
if (this.multipartResolver != null && this.multipartResolver.isMultipart(request)) {
processedRequest = this.multipartResolver.resolveMultipart(request);
// 标记以便请求结束后清理
requestNotMultipart = false;
}处理完成后,在 processDispatchResult() 或 finally 块中调用 cleanupMultipart() 释放临时资源。
3. CommonsMultipartResolver vs StandardServletMultipartResolver
3.1 CommonsMultipartResolver
基于 Apache Commons FileUpload 库实现,适用于 Servlet 3.0 之前的容器或需要精细控制上传行为的场景。
添加依赖:
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.5</version>
</dependency>配置方式:
@Bean(name = "multipartResolver")
public CommonsMultipartResolver multipartResolver() {
CommonsMultipartResolver resolver = new CommonsMultipartResolver();
resolver.setDefaultEncoding("UTF-8");
resolver.setMaxUploadSize(10 * 1024 * 1024L); // 单次请求总大小上限 10MB
resolver.setMaxUploadSizePerFile(2 * 1024 * 1024L); // 单个文件大小上限 2MB
resolver.setMaxInMemorySize(4096); // 缓存阈值 4KB,超出则写入临时文件
resolver.setUploadTempDir(new FileSystemResource("C:/tmp")); // 临时文件目录
return resolver;
}核心组件链路:
CommonsMultipartResolver
└─> DiskFileItemFactory (工厂,控制临时文件策略)
└─> ServletFileUpload (解析器,执行实际解析)
└─> FileItemIterator / FileItemStream (流式迭代)CommonsMultipartResolver 同时实现了 resolveMultipart() 的两种变体——懒解析(返回 DefaultMultipartHttpServletRequest)和预解析(返回 AbstractMultipartHttpServletRequest),默认采用懒解析方式,即只在首次访问 getFile() 时才解析对应 part。
3.2 StandardServletMultipartResolver
基于 Servlet 3.0+ 的 HttpServletRequest.getParts() 和 Part API,无需第三方依赖。
配置方式:
@Bean(name = "multipartResolver")
public StandardServletMultipartResolver multipartResolver() {
return new StandardServletMultipartResolver();
}Servlet 容器级别的配置通过 MultipartConfigElement 完成:
// Java Config 方式
public class AppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected void customizeRegistration(ServletRegistration.Dynamic registration) {
registration.setMultipartConfig(new MultipartConfigElement(
"C:/tmp", // 临时文件目录
10 * 1024 * 1024, // maxFileSize 单个文件最大 10MB
20 * 1024 * 1024, // maxRequestSize 请求总大小最大 20MB
4096 // fileSizeThreshold 缓存阈值 4KB
));
}
}# application.yml 方式(Spring Boot)
spring:
servlet:
multipart:
enabled: true
location: C:/tmp
max-file-size: 10MB
max-request-size: 20MB
file-size-threshold: 4KB
resolve-lazily: false3.3 对比总结
| 维度 | CommonsMultipartResolver | StandardServletMultipartResolver |
|---|---|---|
| 底层实现 | Apache Commons FileUpload | Servlet 3.0 Part API |
| 依赖要求 | commons-fileupload | 无(Servlet 3.0+ 内置) |
| 容器要求 | 无 | Servlet 3.0+(Tomcat 7+, Jetty 9+) |
| Spring Boot 默认 | 不启用 | Spring Boot 1.2+ 默认自动配置 |
| 临时文件控制 | 通过 DiskFileItemFactory | 通过 MultipartConfigElement |
| 性能 | 流式解析,内存占用低 | 同等水平 |
| 懒解析支持 | 原生支持 | 需额外配置 resolve-lazily |
| 精细控制 | 更多配置项(编码、大小等) | 相对较少 |
选择建议: 新项目直接使用 StandardServletMultipartResolver,Spring Boot 项目无需任何额外配置即可使用。如需兼容老旧容器或需要更底层的文件处理控制,再考虑 CommonsMultipartResolver。
4. 大文件配置
4.1 Spring Boot 配置
spring:
servlet:
multipart:
enabled: true
# 单个文件最大值
max-file-size: 500MB
# 单次请求总大小(多文件上传时)
max-request-size: 1GB
# 超过此阈值后写入磁盘临时文件,而非内存
file-size-threshold: 10MB
# 临时文件存放目录
location: /data/uploads/tmp
# 是否延迟解析(访问时才解析,而非请求到达时)
resolve-lazily: false4.2 源码级限制说明
StandardServletMultipartResolver 将大小限制委托给 Servlet 容器的 MultipartConfigElement,各容器的参数名称对应关系如下:
| 配置项 | Tomcat 对应参数 | 含义 |
|---|---|---|
| max-file-size | maxFileSize | 单个 Part 文件最大字节数,-1 表示无限制 |
| max-request-size | maxRequestSize | 整个 multipart/form-data 请求最大字节数,-1 表示无限制 |
| file-size-threshold | fileSizeThreshold | 内存缓存阈值,超过后写入磁盘临时文件 |
注意: 如果 max-request-size 设置过小(比如小于 max-file-size),即使单个文件没超限,多文件场景也可能被提前拒绝。最佳实践:max-request-size ≥ max-file-size * 预期最大文件数 + 表单字段开销。
4.3 全局异常处理
文件上传超出限制时,Spring MVC 抛出 MaxUploadSizeExceededException(Commons)或 MultipartException(Standard),可通过 @ControllerAdvice 统一处理:
@ControllerAdvice
public class FileUploadExceptionAdvice {
@ExceptionHandler(MultipartException.class)
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
@ResponseBody
public Map<String, Object> handleMultipartException(MultipartException ex) {
return Map.of(
"code", 413,
"message", "文件大小超过限制",
"maxFileSize", "500MB",
"maxRequestSize", "1GB"
);
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
@ResponseBody
public Map<String, Object> handleMaxUploadSizeExceeded(MaxUploadSizeExceededException ex) {
return Map.of(
"code", 413,
"message", "上传文件总大小超过限制",
"maxSize", ex.getMaxUploadSize()
);
}
}4.4 Tomcat 容器级别配置
对于 Tomcat 嵌入式(Spring Boot),MultipartConfigElement 的参数会映射到 TomcatServletWebServerFactory 的 maxSwallowSize 等参数。当客户端上传的文件超出限制时,Tomcat 默认会继续读取并丢弃剩余数据(最大 maxSwallowSize,默认 2MB),超出部分可能导致连接重置。生产环境建议调大:
@Bean
public TomcatServletWebServerFactory tomcatFactory() {
TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
factory.addConnectorCustomizers(connector -> {
connector.setProperty("maxSwallowSize", "-1"); // -1 表示不限制
});
return factory;
}5. 流式下载
5.1 传统方式的问题
传统下载通过 HttpServletResponse.getOutputStream() 直接写入字节流:
@GetMapping("/download/{fileId}")
public void download(@PathVariable String fileId, HttpServletResponse response) throws IOException {
File file = new File("/data/files/" + fileId);
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=\"" + file.getName() + "\"");
try (InputStream is = new FileInputStream(file);
OutputStream os = response.getOutputStream()) {
IOUtils.copy(is, os);
os.flush();
}
}这种方式的问题:方法签名中出现 HttpServletResponse,难以单元测试;且返回值类型为 void,不便于统一拦截和处理。
5.2 ResponseEntity + InputStreamResource
Spring MVC 推荐返回 ResponseEntity<Resource>,将响应封装为声明式:
@GetMapping("/download/{fileId}")
public ResponseEntity<Resource> download(@PathVariable String fileId) throws IOException {
// 从文件系统或数据库查找文件元信息
File file = new File("/data/files/" + fileId);
if (!file.exists()) {
return ResponseEntity.notFound().build();
}
// 获取文件的 MediaType
MediaType mediaType = MediaTypeFactory
.getMediaType(file.getName())
.orElse(MediaType.APPLICATION_OCTET_STREAM);
// 创建 InputStreamResource
InputStreamResource resource = new InputStreamResource(new FileInputStream(file));
return ResponseEntity.ok()
.contentType(mediaType)
.contentLength(file.length())
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + URLEncoder.encode(file.getName(), StandardCharsets.UTF_8) + "\"")
.body(resource);
}5.3 InputStreamResource 与 Resource 体系
Spring 的 Resource 接口提供了统一的资源抽象:
| 实现类 | 用途 | 适用场景 |
|---|---|---|
InputStreamResource | 包装 InputStream | 一次读取,不支持重复读取 |
FileSystemResource | 包装 File | 直接引用文件系统路径 |
UrlResource | 包装 URL | 远程资源、类路径资源 |
ByteArrayResource | 包装 byte[] | 内存中的小文件 |
ClassPathResource | 类路径资源 | 读取 classpath 下的静态资源 |
关键区别: InputStreamResource 的 isOpen() 返回 true,这意味着 ResourceHttpMessageConverter 在写入响应时会直接从流中读取且不会重试。而 FileSystemResource 的 isOpen() 返回 false,支持断点续传时更为可靠。
5.4 推荐:FileSystemResource + ResourceRegion
对于大文件下载和断点续传场景,更推荐 FileSystemResource:
@GetMapping("/download/resource/{fileId}")
public ResponseEntity<Resource> downloadResource(@PathVariable String fileId) {
File file = new File("/data/files/" + fileId);
FileSystemResource resource = new FileSystemResource(file);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(file.length())
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + file.getName() + "\"")
.body(resource);
}当 Spring MVC 检测到 Content-Length 存在且 Resource 实现了 ReadableByteChannel 或 InputStream 时,会用零拷贝(FileChannel.transferTo())或高效流方式写入响应,避免将整个文件加载到内存。
6. 断点续传
6.1 HTTP Range 协议基础
断点续传依赖 HTTP 的 Range 头部。客户端在请求中携带 Range 头部指示需要的内容范围,服务端返回 206 Partial Content:
| 请求头 | 含义 | 示例 |
|---|---|---|
Range: bytes=0-1023 | 请求前 1024 字节 | 客户端继续下载中断的部分 |
Range: bytes=1024- | 请求从 1024 字节到文件末尾 | 获取剩余部分 |
Range: bytes=0-100, 200-300 | 请求多个片段 | 多线程下载时 |
对应的响应头:
| 响应头 | 含义 |
|---|---|
Accept-Ranges: bytes | 服务端支持 Range 请求 |
Content-Range: bytes 0-1023/10000 | 当前返回的字节范围 |
Content-Length: 1024 | 当前部分的大小 |
ETag: "abc123" | 资源版本标识,用于验证 |
Last-Modified: ... | 资源最后修改时间 |
6.2 服务端实现
@GetMapping("/download/range/{fileId}")
public ResponseEntity<Resource> downloadWithRange(
@PathVariable String fileId,
@RequestHeader(value = HttpHeaders.RANGE, required = false) String rangeHeader) throws IOException {
File file = new File("/data/files/" + fileId);
if (!file.exists()) {
return ResponseEntity.notFound().build();
}
long fileLength = file.length();
if (rangeHeader == null) {
// 请求 Range 头部 —— 返回完整文件
FileSystemResource resource = new FileSystemResource(file);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(fileLength)
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + file.getName() + "\"")
.body(resource);
}
// 解析 Range 头部
String range = rangeHeader.replace("bytes=", "");
String[] parts = range.split("-");
long start;
long end;
try {
start = Long.parseLong(parts[0]);
if (parts.length > 1 && !parts[1].isEmpty()) {
end = Long.parseLong(parts[1]);
} else {
end = fileLength - 1;
}
} catch (NumberFormatException e) {
return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE).build();
}
// 校验范围有效性
if (start >= fileLength || end >= fileLength || start > end) {
return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
.header(HttpHeaders.CONTENT_RANGE, "bytes */" + fileLength)
.build();
}
// 计算内容长度
long contentLength = end - start + 1;
// 使用 RandomAccessFile 实现部分读取
InputStream inputStream = new FileInputStream(file) {
@Override
public int read(byte[] b, int off, int len) throws IOException {
int totalRead = 0;
while (totalRead < len) {
int read = super.read(b, off + totalRead, len - totalRead);
if (read == -1) break;
totalRead += read;
}
return totalRead == 0 ? -1 : totalRead;
}
};
// 跳过 start 字节
inputStream.skip(start);
InputStreamResource resource = new InputStreamResource(inputStream) {
@Override
public long contentLength() {
return contentLength;
}
};
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(contentLength)
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + fileLength)
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + file.getName() + "\"")
.body(resource);
}6.3 使用 ResourceRegion 简化
Spring 4.3 引入了 ResourceRegion 简化断点续传的实现:
@GetMapping("/download/region/{fileId}")
public ResponseEntity<ResourceRegion> downloadRegion(
@PathVariable String fileId,
@RequestHeader(value = HttpHeaders.RANGE, required = false) String rangeHeader) {
File file = new File("/data/files/" + fileId);
if (!file.exists()) {
return ResponseEntity.notFound().build();
}
FileSystemResource resource = new FileSystemResource(file);
long fileLength = file.length();
if (rangeHeader == null) {
// 无 Range 头部,返回完整文件
ResourceRegion region = new ResourceRegion(resource, 0, fileLength);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.body(region);
}
// 解析 Range 头部
String[] ranges = rangeHeader.replace("bytes=", "").split("-");
long start = Long.parseLong(ranges[0]);
long end = ranges.length > 1 && !ranges[1].isEmpty()
? Long.parseLong(ranges[1])
: fileLength - 1;
if (start >= fileLength || end >= fileLength || start > end) {
return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
.header(HttpHeaders.CONTENT_RANGE, "bytes */" + fileLength)
.build();
}
long rangeLength = end - start + 1;
ResourceRegion region = new ResourceRegion(resource, start, rangeLength);
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
.header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + fileLength)
.body(region);
}ResourceRegion 底层由 ResourceHttpMessageConverter 自动处理流切割,无需手动管理 InputStream。
6.4 客户端示例(JavaScript)
// 断点续传下载器
async function downloadWithResume(url, filePath) {
let downloadedBytes = 0;
// 检查已下载的部分(假设存储在 localStorage 或 IndexedDB 中)
const savedProgress = localStorage.getItem(`download:${url}`);
if (savedProgress) {
downloadedBytes = parseInt(savedProgress, 10);
}
const headers = {};
if (downloadedBytes > 0) {
headers['Range'] = `bytes=${downloadedBytes}-`;
}
const response = await fetch(url, { headers });
if (response.status === 206) {
// 服务器支持断点续传
const contentRange = response.headers.get('Content-Range');
const totalSize = parseInt(contentRange.split('/')[1], 10);
const reader = response.body.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
downloadedBytes += value.length;
localStorage.setItem(`download:${url}`, downloadedBytes.toString());
console.log(`下载进度: ${((downloadedBytes / totalSize) * 100).toFixed(2)}%`);
}
localStorage.removeItem(`download:${url}`);
return new Blob(chunks);
} else if (response.status === 200) {
// 服务器不支持断点续传,整体下载
return response.blob();
}
}7. OSS 直传签名
7.1 传统方式 vs 直传方式
传统上传流程:
客户端 → 应用服务器(中转) → OSS 服务端缺点:应用服务器需要接收完整文件再上传到 OSS,消耗应用服务器带宽、内存和 CPU。
直传流程:
客户端 →(获取预签名 URL)→ 应用服务器
客户端 →(直接上传)→ OSS 服务端优点:文件流不经过应用服务器,减少带宽压力,提升上传速度。
7.2 阿里云 OSS 预签名 URL
阿里云 OSS 的预签名 URL(Presigned URL)授予持有者在指定时间内对指定对象的临时访问权限,可用于上传和下载。
7.3 服务端生成预签名 URL
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.GeneratePresignedUrlRequest;
import java.net.URL;
import java.util.Date;
@Service
public class OssService {
private final OSS ossClient;
public OssService(@Value("${oss.endpoint}") String endpoint,
@Value("${oss.access-key-id}") String accessKeyId,
@Value("${oss.access-key-secret}") String accessKeySecret) {
this.ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
}
/**
* 生成上传预签名 URL
*
* @param bucketName 桶名称
* @param objectKey 对象键(含路径)
* @param expiration 过期时间(秒)
* @return 预签名 URL
*/
public String generateUploadUrl(String bucketName, String objectKey, long expiration) {
Date expirationDate = new Date(System.currentTimeMillis() + expiration * 1000L);
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucketName, objectKey,
com.aliyun.oss.HttpMethod.PUT);
request.setExpiration(expirationDate);
// 设置 Content-Type,防止客户端上传类型不一致
request.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
URL signedUrl = ossClient.generatePresignedUrl(request);
return signedUrl.toString();
}
/**
* 生成下载预签名 URL
*/
public String generateDownloadUrl(String bucketName, String objectKey, long expiration) {
Date expirationDate = new Date(System.currentTimeMillis() + expiration * 1000L);
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucketName, objectKey,
com.aliyun.oss.HttpMethod.GET);
request.setExpiration(expirationDate);
URL signedUrl = ossClient.generatePresignedUrl(request);
return signedUrl.toString();
}
/**
* 获取上传策略和签名(前端表单直传)
*/
public Map<String, String> generateUploadPolicy(String bucketName, String dir, long expiration) {
// 阿里云 OSS PostObject 签名
String accessKeyId = ossClient.getCredentialsProvider().getCredentials().getAccessKeyId();
String accessKeySecret = ossClient.getCredentialsProvider().getCredentials().getSecretAccessKey();
String policy = buildPolicy(bucketName, dir, expiration);
String signature = calculateSignature(policy, accessKeySecret);
return Map.of(
"accessid", accessKeyId,
"policy", Base64.getEncoder().encodeToString(policy.getBytes()),
"signature", signature,
"dir", dir,
"host", "https://" + bucketName + ".oss-cn-hangzhou.aliyuncs.com",
"expire", String.valueOf(expiration)
);
}
private String buildPolicy(String bucketName, String dir, long expiration) {
return String.format("""
{
"expiration": "%s",
"conditions": [
["content-length-range", 0, 1048576000],
["starts-with", "$key", "%s"]
]
}
""",
new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")
.format(new Date(System.currentTimeMillis() + expiration * 1000L)),
dir);
}
private String calculateSignature(String policy, String secret) {
try {
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA1");
mac.init(new javax.crypto.spec.SecretKeySpec(secret.getBytes(), "HmacSHA1"));
return Base64.getEncoder().encodeToString(mac.doFinal(policy.getBytes()));
} catch (Exception e) {
throw new RuntimeException("计算签名失败", e);
}
}
}7.4 前端直传示例(JavaScript)
// 1. 获取预签名 URL 后直接 PUT 上传
async function uploadByPresignedUrl(file) {
// 请求后端获取预签名 URL
const resp = await fetch('/api/oss/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
fileSize: file.size,
expiration: 3600, // 1小时有效期
}),
});
const { url, objectKey } = await resp.json();
// 直接 PUT 到 OSS
const uploadResp = await fetch(url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type || 'application/octet-stream',
},
});
if (uploadResp.ok) {
console.log('上传成功,OSS 对象键:', objectKey);
return objectKey;
}
}
// 2. 使用 PostObject 表单直传(适合大文件、分片上传)
async function uploadByFormData(file) {
// 请求后端获取签名策略
const resp = await fetch('/api/oss/policy', {
method: 'POST',
body: JSON.stringify({ dir: 'uploads/' }),
});
const policy = await resp.json();
const formData = new FormData();
formData.append('key', policy.dir + file.name);
formData.append('policy', policy.policy);
formData.append('OSSAccessKeyId', policy.accessid);
formData.append('signature', policy.signature);
formData.append('file', file);
formData.append('success_action_status', '200');
const uploadResp = await fetch(policy.host, {
method: 'POST',
body: formData,
});
if (uploadResp.ok) {
console.log('上传成功');
}
}8. 实战:内容管理系统大文件分片上传
本节实现一个生产级的内容管理系统(CMS)文件上传模块,包含三大核心接口:
- 秒传校验——通过 MD5/SHA1 哈希比对,文件已存在则直接返回引用
- 分片上传——大文件切割为多个分片,支持断点续传
- OSS 直传签名——大文件分片直连 OSS,不经过应用服务器
8.1 数据库表设计
CREATE TABLE cms_file_info (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
file_md5 VARCHAR(32) NOT NULL COMMENT '文件MD5哈希',
file_sha1 VARCHAR(40) NOT NULL COMMENT '文件SHA1哈希',
file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
file_size BIGINT NOT NULL COMMENT '文件总大小(字节)',
content_type VARCHAR(128) DEFAULT NULL COMMENT 'MIME类型',
storage_type VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT '存储类型:local/oss',
storage_path VARCHAR(500) DEFAULT NULL COMMENT '存储路径(本地路径或OSS对象键)',
upload_status VARCHAR(20) NOT NULL DEFAULT 'uploading' COMMENT '上传状态:uploading/complete/failed',
total_chunks INT DEFAULT 0 COMMENT '总分片数',
uploaded_chunks INT DEFAULT 0 COMMENT '已上传分片数',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_file_md5 (file_md5),
INDEX idx_upload_status (upload_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CMS文件信息表';
CREATE TABLE cms_file_chunk (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
file_md5 VARCHAR(32) NOT NULL COMMENT '文件MD5哈希',
chunk_index INT NOT NULL COMMENT '分片索引(从0开始)',
chunk_size BIGINT NOT NULL COMMENT '分片大小',
chunk_md5 VARCHAR(32) NOT NULL COMMENT '分片MD5',
uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_file_chunk (file_md5, chunk_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CMS文件分片记录表';8.2 哈希校验工具类
public class FileHashUtils {
private static final int BUFFER_SIZE = 8192;
/**
* 计算文件的 MD5 哈希
*/
public static String md5(InputStream inputStream) throws IOException {
return hash(inputStream, "MD5");
}
/**
* 计算文件的 SHA1 哈希
*/
public static String sha1(InputStream inputStream) throws IOException {
return hash(inputStream, "SHA-1");
}
/**
* 同时计算 MD5 和 SHA1(一次读取,两次计算)
*/
public static FileHashResult md5AndSha1(InputStream inputStream) throws IOException {
MessageDigest md5Digest = MessageDigest.getInstance("MD5");
MessageDigest sha1Digest = MessageDigest.getInstance("SHA-1");
byte[] buffer = new byte[BUFFER_SIZE];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
md5Digest.update(buffer, 0, bytesRead);
sha1Digest.update(buffer, 0, bytesRead);
}
return new FileHashResult(
bytesToHex(md5Digest.digest()),
bytesToHex(sha1Digest.digest())
);
}
private static String hash(InputStream inputStream, String algorithm) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance(algorithm);
byte[] buffer = new byte[BUFFER_SIZE];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
return bytesToHex(digest.digest());
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("不支持的哈希算法: " + algorithm, e);
}
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
public record FileHashResult(String md5, String sha1) {}
}8.3 DTO 定义
// 秒传校验请求
public record CheckFileRequest(
@NotBlank String md5,
@NotBlank String sha1,
String fileName
) {}
// 秒传校验响应
public record CheckFileResponse(
boolean exist,
Long fileId,
String storagePath,
String message
) {}
// 初始化分片上传请求
public record InitUploadRequest(
@NotBlank String md5,
@NotBlank String sha1,
@NotBlank String fileName,
@Positive long fileSize,
@Min(1) int totalChunks,
String contentType
) {}
// 初始化分片上传响应
public record InitUploadResponse(
Long fileId,
String uploadId,
int totalChunks,
int chunkSize
) {}
// 上传分片请求
public record UploadChunkRequest(
@NotBlank String md5,
int chunkIndex,
@NotBlank String chunkMd5
) {}
// 合并分片请求
public record MergeChunksRequest(
@NotBlank String md5
) {}
// OSS 直传分片 URL 响应
public record ChunkUploadUrl(
int chunkIndex,
String uploadUrl,
long expiresAt
) {}8.4 分片上传服务
@Service
public class FileUploadService {
private static final int DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private OssService ossService;
@Value("${cms.upload.storage-type:local}")
private String storageType;
@Value("${cms.upload.local-dir:/data/cms-files}")
private String localDir;
@Value("${cms.upload.oss-bucket:cms-files}")
private String ossBucket;
// ==================== 1. 秒传校验 ====================
/**
* 秒传校验:查询文件是否已存在
* 通过 MD5 和 SHA1 双重哈希校验,避免哈希碰撞导致错误
*/
public CheckFileResponse checkFile(CheckFileRequest request) {
// 双重哈希查询,降低碰撞概率
String sql = """
SELECT id, storage_path FROM cms_file_info
WHERE file_md5 = ? AND file_sha1 = ? AND upload_status = 'complete'
LIMIT 1
""";
List<Map<String, Object>> results = jdbcTemplate.queryForList(sql, request.md5(), request.sha1());
if (results.isEmpty()) {
return new CheckFileResponse(false, null, null, "文件不存在,需要上传");
}
Map<String, Object> row = results.get(0);
Long fileId = ((Number) row.get("id")).longValue();
String storagePath = (String) row.get("storage_path");
return new CheckFileResponse(true, fileId, storagePath, "秒传成功,文件已存在");
}
// ==================== 2. 初始化分片上传 ====================
/**
* 初始化分片上传:创建文件记录并返回分片参数
*/
public InitUploadResponse initUpload(InitUploadRequest request) {
// 计算分片大小(除最后一片外,每片固定大小)
int chunkSize = DEFAULT_CHUNK_SIZE;
int totalChunks = (int) Math.ceil((double) request.fileSize() / chunkSize);
// 生成存储路径
String storagePath = storageType.equals("oss")
? String.format("cms/%s/%s_%s", request.md5().substring(0, 2), request.md5(), request.fileName())
: localDir + "/" + request.md5().substring(0, 2) + "/" + request.md5() + "_" + request.fileName();
// 插入文件记录(先检查是否已存在未完成的记录)
String selectSql = "SELECT id FROM cms_file_info WHERE file_md5 = ? AND upload_status = 'uploading' LIMIT 1";
List<Long> existing = jdbcTemplate.queryForList(selectSql, Long.class, request.md5());
if (!existing.isEmpty()) {
// 返回已有记录,前端可继续上传未完成的分片
return new InitUploadResponse(existing.get(0), request.md5(), totalChunks, chunkSize);
}
String insertSql = """
INSERT INTO cms_file_info
(file_md5, file_sha1, file_name, file_size, content_type,
storage_type, storage_path, upload_status, total_chunks, uploaded_chunks)
VALUES (?, ?, ?, ?, ?, ?, ?, 'uploading', ?, 0)
""";
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS);
ps.setString(1, request.md5());
ps.setString(2, request.sha1());
ps.setString(3, request.fileName());
ps.setLong(4, request.fileSize());
ps.setString(5, request.contentType());
ps.setString(6, storageType);
ps.setString(7, storagePath);
ps.setInt(8, totalChunks);
return ps;
}, keyHolder);
Long fileId = keyHolder.getKey().longValue();
return new InitUploadResponse(fileId, request.md5(), totalChunks, chunkSize);
}
// ==================== 3. 本地上传分片 ====================
/**
* 上传单个分片(本地存储模式)
*/
public void uploadChunk(UploadChunkRequest request, MultipartFile chunkFile) throws IOException {
// 验证分片记录是否存在
String fileSql = "SELECT id, upload_status FROM cms_file_info WHERE file_md5 = ?";
List<Map<String, Object>> files = jdbcTemplate.queryForList(fileSql, request.md5());
if (files.isEmpty()) {
throw new IllegalArgumentException("文件记录不存在,请先初始化上传");
}
// 检查分片是否已上传(幂等性去重)
String checkChunkSql = "SELECT COUNT(1) FROM cms_file_chunk WHERE file_md5 = ? AND chunk_index = ?";
Integer count = jdbcTemplate.queryForObject(checkChunkSql, Integer.class, request.md5(), request.chunkIndex());
if (count != null && count > 0) {
return; // 分片已存在,跳过
}
// 校验分片 MD5
String actualChunkMd5;
try (InputStream is = chunkFile.getInputStream()) {
actualChunkMd5 = FileHashUtils.md5(is);
}
if (!actualChunkMd5.equals(request.chunkMd5())) {
throw new IllegalArgumentException("分片 MD5 不匹配,文件可能已损坏");
}
// 保存分片到临时目录
String chunkDir = localDir + "/chunks/" + request.md5();
Files.createDirectories(Paths.get(chunkDir));
String chunkPath = chunkDir + "/" + request.chunkIndex;
chunkFile.transferTo(new File(chunkPath));
// 记录分片上传记录
String insertChunkSql = """
INSERT INTO cms_file_chunk (file_md5, chunk_index, chunk_size, chunk_md5)
VALUES (?, ?, ?, ?)
""";
jdbcTemplate.update(insertChunkSql, request.md5(), request.chunkIndex(),
chunkFile.getSize(), request.chunkMd5());
// 更新已上传分片数
String updateSql = """
UPDATE cms_file_info SET uploaded_chunks = uploaded_chunks + 1
WHERE file_md5 = ?
""";
jdbcTemplate.update(updateSql, request.md5());
}
// ==================== 4. 合并分片(本地模式) ====================
/**
* 合并分片:将所有分片按顺序合并为完整文件
*/
public CheckFileResponse mergeChunks(MergeChunksRequest request) throws IOException {
// 查询文件信息
String fileSql = """
SELECT id, file_name, file_md5, file_sha1, total_chunks, uploaded_chunks, storage_path
FROM cms_file_info WHERE file_md5 = ? AND upload_status = 'uploading'
""";
List<Map<String, Object>> files = jdbcTemplate.queryForList(fileSql, request.md5());
if (files.isEmpty()) {
throw new IllegalArgumentException("文件记录不存在或已合并");
}
Map<String, Object> fileRecord = files.get(0);
int totalChunks = ((Number) fileRecord.get("total_chunks")).intValue();
int uploadedChunks = ((Number) fileRecord.get("uploaded_chunks")).intValue();
if (uploadedChunks < totalChunks) {
throw new IllegalStateException("分片未上传完整,已上传 " + uploadedChunks + "/" + totalChunks);
}
// 合并文件
String storagePath = (String) fileRecord.get("storage_path");
File targetFile = new File(storagePath);
Files.createDirectories(targetFile.getParentFile().toPath());
String chunkDir = localDir + "/chunks/" + request.md5();
try (FileChannel out = new FileOutputStream(targetFile, false).getChannel()) {
for (int i = 0; i < totalChunks; i++) {
File chunkFile = new File(chunkDir + "/" + i);
try (FileChannel in = new FileInputStream(chunkFile).getChannel()) {
out.transferFrom(in, out.size(), in.size());
}
// 合并后删除分片文件
chunkFile.delete();
}
}
// 删除分片目录
Files.deleteIfExists(Paths.get(chunkDir));
// 验证合并后文件哈希
try (InputStream is = new FileInputStream(targetFile)) {
FileHashUtils.FileHashResult hashResult = FileHashUtils.md5AndSha1(is);
String expectedMd5 = (String) fileRecord.get("file_md5");
String expectedSha1 = (String) fileRecord.get("file_sha1");
if (!hashResult.md5().equals(expectedMd5) || !hashResult.sha1().equals(expectedSha1)) {
targetFile.delete();
jdbcTemplate.update("UPDATE cms_file_info SET upload_status = 'failed' WHERE file_md5 = ?",
request.md5());
throw new IllegalStateException("文件合并后哈希校验失败,文件可能已损坏");
}
}
// 更新状态为完成
jdbcTemplate.update("UPDATE cms_file_info SET upload_status = 'complete' WHERE file_md5 = ?",
request.md5());
Long fileId = ((Number) fileRecord.get("id")).longValue();
return new CheckFileResponse(true, fileId, storagePath, "文件合并成功");
}
// ==================== 5. OSS 直传分片签名 ====================
/**
* 获取 OSS 分片直传的预签名 URL 列表(OSS 模式)
* 前端直接使用预签名 URL 将每个分片上传到 OSS,不经过应用服务器
*/
public List<ChunkUploadUrl> generateChunkUploadUrls(InitUploadRequest request) {
if (!"oss".equals(storageType)) {
throw new UnsupportedOperationException("当前存储模式不支持 OSS 直传");
}
int chunkSize = DEFAULT_CHUNK_SIZE;
int totalChunks = (int) Math.ceil((double) request.fileSize() / chunkSize);
long expirationSeconds = 7200; // 2小时有效期
List<ChunkUploadUrl> urls = new ArrayList<>();
for (int i = 0; i < totalChunks; i++) {
String objectKey = String.format("cms/chunks/%s/%s/part-%05d",
request.md5().substring(0, 2), request.md5(), i);
String uploadUrl = ossService.generateUploadUrl(ossBucket, objectKey, expirationSeconds);
urls.add(new ChunkUploadUrl(i, uploadUrl, System.currentTimeMillis() + expirationSeconds * 1000));
}
// 创建文件记录
initUpload(request);
return urls;
}
/**
* OSS 模式:所有分片上传完成后,调用此接口合并(实际为记录合并状态)
* OSS 侧自动合并需使用 OSS 的 CompleteMultipartUpload 或通过服务端组合
*/
public CheckFileResponse completeOssUpload(MergeChunksRequest request) {
// 更新状态
jdbcTemplate.update("""
UPDATE cms_file_info SET upload_status = 'complete'
WHERE file_md5 = ? AND upload_status = 'uploading'
""", request.md5());
List<Map<String, Object>> files = jdbcTemplate.queryForList(
"SELECT id, storage_path FROM cms_file_info WHERE file_md5 = ?", request.md5());
if (files.isEmpty()) {
throw new IllegalArgumentException("文件记录不存在");
}
Map<String, Object> file = files.get(0);
return new CheckFileResponse(true, ((Number) file.get("id")).longValue(),
(String) file.get("storage_path"), "OSS 上传完成");
}
}