• java字节流与字符流,大文件断点续传,分片下载以及合并


    一:字节流和字符流详解

    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

    @RequestMapping("/getUploadHtml")
        public String getBreakPointHtml(){
            return "breakPoint/upload";
        }
    
    • 1
    • 2
    • 3
    • 4
    DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>webuploadertitle>
    head>
    
    <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>
    
    <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 = '' +
                '
    ' + '文件名:'+file.name+'' + '+file.id+'" class="btn-delete">删除' + '+file.id+'" class="btn-retry">重试' + '
    +file.id+'" style="width: 0%;">
    '
    + '
    '
    ; $('#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>
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112

    3.2 代码实现

    /**
         * 文件上传的路径
         */
        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();
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130

    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 分片下载接口编写

    /**
         * 静态内部类
         */
        static class FileInfo{
    
            public long fileSize;
            public String fileName;
    
            public FileInfo(long fileSize, String fileName) {
                this.fileSize = fileSize;
                this.fileName = fileName;
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    @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);
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    4.2 编写多线程类

     /**
         * 下载
         */
        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();
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    4.3 编写download的核心方法

    /**
         * 大文件下载至客户端
         * @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();
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98

    4.4 分片下载方法

    /**
         * 下载
         * @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);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44

    4.5 合并文件方法

    /**
         * 合并文件
         * @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("合并完成");
            //文件子节计算导致文件不完整
            //流未关闭
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    4.6 完整代码奉上

    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;
            }
        }
    
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209
    • 210
    • 211
    • 212
    • 213
    • 214
    • 215
    • 216
    • 217
    • 218
    • 219
    • 220
    • 221
    • 222
    • 223
    • 224
    • 225
    • 226
    • 227
    • 228
    • 229
    • 230
    • 231
    • 232
    • 233
    • 234
    • 235
    • 236
    • 237
    • 238
    • 239
    • 240
    • 241
    • 242
    • 243
    • 244
    • 245
    • 246
    • 247
    • 248
    • 249
    • 250
    • 251
    • 252
    • 253
    • 254
    • 255
    • 256
    • 257
    • 258
    • 259
    • 260
    • 261
    • 262
    • 263
    • 264
    • 265
    • 266
    • 267
    • 268
    • 269
    • 270
    • 271
    • 272
    • 273
    • 274
    • 275
    • 276
    • 277
    • 278
    • 279
    • 280
    • 281
    • 282
    • 283
    • 284
    • 285
    • 286
    • 287
    • 288
    • 289
    • 290
    • 291
    • 292
    • 293
    • 294
    • 295
    • 296
    • 297
    • 298
    • 299
    • 300
    • 301
    • 302
    • 303
    • 304
    • 305
    • 306
    • 307
    • 308
    • 309
    • 310
    • 311
    • 312
    • 313
    • 314
    • 315
    • 316
    • 317
    • 318
    • 319
    • 320
    • 321
    • 322
    • 323
    • 324
    • 325
    • 326
    • 327
    • 328
    • 329
    • 330
    • 331
    • 332
    • 333
    • 334
    • 335
    • 336
    • 337
    • 338
    • 339
    • 340
    • 341
    • 342
    • 343
    • 344
    • 345
    • 346
    • 347
    • 348
    • 349
    • 350
    • 351
    • 352
    • 353
    • 354
    • 355
    • 356
    • 357
    • 358
    • 359
    • 360
    • 361
    • 362
    • 363
    • 364
    • 365
    • 366
    • 367
    • 368
    • 369
    • 370
    • 371
    • 372
    • 373
    • 374
    • 375
    • 376
    • 377
    • 378
    • 379
    • 380
    • 381
    • 382
    • 383
    • 384
    • 385
    • 386
    • 387
    • 388
    • 389
    • 390
    • 391
    • 392
    • 393
    • 394
    • 395
    • 396
    • 397
    • 398
    • 399
    • 400
    • 401
    • 402
    • 403
    • 404
    • 405
    • 406
    • 407
    • 408
    • 409
    • 410
    • 411
    • 412
    • 413
    • 414
    • 415
    • 416
    • 417
    • 418
    • 419
    • 420
    • 421
    • 422
    • 423
    • 424
    • 425
  • 相关阅读:
    第七章 块为结构建模 P5|系统建模语言SysML实用指南学习
    安卓高级编程之实现类似三星系统的设置界面,并用lucene建立搜索系统
    neo4j下载安装配置步骤
    go——内存分配机制
    Vue3 组合式 API:依赖注入(四)
    让学前端不再害怕英语单词(二)
    JVM | 命令行诊断与调优 jhsdb jmap jstat jps
    洛谷P8815:逻辑表达式 ← CSP-J 2022 复赛第3题
    ThreeDPoseTracker项目解析
    sqoop 脚本密码管理
  • 原文地址:https://blog.csdn.net/suiyishiguang/article/details/126704506