Java文件传输黑科技:分片 + 断点续传全流程实战

@[toc]

一:字节流和字符流详解

1.1 流的概念

流是一种抽象的概念,就好比"水流",从一段流向另一端。在程序中所有的数据都是以流的方式进行传输或保存的,程序需要数据的时候要使用输入流读取数据,而当程序需要将一些数据保存起来的时候,就要使用输出流完成。程序中的输入输出都是以流的形式保存的,流中保存的实际上全都是字节文件。

1.2 流的分类

按照传输单位可以分为 :字节流和字符流 按照流向可以分为 :输入流(如:键盘,鼠标),输出流(如:显示器,音箱) 输入流 :把数据从其他设备上读取到内存中的流。 输出流 :把数据从内存中写出到其他设备上的流。

1.3 字节流,字符流区别与使用场景

1.3.1 区别

字节流(InputStream和OutputStream): 它处理单元为1个字节(byte),操作字节和字节数组,存储的是二进制文件,如果是音频文件、图片、歌曲,就用字节流好点(1byte = 8位); 字符流(Reader和Writer): 它处理的单元为2个字节的Unicode字符,分别操作字符、字符数组或字符串,字符流是由Java虚拟机将字节转化为2个字节的Unicode字符为单位的字符而成的,如果是关系到中文(文本)的,用字符流好点(1Unicode = 2字节 = 16位);

1.3.2 使用场景

字节流:字节流可用于任何类型的对象,包括二进制对象。字节流提供了处理任何类型的IO操作的功能。例如音频文件、图片、歌曲等。但它不能直接处理Unicode字符,而字符流就可以。 字符流:将文件在记事本里面打开,如果打开后能看的懂的就是字符流,如果看不懂那就是字节流。

1.3.3 顶级父类

1.3.4 对比------总结对比字节流和字符流

  • 字节流操作的基本单元是字节;字符流操作的基本单元为Unicode码元。
  • 字节流在操作的时候本身不会用到缓冲区的,是与文件本身直接操作的;而字符流在操作的时候使用到缓冲区的。
  • 所有文件的存储都是字节(byte)的存储,在磁盘上保留的是字节。
  • 在使用字节流操作中,即使没有关闭资源(close方法),也能输出;而字符流不使用close方法的话,不会输出任何内容

二:断点续传的简述

2.1 概念

从文件上次中断的地方开始重新下载或上传,当下载或上传文件的时候,如果没有实现断点续传功能,那么每次出现异常或者用户主动的暂停,都会去重头下载,这样很浪费时间。所以断点续传的功能就应运而生了。要实现断点续传的功能,需要客户端记录下当前的下载或上传进度,并在需要续传的时候通知服务端本次需要下载或上传的内容片段。

2.2 详细讲解

从 HTTP1.1 协议开始就已经支出获取文件的部分内容,断点续传技术就是利用 HTTP1.1 协议的这个特点在Header 里添加两个参数来实现的 。这两个参数分别是客户端请求时发送的 Range 和服务器返回信息时返回的 Content-Range - Range,Range 用于指定第一个字节和最后一个字节的位置,格式如下:Range:(unit=first byte pos)-[last byte pos]

2.2.1 Range 常用的格式有如下几种情况:

Range:bytes=0-1024 ,表示传输的是从开头到第1024字节的内容; Range:bytes=1025-2048 ,表示传输的是从第1025到2048字节范围的内容; Range:bytes=-2000 ,表示传输的是最后2000字节的内容; Range:bytes=1024- ,表示传输的是从第1024字节开始到文件结束部分的内容; Range:bytes=0-0,-1 表示传输的是第一个和最后一个字节 ; Range:bytes=1024-2048,2049-3096,3097-4096 ,表示传输的是多个字节范围。

2.2.2 Content-Range

Content-Range 用于响应带有 Range 的请求。服务器会将 Content-Range 添加在响应的头部,格式如下:Content-Range:bytes(unit first byte pos)-[last byte pos]/[entity length] 常见的格式内容如下: Content-Range:bytes 2048-4096/10240 这里边 2048-4096 表示当前发送的数据范围, 10240 表示文件总大小。 这里我顺便说一下,如果在客户端请求报文头中,对 Range 填入了错误的范围值,服务器会返回 416 状态码。416 状态码表示服务器无法处理所请求的数据区间,常见的情况是请求的数据区间不在文件范围之内,也就是说,Range 值,从语法上来说是没问题的,但从语义上来说却没有意义。

注意:当使用断点续传的方式上传下载软件时 HTTP 响应头将会变为:HTTP/1.1 206 Partial Content 当然光有 Range 和 Content-Range 还是不够的,我们还要知道服务端是否支持断点续传,只需要从如下两方面判断即可: 判断服务端是否只 HTTP/1.1 及以上版本,如果是则支持断点续传,如果不是则不支持 服务端返回响应的头部是否包含 Access-Ranges ,且参数内容是 bytes 符合以上两个条件即可判定位支持断点续传。

2.2.3 校验

这里的校验主要针对断点续传下载来说的。当服务器端的文件发生改变时,客户端再次向服务端发送断点续传请求时,数据肯定就会发生错误。这时我们可以利用Last-Modified 来标识最后的修改时间,这样就可以判断服务器上的文件是否发生改变。和 Last-Modified具有同样功能的还有 if-Modified-Since,它俩的不同点是 Last-Modified 由服务器发送给客户端,而if-Modified-Since 是由客户端发出, if-Modified-Since 将先前服务器发送给客户端的Last-Modified发送给服务器,服务器进行最后修改时间验证后,来告知客户端是否需要重新从服务器端获取新内容。客户端判断是否需要更新,只需要判断服务器返回的状态码即可,206代表不需要重新获取接着下载就行,200代表需要重新获取。 但是 Last-Modified 和 if-Modified-Since存在一些问题:

  • 某些文件只是修改了修改时间而内容却没变,这时我们并不希望客户端重新缓存这些文件;
  • 某些文件修改频繁,有时一秒要修改十几次,但是 if-Modified-Since 是秒级的,无法判断比秒更小的级别; 部分服务器无法获得精确的修改时间。 要解决上述问题我们就需要用到 Etag ,只需将相关标记(例如文件版本号等)放在引号内即可。
  • 当使用校验的时候我们不需要手动实现验证,只需要利用 if-Range 结合 Last-Modified 或者 Etage 来判断是否发生改变,如果没有发生改变服务器将向客户端发送剩余的部分,否则发送全部。

注意:If-Range 必须与 Range 配套使用。缺少其中任意一个另一个都会被忽略。

三:断点续传至服务器指定路径

3.1 前端文件------upload.html

java 复制代码
@RequestMapping("/getUploadHtml")
    public String getBreakPointHtml(){
        return "breakPoint/upload";
    }
html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>webuploader</title>
</head>
<!--引入CSS-->
<link rel="stylesheet" type="text/css" href="/css/webuploader.css">
<script src="/js/jquery.min.js"></script>
<script src="/js/webuploader.min.js"></script>
<style>
    #upload-container, #upload-list{width: 500px; margin: 0 auto; }
    #upload-container{cursor: pointer; border-radius: 15px; background: #EEEFFF; height: 200px;}
    #upload-list{height: 800px; border: 1px solid #EEE; border-radius: 5px; margin-top: 10px; padding: 10px 20px;}
    #upload-container>span{widows: 100%; text-align: center; color: gray; display: block; padding-top: 15%;}
    .upload-item{margin-top: 5px; padding-bottom: 5px; border-bottom: 1px dashed gray;}
    .percentage{height: 5px; background: green;}
    .btn-delete, .btn-retry{cursor: pointer; color: gray;}
    .btn-delete:hover{color: orange;}
    .btn-retry:hover{color: green;}
</style>
<!--引入JS-->
<body>
<div id="upload-container">
    <span>点击或将文件拖拽至此上传</span>
</div>
<div id="upload-list">
</div>
<button id="picker" style="display: none;">点击上传文件</button>
</body>

<script>
    $('#upload-container').click(function(event) {
        $("#picker").find('input').click();
    });
    var uploader = WebUploader.create({
        auto: true,// 选完文件后,是否自动上传。
        swf: 'Uploader.swf',// swf文件路径
        server: 'http://localhost:8989/breakPoint/upload',// 文件接收服务端。
        dnd: '#upload-container',
        pick: '#picker',// 内部根据当前运行是创建,可能是input元素,也可能是flash. 这里是div的id
        multiple: true, // 选择多个
        chunked: true,// 开启分片上传。
        threads: 20, // 上传并发数。允许同时最大上传进程数。
        method: 'POST', // 文件上传方式,POST或者GET。
        fileSizeLimit: 1024*1024*1024*20, //验证文件总大小是否超出限制, 超出则不允许加入队列。
        fileSingleSizeLimit: 1024*1024*1024*5, //验证单个文件大小是否超出限制, 超出则不允许加入队列。
        fileVal:'upload' // [默认值:'file'] 设置文件上传域的name。
    });

    uploader.on("beforeFileQueued", function(file) {
        console.log(file); // 获取文件的后缀
    });

    uploader.on('fileQueued', function(file) {
        // 选中文件时要做的事情,比如在页面中显示选中的文件并添加到文件列表,获取文件的大小,文件类型等
        console.log(file.ext); // 获取文件的后缀
        console.log(file.size);// 获取文件的大小
        console.log(file.name);
        var html = '' +
            '<div class="upload-item">' +
            '<span>文件名:'+file.name+'</span>' +
            '<span data-file_id="'+file.id+'" class="btn-delete">删除</span>' +
            '<span data-file_id="'+file.id+'" class="btn-retry">重试</span>' +
            '<div class="percentage '+file.id+'" style="width: 0%;"></div>' +
            '</div>';
        $('#upload-list').append(html);
        uploader.md5File( file )//大文件秒传

            // 及时显示进度
            .progress(function(percentage) {
                console.log('Percentage:', percentage);
            })

            // 完成
            .then(function(val) {
                console.log('md5 result:', val);
            });
    });

    uploader.on('uploadProgress', function(file, percentage) {
        console.log(percentage * 100 + '%');
        var width = $('.upload-item').width();
        $('.'+file.id).width(width*percentage);
    });

    uploader.on('uploadSuccess', function(file, response) {
        console.log(file.id+"传输成功");
    });

    uploader.on('uploadError', function(file) {
        console.log(file);
        console.log(file.id+'upload error')
    });

    $('#upload-list').on('click', '.upload-item .btn-delete', function() {
        // 从文件队列中删除某个文件id
        file_id = $(this).data('file_id');
        // uploader.removeFile(file_id); // 标记文件状态为已取消
        uploader.removeFile(file_id, true); // 从queue中删除
        console.log(uploader.getFiles());
    });

    $('#upload-list').on('click', '.btn-retry', function() {
        uploader.retry($(this).data('file_id'));
    });

    uploader.on('uploadComplete', function(file) {
        console.log(uploader.getFiles());
    });
</script>
</html>

3.2 代码实现

java 复制代码
/**
     * 文件上传的路径
     */
    public static final String UPLOAD_PATH = "D:\\fileItem";

    /**
     * 编码格式
     */
    public static final String UTF8 = "UTF-8";

    @Autowired
    private FileUploadDownService fileUploadDownService;

    /**
     * 断点续传
     * 会将文件进行分片,每次只调用后台接口上传一个分片
     * @param request
     * @param response
     * @throws IOException
     */
    @RequestMapping("/upload")
    public void upload( HttpServletRequest request , HttpServletResponse response ) {
        //分片
        response.setCharacterEncoding(UTF8);
        Integer chunk = null;
        Integer chunks = null;
        String name = null;
        BufferedOutputStream os = null;
        try{
            /**
             * DiskFileItemFactory
             * 作用:可以设置缓存大小以及临时文件保存位置.
             * DiskFileItemFactory(int sizeThreshold, File repository)
             * sizeThreshold :缓存大小,默认缓存大小是  10240(10k).
             * repository:临时文件存储位置,临时文件默认存储在系统的临时文件目录下.(可以在环境变量中查看)
             */
            DiskFileItemFactory factory = new DiskFileItemFactory();
            factory.setSizeThreshold(1024);
            factory.setRepository(new File(UPLOAD_PATH));
            ServletFileUpload upload = new ServletFileUpload(factory);
            upload.setFileSizeMax(5L * 1024L * 1024L * 1024L);
            upload.setSizeMax(20L * 1024L * 1024L * 1024L);
            List<FileItem> items = upload.parseRequest(request);
            for(FileItem item : items){
                /**
                 * boolean  isFormField()。
                 * 如果是表单域,非文件域
                 * isFormField方法用来判断FileItem对象里面封装的数据是一个普通文本表单字段,还是一个文件表单字段。
                 * 如果是普通文本表单字段,返回一个true否则返回一个false。因此可以用该方法判断是否是普通表单域还是文件上传表单域。
                 */
                if(item.isFormField()){
                    if("chunk".equals(item.getFieldName())){
                        String value = item.getString(UTF8);
                        chunk = Integer.parseInt(value);
                    }
                    if("chunks".equals(item.getFieldName())){
                        String value = item.getString(UTF8);
                        chunks = Integer.parseInt(value);
                    }
                    if("name".equals(item.getFieldName())){
                        name = item.getString(UTF8);
                    }
                } else {
                    String temFileName = name;
                    if(name != null){
                        if(chunk != null){
                            temFileName = chunk + "_" + name;
                        }
                        // 如果文件夹没有,则创建
                        File file = new File(UPLOAD_PATH);
                        if (!file.exists()) {
                            file.mkdirs();
                        }
                        // 保存文件
                        File chunkFile = new File(UPLOAD_PATH,temFileName);
                        if(chunkFile.exists()){
                            chunkFile.delete();
                            chunkFile = new File(UPLOAD_PATH,temFileName);
                        }
                        item.write(chunkFile);
                    }
                }
            }

            /**
             * 是否全部上传完成
             * 所有分片都存在才说明整个文件上传完成
             */
            boolean uploadDone = true;
            if(chunks != null){
                for (int i = 0; i < chunks; i++) {
                    File partFile = new File(UPLOAD_PATH,i + "_" + name);
                    if (!partFile.exists()) {
                        uploadDone = false;
                    }
                }
            }

            /**
             * 合并文件
             */
            if(uploadDone && chunk != null && chunks != null && name != null && chunk == chunks - 1){
                File tempFile = new File(UPLOAD_PATH,name);
                while(!tempFile.exists()){
                    //为了防止分片文件没下载完成,此时需要让线程休眠100毫秒
                    Thread.sleep(100);
                }
                os = new BufferedOutputStream(new FileOutputStream(tempFile));
                for(int i = 0 ;i < chunks; i++){
                    File file = new File(UPLOAD_PATH,i+"_"+name);
                    byte[] bytes = FileUtils.readFileToByteArray(file);
                    os.write(bytes);
                    os.flush();
                    file.delete();
                }
                os.flush();
            }
            response.getWriter().write("上传成功"+name);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try{
                if(os != null){
                    os.close();
                }
            }catch (IOException e){
                e.printStackTrace();
            }
        }
    }

3.2.1 核心API介绍------DiskFileItemFactory

作用:可以设置缓存大小以及临时文件保存位置。默认缓存大小是 10240(10k).临时文件默认存储在系统的临时文件目录下.(可以在环境变量中查看)

  • new DiskFileItemFactory();缓存大小与临时文件存储位置使用默认的.
  • DiskFileItemFactory(int sizeThreshold, File repository) sizeThreshold :缓存大小。repository:临时文件存储位置
  • 注意,对于无参数构造,也可以设置缓存大小以及临时文件存储位置. setSizeThreshold(int sizeThreshold)。setRepository(File repository)

3.2.2 核心API介绍------ServletFileUpload

  • ServletFileUpload upload=new ServletFileUpload(factory); 创建一个上传工具,指定使用缓存区与临时文件存储位置.
  • List items=upload.parseRequest(request); 它是用于解析request对象,得到所有上传项.每一个FileItem就相当于一个上传项.
  • boolean flag=upload.isMultipartContent(request);用于判断是否是上传.可以简单理解,就是判断encType="multipart/form-data";
  • 设置上传文件大小void setFileSizeMax(long fileSizeMax) 设置单个文件上传大小 。void setSizeMax(long sizeMax) 设置总文件上传大小
  • 解决上传文件中文名称乱码。setHeaderEncoding("utf-8");注意:如果使用reqeust.setCharacterEncoding("utf-8")也可以,但不建议使用。

3.2.3 实现步骤

  1. 创建DiskFileItemFactory对象,设置缓冲区大小和临时文件目录
  2. 使用DiskFileItemFactory 对象创建ServletFileUpload对象,并设置上传文件的大小限制。
  3. 调用ServletFileUpload.parseRequest方法解析request对象,得到一个保存了所有上传内容的List对象。
  4. 对list进行迭代,每迭代一个FileItem对象,调用其isFormField方法判断是否是上传文件。True 为普通表单字段,则调用getFieldName、getString方法得到字段名和字段值False 为上传文件,则调用getInputStream方法得到数据输入流,从而读取上传数据。编码实现文件上传

四:大文件下载至服务端

4.1 分片下载接口编写

java 复制代码
/**
     * 静态内部类
     */
    static class FileInfo{

        public long fileSize;
        public String fileName;

        public FileInfo(long fileSize, String fileName) {
            this.fileSize = fileSize;
            this.fileName = fileName;
        }
    }
java 复制代码
@RequestMapping("/downloadFile")
    public void downloadFile() throws Exception {
        //获取文件大小和文件名称
        FileInfo fileInfo = downloadFile( 0, 10, -1, null);
        //总分片数量,取模以后加一,表示下载最后一个分片
        long countChunks = fileInfo.fileSize / PER_PAGE;
        //多线程分片下载
        for (long i = 0; i <= countChunks; i++) {
            //减一是防止和下次下载的第一个字节重复
            DownloadThread downloadThread =
                    new DownloadThread(i * PER_PAGE, (i + 1) * PER_PAGE - 1, i, fileInfo.fileName);
            executorService.submit(downloadThread);
        }
    }

4.2 编写多线程类

java 复制代码
 /**
     * 下载
     */
    class DownloadThread implements Runnable{
        long start;
        long end;
        long page;
        String fileName;

        public DownloadThread(long start, long end, long page, String fileName) {
            this.start = start;
            this.end = end;
            this.page = page;
            this.fileName = fileName;
        }

        @Override
        public void run() {
            try {
                downloadFile(start, end, page, fileName);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

4.3 编写download的核心方法

java 复制代码
/**
     * 大文件下载至客户端
     * @param request
     * @param response
     */
    @RequestMapping("/download")
    public void download(HttpServletRequest request, HttpServletResponse response) throws IOException {
        //设置响应的编码格式为utf8
        response.setCharacterEncoding(UTF8);
        File file = new File(DOWNLOAD_PATH);
        //字节流,将文件写到Java程序中来,输入流
        InputStream is = null;
        //字节流,将Java程序写到文件中,输出流
        OutputStream os = null;
        try {
            //获取文件长度,进行分片下载
            long fileSize = file.length();
            //定义文件名称,将文件名进行编码校验,防止文件名称乱码
            String fileName = URLEncoder.encode(file.getName(), UTF8);
            //设置头信息
            //告诉前端需要下载文件
            response.setContentType("application/x-download");
            //弹出另存为的对话框
            response.addHeader("Content-Disposition","attachment;filename=" + fileName);
            //告诉前端是否支持分片下载
            response.setHeader("Accept-Range","bytes");
            //将文件的大小返回给前端
            response.setHeader("fileSize",String.valueOf(fileSize));
            //响应文件名称
            response.setHeader("fileName",fileName);
            /**
             * 记录文件读取的位置
             * start:读取的起始位置
             * end:读取的结束位置
             * sum:已经读取文件的大小
             */
            long start =  0 , end = fileSize - 1 , sum = 0;
            if(request.getHeader("Range") != null){
                //设置响应码为206,表示分片下载
                response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
                /**
                 * Range:bytes=0-1024 ,表示传输的是从开头到第1024字节的内容;
                 * Range:bytes=1025-2048 ,表示传输的是从第1025到2048字节范围的内容;
                 * Range:bytes=-2000 ,表示传输的是最后2000字节的内容;
                 * Range:bytes=1024- ,表示传输的是从第1024字节开始到文件结束部分的内容;
                 * Range:bytes=0-0,-1 表示传输的是第一个和最后一个字节 ;
                 * Range:bytes=1024-2048,2049-3096,3097-4096 ,表示传输的是多个字节范围。
                 */
                String numRange = request.getHeader("Range").replaceAll("bytes=", "");
                String[] strRange = numRange.split("-");
                //判断strRange的长度
                if(strRange.length == 2){
                    //获取文件的起始位置和结束位置
                    //trim,去掉字符串开头和结尾的空格
                    start = Long.parseLong(strRange[0].trim());
                    end = Long.parseLong(strRange[1].trim());
                    if( end > fileSize - 1){
                        end = fileSize - 1;
                    }
                } else {
                    //若只给一个长度  开始位置一直到结束
                    start = Long.parseLong(numRange.replaceAll("-","").trim());
                }
            }
            //需要读取文件的长度, 2-5 ,读取的长度为2,3,4,5为4
            long rangeLength = end - start + 1;
            //告诉客户端当前读取的是哪一段
            String contentRange = "bytes " + start + "-" + end + "/" + fileSize;
            //Content-Range为bytes 2048-4096/10240
            response.setHeader("Content-Range",contentRange);
            //当前分片读取得长度
            response.setHeader("Content-Length",String.valueOf(rangeLength));
            //将当前分片的内容保存到输出流中,返回给前端页面
            os = new BufferedOutputStream(response.getOutputStream());
            //将路径的文件保存到is中
            is = new BufferedInputStream(new FileInputStream(file));
            //skip,跳过并丢弃输入流的n个字节
            is.skip(start);
            byte[] bytes = new byte[1024];
            int length = 0;
            //当读取总量小于需要读取的大小时,则需要一直读取
            while( sum < rangeLength ){
                length = is.read(bytes,0,((rangeLength-sum) <= bytes.length ? ((int)(rangeLength-sum)) :  bytes.length));
                sum = sum + length;
                os.write(bytes,0,length);
            }
            log.info("下载完成");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if(is != null){
                is.close();
            }
            if(os != null){
                os.close();
            }
        }
    }

4.4 分片下载方法

java 复制代码
/**
     * 下载
     * @param start 开始下载的位置
     * @param end 结束下载的位置
     * @param page 第几个分片
     * @param fileName 文件名称
     * @return FileInfo
     * @throws Exception
     */
    public FileInfo downloadFile(long start, long end , long page , String fileName) throws Exception {
        File file = new File(DOWN_PATH,page + "_" + fileName);
        /**
         * 1.判断文件存不存在
         * 2.当page != -1时判断是否为探测下载
         * 3.对分片下载的文件大小进行校验,防止文件只下载一半,程序断了
         */
        if(file.exists() && page != -1 && file.length() == PER_PAGE){
            return null;
        }
        String url = "http://127.0.0.1:8989/breakPoint/download";
        HttpClient client = HttpClients.createDefault();
        HttpGet httpGet = new HttpGet(url);
        httpGet.setHeader("Range","bytes=" + start + "-" + end);
        HttpResponse response = client.execute(httpGet);
        HttpEntity entity = response.getEntity();
        InputStream is = entity.getContent();
        //当前分片的分片大小
        String fileSize = response.getFirstHeader("fileSize").getValue();
        fileName = URLDecoder.decode(response.getFirstHeader("fileName").getValue(),UTF8);
        FileOutputStream fis = new FileOutputStream(file);
        byte[] buffer = new byte[1024];
        int ch = 0;
        while((ch = is.read(buffer)) != -1){
            fis.write(buffer,0,ch);
        }
        is.close();
        fis.flush();
        fis.close();
        //判断是否为最后一个分片,最后一个分片
        if(end - Long.parseLong(fileSize) >= 0){
            mergeFile(fileName,page);
        }
        return new FileInfo(Long.parseLong(fileSize), fileName);
    }

4.5 合并文件方法

java 复制代码
/**
     * 合并文件
     * @param fileName 文件名称
     * @param page 当前是第几个分片
     * @throws Exception
     */
    private void mergeFile(String fileName, long page) throws Exception {
        File tempFile = new File(DOWN_PATH , fileName);
        BufferedOutputStream os = new BufferedOutputStream(new FileOutputStream(tempFile));
        for(int i = 0; i <= page; i++){
            File file = new File(DOWN_PATH,i + "_" + fileName);
            while(!file.exists() || (i != page && file.length() < PER_PAGE)){
                //为了防止分片文件没下载完成,此时需要让线程休眠100毫秒
                Thread.sleep(100);
            }
            byte[] bytes = FileUtils.readFileToByteArray(file);
            os.write(bytes);
            os.flush();
            file.delete();
        }
        //删除探测文件
        File file = new File( DOWN_PATH ,-1 + "_null");
        file.delete();
        os.flush();
        os.close();
        log.info("合并完成");
        //文件子节计算导致文件不完整
        //流未关闭
    }

4.6 完整代码奉上

java 复制代码
package com.sysg.file.controller;

import com.sysg.file.service.FileUploadDownService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
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.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;


/**
 * @author ikun
 */
@Slf4j
@RestController
@RequestMapping("/breakPoint")
public class FileUploadDownController {

    /**
     * 文件上传的路径
     */
    public static final String UPLOAD_PATH = "D:\\fileItem";

    /**
     * 需要下载文件的路径
     */
    public static final String DOWNLOAD_PATH = "D:\\video.mp4";

    /**
     * 分片存储的目录
     */
    private final static String DOWN_PATH = "D:\\test";

    /**
     * 每个分片的大小-50M
     */
    public static final long PER_PAGE = 1024L * 1024L * 50L;

    /**
     * 线程池
     */
    public static final ExecutorService executorService = Executors.newFixedThreadPool(10);

    /**
     * 编码格式
     */
    public static final String UTF8 = "UTF-8";

    @Autowired
    private FileUploadDownService fileUploadDownService;

    @RequestMapping("/getUploadHtml")
    public String getBreakPointHtml(){
        return "breakPoint/upload";
    }

    /**
     * 断点续传
     * 会将文件进行分片,每次只调用后台接口上传一个分片
     * @param request
     * @param response
     * @throws IOException
     */
    @RequestMapping("/upload")
    public void upload( HttpServletRequest request , HttpServletResponse response ) {
        //分片
        response.setCharacterEncoding(UTF8);
        Integer chunk = null;
        Integer chunks = null;
        String name = null;
        BufferedOutputStream os = null;
        try{
            /**
             * DiskFileItemFactory
             * 作用:可以设置缓存大小以及临时文件保存位置.
             * DiskFileItemFactory(int sizeThreshold, File repository)
             * sizeThreshold :缓存大小,默认缓存大小是  10240(10k).
             * repository:临时文件存储位置,临时文件默认存储在系统的临时文件目录下.(可以在环境变量中查看)
             */
            DiskFileItemFactory factory = new DiskFileItemFactory();
            factory.setSizeThreshold(1024);
            factory.setRepository(new File(UPLOAD_PATH));
            ServletFileUpload upload = new ServletFileUpload(factory);
            upload.setFileSizeMax(5L * 1024L * 1024L * 1024L);
            upload.setSizeMax(20L * 1024L * 1024L * 1024L);
            List<FileItem> items = upload.parseRequest(request);
            for(FileItem item : items){
                /**
                 * boolean  isFormField()。
                 * 如果是表单域,非文件域
                 * isFormField方法用来判断FileItem对象里面封装的数据是一个普通文本表单字段,还是一个文件表单字段。
                 * 如果是普通文本表单字段,返回一个true否则返回一个false。因此可以用该方法判断是否是普通表单域还是文件上传表单域。
                 */
                if(item.isFormField()){
                    if("chunk".equals(item.getFieldName())){
                        String value = item.getString(UTF8);
                        chunk = Integer.parseInt(value);
                    }
                    if("chunks".equals(item.getFieldName())){
                        String value = item.getString(UTF8);
                        chunks = Integer.parseInt(value);
                    }
                    if("name".equals(item.getFieldName())){
                        name = item.getString(UTF8);
                    }
                } else {
                    String temFileName = name;
                    if(name != null){
                        if(chunk != null){
                            temFileName = chunk + "_" + name;
                        }
                        // 如果文件夹没有,则创建
                        File file = new File(UPLOAD_PATH);
                        if (!file.exists()) {
                            file.mkdirs();
                        }
                        // 保存文件
                        File chunkFile = new File(UPLOAD_PATH,temFileName);
                        if(chunkFile.exists()){
                            chunkFile.delete();
                            chunkFile = new File(UPLOAD_PATH,temFileName);
                        }
                        item.write(chunkFile);
                    }
                }
            }

            /**
             * 是否全部上传完成
             * 所有分片都存在才说明整个文件上传完成
             */
            boolean uploadDone = true;
            if(chunks != null){
                for (int i = 0; i < chunks; i++) {
                    File partFile = new File(UPLOAD_PATH,i + "_" + name);
                    if (!partFile.exists()) {
                        uploadDone = false;
                    }
                }
            }

            /**
             * 合并文件
             */
            if(uploadDone && chunk != null && chunks != null && name != null && chunk == chunks - 1){
                File tempFile = new File(UPLOAD_PATH,name);
                while(!tempFile.exists()){
                    //为了防止分片文件没下载完成,此时需要让线程休眠100毫秒
                    Thread.sleep(100);
                }
                os = new BufferedOutputStream(new FileOutputStream(tempFile));
                for(int i = 0 ;i < chunks; i++){
                    File file = new File(UPLOAD_PATH,i+"_"+name);
                    byte[] bytes = FileUtils.readFileToByteArray(file);
                    os.write(bytes);
                    os.flush();
                    file.delete();
                }
                os.flush();
            }
            response.getWriter().write("上传成功"+name);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try{
                if(os != null){
                    os.close();
                }
            }catch (IOException e){
                e.printStackTrace();
            }
        }
    }

    /**
     * 大文件下载至客户端
     * @param request
     * @param response
     */
    @RequestMapping("/download")
    public void download(HttpServletRequest request, HttpServletResponse response) throws IOException {
        //设置响应的编码格式为utf8
        response.setCharacterEncoding(UTF8);
        File file = new File(DOWNLOAD_PATH);
        //字节流,将文件写到Java程序中来,输入流
        InputStream is = null;
        //字节流,将Java程序写到文件中,输出流
        OutputStream os = null;
        try {
            //获取文件长度,进行分片下载
            long fileSize = file.length();
            //定义文件名称,将文件名进行编码校验,防止文件名称乱码
            String fileName = URLEncoder.encode(file.getName(), UTF8);
            //设置头信息
            //告诉前端需要下载文件
            response.setContentType("application/x-download");
            //弹出另存为的对话框
            response.addHeader("Content-Disposition","attachment;filename=" + fileName);
            //告诉前端是否支持分片下载
            response.setHeader("Accept-Range","bytes");
            //将文件的大小返回给前端
            response.setHeader("fileSize",String.valueOf(fileSize));
            //响应文件名称
            response.setHeader("fileName",fileName);
            /**
             * 记录文件读取的位置
             * start:读取的起始位置
             * end:读取的结束位置
             * sum:已经读取文件的大小
             */
            long start =  0 , end = fileSize - 1 , sum = 0;
            if(request.getHeader("Range") != null){
                //设置响应码为206,表示分片下载
                response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
                /**
                 * Range:bytes=0-1024 ,表示传输的是从开头到第1024字节的内容;
                 * Range:bytes=1025-2048 ,表示传输的是从第1025到2048字节范围的内容;
                 * Range:bytes=-2000 ,表示传输的是最后2000字节的内容;
                 * Range:bytes=1024- ,表示传输的是从第1024字节开始到文件结束部分的内容;
                 * Range:bytes=0-0,-1 表示传输的是第一个和最后一个字节 ;
                 * Range:bytes=1024-2048,2049-3096,3097-4096 ,表示传输的是多个字节范围。
                 */
                String numRange = request.getHeader("Range").replaceAll("bytes=", "");
                String[] strRange = numRange.split("-");
                //判断strRange的长度
                if(strRange.length == 2){
                    //获取文件的起始位置和结束位置
                    //trim,去掉字符串开头和结尾的空格
                    start = Long.parseLong(strRange[0].trim());
                    end = Long.parseLong(strRange[1].trim());
                    if( end > fileSize - 1){
                        end = fileSize - 1;
                    }
                } else {
                    //若只给一个长度  开始位置一直到结束
                    start = Long.parseLong(numRange.replaceAll("-","").trim());
                }
            }
            //需要读取文件的长度, 2-5 ,读取的长度为2,3,4,5为4
            long rangeLength = end - start + 1;
            //告诉客户端当前读取的是哪一段
            String contentRange = "bytes " + start + "-" + end + "/" + fileSize;
            //Content-Range为bytes 2048-4096/10240
            response.setHeader("Content-Range",contentRange);
            //当前分片读取得长度
            response.setHeader("Content-Length",String.valueOf(rangeLength));
            //将当前分片的内容保存到输出流中,返回给前端页面
            os = new BufferedOutputStream(response.getOutputStream());
            //将路径的文件保存到is中
            is = new BufferedInputStream(new FileInputStream(file));
            //skip,跳过并丢弃输入流的n个字节
            is.skip(start);
            byte[] bytes = new byte[1024];
            int length = 0;
            //当读取总量小于需要读取的大小时,则需要一直读取
            while( sum < rangeLength ){
                length = is.read(bytes,0,((rangeLength-sum) <= bytes.length ? ((int)(rangeLength-sum)) :  bytes.length));
                sum = sum + length;
                os.write(bytes,0,length);
            }
            log.info("下载完成");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if(is != null){
                is.close();
            }
            if(os != null){
                os.close();
            }
        }
    }

    @RequestMapping("/downloadFile")
    public void downloadFile() throws Exception {
        //获取文件大小和文件名称
        FileInfo fileInfo = downloadFile( 0, 10, -1, null);
        //总分片数量,取模以后加一,表示下载最后一个分片
        long countChunks = fileInfo.fileSize / PER_PAGE;
        //多线程分片下载
        for (long i = 0; i <= countChunks; i++) {
            //减一是防止和下次下载的第一个字节重复
            DownloadThread downloadThread =
                    new DownloadThread(i * PER_PAGE, (i + 1) * PER_PAGE - 1, i, fileInfo.fileName);
            executorService.submit(downloadThread);
        }
    }

    /**
     * 下载
     * @param start 开始下载的位置
     * @param end 结束下载的位置
     * @param page 第几个分片
     * @param fileName 文件名称
     * @return FileInfo
     * @throws Exception
     */
    public FileInfo downloadFile(long start, long end , long page , String fileName) throws Exception {
        File file = new File(DOWN_PATH,page + "_" + fileName);
        /**
         * 1.判断文件存不存在
         * 2.当page != -1时判断是否为探测下载
         * 3.对分片下载的文件大小进行校验,防止文件只下载一半,程序断了
         */
        if(file.exists() && page != -1 && file.length() == PER_PAGE){
            return null;
        }
        String url = "http://127.0.0.1:8989/breakPoint/download";
        HttpClient client = HttpClients.createDefault();
        HttpGet httpGet = new HttpGet(url);
        httpGet.setHeader("Range","bytes=" + start + "-" + end);
        HttpResponse response = client.execute(httpGet);
        HttpEntity entity = response.getEntity();
        InputStream is = entity.getContent();
        //当前分片的分片大小
        String fileSize = response.getFirstHeader("fileSize").getValue();
        fileName = URLDecoder.decode(response.getFirstHeader("fileName").getValue(),UTF8);
        FileOutputStream fis = new FileOutputStream(file);
        byte[] buffer = new byte[1024];
        int ch = 0;
        while((ch = is.read(buffer)) != -1){
            fis.write(buffer,0,ch);
        }
        is.close();
        fis.flush();
        fis.close();
        //判断是否为最后一个分片,最后一个分片
        if(end - Long.parseLong(fileSize) >= 0){
            mergeFile(fileName,page);
        }
        return new FileInfo(Long.parseLong(fileSize), fileName);
    }

    /**
     * 合并文件
     * @param fileName 文件名称
     * @param page 当前是第几个分片
     * @throws Exception
     */
    private void mergeFile(String fileName, long page) throws Exception {
        File tempFile = new File(DOWN_PATH , fileName);
        BufferedOutputStream os = new BufferedOutputStream(new FileOutputStream(tempFile));
        for(int i = 0; i <= page; i++){
            File file = new File(DOWN_PATH,i + "_" + fileName);
            while(!file.exists() || (i != page && file.length() < PER_PAGE)){
                //为了防止分片文件没下载完成,此时需要让线程休眠100毫秒
                Thread.sleep(100);
            }
            byte[] bytes = FileUtils.readFileToByteArray(file);
            os.write(bytes);
            os.flush();
            file.delete();
        }
        //删除探测文件
        File file = new File( DOWN_PATH ,-1 + "_null");
        file.delete();
        os.flush();
        os.close();
        log.info("合并完成");
        //文件子节计算导致文件不完整
        //流未关闭
    }

    /**
     * 下载
     */
    class DownloadThread implements Runnable{
        long start;
        long end;
        long page;
        String fileName;

        public DownloadThread(long start, long end, long page, String fileName) {
            this.start = start;
            this.end = end;
            this.page = page;
            this.fileName = fileName;
        }

        @Override
        public void run() {
            try {
                downloadFile(start, end, page, fileName);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 静态内部类
     */
    static class FileInfo{

        public long fileSize;
        public String fileName;

        public FileInfo(long fileSize, String fileName) {
            this.fileSize = fileSize;
            this.fileName = fileName;
        }
    }

}
相关推荐
快乐就是哈哈哈2 小时前
Java 短信验证码实战:发送、验证、防刷一步到位
后端
快乐就是哈哈哈2 小时前
Java 开发必备:注册登录功能 + MD5 盐值加密 + 异常处理实现
后端
夕颜1112 小时前
Cursor 分析 bug 记录
后端
bobz9653 小时前
FastAPI-MCP 下载量超 250k
后端
掘金码甲哥4 小时前
jwt 这点小秘密,你们肯定都知道!
后端
David爱编程5 小时前
Java 字符串拼接用 +、StringBuilder 还是 StringBuffer?一篇给你终极答案
java·后端
莹莹啦6 小时前
Java 内存模型(Java Memory Model, JMM)解析
后端
码事漫谈6 小时前
C#文件复制异常深度剖析:解决"未能找到文件"之谜
后端
布列瑟农的星空6 小时前
34岁老前端的一周学习总结(2025/8/15)
前端·后端