Spring Boot 项目下载文件接口问题
背景⌗
今天组内研发同学报告说项目中有一个模块提供了文件下载的接口, 在实际使用中遇到有客户使用 迅雷
进行离线下载的情况, 同时模块中针对文件下载的接口做了 AOP
切面进行计费统计. 而 迅雷
会针对该接口进行多线程并发下载, 同时即使退出 迅雷
, 迅雷
还会继续发起对文件下载接口的调用. 于是便抽空大致看了下项目内的代码, 出问题的接口功能也比较简单, 并没有很复杂的逻辑. 其大致实现如下:
@ApiOperation(value = "下载文件模板")
@GetMapping("/download/{y}/{m}/{d}/{filePath}")
public ResponseEntity<FileSystemResource> download(@PathVariable String y,
@PathVariable String m,
@PathVariable String d,
@PathVariable String filePath) {
filePath = y + "/" + m + "/" + d + "/" + filePath;
return HBFileUtil.downloadFile(fileManageService.download(filePath));
}
----
public static ResponseEntity downloadFile(File file) {
if (!file.isFile() || FileUtil.isEmpty(file)) {
ResultVO notFound = ResultUtil.error(HttpStatus.NOT_FOUND.value(), HttpStatus.NOT_FOUND.getReasonPhrase());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(notFound);
}
HttpHeaders headers = new HttpHeaders();
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
headers.add("Content-Disposition", "attachment; filename=" + file.getName());
headers.add("Pragma", "no-cache");
headers.add("Expires", "0");
headers.add("Last-Modified", new Date().toString());
headers.add("ETag", String.valueOf(System.currentTimeMillis()));
return ResponseEntity.ok()
.headers(headers)
.contentLength(file.length())
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(new FileSystemResource(file));
}
排查 - 1⌗
首先的疑问便是 迅雷
果不其然如此暴力, 但转念一想也不应该. 即使 迅雷
以流氓著称, 但其核心还是进行多线程下载. 而多线程下载的前提是下载接口支持分片, 显然模块内接口是不支持的. 所以这个问题应该不那么简单.
排查 - 2⌗
首先是起了一个 DEMO
工程, 参照现有模块中文件下载接口 Mock
了一个类似的接口:
@RequestMapping(path = "/download", method = RequestMethod.GET)
private void downadFileGET(@RequestParam(value = "id", required = false, defaultValue = "1") String fileId,
HttpServletResponse response) {
FileDownloadBO fileDownloadBO = FileDownloadBO.builder()
.id(fileId)
.build();
log.info("下载开始: {} 时间: {}", fileDownloadBO.getId(), System.currentTimeMillis());
try {
File file = new File(fileService.checkFile(fileDownloadBO));
BufferedInputStream is = new BufferedInputStream(new FileInputStream(file));
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Content-Disposition", "attachment; filename=" + file.getName());
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
response.setHeader("Last-Modified", new Date().toString());
response.setHeader("ETag", String.valueOf(System.currentTimeMillis()));
response.setContentLengthLong(file.length());
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
IOUtils.copy(is, response.getOutputStream());
response.flushBuffer();
log.info("下载成功: {} 时间: {}", fileDownloadBO.getId(), System.currentTimeMillis());
} catch (IOException e) {
log.error("拷贝文件流异常: {} 时间: {}", e.getMessage(), System.currentTimeMillis());
} finally {
log.info("下载结束: {} 时间: {}" , fileDownloadBO.getId(), System.currentTimeMillis());
}
}
通过 curl
测试可正常下载文件:
export CURL="/usr/bin/curl"
export BASE_URL="http://192.168.1.4:8080"
"${CURL}" -X GET "${BASE_URL}/v1/file/download"
再通过 axel
工具进行测试, 发现 axel
指出该接口不支持多线程下载并切换为单线程下载:
export AXEL="/usr/bin/axel"
export BASE_URL="http://192.168.1.4:8080"
"${AXEL}" -v -n 20 -o f.f "${BASE_URL}/v1/file/download"
输出如下:
Initializing download: http://192.168.1.3:8080/v1/file/download
File size: 173.246 Megabyte(s) (181661264 bytes)
Opening output file f.f
Server unsupported, starting from scratch with one connection.
Starting download
[ 12%] [....0 ] [ 10.2MB/s] [00:14]
对应的服务端日志为:
2023-02-24 09:47:16.001 INFO 80611 --- [nio-8080-exec-4] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203236001
2023-02-24 09:47:16.003 ERROR 80611 --- [nio-8080-exec-4] c.e.d.controller.FileController : 拷贝文件流异常: java.io.IOException: Broken pipe 时间: 1677203236003
2023-02-24 09:47:16.003 INFO 80611 --- [nio-8080-exec-4] c.e.d.controller.FileController : 下载结束: 1 时间: 1677203236003
2023-02-24 09:47:16.006 INFO 80611 --- [nio-8080-exec-5] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203236006
可以明显看到 axel
调用了两次文件下载接口. 这是符合预期的, 因为服务端不支持分片/断点下载, 所以 axel
在尝试分片失败后切换为常规单线程下载.
排查 - 3⌗
经与模块内有问题的代码进行对比发现区别较大的地方在于:
一个是接口的返回类型, 有问题的接口返回的是 `ResponseEntity`, 而正常的接口无返回值; 也正因为此, 有问题的接口通过 ResponseEntity 通过 body 参数返回, 而正常的接口则通过 IO 流拷贝的形式传输数据.
因此同样 Mock
了一个返回类型为 ResponseEntity
的接口如下:
@RequestMapping(path = "/downloadR", method = RequestMethod.GET)
private ResponseEntity downloadFileGETResponseEntity(@RequestParam(value = "id", required = false, defaultValue = "1") String fileId) {
FileDownloadBO fileDownloadBO = FileDownloadBO.builder()
.id(fileId)
.build();
log.info("下载开始: {} 时间: {}", fileDownloadBO.getId(), System.currentTimeMillis());
File file = new File(fileService.checkFile(fileDownloadBO));
HttpHeaders headers = new HttpHeaders();
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
headers.add("Content-Disposition", "attachment; filename=" + file.getName());
headers.add("Pragma", "no-cache");
headers.add("Expires", "0");
headers.add("Last-Modified", new Date().toString());
headers.add("ETag", String.valueOf(System.currentTimeMillis()));
ResponseEntity res = null;
try {
res = ResponseEntity.ok()
.headers(headers)
.contentLength(file.length())
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(new FileSystemResource(file));
} catch (Exception e) {
throw new RuntimeException(e);
}
return res;
}
同样分别通过 curl
和 axel
针对该接口进行测试发现, axel
接口在该接口上进行了多线程并发下载, axel
的输出如下:
Initializing download: http://192.168.1.3:8080/v1/file/downloadR
File size: 173.246 Megabyte(s) (181661264 bytes)
Opening output file f.f
Starting download
[ 9%] [.01.2.3.45 6 7 8 9A B C D E F G H I J ] [ 10.1MB/s] [00:15]
同时也可看到服务端日志也明确说明该接口被进行了多次调用:
2023-02-24 09:56:05.877 INFO 80611 --- [nio-8080-exec-7] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765877
2023-02-24 09:56:05.882 INFO 80611 --- [nio-8080-exec-8] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765882
2023-02-24 09:56:05.882 INFO 80611 --- [nio-8080-exec-9] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765882
2023-02-24 09:56:05.882 INFO 80611 --- [nio-8080-exec-1] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765882
2023-02-24 09:56:05.882 INFO 80611 --- [nio-8080-exec-2] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765882
2023-02-24 09:56:05.882 INFO 80611 --- [io-8080-exec-10] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765882
2023-02-24 09:56:05.883 INFO 80611 --- [nio-8080-exec-3] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765883
2023-02-24 09:56:05.884 INFO 80611 --- [nio-8080-exec-4] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765884
2023-02-24 09:56:05.884 INFO 80611 --- [nio-8080-exec-5] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765884
2023-02-24 09:56:05.885 INFO 80611 --- [io-8080-exec-11] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765885
2023-02-24 09:56:05.886 INFO 80611 --- [nio-8080-exec-7] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765886
2023-02-24 09:56:05.885 INFO 80611 --- [nio-8080-exec-6] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765885
2023-02-24 09:56:05.889 INFO 80611 --- [io-8080-exec-13] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765889
2023-02-24 09:56:05.893 INFO 80611 --- [io-8080-exec-14] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765893
2023-02-24 09:56:05.894 INFO 80611 --- [io-8080-exec-15] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765894
2023-02-24 09:56:05.897 INFO 80611 --- [io-8080-exec-16] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765897
2023-02-24 09:56:05.900 INFO 80611 --- [io-8080-exec-17] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765900
2023-02-24 09:56:05.901 INFO 80611 --- [io-8080-exec-18] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765901
2023-02-24 09:56:05.903 INFO 80611 --- [io-8080-exec-19] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765903
2023-02-24 09:56:05.908 INFO 80611 --- [io-8080-exec-20] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765908
2023-02-24 09:56:05.886 INFO 80611 --- [io-8080-exec-12] c.e.d.controller.FileController : 下载开始: 1 时间: 1677203765886
分析 - 1⌗
至此问题便比较清晰了, 如果使用 ResponseEntity
的话客户端就会误认为该接口支持分片下载, 而如果直接通过 IO
流进行拷贝的话则不会有这个问题. 那么为什么会导致这个问题呢, 是哪里让客户端误认为服务器支持分片下载呢 ? 首先想到的可能便是服务端在返回时通过 Header
告诉客户端该接口是支持分片下载的, 通过 curl
的 -v
参数可以看到请求和响应头:
以下内容是正常接口的请求和响应头:
* TCP_NODELAY set
* Connected to 192.168.1.3 (192.168.1.3) port 8080 (#0)
> GET /v1/file/download HTTP/1.1
> Host: 192.168.1.3:8080
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200
< Cache-Control: no-cache, no-store, must-revalidate
< Content-Disposition: attachment; filename=small.data
< Pragma: no-cache
< Expires: 0
< Last-Modified: Fri Feb 24 10:21:14 CST 2023
< ETag: 1677205274463
< Content-Type: application/octet-stream
< Content-Length: 181661264
< Date: Fri, 24 Feb 2023 02:21:14 GMT
<
以下内容是客户端误认为支持分片下载的请求和响应头:
> GET /v1/file/downloadR HTTP/1.1
> Host: 192.168.1.3:8080
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200
< ETag: "1677205253508"
< Cache-Control: no-cache, no-store, must-revalidate
< Content-Disposition: attachment; filename=large.data
< Pragma: no-cache
< Expires: 0
< Accept-Ranges: bytes
< Content-Type: application/octet-stream
< Content-Length: 181661264
< Date: Fri, 24 Feb 2023 02:20:53 GMT
<
我们主要分析响应部分, 通过对比可以发现有问题的接口在响应头里多了一个 Accept-Ranges: bytes
, 很明显该 Header
是用于分片/断点下载的.
而分析上面的代码可以看到没有配置该 Header
, 那么是哪里添加了该内容呢 ? 不难想到应该是 Spring
替我们做了这些事情, 那么如何知道 Spring
是在哪一步为我们做这些事情的呢 ?
继续分析⌗
我们通过在 application.properities
文件中中增加 debug=true
配置来查看在我们的 Controller
return
以后还走了哪些路径. 此时我们仍然通过 curl
进行调用而不再使用支持断点续传的客户端以免产生多次调用影响我们的分析进程. 我们只关心 Controller
返回以后的逻辑, 故服务端日志如下:
2023-02-24 10:28:41.202 INFO 81955 --- [nio-8080-exec-1] c.e.d.controller.FileController : 下载开始: 1 时间: 1677205721202
2023-02-24 10:28:41.218 DEBUG 81955 --- [nio-8080-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor : Found 'Content-Type:application/octet-stream' in response
2023-02-24 10:28:41.218 DEBUG 81955 --- [nio-8080-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor : Writing [file [/tmp/large.data]]
2023-02-24 10:28:41.232 DEBUG 81955 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Failed to complete request: org.apache.catalina.connector.ClientAbortException: java.io.IOException: Broken pipe
最后一行报错亦可忽略, 这是因为 curl
不支持文件下载的原因(仅测试, 未完全配置). 通过以上日志可以看到, 在我们的 Controller
返回后, 还额外走了 HttpEntityMethodProcessor
两部分的逻辑. 在 HttpEntityMethodProcessor
内通过相关的关键字没有匹配到具体的代码, 但 HttpEntityMethodProcessor
继承自 AbstractMessageConverterMethodProcessor
, 最终我们在 AbstractMessageConverterMethodProcessor
内通过 Found
, Writing
关键字可定位具体的代码. 由于 Spring
替我们增加了 Accept-Ranges: bytes
头, 因此我们再通过 bytes
关键字
最终我们定位到在 AbstractMessageConverterMethodProcessor.java
文件中有我们想要的内容:
, 在其中搜索 bytes
便可找到如下代码:
if (isResourceType(value, returnType)) {
outputMessage.getHeaders().set(HttpHeaders.ACCEPT_RANGES, "bytes");
if (value != null && inputMessage.getHeaders().getFirst(HttpHeaders.RANGE) != null &&
outputMessage.getServletResponse().getStatus() == 200) {
Resource resource = (Resource) value;
try {
List<HttpRange> httpRanges = inputMessage.getHeaders().getRange();
outputMessage.getServletResponse().setStatus(HttpStatus.PARTIAL_CONTENT.value());
body = HttpRange.toResourceRegions(httpRanges, resource);
valueType = body.getClass();
targetType = RESOURCE_REGION_LIST_TYPE;
}
catch (IllegalArgumentException ex) {
outputMessage.getHeaders().set(HttpHeaders.CONTENT_RANGE, "bytes */" + resource.contentLength());
outputMessage.getServletResponse().setStatus(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE.value());
}
}
}
至此, 可以知道确实是 Spring
为我们主动做了这件事, 如何解决呢 ?
可以看到这部分有一个前置条件:
protected boolean isResourceType(@Nullable Object value, MethodParameter returnType) {
Class<?> clazz = getReturnValueType(value, returnType);
return clazz != InputStreamResource.class && Resource.class.isAssignableFrom(clazz);
}
因此一个解决方案便是输出流使用 InputStreamResource
而不是 FileSystemResource
. 至于为何修改该判断条件的前半段, 即:
clazz != InputStreamResource.class
而非后半段呢 ? 原因在于 FileSystemResource
和 InputStreamResource
都继承自 AbstractResource
, 而 AbstractResource
则继承自 Resource
.
修改后的代码如下:
res = ResponseEntity.ok()
.headers(headers)
.contentLength(file.length())
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(new InputStreamResource(new FileInputStream(file)));
补充⌗
以上修改后
axel
不再进行多线程下载了, 但迅雷
还是会进行多线程下载. 经多次测试发现, 如果响应头中包含Content-Length
则迅雷还是会进行多线程下载. 解决方案是不再设置该Header
. 另一点需要注意是, 如果不设置Content-Length
则Nginx
会自动为我们加上Transfer-Encoding: chunked
头. 根据定义可知Transfer-Encoding
和Content-Length
是不兼容的.通过以上排查路径可以知道, 这里关键的问题在于服务端在给客户端的响应头里额外增加了
Accept-Ranges: bytes
, 如在一些不便于修改代码的场景下也可以通过Nginx
将部分响应头去除的方式修复.2.1. 使用
openresty/openresty
镜像代替默认的nginx
;2.2. 相对于原始的
nginx
配置文件做如下修改:# nginx.conf 中添加 load_module "modules/ngx_http_headers_more_filter_module.so"; # 在 server 的 location 段落中添加 more_clear_headers 'Accept-Ranges'; more_clear_headers 'Content-Length';
axel
在移除Accept-Ranges
即不再进行多线程下载, 但迅雷
在检测到Content-Length
响应头后依然会发起多线程进行下载.2.3. 重启容器即可;
也可使用
debian
容器中安装nginx-extras
包来应用headers_more_filter
模块:
FROM debian:latest
RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list && \
apt-get update -y && \
apt-get install nginx nginx-extras -y && \
rm -rf /var/lib/apt/lists/*
CMD ["nginx", "-g", "daemon off;"]