用JAVA实现大文件上传及显示进度信息


文章转自:
原创源码(参考):https://gitee.com/fanjiangfeng/UploadBigFile
我的源码:https://gitee.com/fanjiangfeng/xxx_edu

一,理想效果

二. 大文件上传基础描述

  各种WEB框架中,对于浏览器上传文件的请求,都有自己的处理对象负责对Http MultiPart协议内容进行解析,并供开发人员调用请求的表单内容。

比如:

Spring 框架中使用类似CommonsMultipartFile对象处理表二进制文件信息。

而.NET 中使用HtmlInputFile/ HttpPostedFile对象处理二进制文件信息。

优点:使用框架内置对象可以很方便的处理来自浏览器的MultiPart二进制信息请求,协议分析操作不用开发人员参与。

缺点:其接收数据包过程完全被封闭在框架内置对象中,直到本次请求信息处理(接收)完毕后,才允许开发人员从接口调取表单及文件内容。上传过程中的进度信息无法访问,无法上传大尺寸文件(比如几百兆以上的大文件二进制信息)。

目标:我们要在JAVA WEB框架中,依靠Filter过滤器的能力,实现不依靠框架内置对象,从浏览器请求字节流中解析MultiPart协议,取得本次用户请求的所有信息,包括多二进制文件信息及其他表单项信息。用户上传的文件尺寸将不受限制。而且在传输过程中,我们可以实时获得当前传输进度信息。

注:.NET框架中可依靠IHttpModule接口对象达到JAVA框架中Filter的能力,本文不做描述。

本文最终完成图:

1 普通Post请求协议及MultiPart协议

普通POST请求协议,见图:

Content-Length为请求信息内容的字节长度

最下方红圈内为本次表单请求信息

MultiPart请求协议,见图:

Content-Length 为本次请求的内容长度字节,本例729366

Content-Type 为multipart/form-data,二进制多段表单

Boundary为多段表单信息的分隔符,这里为-----------------------------------7dflaxxxxxxxxxxx

最后一段信息中,name="file1",为本文件表单的单元名称,filename="untitled2.png"为该文件名,content-type: image/png为内容区文件格式

最下方的红框中为该文件的二进制信息。

由以上两图可见,MultiPart与普通的POST在协议结构上有明显区别,所以我们接下来的工作就是按字节流的方式接收MultiPart请求数据包,并对其进行分析。

2 可实时获取当前传输进度信息

  由于我们可以从上述的Http头中获取本次请求内容区长度,即字节总量。由于我们可以从Filter中按字节单位接收来自浏览器的数据包,所以我们也能实时的获得当前接收字节量。因此我们可以实时的获得当前传输进度百分比,用当前接收的字节量除以接收时间即可获得当前传输率(字节/秒)。

  由此,我们可获得以下传输过程信息:

  • 本次数据包总字节数
  • 当前已接收的字节数
  • 本次请求发起时间
  • 当前进度节点时间
  • 当前进度状态(初始状态,接收数据中,接收数据完毕等)

  接下来,我们只需把这些进度信息以进度Id做标识(progId),在SERVER端放入Java框架中的一个公有内存区即可,在浏览器中我们可使用JS以一定时间间隔访问SERVER中的某一URL,以进度Id为标识,从SERVER的公有内存区获得当前请求的进度信息。取得信息后,即可实时操控进度条运行。

  在Java框架中,公有内存区为ServletContext对象(例,使用setAttribute方法,以键值对的形式将单个用户进度信息存入HashMap对象)。在.NET框架中,公有内存区为HttpApplicationState对象。

注:向公有内存区(HashMap对象)写操作时要进行同步锁控制(synchronized),因为公有内存区可能会产生多用户(多线程)并发操作的现象。

三. 问题点分析:

1 分段接收

因为一次传输的大文件MultiPart数据包,字节数可能会很大(1G甚至以上),为了获取实时进度信息,以及内存开销控制,我们需要将接收过程分成多段处理,即将数据包分段循环接收(例:每次循环只接收64K数据,期间即可更新当前的进度信息)。

2 完整数据包解析?/部分数据包实时解析?

  普通的解析协议方式是,将数据包全部接收后,再进行解析。以下有两种方式实现。

  数据包全部加载入内存:对于大文件的MultiPart数据量来说,这种方式会占用大量内存(比如一个用户正在上传1G的数据,那么内存区必须接收到全部1G数据后才能进行解析,如果多用户同时操作会导致服务器崩溃),这种方式不可用。

  数据包全部写入文件后再加载入内存:只能解决在接收过程中开启小内存并分段写入文件,当数据全部写入文件后,还需要加载入内存中进行整体协议分析,也会突发性导致内存开销过大,导致服务器崩溃,这种方式也不可取。

  我们这里采用的是分段接收,分段解析,分段写文件的处理方式。当数据包全部接收完毕后我们的整个分析过程也即终止,并得到用户上传的文件及其他表单信息结果。这样我们每次只需要很小的内存区(比如64K)即可完成任务。

  但这种方式会面临本次接收的分段信息内含有多个表单项信息及剩余的不完整表单信息,或本次接收的分段信息实际上不包含任何表单信息,仅仅是大文件二进制信息的一个片段。所以,这种方式在编码上会带来一定的复杂度。

情况1:

情况2:

情况3:

四. 源码解析

1 项目构成要点

本次我们采用Spring框架来实现“大文件传输”功能,要点设计结构图如下:

Filter对象:

  用于负责接收MultiPart原始数据的Filter,用以在Spring内置对象之前接收用户请求。需要在Web.xml中进行配置,Web启动后,该Filter即启动,当用户请求到来时需要判断该MultiPart数据信息是否合法,接收并进行解析。

ServletInputStream/BufferedInputStream对象:

  使用以上两对象,可对本次请求进行按字节流接收。在此可创建比较小的接收缓冲区,依靠BufferedInputStream的read进行分段循环接收。

getBoundarySectFromBuf()函数:

  自定义函数,我们需要该函数从分段缓冲区中分析可能包含的多个Form表单信息,或者部分表单信息,或者二进制文件片段信息。对于表单信息分析后填充表单数据结构,对于二进制文件信息需要写文件。该函数需要完成边接收边解析边写文件的重要工作。

ProgressInfo对象:

  进度信息类,描述了一次上传请求的进度信息。该对象会用来被客户端轮询请求,以获得当前传输大文件过程中的进度信息。

FormPart对象及listFormPart集合:

  FormPart对于单个Form表单的描述。listFormPart为本次请求的全部表单描述集合。即供后续代码调用的全部表单项内容。

Controller层getProgInfo()处理函数:

  该函数将接受来自浏览器的“获得进度信息请求”,并从当前ServletContext公共内存区中找到与Progesss ID对应的进度信息对象ProgressInfo,以XML的形式返回给浏览器。该函数会被客户端轮询请求。

multi-form.jsp页面:

  本次表单的显示页面,包含多种表单项(Input,Textarea,File等)。该页面还将显示用于本次传输的进度条,传输状态,传输率等信息。页面中进度信息将使用js向服务器进行周期性轮询请求,获得及显示。

upload-result.jsp页面:

  用来显示本次请求的所有表单项信息,包括普通Input表单,及File表单信息。

2 重点模块解析

(本节可参考示例代码中注释)

五. 扩展及相关

1 断点续传

  一般常说的断点续传是指文件下载的断点续传。 即利用HTTP协议中的Content-Range关键字(在HTTP Header中),向服务器发请求,服务器接收请求后,查看Content-Range属性的文件偏移量,从而发送后续文件二进制信息给浏览器。比如网络蚂蚁类的下载软件,即开启多线程利用Content-Range关键字将某个网络资源分布接收,最终整合保存在本地。

  而在WEB中我们所使用的上传文件断点续传功能,大多是需要下载ActiveX控件来实现。即相当于在本地下载了一个应用程序,同服务器间文件传输协议也不用使用HTTP协议,可自定义协议完成。

  利用存粹的HTTP协议进行上传文件的断点续传目前还比较少,据说利用Ajax 中的Slice方法把本地文件分成多个HTTP包POST给服务器,而服务器需要将这些包接收后并整合来实现。操作方式比较复杂,本人没尝试过,有感兴趣的朋友可深入探讨。

2 本项目待完善要点

  由于时间仓促,本项目目前只完成了大文件上传及进度显示的主要功能。在浏览器前端进度信息的动态显示上,前端使用的JS框架(Ext JS, JQuery)等都需要更深入的支持。

  在服务器端,也可以依靠对Filter的配置信息,对文件上传信息进行核查或过滤,比如不能上传某些扩展名的文件,文件上传尺寸控制,另存后的文件名唯一性控制等也都需要更细致的描述。

附件文件列表:

MultiData.txt :一次截获的全部MultiPart数据包信息

multi-form.jsp:多文件上传显示页面,包括获取进度信息JS脚本

upload_result.jsp:用于显示上传结果的表单项集合页面

MultiForm.java:主过滤器,Filter。用来处理全部上传过程。

UploadProgInfo.java:Controller层的Spring Bean对象,用来获取当前的进度信息。

六,新的实现方式和源代码

上面是转载自博客园的某位大神,但是我发现,配置文件配置上传文件的大小限制为足够大后可以上传10G以内的文件,所以,这里上传进度借鉴上面那位大神的思路,实现的。

思路描述

  1. 后台进行上传,输入流写入输出流
  2. 写一个进度实体类,存入文件总大小,实际写入大小,以及计算出百分比,剩下时间(预估),上传速度等信息
  3. 在每次写入输出流的while循环中,不断更新进度
  4. 进度信息存放到了ServletContext中(信息共享区域),然后指定用户id为key,进度实体类为值
  5. 当前端上传请求发出去之后,再发一个异步请求(根据用户id)不断请求当前上传进度,渲染页面,即可实现

1 进度实体类

public class ProgressInfo {
    //当前进度状态

    /**
     *          PROSTATE_NULL,      //无状态
     *         PROSTATE_LOADING,   //上传数据
     *         PROSTATE_LOADED,    //上传数据完毕
     *         PROSTATE_PARSE,     //数据分析中
     *         PROSTATE_END;        //全部完毕
     */
    public String progressStatus;

    //当前接收字节数
    private long curBytes=-1l;
    //整个http请求的总共字节数
    private long totalBytes=-1l;
    //请求发起起始时间
    private Date startTime=new Date();
    //当前进度时刻时间
    private Date curTime=new Date();
    //当前进度状态

    public String getProgressStatus() {
        return progressStatus;
    }

    public void setProgressStatus(String progressStatus) {
        this.progressStatus = progressStatus;
    }

    public long getCurBytes() {
        return curBytes;
    }

    public void setCurBytes(long curBytes) {
        this.curBytes = curBytes;
    }

    public long getTotalBytes() {
        return totalBytes;
    }

    public void setTotalBytes(long totalBytes) {
        this.totalBytes = totalBytes;
    }

    public Date getStartTime() {
        return startTime;
    }

    public void setStartTime(Date startTime) {
        this.startTime = startTime;
    }

    public Date getCurTime() {
        return curTime;
    }

    public void setCurTime(Date curTime) {
        this.curTime = curTime;
    }


    //当前时刻已经花费的时间(秒数)
    public Long getElapseSecond(){
        Long nReturn=0l;
        nReturn=(curTime.getTime()-startTime.getTime())/1000;
        return nReturn;
    }

    //当前上传速度(字节/秒)
    public long getTransRate(){
        long nReturn=0l;
        //获得花费的时间(秒)
        long nSeconds=getElapseSecond();
        //计算当前上传速度,字节/秒
        if(nSeconds>0)
            nReturn=curBytes/nSeconds;
        return nReturn;
    }

    //当前进度百分比(0--100之间的数)
    public int getTransPercent(){
        int nReturn=0;
        if(totalBytes>0)
            nReturn=(int)(curBytes*100/totalBytes);
        return nReturn;
    }

    //当前剩余秒数(小于零为未知时间)
    public int getRemainSecond(){
        int nReturn=0;
        long nRate=getTransRate();
        if(nRate>0)
            nReturn=(int)((totalBytes-curBytes)/nRate);
        return nReturn;
    }
}

2 给ServletContext中存储进度对象

/**
 * @Description TODO 上传的进度信息存储在servletContext中
 */
public class EduContext {

    private static final String APP_KEY_APPNAME="progress";

    /**
     * 将当前的ProgressInfo对象加入context(Application共享内存区)
     * @param context
     * @param progId
     * @param objProgress
     */
    public static void updateProgressInfo(ServletContext context,String progId, ProgressInfo objProgress){
        progId = progId.trim();
        if (progId.isEmpty())
            return;
        //如果当前上下文中Application为空,那么创建ProgressInfo字典进入Application
        HashMap progMap = (HashMap)(
                context.getAttribute(APP_KEY_APPNAME));
        //对全局context中对象修改需要进行线程锁控制
        if (progMap == null)
        {
            progMap = new HashMap(31);
            context.setAttribute(APP_KEY_APPNAME, progMap);
        }
        //由于针对Application操作是多线程并发,所以需要加同步锁控制
        synchronized(progMap){
            //从字典中寻找当前ID的ProgressInfo
            ProgressInfo objCur = (ProgressInfo)progMap.get(progId);
            //如果没找到则将当前ProgressInfo加入其中
            if (objCur == null){
                objCur = new ProgressInfo();
                progMap.put(progId, objCur);
            }
            objCur.setCurBytes(objProgress.getCurBytes());
            objCur.setCurTime(objProgress.getCurTime());
            objCur.setStartTime(objProgress.getStartTime());
            objCur.setTotalBytes(objProgress.getTotalBytes());
            objCur.setProgressStatus(objProgress.getProgressStatus());
        }
    }


    //根据progId取得在context中的进度对象
    //注意,进度对象都被存储在context中的Map中(Application级别的内存共享区)
    public static ProgressInfo getProgressInfo(ServletContext context,String progId){
        if (progId == null)
            return null;
        progId = progId.trim();
        ProgressInfo objInfo = null;
        if (progId.isEmpty())
            return objInfo;
        objInfo = new ProgressInfo();

        HashMap progMap=(HashMap)
                context.getAttribute(APP_KEY_APPNAME);
        if (progMap != null){
            ProgressInfo objTemp= (ProgressInfo)progMap.get(progId);
            if (objTemp != null){
                objInfo.setCurBytes(objTemp.getCurBytes());
                objInfo.setCurTime(objTemp.getCurTime());
                objInfo.setStartTime(objTemp.getStartTime());
                objInfo.setTotalBytes(objTemp.getTotalBytes());
                objInfo.setProgressStatus(objTemp.getProgressStatus());
            }
        }
        return objInfo;
    }

    /**
     * 从context的map中删除指定progId的进度对象
     * @param context
     * @param progId
     */
    public static void delProgressInfo(ServletContext context,String progId){
        progId = progId.trim();
        if (progId.isEmpty())
            return;
        //如果当前上下文中Application为空,那么创建ProgressInfo字典进入Application
        HashMap progMap = (HashMap)(
                context.getAttribute(APP_KEY_APPNAME));
        if(progMap==null)
            return;
        //对全局context中对象修改需要进行线程锁控制
        synchronized(progMap){
            ProgressInfo objTemp= (ProgressInfo)progMap.get(progId);
            progMap.remove(progId);
            if(progMap.size()<=0)
                context.removeAttribute(APP_KEY_APPNAME);
        }
    }
    }

3 改造文件上传类

/**
     * 文件上传
     * @param file 文件
     * @param newFileName 新文件名
     * @return
     * @throws IOException
     */
    public String uploadFile(MultipartFile file, String newFileName,HttpServletRequest request) throws IOException {
        String uuid = UUIDUtil.getUUID();
        String originalFilename = file.getOriginalFilename();
        String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
        InputStream fis = file.getInputStream();
        handlePicPath(path + File.separator + uuid + File.separator + newFileName);
        OutputStream fos = new FileOutputStream(new File(path + File.separator + uuid + File.separator + newFileName));

        //创建进度对象
        ProgressInfo progressInfo=new ProgressInfo();
        progressInfo.setProgressStatus("PROSTATE_LOADING");
        progressInfo.setTotalBytes(file.getSize());
        progressInfo.setStartTime(new Date());
        progressInfo.setCurTime(new Date());
        //当前已读字节数
        long currentByte=0L;
        //当前登录人
        String currentAccount = UserContext.getCurrentAccount(request);
        ServletContext servletContext = null;
        try {
            //创建最大读取缓冲区
            byte[] bytes = new byte[64*1024];
            int temp = 0;
            while ((temp = fis.read(bytes)) != -1) {
                //将byte数组中内容直接写入
                currentByte+=temp;
                progressInfo.setCurBytes(currentByte);
                progressInfo.setCurTime(new Date());
                //获取上下文对象
                 servletContext = request.getServletContext();
                //设置进度到上下文
                EduContext.updateProgressInfo(servletContext,currentAccount,progressInfo);
                fos.write(bytes, 0, temp);
            }
            //进度完成
            progressInfo.setProgressStatus("PROSTATE_LOADED");
            //删除进度
            EduContext.delProgressInfo(servletContext,currentAccount);

        } catch (IOException e) {
            logger.error("上传文件发生错误");
        }finally {
            fos.close();
            fis.close();
        }
        return path+File.separator+uuid+File.separator+newFileName;
    }

4 前端操作

//新增课件
         $("#addCourse").click(function () {
             $(this).attr("disabled","disabled");
             $(this).html("正在上传,请耐心等候...");
             $("#progress").show();
             //开始轮询取进度
             beginTask();
             $.ajax({
                 method:"post",
                 url:"/course/addCourse",
                 processData: false, //加入这属性    processData默认为true,为true时,提交不会序列化data。
                 cache: false,
                 contentType: false,
                 data:new FormData($("#insertCourse")[0]),
                 success:function (data) {
                     if(data.data=="操作成功"){
                         //关闭轮询
                         closeTask();
                         window.location.href=window.location.href;
                     }
                 },
                 error:function (e) {

                 }
             });
         });

          //定时任务
          var progressTask=null;
          //开始上传,开启定时任务,轮询上传进度
          function beginTask() {
              progressTask=setInterval(function () {
                  //取进度
                  $.ajax({
                      url:"/getProgressByAccountId",
                      data:{accountId:$("#account").html()},
                      type:"post",
                      async:true,
                      success:function (data) {
                          //百分比
                          var transPercent=data.transPercent;
                          //剩余秒数
                          var remainSecond=data.remainSecond;
                          //当前上传速度(子节/秒)
                          var transRate=data.transRate;
                          //上传状态
                          var progressStatus=data.progressStatus;
                          if(transPercent == 0){
                              //准备阶段
                              $("#connecting").show();

                          }else{
                              $("#connecting").hide();
                              $("#connected").show();
                              $("#first").html(transPercent+"%");
                              $("#second").html(remainSecond+"s");
                              var sd = change(transRate);
                              $("#third").html(sd);
                              $(".progress-bar").css("width",transPercent+"%");

                          }
                      }
                  });
              },1000);
          }

          //上传成功后,关闭定时任务
         function closeTask() {
              clearInterval(progressTask);

         }

5 取进度的接口

@PostMapping("/getProgressByAccountId")
    public Map getProgressByAccountId(String accountId, HttpServletRequest request){
        ServletContext servletContext = request.getServletContext();
        ProgressInfo progressInfo = EduContext.getProgressInfo(servletContext, accountId);
        Map map=new HashMap();
        //当前进度百分比
        int transPercent = progressInfo.getTransPercent();
        map.put("transPercent",transPercent);
        //当前剩余秒数(小于零为未知时间)
        int remainSecond = progressInfo.getRemainSecond();
        map.put("remainSecond",remainSecond);
        //当前上传速度(字节/秒)
        long transRate = progressInfo.getTransRate();
        map.put("transRate",transRate);
        //上传状态
        String progressStatus = progressInfo.getProgressStatus();
        map.put("progressStatus",progressInfo);
        return map;
    }

七,以上方案不可取,重构

本来实现方式是按照上面第六条的步骤做的,但是弊端显而易见,一次性上传大文件的话,服务器会给你卡到爆,用户体验也不用讲了,上传按钮点了半天了,一直都没有反应,等大文件完全传输到服务器后进度条才开始动,文件才开始真正写到服务器磁盘。

因此上传部分进行重写

1 重写后的方案

  • 前端进行文件分片上传、断点续传、文件秒传

    • 其中文件秒传是每次上传时计算上传文件和服务器磁盘中的文件的MD5值是否相同,如果相同就不重复上传了(这里没有实现)
  • 后端写了一个文件分片合并的工具类,进行文件合并

  • 业务上传接口的处理:调用文件分片合并工具类,进行合并文件,合并完成后会继续执行具体业务代码

  • 上传进度条的实现

    • 在前端分片上传的ajax的回调函数中对进度条进行动态渲染
    • (for循环和while循环不可用,因为对html的操作如果在for循环和while循环中的话,会最后才执行,没有中间变化的过程,因此这里使用了递归方式)

2 前端的文件分片上传js组件(自己写的)

此为上传大文件的【分片上传、断点续传、文件秒传】的公共js文件,此文件依赖jquery.js文件。

参数说明

  • file:type=file的input标签对象
  • url:分片上传、断点续传的接口地址
  • form: var formData = new FormData(); 传一个formData对象,里面存放业务参数并传过来
  • params:formData中存放的业务参数名称,拼接成逗号分割的字符串传过来
function uploadBigFile(file,url,form,params) {
    console.log('upload...');
    var bytesPerPiece = 1024 * 1024; // 每个文件切片大小定为1MB
    var totalPieces; // 总的切片数量
    var blob=file[0].files[0]; //得到文件的blob
    var start = 0;
    var end;
    var index = 0;
    var filesize = blob.size;
    var filename = blob.name;
    totalPieces = Math.ceil(filesize / bytesPerPiece); //计算文件切片总数

         var loop = function () {
             if(start >= filesize){
                 window.location.href=window.location.href;
                 return;
             }//退出循环 
             end = start + bytesPerPiece;
             if(end > filesize) {
                 end = filesize;
             }
             var chunk = blob.slice(start,end); //切割文件
             var sliceIndex= blob.name + index; //分片索引(第几个分片)

             //存业务属性
             var formData = new FormData();
             var strs= new Array();
             strs=params.split(",");
             for (i=0;i n) {
            break;
        }
    }
}

3 后端的文件合并工具类

文件分片上传、断点续传、文件秒传的工具类,此处的操作均要和前端组件(bigfile-upload.js)进行完美配合才行。

/**
 * @Description TODO 文件分片上传、断点续传、文件秒传的工具类
 * TODO 此处的操作均要和前端组件(bigfile-upload.js)进行完美配合才行
 */
public class BigFileUploadUtil {
    private static final String TEMP_DIR="D:\\IdeaProjects\\XXX_EDU_FILE";
    //大文件分片上传合并
    public static String uploadBigFile(MultipartFile file,
                                HttpServletRequest request) throws IOException, InterruptedException {
        //文件名 test.jpg
        String fileName=request.getParameter("fileName");
        //文件名 分片索引 test.jpg0 / test.jpg1 ....
        String sliceIndex=request.getParameter("sliceIndex");
        //切片总数
        Integer totalPieces=Integer.valueOf(request.getParameter("totalPieces"));

        //创建临时目录
        String tempDir=TEMP_DIR+File.separator+fileName2md5(fileName);
        //定义合并后的文件位置
        String url=tempDir+File.separator+fileName;
        //得到索引数
        String index = sliceIndex.substring(fileName.length());
        Integer indexValue = Integer.valueOf(index);
        //写文件分片到临时目录
        InputStream is = file.getInputStream();
        if(indexValue

4 前端上传文件(html)


               

5 前端上传文件(js)




 $("#addCourse").click(function () {
             $(this).attr("disabled","disabled");
             $(this).html("正在上传,请耐心等候...");
             $("#progress").show();
             $("#connected").show();
             $(".progress-bar").css("width","0%");
             var form = new FormData();
             form.append("fileDes",$("#testFileDes").val());
             //开始上传
             uploadBigFile($("#inputPassword"),"/course/addCourse",form,"fileDes");
 });

监控上传目录会看到,把大文件切成多份,先把所有文件切片写入到目录中,最后再合并为一个文件。

值得注意的是,上面第六条的进度条的数据是开了一个定时器,请求接口取进度数据(数据是存到了ServletContext公共区域中),因为文件只会上传一个,所以利用已读的流和文件总大小创建了进度对象存到了ServletContext中;而重构之后就不再使用这种方式了。

【新的方式】

前端已经把文件分片了,直接利用已经上传的分片数除以总分片数就是进度了,干脆利落

6 上传效果(激动人心的时刻)

调整了一下样式和数字精确度后的成品