Spring的大文件上传与下载

简述

​ 这周遇到两个新问题,就是大文件的上传和下载。

场景 之前 问题
大文件上传云存储 文件上传到服务器,在从服务器上传到云存储 1、通过nginx、nacos转发耗时太长 
2、太消耗服务器资源
用户下载视频 从其他端下载视频,再将视频转发给客户端 同上

​ 这两个问题的解决,还得感谢chatgpt,这玩意还是好用的。

大文件上传

​ 之前都是直接将文件上传到服务器,通过服务器将文件上传到云端,拿到链接后保存链接。这样刚开始使用的时候还行,上传的文件比较小,后来要上传大文件,问题就暴露出来了,耗时太长。文件要经过nginx、nacos的转发,一个200M的文件,到服务器后端代码开始处理已经过去十多分钟了。除了耗时,还太消耗资源,nginx、nacos、spring服务这每一项都要转发一下大文件,都要消耗内存流量。

​ 解决方案:1、改长相应时间,2、修改设计方案。第一个解决方案明显不行,没解决根本问题,耗时长,资源消耗大的问题还是存在。那就只能考虑第二个解决方案了。

​ 其实刚开始,我也没啥好的解决方案(见识短浅)。后来问了一下chatgpt,解决方案直接就出来了,生成预链接,让前端通过预链接上传文件。说是业界都用的这种方案,我们都不知道,还是落后呢脱节了啊。我们公司用的是AWS的S3存储,它是支持生成预链接的,除了AWS,其他的云厂商都是支持的(阿里云,腾讯云等)。为了保障链接的有效性,我在后加了一个地址校验,校验一下预链接的地址前端有没有上传文件,防止前端没有上传。代码编写随便百度一下大把。

image-20250705170312520

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
/**
* 生成一个可用于 PUT 上传的预签名 URL
*
* @param bucketName S3桶名
* @param objectKey 上传到 S3 的 key(路径+文件名)
* @param contentType 上传内容类型(如 "image/jpeg", "application/pdf")
* @param validMinutes URL 有效时间(分钟)
* @return 可用的 PUT 请求 URL
*/
public URL generatePresignedPutUrl(String bucketName, String objectKey, String contentType, int validMinutes) {
// 初始化 S3 PreSigner(线程安全,可复用)
try (S3Presigner preSigner = S3Presigner.builder()
.region(REGION)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)
))
.build()) {

PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(objectKey)
.acl(ObjectCannedACL.PUBLIC_READ)
.contentType(contentType)
.build();

PresignedPutObjectRequest preSignedRequest = preSigner.presignPutObject(builder -> builder
.putObjectRequest(putObjectRequest)
.signatureDuration(Duration.ofMinutes(validMinutes)) // 有效期
);
// 可用于前端 PUT 上传
return preSignedRequest.url();
}
}

视频下载

​ 背景:本来可以提供三方的视频链接直接给前端下载的,可以视频链接有反扒校验,预ip绑定,其他ip都打不开链接,所以只能后端服务器下载了传给前端。

​ 初次解决方案:服务器直接下载完,再转发给前端下载,但是这样要与前端保持链接的时间太长了,服务端要下载一遍,前端也要接着下载一遍。同时也消耗服务器资源,带宽流量和服务器内存。

​ 优化方案:通过服务器直接透传给前端,这样服务器不用保存,可以减少内存的压力,同时也减少相应的时间。不过这也有问题,前端没那么稳定,会断,导致整个视频下载不了。

​ 最终方案:分片返回给前端,将一个视频流切成很多片,每次请求一片,这样将一个长连接变成了许多短连接。不会因前端的一次断开,导致整个下载失败。

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
@PostMapping("/pronhub/url/download")
public ResponseEntity<StreamingResponseBody> downloadVideoWithRange(
HttpServletRequest request,
HttpServletResponse response,
@RequestBody PronhubUrlDownLoadRequest body) throws IOException {

String videoUrl = body.getUrl();
URL url = new URL(videoUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestProperty("User-Agent", "Mozilla/5.0");

int responseCode = conn.getResponseCode();
if (responseCode != 200) {
return ResponseEntity.status(responseCode)
.contentType(MediaType.TEXT_PLAIN)
.body(out -> out.write(("视频请求失败: " + responseCode).getBytes(StandardCharsets.UTF_8)));
}

String contentType = conn.getContentType();
int fullLength = conn.getContentLength();

String rangeHeader = request.getHeader("Range");
int start = 0;
int end;

if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
String[] ranges = rangeHeader.replace("bytes=", "").split("-");
start = Integer.parseInt(ranges[0].trim());
if (ranges.length > 1 && !ranges[1].isEmpty()) {
end = Integer.parseInt(ranges[1].trim());
} else {
// 如果客户端只给了 start,没有 end,主动限制最多 CHUNK_SIZE
end = Math.min(start + CHUNK_SIZE - 1, fullLength - 1);
}
} else {
// 没有 Range 请求时,默认返回从头开始的一个 5MB 分片
end = Math.min(start + CHUNK_SIZE - 1, fullLength - 1);
}

int rangeLength = end - start + 1;
conn.setRequestProperty("Range", "bytes=" + start + "-" + end);
InputStream inputStream = conn.getInputStream();

final int finalStart = start;
final int finalEnd = end;

StreamingResponseBody stream = outputStream -> {
try (InputStream in = inputStream) {
byte[] buffer = new byte[8192];
int bytesRead;
long totalRead = 0;
long lastFlush = System.currentTimeMillis();

while ((bytesRead = in.read(buffer)) != -1 && totalRead < rangeLength) {
int toWrite = (int) Math.min(bytesRead, rangeLength - totalRead);
outputStream.write(buffer, 0, toWrite);
totalRead += toWrite;

if (System.currentTimeMillis() - lastFlush > 2000) {
outputStream.flush();
lastFlush = System.currentTimeMillis();
}
}

outputStream.flush();
} catch (IOException ex) {
if (ex.getMessage().contains("Broken pipe")) {
log.warn("客户端连接中断: {}", ex.getMessage());
} else {
log.error("视频传输异常: ", ex);
}
}
};

return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.header("Accept-Ranges", "bytes")
.header("Content-Type", contentType != null ? contentType : "video/mp4")
.header("Content-Length", String.valueOf(rangeLength))
.header("Content-Range", String.format("bytes %d-%d/%d", finalStart, finalEnd, fullLength))
.header("Full-Length", String.valueOf(fullLength))
.header("Access-Control-Expose-Headers", "Content-Range,Full-Length")
.header("Content-Disposition", "attachment; filename=video.mp4")
.body(stream);
}