对Web(Springboot + Vue)实现文件下载功能的改进

此为 软件开发与创新 课程的作业

  • 对已有项目(非本人)阅读分析
  • 找出软件尚存缺陷
  • 改进其软件做二次开发
  • 整理成一份博客

原项目简介

本篇博客所分析的项目来自于 ジ绯色月下ぎ——vue+axios+springboot文件下载 的博客,在其基础之上进行了一些分析和改进。

原项目前端使用了Vue框架,后端采用Springboot框架进行搭建,通过前端发送请求,后端返回文件流给前端进行文件下载。

源码解读

  • 后端主要代码
public class DownLoadFile {

  @RequestMapping(value = "/downLoad", method = RequestMethod.GET)
  public static final void downLoad(HttpServletResponse res) throws UnsupportedEncodingException {
    //文件名 可以通过形参传进来
    String fileName = "t_label.txt";
    //要下载的文件地址 可以通过形参传进来
    String filepath = "f:/svs/" + fileName;

    OutputStream os = null;//输出文件流
    InputStream is = null;//输入文件流
    try {
      // 取得输出流
      os = res.getOutputStream();
      // 清空输出流
      res.reset();
      res.setContentType("application/x-download;charset=GBK");//设置响应头为文件流
      res.setHeader("Content-Disposition","attachment;filename=" 
                    + new String(fileName.getBytes("utf-8"), "iso-8859-1"));//设置文件名
      // 读取流
      File f = new File(filepath);
      is = new FileInputStream(f);
      if (is == null) {
        System.out.println("下载附件失败");
      }
      // 复制
      IOUtils.copy(is, res.getOutputStream());//通过IOUtils的copy函数直接将输入文件流的内容复制到输出文件流内
        res.getOutputStream().flush();//刷新输出流
      } catch (IOException e) {
        System.out.println("下载附件失败");
      }
      // 文件的关闭放在finally中
      finally {
        try {
          if (is != null) {
            is.close();
          }
        } catch (IOException e) {
          System.out.println("输入流关闭异常");
        }
        try {
          if (os != null) {
            os.close();
          }
        } catch (IOException e) {
            	System.out.println("输出流关闭异常");
        }
      }
    }
}

原作者后端利用IOUtils.copy完成了输入输出流的写入,此函数内部调用了缓冲区,实现稳定的文件流的写出,后端基本能够应对各种文件的文件流传输。

但查阅相关文档,发现copy方法的buffer大小为固定的 4K

而不同大小的文件不同网速的用户对于文件的下载时缓冲区的大小其实通过调整能够有明显提速,所以需要进一步测试是否通过调整buffer大小能够使用户体验明显提升。

  • 前端
<el-button size="medium" type="primary" @click="downloadFile">Test</el-button>

//js
downloadFile(){
      this.axios({
        method: "get",
        url: '/api/downloadFile',
        responseType: 'blob',
        headers: {
          Authorization: localStorage.getItem("token")
        }
      })
        .then(response => {
       //文件名 文件保存对话框中的默认显示
         let fileName = 'test.txt';
         let data = response.data;
         if(!data){
           return
         }
         console.log(response);
      //构造a标签 通过a标签来下载
         let url = window.URL.createObjectURL(new Blob([data]))
         let a = document.createElement('a')
         a.style.display = 'none'
         a.href = url
       //此处的download是a标签的内容,固定写法,不是后台api接口
         a.setAttribute('download',fileName)
         document.body.appendChild(a)
         //点击下载
         a.click()
         // 下载完成移除元素
         document.body.removeChild(a);
         // 释放掉blob对象
         window.URL.revokeObjectURL(url);
        })
        .catch(response => {
          this.$message.error(response);
        });
    },

作者前端使用动态创建a标签的方式进行前端用户进行文件下载的操作。这里就有一个比较大的问题。

这个问题是由axios自身的特性产生的,在使用axios进行下载请求后,axios会将所有的返回数据先进行缓存,等全部缓存完成后再调用then方法。也就是用axios的then方法接收返回数据时,会将用户需要下载的文件先缓存在内存中,等文件全部下载完成再运行then内的代码。

这个特性也是导致问题的关键,导致的问题有:

  • 下载大文件占用内存很高
  • 在文件下载完成前,用户不会收到任何提示

改进方案

测试文件下载耗时

首先是针对后端的一些优化的尝试

粗略测试方法:使用本地搭建前后端,将 F盘文件夹作为服务器存放文件的位置,文件通过前端下载至 D盘,理论下载速度为100M/s(由实际复制速度估算),通过改变 buffer大小测试文件下载速度差异,平均耗时计算方法为去掉最低最高耗时,取剩下平均值

下载的文件大小为700M,理论最快下载耗时 7s

  • 使用copy方法
public String downloadFile(@RequestParam("filename") String filename, @RequestParam("sha1Hash") String sha1Hash, HttpServletRequest request, HttpServletResponse response) throws IOException {
        //…………………………略去细节
        FileInfo fileInfo = new FileInfo();//将请求信息转为bean
        fileInfo.setFilename(filename);
        fileInfo.setSha1Hash(sha1Hash);
        String resPath = fileInfoRepository.searchFilePath(fileInfo);//查询文件在服务器的位置

        FileInputStream fileInputStream = null;//输入流
        ServletOutputStream os = null;//输出流
        try {
            File fileRes = new File(resPath);//通过路径获取文件

            os = response.getOutputStream();//获取输出流
            
            fileInputStream = new FileInputStream(fileRes);//获取文件流

            long start = System.currentTimeMillis();//下载开始时间

            IOUtils.copy(fileInputStream , response.getOutputStream());//使用已有库进行数据流传输

            long end = System.currentTimeMillis();//下载结束时间
            System.out.println("遍历" + filename + "文件流,耗时:" + (end - start) + " ms");//输出下载所用时间

            os.flush();//刷新输出流
            response.setStatus(HttpServletResponse.SC_OK);
           //……………………
    }
次序 耗时
1 21823ms
2 20098ms
3 12643ms
4 22284ms
5 23779ms

平均耗时:21402ms——21.4s

  • 使用copyLarge方法
            long start = System.currentTimeMillis();//下载开始时间

            IOUtils.copyLarge(fileInputStream , response.getOutputStream());//使用已有库进行数据流传输

            long end = System.currentTimeMillis();//下载结束时间

            System.out.println("遍历" + filename + "文件流,耗时:" + (end - start) + " ms");//输出下载所用时间
次序 耗时
1 23351ms
2 21046ms
3 26786ms
4 22190ms
5 28389ms

平均耗时:24109ms——24.1s

  • 使用自定义buffer循环读取(20M)
            byte[] bytes = new byte[1024 * 1024 * 20];//静态buffer
            int len = 0;
 
	    long start = System.currentTimeMillis();//下载开始时间

            while ((len = bufferedInputStream.read(bytes)) != -1) {
                os.write(bytes, 0, len);
            }

            long end = System.currentTimeMillis();//下载结束时间

            System.out.println("遍历" + filename + "文件流,耗时:" + (end - start) + " ms");//输出下载所用时间
次序 耗时
1 20212ms
2 16648ms
3 15591ms
4 15496ms
5 13185ms

平均耗时:15911ms——15.9s

  • 使用自定义buffer循环读取(40M)
	    byte[] bytes = new byte[1024 * 1024 * 40];//静态buffer

            int len = 0;
 
	    long start = System.currentTimeMillis();//下载开始时间

            while ((len = bufferedInputStream.read(bytes)) != -1) {
                os.write(bytes, 0, len);
            }

            long end = System.currentTimeMillis();//下载结束时间

            System.out.println("遍历" + filename + "文件流,耗时:" + (end - start) + " ms");//输出下载所用时间
次序 耗时
1 12194ms
2 10198ms
3 9794ms
4 15116ms
5 16523ms

平均耗时:12503ms——12.5s

结论:可见在网速恒定,文件大小恒定的情况下,缓冲区大小对于文件下载速度会造成一定差异。而在实际应用环境中缓冲区大小会受:文件大小、内存使用情况、网速情况、带宽占用量的多方面因素影响,所以选择一个合适的缓冲区大小,甚至是动态调整缓冲区大小都是能够改善用户体验的一个方法。

询问搜索触发下载的替代方案

这是针对前端axios下载问题的改进之路

  • 首先通过搜素了解为何无法正常触发浏览器下载

    • 知乎评论区中找到了相似提问->传送门

  • 其次通过搜素和询问找到了如下几种解决方案

    • 使用a标签以前端静态资源的方式提供下载
    • 使用form表单进行文件下载
    • 询问了解相关建议和解决方案

通过实践,采用第二种方式即使用form表单代替axios的then方法进行文件下载

实现替代方案

  • 前端改用动态创建form表单的方式下载文件
downloadFile (file,scope) {
        var form = document.createElement("form");//创建form元素
        form.setAttribute("style", "display:none");
        form.setAttribute("method", "post");//post方式提交
        var input = document.createElement('input');//用input标签传递参数
        input.setAttribute('type', 'hidden');
        input.setAttribute('name', 'filename');
        input.setAttribute('value', file.filename);
        form.append(input);
        var input2 = document.createElement('input');
        input2.setAttribute('name', 'sha1Hash');
        input2.setAttribute('value', file.sha1Hash);
        form.append(input2);
        form.setAttribute("action", initialization.downloadFileInterface);//请求地址
        form.setAttribute("target", "_self");//不跳转至新页面
        var body = document.createElement("body");
        body.setAttribute("style", "display:none");
        document.body.appendChild(form);
        form.submit();
        form.remove();
      },
  • 后端处理表单请求
public String downloadFile(@RequestParam("filename") String filename, @RequestParam("sha1Hash") String sha1Hash, HttpServletRequest request, HttpServletResponse response) throws IOException {
        request.setCharacterEncoding("UTF-8");
        FileInfo fileInfo = new FileInfo();
        fileInfo.setFilename(filename);
        fileInfo.setSha1Hash(sha1Hash);
        String resPath = fileInfoRepository.searchFilePath(fileInfo);

        FileInputStream fileInputStream = null;
        BufferedInputStream bufferedInputStream = null;
        ServletOutputStream os = null;
        try {
            File fileRes = new File(resPath);
            
            response.reset();
            response.addHeader("Access-Control-Allow-Origin", "*");//设置响应头
            response.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
            response.addHeader("Access-Control-Allow-Headers", "Content-Type");
            response.setContentType("application/octet-stream");
            response.setHeader("Content-Disposition", "attachment;filename=" + new String(fileInfo.getFilename().getBytes(StandardCharsets.UTF_8), "ISO-8859-1"));

            os = response.getOutputStream();
            fileInputStream = new FileInputStream(fileRes);
            bufferedInputStream = new BufferedInputStream(fileInputStream);

            byte[] bytes = new byte[1024 * 1024 * 20];//静态buffer

            int len = 0;

            while ((len = bufferedInputStream.read(bytes)) != -1) {//循环读取
                os.write(bytes, 0, len);
            }

            os.flush();
            response.setStatus(HttpServletResponse.SC_OK);
            return "success";
        }
        catch (Exception e){
            response.setStatus(HttpServletResponse.SC_EXPECTATION_FAILED);
            return null;
        }
        finally {
            try{
                if(bufferedInputStream != null)
                	bufferedInputStream.close();
            }catch(IOException e){
                System.out.println("bufferedInputStream关闭异常");
            }
            try{
            	if(fileInputStream != null)
                	fileInputStream.close();
            }catch(IOException e){
                System.out.println("fileInputStream关闭异常");
            }
            try{
            	if(os != null)
                	os.close();
            }catch(IOException e){
                System.out.println("os关闭异常");
            }
        }
    }

改进效果

  • 前端下载截图

posted @ 2021-03-05 18:09  Oto_G  阅读(1437)  评论(1编辑  收藏  举报