背景

今天组内研发同学报告说项目中有一个模块提供了文件下载的接口, 在实际使用中遇到有客户使用 迅雷 进行离线下载的情况, 同时模块中针对文件下载的接口做了 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;
    }

同样分别通过 curlaxel 针对该接口进行测试发现, 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

而非后半段呢 ? 原因在于 FileSystemResourceInputStreamResource 都继承自 AbstractResource, 而 AbstractResource 则继承自 Resource.

修改后的代码如下:

            res = ResponseEntity.ok()
                    .headers(headers)
                    .contentLength(file.length())
                    .contentType(MediaType.APPLICATION_OCTET_STREAM)
                    .body(new InputStreamResource(new FileInputStream(file)));

补充

  1. 以上修改后 axel 不再进行多线程下载了, 但 迅雷 还是会进行多线程下载. 经多次测试发现, 如果响应头中包含 Content-Length 则迅雷还是会进行多线程下载. 解决方案是不再设置该 Header. 另一点需要注意是, 如果不设置 Content-LengthNginx 会自动为我们加上 Transfer-Encoding: chunked 头. 根据定义可知 Transfer-EncodingContent-Length 是不兼容的.

  2. 通过以上排查路径可以知道, 这里关键的问题在于服务端在给客户端的响应头里额外增加了 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. 重启容器即可;

  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;"]

REF

  1. Support HTTP range requests in MVC Controllers
  2. Content-Length header versus chunked encoding