背景需求

  1. 最近发现系统中有不少功能的下载文件涉及到较大文件
  2. 当超过1G的文件下载时,直接通过浏览器下载,可能出现下载失败现象
  3. 下载失败表现为下载文件损坏,或重复重试下载
  4. 大文件的下载会因为网络波动、会话连接超时、文件IO读写错误等各种因素导致出现问题
  5. 因此考虑提供一种可以支持文件分片下载、端点续传的文件下载服务
  6. 下面看具体实现,代码注释都很丰富,具体可细参考

解决方案

  1. 核心利用HTTP请求头中携带的Range参数确定下载的文件分片
  2. 关键要保证每个分片的完整性和不重叠性
  3. 每个分片大小具体取决与客户端
  4. 该下载服务,支持IDM、迅雷等专业下载工具分片并行下载
  5. 同时也支持网络中断后,再次重试下载时可接续下载,而非重头在开始
  6. 注意:亲测Chrome浏览器默认的文件机制为单线程下载,不支持分片

架构说明

  1. Springboot2.x
  2. 第三方依赖见如下
<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-autoconfigure</artifactId></dependency><!-- 文件上传工具类--><dependency><groupId>commons-fileupload</groupId><artifactId>commons-fileupload</artifactId><version>1.3.1</version></dependency><!-- http客户端工具包 --><dependency><groupId>org.apache.httpcomponents</groupId><artifactId>httpcore</artifactId></dependency><dependency><groupId>org.apache.httpcomponents</groupId><artifactId>httpclient</artifactId></dependency><dependency><groupId>commons-io</groupId><artifactId>commons-io</artifactId><version>2.6</version></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency>
</dependencies>

核心下载服务实现

/*** @author qinchen* @date 2021/7/27 15:39* @description 文件下载控制器*/
@Slf4j(topic = "DownloadController")
@RestController
@RequestMapping("/file/download")
public class DownloadController {/*** 请求头key  Range*/public static final String HEADER_RANGE = "Range";/*** 支持分片端点下载,关键在于获取到请求头中的Range参数进行控制** @param request* @param response* @return*/@GetMapping("/range/{fileId}")public String downloadFileByRange(@PathVariable("fileId")String fileId,@RequestParam(value = "delay",defaultValue = "100") Long delayFactor,HttpServletRequest request, HttpServletResponse response) {File file = getDownloadFile(fileId);if(file == null) {return "Download fail, file is not exists";}log.info("准备下载文件,文件名为 {}", file.getName());InputStream is = null;OutputStream os = null;try {// 获取到文件总大小long fSize = file.length();log.info("文件总大小为 {}", fSize);response.setCharacterEncoding("UTF-8");response.setContentType("application/x-download");String fileName = URLEncoder.encode(file.getName(), "UTF-8");response.addHeader("Content-Disposition", "attachement;fileName=" + fileName);// 告诉浏览器,支持分片下载response.addHeader("Accept-Range", "bytes");// 下面两个自定义响应头,给 Java 客户端用response.addHeader("fSize", String.valueOf(fSize));response.addHeader("fName", fileName);// pos 为开始位置,last最后位置,sum已下载总和long pos = 0, last = fSize - 1, sum = 0;// 判断客户是否支持分片下载,请求头中带有 Range 即为支持分片,Chrome浏览器不支持,需要专用工具如:IDM,迅雷,或Java客户端等if (null != request.getHeader(HEADER_RANGE)) {log.info("分片下载中,range信息为 {}", request.getHeader(HEADER_RANGE));// 206 表示响应给下载客户端,服务器支持分片下载response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);// 分片头 Range 的格式, http 协议约定为: Range bytes=100-1000,下面做解析,拿到分片起始与终止位置标识String numRange = request.getHeader(HEADER_RANGE).replaceAll("bytes=", "");String[] strRange = numRange.split(FileConstants.DASH);if (strRange.length == 2) {pos = Long.parseLong(strRange[0].trim());last = Long.parseLong(strRange[1].trim());// 下载器正常情况下不会出现该情况,此处是为了防止,增强代码可靠性if (last > fSize - 1) {last = fSize - 1;}} else {// 某些特殊情况下,有可能传过来 range 是 bytes=129019- 这样的格式pos = Long.parseLong(numRange.replace(FileConstants.DASH, "").trim());}} else {log.warn("客户端本次进行非分片下载");}// 下面考虑每次读取文件的多少位置内容,返回给客户端? 假设:pos=0 last=9,则读 10 个长度long rangeLength = last - pos + 1;// http 分片下载 约定的格式,Http 规范,注意bytes后面有个【空格】,设置到响应头中String contentRange = new StringBuffer("bytes ").append(pos).append(FileConstants.DASH).append(last).append("/").append(fSize).toString();response.setHeader("Content-Range", contentRange);response.setHeader("Content-Length", String.valueOf(rangeLength));os = new BufferedOutputStream(response.getOutputStream());// 创建整个分片文件的输入流is = new BufferedInputStream(new FileInputStream(file));// 很关键:跳过前面的分片段,相当于从pos开始读取文件段is.skip(pos);byte[] buffer = new byte[1024];int length = 0;int j = 0;// 读取的分片总长度和 小于 当前文件分片长度,则继续循环读取while (sum < rangeLength) {// 分片总长度 - 已读长度 = 剩余需读取的长度long readLength = rangeLength - sum;// 剩余需读取的长度若大于缓冲区长度,则先就读 缓冲区长度,剩余部分等下次循环继续读if (readLength > buffer.length) {readLength = buffer.length;}length = is.read(buffer, 0, (int) readLength);sum = sum + length;os.write(buffer, 0, length);// 延迟因子参数可故意让下载拖延时间,以便观察和调试if (delayFactor != 0 && j % 2000 == 0) {TimeUnit.MILLISECONDS.sleep(delayFactor);log.info(">>>>> 文件下载中 length = {}", length);}j++;}log.info("文件下载完毕");return "Download file success";} catch (IOException e) {log.warn("下载异常终止:{}", e.getMessage());} catch (Exception e) {e.printStackTrace();} finally {try {is.close();os.close();} catch (IOException e) {log.warn("关闭文件流异常:{}", e.getMessage());}}return "Download fail";}/*** 通过文件ID获取文件对象* @param fileId* @return*/private File getDownloadFile(String fileId) {// 演示下载的文件对象File file = null;if("1".equals(fileId)) {file = new File("E:\\soft\\BootCamp5.0.5033.zip");} else if("2".equals(fileId)) {file = new File("E:\\life\\movie\\阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv");} else {// 根据fileId查询数据库,得到文件信息,一般从文件服务器先下载到本地,后面逻辑进行对文件的分片响应}return file;}/*** 非分片下载,普通小文件下载** @param fileId 文件 ID* @param response* @return*/@GetMapping("/{fileId}")public String downloadFile(@PathVariable("fileId")String fileId, HttpServletResponse response) {// 这里作为演示,将要下载的文件直接写死File file = getDownloadFile(fileId);FileInputStream fis = null;BufferedInputStream bis = null;// 设置响应头等信息,注意,如果文件名称包含中文,需要使用URLEncoder转码,否则文件名将乱码try {String fileName = URLEncoder.encode(file.getName(), "UTF-8");response.setContentType("application/x-download");response.addHeader("Content-Disposition", "attachement;fileName=" + fileName);// 设置缓冲区大小byte[] buffer = new byte[1024];fis = new FileInputStream(file);bis = new BufferedInputStream(fis);ServletOutputStream outputStream = response.getOutputStream();int i = bis.read(buffer);int j = 0;while (i != -1) {// 此处为了本地演示下载过程,故将文件下载速度放慢if (j % 2000 == 0) {TimeUnit.MILLISECONDS.sleep(100);log.info(">>>>> 文件下载中 i = {}", i);}j++;outputStream.write(buffer, 0, i);i = bis.read(buffer);}log.info("下载完毕");return "download success";} catch (IOException e) {log.warn("下载异常终止:{}", e.getMessage());} catch (Exception e) {e.printStackTrace();} finally {try {bis.close();fis.close();} catch (IOException e) {e.printStackTrace();}}return "download fail";}
}

对比普通下载,分片下载的逻辑相对复杂不少,所以,编写代码过长中需细心,仔细考虑每个细节,否则将导致客户下载的文件最终合并时得不到完整的源文件;

Java下载客户端

  1. 为了测试,可以自己编写Java客户进行下载测试
  2. 当然,也可以用IDM、迅雷等客户端测试并发下载的效果
  3. 这里编写此客户端,仅为了测试
  4. 当然,该客户端也可以用于系统中下载外部文件时使用,加快下载大文件速度
  5. 也可以自己日常用,下载第三方大型软件等场景,当然前提是人家的下载服务器支持分片下载
/*** @author qinchen* @date 2021/8/4 11:23* @description 下载客户端,模拟多线程文件下载*/
@Slf4j(topic = "DownloadClient")
@RestController
@RequestMapping("/file/client")
public class DownloadClient {/*** 定义文件下载的分片大小,这里为 50M*/private final static long PER_PAGE = 1024 * 1024 * 10L;/*** 下载的本地目录*/private final static String DOWN_PATH = "E:\\fileItem";/*** 多线程下载的线程池,定义为【4】个线程同时下载*/ExecutorService pool = Executors.newFixedThreadPool(4);/*** 需要的参数:文件大小、名称* 探测:先探测下,获取到文件信息* 多线程分片下载* 最后一个分片下载完成,开始合并** @return*/@PostMapping("/download")public String downloadFile(@RequestBody FileInfo downloadFileInfo) {if (StringUtils.isEmpty(downloadFileInfo.getDownloadUrl())) {// 测试地址downloadFileInfo.setDownloadUrl("http://192.168.2.12:8080/file/download/range/2");}// 第一次探测,只下载【10】个字节,目的只为了拿到文件的基本信息,文件名称暂时传空,实际应用文件id,防止多文件下载出现并发问题FileInfo fileInfo = download(downloadFileInfo.getDownloadUrl(), 0, 10, -1, null);// 分片下载if (fileInfo != null) {// 拿下载文件总大小 除 分片大小,得到总分片数long pages = fileInfo.getfSize() / PER_PAGE;for (long i = 0; i <= pages; i++) {// 特别注意,字节不能重复long start = i * PER_PAGE;long end = (i + 1) * PER_PAGE - 1;pool.submit(new DownLoadTask(downloadFileInfo.getDownloadUrl(), start, end, i, fileInfo.getfName()));}}return "success";}/*** 内部类,下载任务,对Runnable进行简单包装*/class DownLoadTask implements Runnable {/*** 下载的文件地址*/private String downloadUrl;/*** 起始位置*/private long start;/*** 结束位置*/private long end;/*** 分片页码*/private long page;/*** 文件名称*/private String fName;@Overridepublic void run() {// 执行下载动作FileInfo download = download(downloadUrl, start, end, page, fName);log.info("文件名 {} 分片码 {} 对应的分片内容已经下载完毕", download.getfName(), page);}public DownLoadTask(String downloadUrl, long start, long end, long page, String fName) {this.start = start;this.end = end;this.page = page;this.fName = fName;this.downloadUrl = downloadUrl;}}/*** 文件下载* 分片文件的信息:** @param start 起始位置* @param end   结束位置* @param page  当前片码* @param fName 文件名称* @return 分片文件对象*/public FileInfo download(String downloadUrl, long start, long end, long page, String fName) {if (page == -1) {log.info("下载探测文件:{}", fName);} else {log.info("下载分片文件:{},分片序号 {}", fName, page);}// 创建一个分片文件对象File file = new File(DOWN_PATH, page + "-" + fName);// 两次分片下载,分片需要一致,否则重新开始下载;还要考虑分片是否损坏,page == -1 表示 是 探测下载if (file.exists() && page != -1 && file.length() == PER_PAGE) {// 分片文件已存在,并且是非探测下载,并且长度与分片大小一致,说明该分片在之前已经成功下载过了log.info("此分片文件 {} 已存在,免下载", page);return null;}long fSize = 0L;try {HttpClient client = HttpClients.createDefault();HttpGet httpGet = new HttpGet(downloadUrl);// 此处是关键,告诉服务端,这次下载给客户端传下载文件的哪个段范围的内容httpGet.setHeader("Range", "bytes=" + start + "-" + end);HttpResponse response = client.execute(httpGet);// 获取到文件大小及文件名称if (response.getFirstHeader("fSize") == null) {URL url = new URL(downloadUrl);URLConnection conn = url.openConnection();int fileSize = conn.getContentLength();fSize = fileSize;} else {fSize = Long.valueOf(response.getFirstHeader("fSize").getValue());}log.info("将要下载的文件大小为:{} bytes, headerFields {}", fSize);if (response.getFirstHeader("fName") == null) {fName = downloadUrl.substring(downloadUrl.lastIndexOf("/") + 1);} else {fName = URLDecoder.decode(response.getFirstHeader("fName").getValue(), "UTF-8");}HttpEntity entity = response.getEntity();InputStream is = entity.getContent();// 将分片内容写入临时存储分片文件FileOutputStream fos = new FileOutputStream(file);byte[] buffer = new byte[1024];int ch;while ((ch = is.read(buffer)) != -1) {fos.write(buffer, 0, ch);}is.close();fos.flush();fos.close();// 判断是否是最后一个分片if (end - fSize >= 0) {log.info("最后一个分片文件已下载完毕,准备合并文件");mergeFile(fName, page);}} catch (IOException e) {e.printStackTrace();}return new FileInfo(fSize, fName);}/*** 将已经下载的分片内容在本地进行合并,最终得到一个完整的文件** @param fName* @param page* @throws IOException* @throws InterruptedException*/private void mergeFile(String fName, long page) {// TODO 这里要考虑完善,如果下载的文件在目录中已经存在,是覆盖、还是重命名?File file = new File(DOWN_PATH, fName);try {BufferedOutputStream os = new BufferedOutputStream(new FileOutputStream(file));// 遍历所有的分片文件,按顺序合并for (int i = 0; i <= page; i++) {// 得到每个分片文件对象File tempFile = new File(DOWN_PATH, i + "-" + fName);// 此类逻辑与分片上传是同理while (!tempFile.exists() || (i != page && tempFile.length() < PER_PAGE)) {try {Thread.sleep(100);} catch (InterruptedException interruptedException) {interruptedException.printStackTrace();}// 这里考虑异常情况导致死循环问题处理}log.info("所有分片文件已下载完毕,合并文件中,合并分片 {}", i);byte[] bytes = FileUtils.readFileToByteArray(tempFile);os.write(bytes);os.flush();// 合并后,就将临时分片文件删除掉tempFile.delete();log.info("分片 {} 已合并完成,进行清理", i);}log.info("文件合并结束,最后删除探测文件");File tanceFile = new File(DOWN_PATH, "-1-null");tanceFile.delete();os.flush();os.close();} catch (IOException e) {e.printStackTrace();}log.info("恭喜您,文件{}下载完成!", fName);}
}

最后效果展示

  1. 简单写个UI,测试下
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>文件下载</title><script src="https://cdn.jsdelivr.net/npm/vue@2"></script><script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
<div id="app"><form action="###">请输入文件下载地址:<input type="text" v-model="downloadUrl" name="downloadUrl"><按钮 @click="download">下载</按钮></form>
</div><script>var app = new Vue({el: '#app',data: {message: 'Hello Vue!',downloadUrl: ''},methods: {download: function(){//alert(this.downloadUrl);//return;axios.post("/file/client/download",{'downloadUrl':this.downloadUrl},{headers:{'Content-Type':'application/json'}}).then(function(d) {console.log(d);}).catch(function(error){console.log(error)});}}})
</script>
</body>
</html>
  1. 后台日志信息:
: 下载探测文件:null
: 准备下载文件,文件名为 阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv
: 文件总大小为 1353617649
: 分片下载中,range信息为 bytes=0-10
: >>>>> 文件下载中 length = 11
: 文件下载完毕
: 将要下载的文件大小为:1353617649 bytes, headerFields {}
: 下载分片文件:阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv,分片序号 1
: 下载分片文件:阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv,分片序号 2
: 下载分片文件:阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv,分片序号 0
: 下载分片文件:阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv,分片序号 3
: 准备下载文件,文件名为 阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv
: 准备下载文件,文件名为 阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv
: 准备下载文件,文件名为 阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv
: 准备下载文件,文件名为 阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv
: 文件总大小为 13536176492021-08-06 11:34:08.265  INFO 23228 --- [nio-8080-exec-2] DownloadController
: 文件总大小为 1353617649
: 文件总大小为 1353617649: 分片下载中,range信息为 bytes=0-10485759
: 分片下载中,range信息为 bytes=31457280-41943039
: 分片下载中,range信息为 bytes=20971520-31457279
: 分片下载中,range信息为 bytes=10485760-2097151……
: 所有分片文件已下载完毕,合并文件中,合并分片 128
: 分片 129 已合并完成,进行清理
: 文件合并结束,最后删除探测文件
: 恭喜您,文件阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv下载完成!
: 文件名 阳光电影www.ygdy8.com.恐怖分子的孩子.BD.720p.中英双字幕.mkv 分片码 129 对应的分片内容已经下载完毕

Java Web 实现文件多线程分片下载方案相关推荐

  1. java web fileupload_javaweb 文件上传(fileupload) 下载

    1 文件上传 html中通过可以向服务器上传文件.不过后台需要手动解析请求,比较复杂,所以可以使用smartupload或apache的fileupload组件进行文件的上传.smartupload据 ...

  2. Java实现大文件多线程下载,提速30倍!想学?我教你啊

    前言 在上一篇文章 <面试官不讲武德>对Java初级程序猿死命摩擦Http协议 中,我们有提到大文件下载和断点续传,本篇我们就来开发一个多线程文件下载器,最后我们用这个多线程下载器来突破云 ...

  3. SpringMVC Web实现文件上传下载功能实例解析

    需求: 项目要支持大文件上传功能,经过讨论,初步将文件上传大小控制在20G内,因此自己需要在项目中进行文件上传部分的调整和配置,自己将大小都以20G来进行限制. PC端全平台支持,要求支持Window ...

  4. android 多文件多线程断点续传下载

    今天跟大家一起分享下android开发中比较难的一个环节,可能很多人看到这个标题就会感觉头很大,的确如果没有良好的编码能力和逻辑思维,这块是很难搞明白的,前面2次总结中已经为大家分享过有关技术的一些基 ...

  5. Java Servlet实现文件上传下载操作

    1.配置对应的文件,导入相应的包 对应包下载地址:https://wws.lanzous.com/ipFtEoyv1je 2.编写jsp页面 代码如下: <%@ taglib prefix=&q ...

  6. java web打开文件_Java web 如何打开本地文件夹?

    使用情景 有一个只在一台 Windows7 上使用的 Java web 项目,浏览器是 Chrome.它需要点击一个链接可以打开指定的本地文件夹,目录会有中文. 服务器环境 Jdk1.7.Tomcat ...

  7. Box浅度接触-Java实现Box文件上传下载

    背景 Box(https://www.box.com/home)是定义为内容云,在我有限认知里面,感觉应该和云存储系统没啥区别.近日,有幸和Box做了一次浅度接触,颇为缠绵,记录在这里供有需要的朋友参 ...

  8. 初学Java Web(7)——文件的上传和下载

    文件上传 文件上传前的准备 在表单中必须有一个上传的控件 <input type="file" name="testImg"/> 因为 GET 方式 ...

  9. Java Web之文件的上传及下载

    一.文件的上传 1. 简介 > 将一个客户端的本地的文件发送到服务器中保存. > 上传文件是通过流的形式将文件发送给服务器. 2.表单的设置 1.向服务器上传一个文件时,表单要使用post ...

最新文章

  1. ORA-01940无法删除当前已连接用户
  2. 如何安装和使用RAutomation
  3. 2022-02-03--银河麒麟-银河麒麟v4与.netcore安装
  4. 细思恐极,插上U盘就开始执行Python代码的程序
  5. 前端学习(3267):js中this在类中的表现
  6. git添加多远端服务器并且实现push代码
  7. 听说你的模型训练耗时太长?来昇腾开发者沙龙找解决方案
  8. jdk线程的同步问题
  9. typecho图标_使你的Typecho支持Emoji表情
  10. 转:施炜:铁军组织是怎样炼成的?高能组织=人×管理体系×数字标准
  11. 经典论文阅读笔记——VIT、Swin Transformer、MAE、CILP
  12. 双机热备 ip地址_双机热备软件哪个好?双机热备软件推荐
  13. 五面阿里拿下飞猪事业部offer,先睹为快
  14. 一亩三分地-每日答题
  15. 如何在网页中嵌入商务通对话框
  16. [work] 什么是对抗攻击
  17. 畅购商城 04商品发布
  18. [Swift通天遁地]一、超级工具-(16)使用JTAppleCalendar制作美观的日历
  19. java生成图片水印
  20. 洛谷 P2181对角线——排列组合

热门文章

  1. 线性规划之二 —— 单纯形算法(详解)
  2. 【翻译】Wide Deep Learning for Recommender Systems--推荐系统的广泛深度学习
  3. 《数据结构》实验报告二:顺序表 链表
  4. Web课程设计高校物资管理系统
  5. 思科SCCP CIPC软电话安装成SIP软电话
  6. Linux firefox 网页截图
  7. 解读电力调度、电力市场、技术创新,国网南网新型电力系统行动方案
  8. RuoYi-Flowable 工作流管理平台
  9. [转]String 之 new String()和 intern()方法深入分析
  10. Go实战--golang中使用Goji微框架(Goji+Mongodb构建微服务)