分片下载大文件

分片下载大文件

package com.jero.modules.system.controller;

import com.jero.common.aspect.annotation.AutoLog;
import com.jero.modules.oss.service.IOSSFileService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;

/**
 * @author qinchen
 * @date 2021/7/27 15:39
 * @description 文件下载控制器
 */
@Slf4j(topic = "DownloadController")
@RestController
@Api("多线程分片下载大文件")
@RequestMapping("/file/download")
public class DownloadController {

    @Resource
    private IOSSFileService ossFileService;

    /**
     * 请求头key  Range
     */
    public static final String HEADER_RANGE = "Range";

    /**
     * 支持分片端点下载,关键在于获取到请求头中的Range参数进行控制
     *
     * @param request
     * @param response2
     * @return
     */
    @GetMapping(value = "/range/{fileId}",produces ="application/octet-stream" )
    @AutoLog(value = "文件-分片下载大文件-浏览器直接下载")
    @ApiOperation(value="文件-分片下载大文件-浏览器直接下载", notes="文件-分片上传-分片下载大文件-浏览器直接下载")
    public String downloadFileByRange(@PathVariable("fileId")String fileId,
                                      @RequestParam(value = "delay",defaultValue = "100") Long delayFactor,
                                      HttpServletRequest request, HttpServletResponse response) {
        response.addHeader("Access-Control-Expose-Headers", "Accept-Range");
        response.addHeader("Access-Control-Expose-Headers", "fSize");
        response.addHeader("Access-Control-Expose-Headers", "fName");
        response.addHeader("Access-Control-Expose-Headers", "Content-Length");

        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("-");
                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("-", "").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("-").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));

            //TODO 不要删 很关键:跳过前面的分片段,相当于从pos开始读取文件段
            is.skip(pos);

            byte[] buffer = new byte[1024*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 {
                if (is != null) {
                    is.close();
                }
                if (os != null) {
                    os.close();
                }
            } catch (IOException e) {
                log.warn("关闭文件流异常:{}", e.getMessage());
            }
        }

        return "Download fail";
    }

    /**
     * 通过文件ID获取文件对象
     * @param fileId
     * @return
     */
    private File getDownloadFile(String fileId) {
        String url = ossFileService.getById(fileId).getUrl();
        return new File(url);
    }
}

DownloadController.java

多线程下载器客户端

package com.jero.modules.system.controller;

import com.jero.common.util.ServletUtil;
import com.jero.modules.system.dto.FileInfoDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.HttpClients;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLDecoder;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @author Administrator
 * @description 下载客户端,模拟多线程文件下载
 */
@Slf4j(topic = "DownloadClient")
@RestController
@RequestMapping("/file/client")
public class DownloadClient {
    /**服务端下载文件接口地址**/
    private static String HTTP = "http://localhost:7001/file/download/range/";
    /**
     * 定义文件下载的分片大小,这里为 50M
     */
    private final static long PER_PAGE = 1024 * 1024 * 10L;

    /**
     * 下载的本地目录
     * "/data/DeploymentPackage/all_one/system/downloadFileItem"
     */
    private final static String DOWN_PATH = "D:\\fileItem";

    /**
     * 多线程下载的线程池,定义为【4】个线程同时下载
     */
    ExecutorService pool = Executors.newFixedThreadPool(4);

    /**
     * 需要的参数:文件大小、名称
     * 探测:先探测下,获取到文件信息
     * 多线程分片下载
     * 最后一个分片下载完成,开始合并
     *
     * @return
     */
    @GetMapping(value = "/download",produces ="application/octet-stream")
    //@RequestBody FileInfoDTO downloadFileInfo
    public void downloadFile(@RequestParam("fileId") String fileId,HttpServletRequest request, HttpServletResponse response) {
        FileInfoDTO downloadFileInfo = new FileInfoDTO();
        // 测试地址
        downloadFileInfo.setDownloadUrl(HTTP+fileId);
        // 第一次探测,只下载【10】个字节,目的只为了拿到文件的基本信息,文件名称暂时传空,实际应用文件id,防止多文件下载出现并发问题
        FileInfoDTO 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()));
            }
        }

    }

    /**
     * 内部类,下载任务,对Runnable进行简单包装
     */
    class DownLoadTask implements Runnable {

        /**
         * 下载的文件地址
         */
        private String downloadUrl;

        /**
         * 起始位置
         */
        private long start;
        /**
         * 结束位置
         */
        private long end;
        /**
         * 分片页码
         */
        private long page;

        /**
         * 文件名称
         */
        private String fName;

        @Override
        public void run() {
            // 执行下载动作
            FileInfoDTO 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 FileInfoDTO 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);
            httpGet.setHeader("X-Access-Token",ServletUtil.getRequest().getHeader("X-Access-Token"));
            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 = null;
            FileOutputStream fos = null;
            try {
                is = entity.getContent();

                // 将分片内容写入临时存储分片文件
                fos = new FileOutputStream(file);
                byte[] buffer = new byte[1024];
                int ch;
                while ((ch = is.read(buffer)) != -1) {
                    fos.write(buffer, 0, ch);
                }
            } catch (UnsupportedOperationException e) {
                e.printStackTrace();
            } finally {
                if (is != null) {
                    is.close();
                }
                if (fos != null) {
                    fos.flush();
                }
                if (fos != null) {
                    fos.close();
                }
            }


            // 判断是否是最后一个分片
            if (end - fSize >= 0) {
                log.info("最后一个分片文件已下载完毕,准备合并文件");
                mergeFile(fName, page, ServletUtil.getRequest(),ServletUtil.getResponse());
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return new FileInfoDTO(fSize, fName);
    }

    /**
     * 将已经下载的分片内容在本地进行合并,最终得到一个完整的文件
     *
     * @param fName
     * @param page
     * @throws IOException
     * @throws InterruptedException
     */
    private void mergeFile(String fName, long page,HttpServletRequest request, HttpServletResponse response) {
        // TODO 这里要考虑完善,如果下载的文件在目录中已经存在,是覆盖、还是重命名?
        File file = new File(DOWN_PATH, fName);
        try {
            FileOutputStream out = null;
            BufferedOutputStream os = null;
            try {
                out = new FileOutputStream(file);
                try {
                    os = new BufferedOutputStream(out);
                    // 遍历所有的分片文件,按顺序合并
                    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();
                                // Restore interrupted state...
                                Thread.currentThread().interrupt();
                            }
                            // 这里考虑异常情况导致死循环问题处理
                        }
                        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();
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    if (os != null) {
                        os.close();
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                if (out != null) {
                    out.close();
                }
                if (os != null) {
                    os.flush();
                }
                os.close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        log.info("恭喜您,文件{}下载完成!", fName);
    }
}

DownloadClient.java

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值