Spring Boot文件下载
# Spring Boot文件下载
# 一、文件下载技术基础与实现方案概述
在企业级项目中,文件下载是一个常见且关键的功能需求。根据不同的业务场景和安全需求,我们可以采用多种方案实现文件下载功能。本文将从基础实现到高级应用,详细介绍各种文件下载技术及其最佳实践。
目前主流的后端文件下载实现方式有以下几种:
- 基于ResponseEntity的标准下载:Spring Boot推荐的标准实现方式
- 基于HttpServletResponse的原生下载:Servlet API的底层实现方式
- 带安全认证的受控下载:适合需要权限控制的场景
- 支持断点续传的分片下载:适合大文件下载场景
- 基于云存储的文件下载:适合分布式系统架构
- 基于下载码的临时授权下载:安全与灵活性兼顾的解决方案
# 二、基于Spring框架的标准文件下载实现
# 2.1 使用ResponseEntity实现文件下载
ResponseEntity
是Spring框架提供的HTTP响应封装类,使用它实现文件下载具有代码简洁、易于维护的优势。
/**
* 文件下载控制器 - 使用ResponseEntity实现
* 提供基于Spring框架推荐的文件下载标准实现
*/
@RestController
@RequestMapping("/api/download")
public class StandardFileDownloadController {
/**
* 文件存储根目录,实际项目中应通过配置文件注入
* 避免硬编码存储路径,提高代码可维护性
*/
private final Path fileStorageLocation;
/**
* 构造函数,初始化文件存储路径
*
* @param fileStoragePath 配置的文件存储路径
*/
@Autowired
public StandardFileDownloadController(@Value("${file.storage.path:C:/uploads}") String fileStoragePath) {
this.fileStorageLocation = Paths.get(fileStoragePath)
.toAbsolutePath()
.normalize();
// 确保目录存在,不存在则创建
try {
Files.createDirectories(this.fileStorageLocation);
} catch (IOException e) {
throw new RuntimeException("无法创建文件存储目录", e);
}
}
/**
* 标准文件下载接口
* 通过文件名直接下载指定文件
*
* @param fileName 需要下载的文件名
* @return 包含文件资源的ResponseEntity对象
*/
@GetMapping("/{fileName:.+}")
public ResponseEntity<Resource> downloadFile(@PathVariable String fileName) {
try {
// 1. 构建完整的文件路径
Path filePath = fileStorageLocation.resolve(fileName).normalize();
// 2. 创建文件资源对象
Resource resource = new UrlResource(filePath.toUri());
// 3. 检查文件是否存在且可读
if (!resource.exists() || !resource.isReadable()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(null); // 文件不存在或不可读时返回404状态码
}
// 4. 检测文件的MIME类型
String contentType = null;
try {
// 使用Files.probeContentType自动检测文件类型
contentType = Files.probeContentType(filePath);
} catch (IOException e) {
// 内容类型检测失败时不中断下载
logger.warn("无法检测文件类型: {}", e.getMessage());
}
// 5. 如果无法检测到内容类型,则设置为通用二进制流
if (contentType == null) {
contentType = "application/octet-stream";
}
// 6. 构建HTTP响应
return ResponseEntity.ok()
// 设置内容类型
.contentType(MediaType.parseMediaType(contentType))
// Content-Disposition头告诉浏览器如何处理文件
// attachment表示下载;inline表示在浏览器中直接显示
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + URLEncoder.encode(fileName, "UTF-8") + "\"")
// 设置缓存控制(可选)
.cacheControl(CacheControl.noCache())
// 返回文件资源
.body(resource);
} catch (MalformedURLException e) {
// URL格式错误,通常是文件路径构建有问题
logger.error("文件URL格式错误: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
} catch (IOException e) {
// IO异常,通常是文件读取或编码问题
logger.error("文件下载过程中发生IO异常: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
}
}
}
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
92
93
94
核心技术点解析:
@PathVariable String fileName
:从URL路径中获取文件名,:.+
正则表达式确保可以正确处理带点的文件名UrlResource
:Spring框架提供的资源抽象,可加载本地文件或远程URLFiles.probeContentType()
:自动检测文件MIME类型,优化浏览器处理方式URLEncoder.encode()
:确保文件名正确编码,处理中文等特殊字符- 异常处理:针对不同类型的异常返回相应的HTTP状态码,提高系统健壮性
# 2.2 使用HttpServletResponse实现原生文件下载
HttpServletResponse
是Servlet规范的核心接口,提供了更底层的HTTP响应控制能力。当需要精细控制文件下载过程时,这种方式更为适合。
/**
* 文件下载控制器 - 使用HttpServletResponse实现
* 提供基于Servlet API的原生文件下载实现
*/
@RestController
@RequestMapping("/api/native-download")
public class NativeFileDownloadController {
private static final Logger logger = LoggerFactory.getLogger(NativeFileDownloadController.class);
/**
* 文件存储根目录,实际项目中应通过配置文件注入
*/
private final Path fileStorageLocation;
/**
* 构造函数,初始化文件存储路径
*/
@Autowired
public NativeFileDownloadController(@Value("${file.storage.path:C:/uploads}") String fileStoragePath) {
this.fileStorageLocation = Paths.get(fileStoragePath).toAbsolutePath().normalize();
}
/**
* 使用HttpServletResponse实现文件下载
* 通过原生Servlet响应对象处理文件下载
*
* @param fileName 需要下载的文件名
* @param response Servlet响应对象,用于流式传输文件数据
*/
@GetMapping("/servlet/{fileName:.+}")
public void downloadFileUsingServletResponse(
@PathVariable String fileName,
HttpServletResponse response) {
// 1. 构建文件完整路径
Path filePath = fileStorageLocation.resolve(fileName).normalize();
File file = filePath.toFile();
// 2. 检查文件是否存在且可读
if (!file.exists() || !file.isFile() || !file.canRead()) {
try {
// 设置HTTP 404状态码
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
response.setContentType("application/json;charset=UTF-8");
// 返回错误信息
response.getWriter().write("{\"error\":\"文件不存在或无法访问\"}");
return;
} catch (IOException e) {
logger.error("响应错误信息时发生异常", e);
return;
}
}
try {
// 3. 获取文件MIME类型
String contentType = Files.probeContentType(filePath);
if (contentType == null) {
// 默认设为二进制流类型
contentType = "application/octet-stream";
}
// 4. 设置响应头
// Content-Type指定文件类型
response.setContentType(contentType);
// Content-Disposition指定文件下载方式和文件名
response.setHeader("Content-Disposition",
"attachment; filename=\"" + URLEncoder.encode(fileName, "UTF-8") + "\"");
// Content-Length指定文件大小,便于浏览器显示下载进度
response.setContentLengthLong(file.length());
// 禁用缓存,确保每次都获取最新文件
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
// 5. 使用缓冲流读取文件并写入响应
try (
// 创建文件输入流
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(file));
// 获取响应输出流
BufferedOutputStream bos = new BufferedOutputStream(response.getOutputStream())
) {
// 创建适当大小的缓冲区,提高传输效率
byte[] buffer = new byte[4096]; // 4KB缓冲区大小
int bytesRead;
// 循环读取并写入数据
while ((bytesRead = bis.read(buffer)) != -1) {
bos.write(buffer, 0, bytesRead);
}
// 确保所有数据都写出
bos.flush();
}
} catch (IOException e) {
logger.error("文件下载过程中发生IO异常", e);
try {
// 设置HTTP 500状态码
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"文件下载失败: " + e.getMessage() + "\"}");
} catch (IOException ex) {
logger.error("响应错误信息时发生异常", ex);
}
}
}
}
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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
核心技术点解析:
- 多重响应头设置:精确控制Content-Type、Content-Disposition等响应头
- 显式指定Content-Length:有助于浏览器显示进度条,提升用户体验
- 缓冲流优化:使用BufferedInputStream和BufferedOutputStream提高传输效率
- 适当的缓冲区大小:设置4KB缓冲区,平衡内存使用和传输效率
- 异常处理与响应:发生异常时设置正确的HTTP状态码并返回错误信息
# 三、安全强化型文件下载实现
# 3.1 带权限认证的安全文件下载
在企业应用中,文件下载通常需要权限控制,确保敏感文件只能被授权用户访问。以下示例结合Spring Security实现带权限验证的文件下载。
/**
* 安全文件下载控制器
* 结合权限验证,确保只有授权用户才能下载文件
*/
@RestController
@RequestMapping("/api/secure-download")
public class SecureFileDownloadController {
private static final Logger logger = LoggerFactory.getLogger(SecureFileDownloadController.class);
/**
* 文件存储根目录
*/
private final Path fileStorageLocation;
/**
* 文件权限服务,用于检查用户是否有权限访问特定文件
*/
private final FilePermissionService permissionService;
/**
* 构造函数,注入必要的依赖
*/
@Autowired
public SecureFileDownloadController(
@Value("${file.storage.path:C:/uploads}") String fileStoragePath,
FilePermissionService permissionService) {
this.fileStorageLocation = Paths.get(fileStoragePath).toAbsolutePath().normalize();
this.permissionService = permissionService;
}
/**
* 安全文件下载接口
* 添加权限检查,确保用户有权限下载请求的文件
*
* @param fileName 文件名
* @param authentication Spring Security认证对象,包含当前用户信息
* @return 文件资源响应或错误信息
*/
@GetMapping("/{fileName:.+}")
public ResponseEntity<Resource> secureDownloadFile(
@PathVariable String fileName,
Authentication authentication) {
// 1. 获取当前登录用户信息
if (authentication == null || !authentication.isAuthenticated()) {
logger.warn("未授权的访问请求: {}", fileName);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(null); // 未登录或认证失败返回401状态码
}
// 从认证对象中获取用户信息
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
logger.info("用户 [{}] 请求下载文件: {}", username, fileName);
try {
// 2. 构建文件路径
Path filePath = fileStorageLocation.resolve(fileName).normalize();
// 3. 权限检查
// 检查用户是否有权限访问此文件
if (!permissionService.hasPermission(username, authorities, fileName)) {
logger.warn("用户 [{}] 尝试下载无权限的文件: {}", username, fileName);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(null); // 无权访问返回403状态码
}
// 4. 创建文件资源
Resource resource = new UrlResource(filePath.toUri());
// 5. 验证文件是否存在
if (!resource.exists() || !resource.isReadable()) {
logger.warn("请求的文件不存在或不可读: {}", filePath);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(null); // 文件不存在返回404状态码
}
// 6. 检测文件内容类型
String contentType = Files.probeContentType(filePath);
if (contentType == null) {
contentType = "application/octet-stream";
}
// 7. 记录下载日志
logger.info("用户 [{}] 成功下载文件: {}, 大小: {} bytes",
username, fileName, Files.size(filePath));
// 8. 构建响应对象
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + URLEncoder.encode(fileName, "UTF-8") + "\"")
.body(resource);
} catch (IOException e) {
logger.error("处理安全下载请求时发生错误", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(null);
}
}
}
/**
* 文件权限服务接口
* 用于检查用户是否有权限访问特定文件
*/
interface FilePermissionService {
/**
* 检查用户是否有权限访问指定文件
*
* @param username 用户名
* @param authorities 用户权限集合
* @param fileName 文件名
* @return 是否有权限
*/
boolean hasPermission(String username,
Collection<? extends GrantedAuthority> authorities,
String fileName);
}
/**
* 文件权限服务实现类
* 实现基于角色和用户的文件访问控制
*/
@Service
class FilePermissionServiceImpl implements FilePermissionService {
/**
* 检查用户是否有权限访问特定文件
* 实际应用中,可能需要查询数据库或调用其他服务来判断权限
*/
@Override
public boolean hasPermission(String username,
Collection<? extends GrantedAuthority> authorities,
String fileName) {
// 示例:管理员可以访问所有文件
boolean isAdmin = authorities.stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
if (isAdmin) {
return true;
}
// 示例:用户只能访问以自己用户名开头的文件
if (fileName.startsWith(username + "_")) {
return true;
}
// 示例:公共文件任何人都可以下载
if (fileName.startsWith("public_")) {
return true;
}
// 默认拒绝访问
return false;
}
}
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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
核心技术点解析:
Authentication authentication
:自动注入当前用户的认证信息- 权限控制逻辑:根据用户身份和权限决定是否允许下载
- 详细的日志记录:记录所有下载请求,包括成功和失败的尝试
- 多级安全检查:先验证用户身份,再检查文件权限,最后验证文件存在性
- 适当HTTP状态码:根据不同情况返回401、403或404等状态码
# 3.2 基于下载码的临时授权文件下载
下载码是一种安全且灵活的文件访问控制机制,尤其适用于临时授权或分享文件的场景。结合Redis实现高效的下载码管理。
/**
* 基于下载码的文件下载控制器
* 实现临时授权文件访问,增强安全性的同时保持灵活性
*/
@RestController
@RequestMapping("/api/token-download")
public class TokenFileDownloadController {
private static final Logger logger = LoggerFactory.getLogger(TokenFileDownloadController.class);
/**
* Redis模板,用于存储和检索下载码信息
*/
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 文件存储根目录
*/
private final Path fileStorageLocation;
/**
* 下载码前缀,用于Redis键命名
*/
private static final String TOKEN_PREFIX = "file_download_token:";
/**
* 构造函数
*/
public TokenFileDownloadController(@Value("${file.storage.path:C:/uploads}") String fileStoragePath) {
this.fileStorageLocation = Paths.get(fileStoragePath).toAbsolutePath().normalize();
}
/**
* 生成文件下载码
* 为指定文件创建一个临时下载授权码
*
* @param fileId 文件标识符
* @param expireMinutes 下载码有效期(分钟)
* @return 包含下载码的响应
*/
@PostMapping("/generate-token")
public ResponseEntity<Map<String, Object>> generateDownloadToken(
@RequestParam String fileId,
@RequestParam(defaultValue = "30") int expireMinutes) {
Map<String, Object> response = new HashMap<>();
try {
// 1. 检查文件是否存在(此处应包含实际的业务逻辑)
String filePath = resolveFilePath(fileId);
if (filePath == null) {
response.put("success", false);
response.put("message", "文件不存在");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
// 2. 生成唯一下载码
String downloadToken = UUID.randomUUID().toString();
// 3. 存储下载码信息到Redis,设置过期时间
String tokenKey = TOKEN_PREFIX + downloadToken;
redisTemplate.opsForValue().set(tokenKey, filePath, expireMinutes, TimeUnit.MINUTES);
// 4. 构建下载URL
String downloadUrl = "/api/token-download/file?token=" + downloadToken;
// 5. 返回下载码信息
response.put("success", true);
response.put("token", downloadToken);
response.put("downloadUrl", downloadUrl);
response.put("expiresIn", expireMinutes * 60); // 过期时间(秒)
logger.info("为文件ID [{}] 生成下载码: {}, 有效期: {} 分钟",
fileId, downloadToken, expireMinutes);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("生成下载码时发生错误", e);
response.put("success", false);
response.put("message", "生成下载码失败: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
/**
* 通过下载码下载文件
* 验证下载码有效性并提供文件下载
*
* @param token 下载码
* @param response HTTP响应对象
*/
@GetMapping("/file")
public void downloadFileByToken(
@RequestParam String token,
HttpServletResponse response) {
try {
// 1. 验证下载码有效性
String tokenKey = TOKEN_PREFIX + token;
String filePath = redisTemplate.opsForValue().get(tokenKey);
// 2. 下载码无效或已过期
if (filePath == null) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"下载码无效或已过期\"}");
logger.warn("尝试使用无效下载码: {}", token);
return;
}
// 3. 创建文件对象
File file = new File(filePath);
if (!file.exists() || !file.isFile() || !file.canRead()) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"文件不存在或无法访问\"}");
logger.warn("下载码 [{}] 对应的文件不存在: {}", token, filePath);
return;
}
// 4. 获取文件名
String fileName = file.getName();
// 5. 检测文件类型
String contentType;
try {
contentType = Files.probeContentType(file.toPath());
} catch (IOException e) {
contentType = "application/octet-stream";
}
// 6. 设置响应头
response.setContentType(contentType);
response.setHeader("Content-Disposition",
"attachment; filename=\"" + URLEncoder.encode(fileName, "UTF-8") + "\"");
response.setContentLengthLong(file.length());
// 7. 传输文件内容
try (FileInputStream fis = new FileInputStream(file);
BufferedInputStream bis = new BufferedInputStream(fis);
OutputStream os = response.getOutputStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = bis.read(buffer)) != -1) {
os.write(buffer, 0, bytesRead);
}
os.flush();
}
// 8. 日志记录
logger.info("文件通过下载码 [{}] 成功下载: {}", token, filePath);
// 9. 可选:单次下载码使用后立即失效
// redisTemplate.delete(tokenKey);
} catch (IOException e) {
logger.error("处理基于下载码的文件下载时发生错误", e);
try {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"文件下载失败: " + e.getMessage() + "\"}");
} catch (IOException ex) {
logger.error("响应错误信息时发生异常", ex);
}
}
}
/**
* 解析文件路径
* 根据文件ID获取实际文件路径(实际应用中通常会查询数据库)
*
* @param fileId 文件标识符
* @return 文件完整路径
*/
private String resolveFilePath(String fileId) {
// 示例实现,实际应用中应查询数据库获取文件路径
try {
// 假设fileId直接对应存储目录下的一个文件
Path path = fileStorageLocation.resolve(fileId).normalize();
File file = path.toFile();
if (file.exists() && file.isFile()) {
return file.getAbsolutePath();
}
} catch (Exception e) {
logger.error("解析文件路径时发生错误: {}", e.getMessage());
}
return null;
}
}
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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
核心技术点解析:
- 下载码生成与管理:使用UUID生成唯一下载码,并存储在Redis中
- 有效期控制:设置下载码的有效期,增强安全性
- 二阶段验证:先验证下载码有效性,再检查文件存在性
- 灵活的授权策略:可以实现单次下载或多次下载
- 完整日志记录:记录下载码生成和使用情况,便于审计
# 四、高级文件下载功能实现
# 4.1 支持断点续传的分片下载
对于大文件下载,支持断点续传是提升用户体验的关键功能。通过处理HTTP Range请求头,可以实现文件的分片下载和断点续传。
/**
* 支持断点续传的文件下载控制器
* 实现大文件分片下载,提高大文件传输效率和可靠性
*/
@RestController
@RequestMapping("/api/resumable-download")
public class ResumableDownloadController {
private static final Logger logger = LoggerFactory.getLogger(ResumableDownloadController.class);
/**
* 文件存储根目录
*/
private final Path fileStorageLocation;
/**
* 构造函数
*/
public ResumableDownloadController(@Value("${file.storage.path:C:/uploads}") String fileStoragePath) {
this.fileStorageLocation = Paths.get(fileStoragePath).toAbsolutePath().normalize();
}
/**
* 支持断点续传的文件下载
* 处理HTTP Range请求,返回指定范围的文件内容
*
* @param fileName 文件名
* @param headers HTTP请求头,用于获取Range信息
* @param response HTTP响应对象
*/
@GetMapping("/{fileName:.+}")
public void downloadFileWithRange(
@PathVariable String fileName,
@RequestHeader HttpHeaders headers,
HttpServletResponse response) {
try {
// 1. 构建文件路径并检查文件
Path filePath = fileStorageLocation.resolve(fileName).normalize();
File file = filePath.toFile();
if (!file.exists() || !file.isFile() || !file.canRead()) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"文件不存在或无法访问\"}");
return;
}
// 2. 获取文件信息
long fileSize = file.length();
String contentType = Files.probeContentType(filePath);
if (contentType == null) {
contentType = "application/octet-stream";
}
// 3. 解析Range请求头
List<HttpRange> ranges = headers.getRange();
// 4. 处理不同类型的请求
if (ranges.isEmpty()) {
// 没有Range头,返回整个文件
handleFullFileDownload(file, fileName, contentType, response);
} else {
// 有Range头,返回请求的文件片段
handleRangeRequest(file, ranges, fileName, contentType, fileSize, response);
}
} catch (IOException e) {
logger.error("处理断点续传下载时发生错误", e);
try {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"文件下载失败: " + e.getMessage() + "\"}");
} catch (IOException ex) {
logger.error("响应错误信息时发生异常", ex);
}
}
}
/**
* 处理完整文件下载请求
* 当请求中没有指定Range头时使用
*/
private void handleFullFileDownload(
File file,
String fileName,
String contentType,
HttpServletResponse response) throws IOException {
// 设置响应头
response.setContentType(contentType);
response.setHeader("Content-Disposition",
"attachment; filename=\"" + URLEncoder.encode(fileName, "UTF-8") + "\"");
response.setHeader("Accept-Ranges", "bytes"); // 告知客户端支持范围请求
response.setContentLengthLong(file.length());
// 传输文件内容
try (FileInputStream fis = new FileInputStream(file);
BufferedInputStream bis = new BufferedInputStream(fis);
OutputStream os = response.getOutputStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = bis.read(buffer)) != -1) {
os.write(buffer, 0, bytesRead);
}
os.flush();
}
logger.info("完整下载文件: {}, 大小: {} bytes", fileName, file.length());
}
/**
* 处理范围请求
* 当请求中包含Range头时使用
*/
private void handleRangeRequest(
File file,
List<HttpRange> ranges,
String fileName,
String contentType,
long fileSize,
HttpServletResponse response) throws IOException {
// 目前只处理第一个范围请求
HttpRange range = ranges.get(0);
// 计算起始和结束位置
long start = range.getRangeStart(fileSize);
long end = range.getRangeEnd(fileSize);
long contentLength = end - start + 1;
// 设置响应头
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206状态码
response.setContentType(contentType);
response.setHeader("Content-Disposition",
"attachment; filename=\"" + URLEncoder.encode(fileName, "UTF-8") + "\"");
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileSize);
response.setContentLengthLong(contentLength);
// 使用RandomAccessFile读取文件指定范围的内容
try (RandomAccessFile raf = new RandomAccessFile(file, "r");
OutputStream os = response.getOutputStream()) {
// 移动文件指针到起始位置
raf.seek(start);
// 读取并传输指定范围的数据
byte[] buffer = new byte[4096];
long remaining = contentLength;
int bytesRead;
while (remaining > 0 && (bytesRead = raf.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) {
os.write(buffer, 0, bytesRead);
remaining -= bytesRead;
}
os.flush();
}
logger.info("范围下载文件: {}, 范围: {}-{}/{}",
fileName, start, end, fileSize);
}
}
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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
核心技术点解析:
- Range头解析:使用
HttpHeaders.getRange()
解析客户端请求的文件范围 - HTTP 206状态码:部分内容响应使用206状态码
- Content-Range头:指定返回内容在整个文件中的位置
- RandomAccessFile:高效地读取文件指定范围的内容
- Accept-Ranges头:告知客户端服务器支持范围请求
# 4.2 基于云存储的文件下载(以阿里云OSS为例)
在分布式系统中,文件通常存储在云服务上。以下示例展示如何从阿里云OSS下载文件。
/**
* 云存储文件下载控制器
* 从阿里云OSS获取文件并下载,适用于分布式系统架构
*/
@RestController
@RequestMapping("/api/cloud-download")
public class CloudStorageDownloadController {
private static final Logger logger = LoggerFactory.getLogger(CloudStorageDownloadController.class);
/**
* OSS客户端,通过配置注入
*/
@Autowired
private OSS ossClient;
/**
* OSS存储配置
*/
@Value("${aliyun.oss.bucket-name}")
private String bucketName;
/**
* 从阿里云OSS下载文件
*
* @param objectKey OSS对象键
* @param response HTTP响应对象
*/
@GetMapping("/{objectKey}")
public void downloadFromOSS(
@PathVariable String objectKey,
HttpServletResponse response) {
try {
// 1. 检查对象是否存在
boolean exists = ossClient.doesObjectExist(bucketName, objectKey);
if (!exists) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"文件不存在或无法访问\"}");
logger.warn("请求的OSS对象不存在: {}", objectKey);
return;
}
// 2. 获取OSS对象元数据
ObjectMetadata metadata = ossClient.getObjectMetadata(bucketName, objectKey);
long contentLength = metadata.getContentLength();
String contentType = metadata.getContentType();
if (contentType == null || contentType.isEmpty()) {
contentType = "application/octet-stream";
}
// 3. 从文件名提取原始文件名(如果对象键包含路径)
String fileName = objectKey;
if (objectKey.contains("/")) {
fileName = objectKey.substring(objectKey.lastIndexOf("/") + 1);
}
// 4. 设置响应头
response.setContentType(contentType);
response.setHeader("Content-Disposition",
"attachment; filename=\"" + URLEncoder.encode(fileName, "UTF-8") + "\"");
response.setContentLengthLong(contentLength);
// 5. 获取OSS对象并传输到客户端
OSSObject ossObject = ossClient.getObject(bucketName, objectKey);
try (
InputStream objectContent = ossObject.getObjectContent();
BufferedInputStream bis = new BufferedInputStream(objectContent);
OutputStream os = response.getOutputStream()
) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = bis.read(buffer)) != -1) {
os.write(buffer, 0, bytesRead);
}
os.flush();
}
logger.info("从OSS成功下载文件: {}, 大小: {} bytes", objectKey, contentLength);
} catch (OSSException oe) {
logger.error("OSS服务异常", oe);
try {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"OSS服务异常: " + oe.getMessage() + "\"}");
} catch (IOException ex) {
logger.error("响应错误信息时发生异常", ex);
}
} catch (ClientException ce) {
logger.error("OSS客户端异常", ce);
try {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"OSS客户端异常: " + ce.getMessage() + "\"}");
} catch (IOException ex) {
logger.error("响应错误信息时发生异常", ex);
}
} catch (IOException e) {
logger.error("处理OSS下载时发生IO异常", e);
try {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"文件下载失败: " + e.getMessage() + "\"}");
} catch (IOException ex) {
logger.error("响应错误信息时发生异常", ex);
}
}
}
/**
* 生成OSS对象的临时下载链接
* 适用于无需通过服务器中转的场景
*
* @param objectKey OSS对象键
* @param expireSeconds 链接有效期(秒)
* @return 包含临时下载链接的响应
*/
@GetMapping("/url/{objectKey}")
public ResponseEntity<Map<String, Object>> generatePresignedUrl(
@PathVariable String objectKey,
@RequestParam(defaultValue = "3600") int expireSeconds) {
Map<String, Object> response = new HashMap<>();
try {
// 1. 检查对象是否存在
boolean exists = ossClient.doesObjectExist(bucketName, objectKey);
if (!exists) {
response.put("success", false);
response.put("message", "请求的OSS对象不存在");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
// 2. 获取对象元数据
ObjectMetadata metadata = ossClient.getObjectMetadata(bucketName, objectKey);
// 3. 生成临时URL
Date expiration = new Date(System.currentTimeMillis() + expireSeconds * 1000L);
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucketName, objectKey);
request.setExpiration(expiration);
request.setMethod(HttpMethod.GET);
// 可选:设置响应头
ResponseHeaderOverrides headerOverrides = new ResponseHeaderOverrides();
// 从对象键提取文件名
String fileName = objectKey;
if (objectKey.contains("/")) {
fileName = objectKey.substring(objectKey.lastIndexOf("/") + 1);
}
headerOverrides.setContentDisposition("attachment; filename=\"" + URLEncoder.encode(fileName, "UTF-8") + "\"");
request.setResponseHeaders(headerOverrides);
// 4. 获取签名URL
URL signedUrl = ossClient.generatePresignedUrl(request);
// 5. 构建响应
response.put("success", true);
response.put("url", signedUrl.toString());
response.put("fileName", fileName);
response.put("fileSize", metadata.getContentLength());
response.put("contentType", metadata.getContentType());
response.put("expiresAt", expiration.getTime()); // 过期时间戳(毫秒)
logger.info("为OSS对象 [{}] 生成临时URL,有效期: {} 秒", objectKey, expireSeconds);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("生成临时下载链接时发生错误", e);
response.put("success", false);
response.put("message", "生成链接失败: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
}
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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
核心技术点解析:
- OSS对象存在性检查:使用
doesObjectExist
方法验证对象是否存在 - 对象元数据获取:获取内容长度、类型等信息,用于设置响应头
- 流式传输:使用缓冲流高效传输OSS对象内容
- 预签名URL生成:提供直接访问OSS的临时URL,绕过服务器中转
- 响应头覆盖:对于预签名URL,通过ResponseHeaderOverrides设置下载时的响应头
# 五、前端文件下载实现示例
为了完整展示文件下载功能,以下提供常见的前端实现方式。
# 5.1 基于Ajax的文件下载
/**
* 通用文件下载函数 - 使用Axios
* 支持监控下载进度和错误处理
*
* @param {string} url 下载文件的URL
* @param {Object} params 请求参数(可选)
* @param {Function} onProgress 进度回调函数(可选)
* @returns {Promise} 下载结果Promise
*/
function downloadFile(url, params = {}, onProgress = null) {
return new Promise((resolve, reject) => {
// 使用Axios发送带进度监控的请求
axios({
method: 'GET',
url: url,
params: params,
responseType: 'blob', // 关键:指定响应类型为blob
// 配置进度回调
onDownloadProgress: onProgress ? (progressEvent) => {
if (progressEvent.lengthComputable) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
onProgress(percentCompleted, progressEvent);
}
} : undefined
}).then(response => {
// 从响应头中提取文件名
let fileName = 'download';
const contentDisposition = response.headers['content-disposition'];
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (fileNameMatch && fileNameMatch[1]) {
fileName = fileNameMatch[1].replace(/['"]/g, '');
// 解码文件名
try {
fileName = decodeURIComponent(fileName);
} catch (e) {
console.warn('文件名解码失败', e);
}
}
}
// 创建下载链接并触发下载
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// 清理资源
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
resolve({
fileName,
size: blob.size,
type: blob.type
});
}).catch(error => {
// 处理服务器端错误
if (error.response) {
const reader = new FileReader();
reader.onload = () => {
try {
// 尝试解析错误消息(通常是JSON格式)
const errorMessage = JSON.parse(reader.result);
reject({
status: error.response.status,
message: errorMessage.error || '下载失败',
error: errorMessage
});
} catch (e) {
// 无法解析JSON时,返回原始错误
reject({
status: error.response.status,
message: '下载失败',
error: error
});
}
};
// 尝试读取响应内容
if (error.response.data) {
reader.readAsText(error.response.data);
} else {
reject({
status: error.response.status,
message: '下载失败',
error: error
});
}
} else {
// 网络错误或请求被取消
reject({
message: error.message || '网络错误',
error: error
});
}
});
});
}
// 使用示例
document.getElementById('downloadBtn').addEventListener('click', function() {
const progressBar = document.getElementById('downloadProgress');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
downloadFile('/api/download/example.pdf', {}, (percent) => {
progressBar.style.width = percent + '%';
progressBar.textContent = percent + '%';
}).then(result => {
console.log('文件下载成功:', result);
alert('文件 ' + result.fileName + ' 下载成功');
}).catch(error => {
console.error('文件下载失败:', error);
alert('下载失败: ' + error.message);
});
});
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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# 5.2 基于下载码的前端实现
/**
* 基于下载码的两步式文件下载
* 先获取下载码,再使用下载码请求文件
*
* @param {string} fileId 文件标识符
* @param {number} expireMinutes 下载码有效期(分钟)
* @returns {Promise} 下载结果Promise
*/
function tokenBasedDownload(fileId, expireMinutes = 30) {
// 第一步:请求下载码
return axios.post('/api/token-download/generate-token', {
fileId: fileId,
expireMinutes: expireMinutes
}).then(response => {
const data = response.data;
if (!data.success) {
throw new Error(data.message || '获取下载码失败');
}
const downloadToken = data.token;
const downloadUrl = data.downloadUrl || `/api/token-download/file?token=${downloadToken}`;
// 第二步:使用下载码下载文件
return downloadFile(downloadUrl);
});
}
// 使用示例
document.getElementById('secureDownloadBtn').addEventListener('click', function() {
const fileId = this.getAttribute('data-file-id');
// 显示加载指示器
showLoading('正在准备下载...');
tokenBasedDownload(fileId)
.then(result => {
hideLoading();
showSuccess('文件下载成功');
})
.catch(error => {
hideLoading();
showError('下载失败: ' + error.message);
});
});
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
# 六、文件下载方案比较与选择指南
在选择合适的文件下载方案时,需要考虑多种因素,包括安全性要求、性能需求和应用场景。以下表格比较了各种下载方案的特点,帮助开发者做出选择。
下载方案 | 优点 | 缺点 | 适用场景 | 安全等级 |
---|---|---|---|---|
基础ResponseEntity下载 | 简单易用,Spring框架标准实现 | 缺乏精细控制,仅适合基本场景 | 公开文档、小型文件、简单场景 | 低 |
HttpServletResponse下载 | 控制精细,可定制响应细节 | 代码复杂度高,需手动管理资源 | 需要精确控制响应的场景 | 低-中 |
带安全认证的下载 | 权限控制完善,适合敏感文件 | 依赖认证框架,配置较复杂 | 企业内部文档、私密资源 | 高 |
断点续传分片下载 | 支持大文件、弱网环境、断点续传 | 实现复杂,需客户端配合 | 大型文件、视频、软件包 | 中 |
基于云存储的下载 | 分布式架构友好,扩展性强 | 依赖第三方服务,成本较高 | 分布式系统、高并发场景 | 中-高 |
基于下载码的下载 | 安全性高,灵活控制访问权限 | 实现复杂,需额外存储系统 | 临时分享、一次性下载 | 高 |
文件下载方案选择决策树
首先考虑文件的安全需求
- 文件是否公开可访问?
- 是 → 基础ResponseEntity下载
- 否 → 继续考虑其他因素
- 文件是否公开可访问?
评估文件大小和下载环境
- 文件是否大于100MB或在弱网环境下使用?
- 是 → 断点续传分片下载
- 否 → 继续评估
- 文件是否大于100MB或在弱网环境下使用?
考虑系统架构
- 是否为分布式/微服务架构?
- 是 → 基于云存储的下载
- 否 → 继续评估
- 是否为分布式/微服务架构?
评估访问控制需求
- 是否需要精细的权限控制?
- 是 → 带安全认证的下载
- 否 → 继续评估
- 是否需要精细的权限控制?
考虑特殊场景
- 是否需要临时授权或一次性分享?
- 是 → 基于下载码的下载
- 否 → 使用HttpServletResponse下载
- 是否需要临时授权或一次性分享?
# 七、文件下载最佳实践与优化建议
# 7.1 安全性最佳实践
路径遍历防护:验证文件路径,避免
../
等路径遍历攻击// 不安全的实现 File file = new File(baseDir + fileName); // 危险!可能导致路径遍历攻击 // 安全的实现 Path basePath = Paths.get(baseDir).normalize(); Path filePath = basePath.resolve(fileName).normalize(); if (!filePath.startsWith(basePath)) { throw new SecurityException("非法文件路径"); }
1
2
3
4
5
6
7
8
9文件类型验证:检查文件MIME类型,防止恶意文件下载
// 检查允许的文件类型 Set<String> allowedTypes = Set.of("application/pdf", "image/jpeg", "image/png"); String contentType = Files.probeContentType(filePath); if (!allowedTypes.contains(contentType)) { throw new SecurityException("不支持的文件类型"); }
1
2
3
4
5
6访问控制日志:记录所有下载请求,便于审计和安全分析
// 记录下载日志 logger.info("用户 [{}] 下载文件: {}, IP: {}, 时间: {}", username, fileName, request.getRemoteAddr(), new Date());
1
2
3
# 7.2 性能优化建议
使用缓冲流:采用适当大小的缓冲区提高传输效率
// 优化的缓冲区大小 byte[] buffer = new byte[8192]; // 8KB缓冲区,平衡内存使用和传输效率
1
2异步下载:对于大文件或高并发场景,使用异步处理
@GetMapping("/async/{fileName}") public DeferredResult<ResponseEntity<Resource>> asyncDownload(@PathVariable String fileName) { DeferredResult<ResponseEntity<Resource>> result = new DeferredResult<>(30000L); // 30秒超时 executorService.submit(() -> { try { // 耗时的文件处理逻辑 result.setResult(prepareFileDownload(fileName)); } catch (Exception e) { result.setErrorResult(e); } }); return result; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15合理使用预签名URL:在适当场景下使用云存储预签名URL,减轻服务器负担
// 生成预签名URL,客户端直接从云存储下载 @GetMapping("/direct/{objectKey}") public ResponseEntity<Map<String, String>> directDownload(@PathVariable String objectKey) { URL presignedUrl = ossClient.generatePresignedUrl(/* ... */); return ResponseEntity.ok(Map.of("downloadUrl", presignedUrl.toString())); }
1
2
3
4
5
6
# 7.3 用户体验优化
提供下载进度反馈:通过响应头和前端处理显示下载进度
// 后端设置Content-Length头 response.setContentLengthLong(fileSize); // 前端监听进度 xhr.addEventListener('progress', (event) => { if (event.lengthComputable) { const percentComplete = (event.loaded / event.total) * 100; updateProgressBar(percentComplete); } });
1
2
3
4
5
6
7
8
9
10提供文件预览选项:对于支持预览的文件类型,提供在线预览功能
@GetMapping("/preview/{fileName}") public ResponseEntity<Resource> previewFile(@PathVariable String fileName) { // 逻辑与下载类似,但Content-Disposition设置为inline return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + fileName + "\"") .body(resource); }
1
2
3
4
5
6
7优雅处理错误:当下载失败时提供友好的错误信息和恢复选项
// 前端错误处理 downloadFile(url).catch(error => { if (error.status === 401) { showLoginDialog("请登录后继续下载"); } else if (error.status === 404) { showError("文件不存在或已被删除"); } else { showError("下载失败: " + error.message); offerRetryOption(() => downloadFile(url)); } });
1
2
3
4
5
6
7
8
9
10
11