文件流下载优化:由表单提交方式修改为Ajax请求

发布于:2024-05-25 ⋅ 阅读:(438) ⋅ 点赞:(0)

如果想直接看怎么写的可以跳转到 解决方法 节!

需求描述

目前我们系统导出文件时,都是通过表单提交后,接收文件流自动下载。但由于在表单提交时没有相关调用前和调用后的回调函数,所以我们存在的问题,假如导出数据需要10秒,这期间前台依然可以操作,用户超过3秒没收到反馈会重复点击多次,导致后台查询压力过大卡死。因此要对功能做以下修改:

  1. 用户点击下载时弹出加载框提示
  2. 如果用户有相同条件的数据正在导出,需要弹出提示“文件正在下载,请稍后”(避免用户开了多个窗口点击)

系统现状

使用的技术框架:jQuery 1.11.3(注意版本号),EasyUI(后端用的Struts2SpringHibernateJDK8
前端请求下载的逻辑是通过iframe跳转,接收到后端传回的二进制文件流,触发浏览器的自动下载来完成的。
前后端代码如下:

<html>
    <body>
        <form id="theForm2" name="theForm2" method="POST" enctype="multipart/form-data">
          <div id="form-data-request-param" style="display: none;"></div>
        </form>
        <iframe id="oIframe" name="oIframe" frameborder="0" width="100%" height="100%" style="display: none;" src="<c:out value="${pageContext.request.contextPath}" />/pages/globals/blank.jsp"></iframe>
    </body>
</html>

<script>
    exportExcel: function() {
        var requestParamForm = $('#form-data-request-param');
        $('#form-data-request-param').empty();
        let inputHiddenDataArr = [];
        
        let rqParams = {};
        rqParams['cond.beginDate'] = '2024-05-20'; // 入参1
        rqParams['cond.endDate'] = '2024-05-21'; // 入参2
        rqParams['cond.other'] = 'Y'; // 入参3
        for (let rqName in rqParams) {
            inputHiddenDataArr.push('<input type="hidden" name="' + rqName + '" value="' + rqParams[rqName] + '"/>');
        }
        $(inputHiddenDataArr.join('')).appendTo(requestParamForm);
        
        let sTarget = 'oIframe';
        let sFormName = 'theForm2';
        let sUri = actionUri + '/exportExcel.shtml';
        let form = document.forms[sFormName];
        form.target = sTarget;
        form.action = sUri;
        // 无法监听到返回,所以也没有做加载框
        form.submit();
    }
</script>
@Controller("businessAction")
@Scope("prototype")
public class BusinessAction extends Struts2Action {
    @Resource
    private BusinessService service;
    private Cond cond;
    public String exportExcel() throws Exception {
        // download方法的源码就不贴了, 内部逻辑是设置response的头信息Content-disposition=attachment; filename=xxx和Content-Type=application/octet-stream, 再通过输出流写出
        FileUtils.download(ServletActionContext.getResponse(), this.service.export(this.getDownloadDir(), this.cond, this.getSessionBean()));
        return null;
    }
    // 省略其他逻辑
}

public class BusinessServiceImpl extends BusinessService {
    @ExportLog(serviceNode = "导出Excel")
    public File export(String downloadDir, Cond cond, SessionBean sessionBean) throws Exception {
        // ...省略查询等数据组装
        File file = new File(downloadDir, "PC" + DateUtils.formatDate(new Date(), "yyyyMMddHHmmss") + ".xls");
        return file;
    }    
}

处理思路及过程

  1. 需要添加和移除加载框,还有展示后端的错误信息,就得用ajax
  2. 后端返回的是文件流,需要确认jQuery的ajax是否支持下载文件流;如果不用文件流,服务器生成文件后返回下载链接到前台也行(但生成的文件在另外一个机器中,不在tomcat目录下,用户无法直接访问,所以还是采用返回文件流的方式)
  3. 不考虑异步导出,因为对于系统的改动比较大,需要引入延时框架或中间件,效益不高
    因此决定后台依然返回文件流,前端用ajax请求,如果判断是文件流则下载,不是则弹出错误提示

过程

使用jQuery的$.ajax一直都无法正常下载文件,后来查了一些文章表示jQuery的$.ajax会把文件流的内容返回为字符串,需要生成Blob对象后下载,使用以下两种写法,结果下载了打开文件会显示损坏

  1. 添加了xhrFields: { responseType: 'blob' },jQuery3.x可正常使用,1.11.x版本使用报错:

Uncaught DOMException: Failed to read the 'responseText' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'text' (was 'blob').

注意:换了3.0版本后可以接收到blob对象,但项目中好多地方用到了jQuery,不敢轻易升级版本

  1. dataType/responseType设置为blob也无效,依然接收到字符串类型,估计是$.ajax将接收到的数据都先序列化成字符串了

折腾了好久,决定不用jQuery$.ajax了,用原生的XMLHttpRequest,查找它的写法来请求,结果终于正常接收到后端返回的Blob对象了
接收到后台返回的Blob类型数据

解决方法

asyncDownloadFile: function(requestUrl, requestData, successCallback, beforeSendCallback, completeCallback, errorCallback) {
    var formData = new FormData();
    for (var key in requestData) {
        formData.append(key, requestData[key]);
    }
    var xhr = new XMLHttpRequest();
    xhr.open('POST', requestUrl, true);
    //定义responseType='blob', 是读取文件成功的关键,这样设置可以解决下载文件乱码的问题
    xhr.responseType = "blob";
    xhr.onload = function() {
        var data = this.response;
        // 如果不是流信息, 说明有报错
        if (response.type.indexOf('text/plain') >= 0) {
            showMessage(data);
        }
        // 非文本内容, 后台返回了文件流, 在此处理
        var disposition = decodeURI(xhr.getResponseHeader("Content-Disposition"))
            ,mimeType=xhr.getResponseHeader("Content-Type")
        //通过Content-Type获取后端的文件名
        var filename= getFilenameFromDisposition(disposition);

        saveAsFile(data, filename, mimeType);
    };
    xhr.onerror = function() {
        if (typeof errorCallback == 'function') {
            errorCallback();
        }
        $.messager.alert('提示', '下载失败, 请联系管理员');
    };
    xhr.onloadend = function() {
        $.messager.progress('close');
        if (typeof completeCallback == 'function') {
            completeCallback();
        }
    };
    xhr.send(formData);
},
/** 解析文本内容*/
showMessage: function(data) {
    var reader= new FileReader();
    reader.readAsText(data,'UTF-8');
    reader.onload = function() {
        var res = JSON.parse(reader.result);
        $.messager.alert('提示', res.ajaxError ? res.ajaxError : "服务器异常, 请联系管理员");
    }
},
/** 通过disposition获取文件流的文件名 */
getFilenameFromDisposition: function (disposition){
    var filename='';
    if (disposition && disposition.indexOf('attachment') !== -1) {
        var filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
        var matches = filenameRegex.exec(disposition);
        if (matches != null && matches[1]) {
            filename = matches[1].replace(/['"]/g, '');
        }
    }
    return filename;
},
/** 保存文件到本地 */
saveAsFile: function (data, filename, mimeType) {
    //兼容ie
    if ('msSaveOrOpenBlob' in navigator) {
        var blob = new Blob([data], { type: mimeType });
        window.navigator.msSaveOrOpenBlob(blob, filename);
    } else {
        var blob = new Blob([data], { type: mimeType });
        var url = window.URL.createObjectURL(blob);
        var link = document.createElement('a');
        document.body.appendChild(link);
        link.style.display = 'none';
        link.download = filename;
        link.href = url;
        link.click();
        window.URL.revokeObjectURL(url);//手动释放blobURL,避免内存溢出
        document.body.removeChild(link);
    }
}

jQuery3.x的写法

$.ajax({
    type: 'POST',
    url: '请求地址',
    xhrFields: {
        responseType: 'blob'
    },
    data: requestData
    success: function(response,status,xhr) {
        if (response.type.indexOf('text/plain') >= 0) {
                    showMessage(response);// 复用上面代码块的方法
                    return;
                }
                // 复用上面代码块的方法

                var fileName = getFilenameFromDisposition(xhr.getResponseHeader('Content-Disposition')); // 设置下载的文件名
                saveAsFile(response, fileName, xhr.getResponseHeader('Content-Type'));
    },
    error: function(jqXHR, textStatus, errorThrown) {
        console.error('Error downloading file:', textStatus, errorThrown);
    }
});

总结及反思

  1. 留意版本问题:在这个需求上耗费的时间主要集中在使用了不同版本的写法,结果大家都忽略了标注上自己的jQuery版本,导致相同的用法在低版本下无效
  2. 后台返回指定内容类型:后台注意区分返回文件流文本的头信息contentType的返回,在我们系统会通过Struts的拦截器类将异常信息使用contentType=text/plain(文件流用的application/octet-stream)写到response的头信息中

参考链接

Ajax处理文件流下载
使用XMLHttpRequest处理文件流下载


网站公告

今日签到

点亮在社区的每一天
去签到