栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

Java Web 实现文件多线程分片下载方案

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

Java Web 实现文件多线程分片下载方案

背景需求
    最近发现系统中有不少功能的下载文件涉及到较大文件 当超过1G的文件下载时,直接通过浏览器下载,可能出现下载失败现象 下载失败表现为下载文件损坏,或重复重试下载 大文件的下载会因为网络波动、会话连接超时、文件IO读写错误等各种因素导致出现问题 因此考虑提供一种可以支持文件分片下载、端点续传的文件下载服务 下面看具体实现,代码注释都很丰富,具体可细参考
解决方案
    核心利用HTTP请求头中携带的Range参数确定下载的文件分片 关键要保证每个分片的完整性和不重叠性 每个分片大小具体取决与客户端 该下载服务,支持IDM、迅雷等专业下载工具分片并行下载 同时也支持网络中断后,再次重试下载时可接续下载,而非重头在开始 注意:亲测Chrome浏览器默认的文件机制为单线程下载,不支持分片
架构说明
    Springboot2.x 第三方依赖见如下

    
        org.springframework.boot
        spring-boot-starter-web
    

    
        org.springframework.boot
        spring-boot-autoconfigure
    

    
    
        commons-fileupload
        commons-fileupload
        1.3.1
    

    
    
        org.apache.httpcomponents
        httpcore
    
    
        org.apache.httpcomponents
        httpclient
    

    
        commons-io
        commons-io
        2.6
    

    
        org.projectlombok
        lombok
    

核心下载服务实现
@Slf4j(topic = "DownloadController")
@RestController
@RequestMapping("/file/download")
public class DownloadController {

    
    public static final String HEADER_RANGE = "Range";

    
    @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";
    }

    
    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;
    }

    
    @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下载客户端
    为了测试,可以自己编写Java客户进行下载测试 当然,也可以用IDM、迅雷等客户端测试并发下载的效果 这里编写此客户端,仅为了测试 当然,该客户端也可以用于系统中下载外部文件时使用,加快下载大文件速度 也可以自己日常用,下载第三方大型软件等场景,当然前提是人家的下载服务器支持分片下载
@Slf4j(topic = "DownloadClient")
@RestController
@RequestMapping("/file/client")
public class DownloadClient {

    
    private final static long PER_PAGE = 1024 * 1024 * 10L;

    
    private final static String DOWN_PATH = "E:\fileItem";

    
    ExecutorService pool = Executors.newFixedThreadPool(4);

    
    @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";
    }

    
    class DownLoadTask implements Runnable {

        
        private String downloadUrl;

        
        private long start;
        
        private long end;
        
        private long page;

        
        private String fName;

        @Override
        public 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;
        }
    }

    
    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);
    }

    
    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);
    }
}
最后效果展示
    简单写个UI,测试下



    
    文件下载
    
    



    
请输入文件下载地址: <按钮 @click="download">下载
    后台日志信息:
: 下载探测文件: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
: 文件总大小为 1353617649

2021-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 对应的分片内容已经下载完毕
转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/713510.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号