SpringBoot文件上传进度监听

所有代码点击此处下载.

开始

最近在做关于文件上传的小项目(前端LayUI,后端SpringBoot),需要实现上传进度监听。首先介绍本次测试代码框架。

代码框架

作为Maven项目,首先就是引入依赖:

    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.0</version>
            <scope>test</scope>
        </dependency>
<!--        Thymeleaf-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <!--        SpringBoot-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

配置文件:

spring:
  servlet:
    multipart:
      max-file-size: 10000MB
      max-request-size: 100000MB

项目启动类:

@SpringBootApplication
public class UploadApplication {
    public static void main(String[] args) {
        SpringApplication.run(UploadApplication.class, args);
    }
}

控制器:

@Controller
public class IndexController {

    private Logger logger = LoggerFactory.getLogger(this.getClass());

    private String baseFile = "D:\\Temp\\Upload";

    @RequestMapping({"/", "/index"})
    public String index() {
        return "index";
    }

    @PostMapping("/upload")
    @ResponseBody
    public String upload(@RequestParam("file") MultipartFile multipartFile) {
        File parentFile = new File(baseFile);
        if (!parentFile.exists()) {
            parentFile.mkdirs();
        }
        String originalFilename = multipartFile.getOriginalFilename();
        File destFile = new File(baseFile + File.separator + originalFilename);
        try {
            multipartFile.transferTo(destFile);
        } catch (IOException e) {
            e.printStackTrace();
            return "{\"code\":0}";
        }

        return "{\"code\":1}";
    }
}

文件上传前端:

<!DOCTYPE HTML>
<html>
<head>
    <title>File Upload</title>
    <link rel="stylesheet" href="https://www.layuicdn.com/layui/css/layui.css"/>
</head>
<body>
<div class="layui-container">
    <div class="layui-row">
        <div class="layui-col-xs4 layui-col-sm7 layui-col-md8">
            <div class="grid-demo layui-bg-green">|</div>
            <br />
            <blockquote class="layui-elem-quote">Upload</blockquote>
            <div class="layui-upload-drag" id="test10">
                <i class="layui-icon"></i>
                <p>点击上传,或将文件拖拽到此处</p>
                <div class="layui-hide" id="uploadDemoView">
                    <hr>
                    <img src="" alt="上传成功" style="max-width: 196px">
                </div>
                <br />
                <!--            进度条-->
                <div class="layui-progress layui-progress-big" lay-showpercent="true" lay-filter="demo">
                    <div id="progress" class="layui-progress-bar layui-bg-red" lay-percent="0%"></div>
                </div>
            </div>
        </div>
    </div>
    <br />
    <button type="button" class="layui-btn" id="testListAction">click to submit</button>
</div>

</body>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://www.layuicdn.com/layui-v2.5.6/layui.js"></script>
<script>
    layui.use('form', function(){
        var form = layui.form;
        form.render();
    });

    layui.use(['upload','jquery','element'], function(){
        var $ = layui.jquery
            ,upload = layui.upload
            ,element = layui.element;
        element.init();
        //拖拽上传
        upload.render({
            elem: '#test10'
            ,url: "/upload"
            //限制大小 100M
            // ,size: 100000
            ,auto: false//是否选完文件后自动上传
            ,bindAction: '#testListAction'//指向一个按钮触发上传
            ,method: 'post'
            //接收任何类型的文件
            ,accept: 'file'
            //完成回调
            ,done: function(res){
                layer.msg(res.code);
                if (res.code){
                    layui.$('#uploadDemoView').removeClass('layui-hide').attr('src');
                }
                element.progress('demo','100%');
            }
        });
    });
</script>
</html>

前端主要使用了LayUI的拖拽上传组件。在想文件上传进度监听的时候,我一开始尝试使用LayUI文件上传监听,可是失败了。

后来,我在看代码时无意间发现SpringBoot的MultipartFile接口实现了InputStreamSource接口,那么就说明,它可以直接获取文件的InputStream,那么如果放弃使用transferTo方法,转而自己完成文件传输代码,同时记录已传输字节数何总字节数,再借助Redis或者Session缓存这个记录,那么便可以提供一个上传进度接口以达到上传进度监听的目的了,想到这,我快乐地开始测试。

第一次尝试——getInputStream()

思路有了代码就简单了。

v1

文件上传映射方法:

    @PostMapping("/upload")
    @ResponseBody
    public String upload(@RequestParam("file") MultipartFile multipartFile) {
        File parentFile = new File(baseFile);
        if (!parentFile.exists()) {
            parentFile.mkdirs();
        }
        String originalFilename = multipartFile.getOriginalFilename();
        File destFile = new File(baseFile + File.separator + originalFilename);
        try {
//            multipartFile.transferTo(destFile);
            doUpload(multipartFile, destFile);
        } catch (IOException e) {
            e.printStackTrace();
            return "{\"code\":0}";
        }

        return "{\"code\":1}";
    }

其他东西没变,就是把transferTo方法换成了自定义的doUpload方法。

doUpload方法主要实现文件传输的具体逻辑同时缓存传输中间状态。

Progress和ProgressData都是封装的进度:

public class Progress {
    private Long bytesRead;
    private Long totalBytes;
    //Getter、Setter
}
//该类主要用于请求返回
public class ProgressData {
    private Integer data;
    private Boolean success;
    //Getter、Setter
}

这些代码毕竟都是测试用的,所以这里就不用Redis了,直接用Session得了。

    private void doUpload(MultipartFile file, File destFile) throws IOException {
        logger.info("Upload Begin {}", new Date().toString());
        Progress progress = new Progress();
        progress.setBytesRead(0l);
        progress.setTotalBytes(file.getSize());
        session.setAttribute("progress", progress);
        BufferedInputStream bufferedInputStream = new BufferedInputStream(file.getInputStream());
        BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(destFile));
        byte[] buffer = new byte[102400];
        int read = 0;
        while ((read = bufferedInputStream.read(buffer)) != -1) {
            bufferedOutputStream.write(buffer);
            Progress targetProgress = (Progress) session.getAttribute("progress");
//                取出数据并更新
            targetProgress.setBytesRead(targetProgress.getBytesRead() + read);
            session.setAttribute("progress", targetProgress);
        }
        logger.info("Upload end {}", new Date().toString());
        bufferedOutputStream.flush();
        session.removeAttribute("progress");
    }

剩下的就很简单了,写个上传进度监听接口:

    @ResponseBody
    @GetMapping("/progress")
    public ProgressData getProgress(HttpServletRequest request) {
        HttpSession session = request.getSession();
        Progress progress = (Progress) session.getAttribute("progress");
        ProgressData progressData = new ProgressData();
        if (progress == null) {
            progressData.setSuccess(false);
            progressData.setData(0);
            return progressData;
        }
        logger.info("read/all {}/{}", progress.getBytesRead(), progress.getTotalBytes());
        progressData.setData((int) ((double)progress.getBytesRead() * 100 / progress.getTotalBytes()));
        progressData.setSuccess(true);
        return progressData;
    }

逻辑一目了然,从Session中获取进度状态并返回。

前端进度条更新:

    layui.use('form', function(){
        var form = layui.form;
        form.render();
    });

    layui.use(['upload','jquery','element'], function(){
        var $ = layui.jquery
            ,upload = layui.upload
            ,element = layui.element;
        element.init();
        //拖拽上传
        upload.render({
            elem: '#test10'
            ,url: "/upload"
            //限制大小 100M
            // ,size: 100000
            ,auto: false//是否选完文件后自动上传
            ,bindAction: '#testListAction'//指向一个按钮触发上传
            ,method: 'post'
            //接收任何类型的文件
            ,accept: 'file'
            ,before: function (obj){
                getProgress(element);
            }
            //完成回调
            ,done: function(res){
                layer.msg(res.code);
                if (res.code){
                    layui.$('#uploadDemoView').removeClass('layui-hide').attr('src');
                }
                element.progress('demo','100%');
            }
        });
    });

    function getProgress(element) {
        $.ajax({
            url: 'http://localhost:8080/progress',
            type: 'get',
            dataType: 'json',
            success: function (data) {
                //方法中传入的参数data为后台获取的数据
                console.log(data);
                var percent = data.data;
                if (percent != 0) {
                    progress = percent;
                    element.progress('demo', progress + '%');
                }
                console.log(percent);
                $('#progress').attr("lay-percent", percent);
                if (data.data != null){
                    setTimeout(getProgress, 2000, element);
                }
            }
        })
    }

简直🐂🍺。

我美滋滋地开始运行测试,结果发现前端获取到的ProgressData全都是

{"data":0,"success":false}

我日噢,怎么回事,在我不断Debug下,终于发现了猫腻,一次Ajax请求一个Session

v2

简单,我直接用第一次请求的Session不就行了。于是我给IndexController加一个HttpSession属性:

    //    仅用于测试!!
    private HttpSession session;

在用户访问首页的时候赋值:

    @RequestMapping({"/", "/index"})
    public String index(HttpServletRequest request) {
        session = request.getSession();
        return "index";
    }

修改进度获取:

    @ResponseBody
    @GetMapping("/progress")
    public ProgressData getProgress() {
        Progress progress = (Progress) session.getAttribute("progress");
        ProgressData progressData = new ProgressData();
        if (progress == null) {
            progressData.setSuccess(false);
            progressData.setData(0);
            return progressData;
        }
        logger.info("read/all {}/{}", progress.getBytesRead(), progress.getTotalBytes());
        progressData.setData((int) ((double)progress.getBytesRead() * 100 / progress.getTotalBytes()));
        progressData.setSuccess(true);
        return progressData;
    }

测试:

upload_progress_1

这美滋滋啊,大功告成。

等我屁颠屁颠把代码传到服务器测试发现,不管怎么获取进度条数据永远都是0。

查看日志发现:

3------------------Sun Sep 13 15:23:17 CST 2020
Time Spent: 591 ms
4------------------Sun Sep 13 15:23:18 CST 2020

100M的文件0.5s通过我这破网络传完你能信?实际等待时间也远远不止0.5s,然而前端获取到的进度数据永远为0.

查询后发现,Tomcat处理文件上传不会直接上传到目的位置,而是先上传到临时目录,比如Linux下是/tmp,Windows下应该是C:\Users\ {username}\AppData\Local\Temp

logger.info(System.getProperty("java.io.tmpdir"));

但我还是没找到是啥原因造成,知道的同志麻烦教教我。

第二次尝试——自定义解析器

我又继续查资料,终于看到这么一篇文章:SpringBoot+fileUpload获取文件上传进度

觉得好像很有道理的样子,于是开始尝试。

导入文件上传依赖:

    <dependency>
        <groupId>commons-fileupload</groupId>
        <artifactId>commons-fileupload</artifactId>
        <version>1.3.3</version>
    </dependency>

自定义解析器:

public class CustomMultipartResolver extends CommonsMultipartResolver {

    @Autowired
    private FileUploadProgressListener progressListener;

    @Override
    protected MultipartParsingResult parseRequest(HttpServletRequest request) throws MultipartException {
        FileUpload fileUpload = prepareFileUpload("UTF-8");
        progressListener.setSession(request.getSession());
        fileUpload.setProgressListener(progressListener);
        try {
            List<FileItem> fileItems = ((ServletFileUpload) fileUpload).parseRequest(request);
            return parseFileItems(fileItems, "UTF-8");
        } catch (FileUploadException e) {
            e.printStackTrace();
        }
        return super.parseRequest(request);
    }
}

这里需要注意的是导入的大部分文件上传相关的类应该在包org.apache.commons.fileupload下。

进度监听器:

@Component
public class FileUploadProgressListener implements ProgressListener{

    private HttpSession session;

    public void setSession(HttpSession session) {
        this.session = session;
        Progress progress = new Progress();
        progress.setTotalBytes(0l);
        progress.setBytesRead(0l);
        session.setAttribute("progress", progress);
    }

    @Override
    public void update(long l, long l1, int i) {
        Progress progress = (Progress) session.getAttribute("progress");
        if (progress.getBytesRead() == progress.getTotalBytes()
                && progress.getBytesRead() != 0) {
            session.removeAttribute("progress");
        }
        progress.setBytesRead(l);
        progress.setTotalBytes(l1);
        session.setAttribute("progress", progress);
    }
}

配置自定义解析器替换原来的解析器:

@Configuration
public class UploadConfig {
    @Bean(name = "multipartResolver")
    public MultipartResolver multipartResolver(){
        CustomMultipartResolver customMultipartResolver = new CustomMultipartResolver();
        return customMultipartResolver;
    }
}

当然,还有,需要关闭文件上传的自动配置:

@SpringBootApplication(exclude = MultipartAutoConfiguration.class)
public class UploadApplication {
    public static void main(String[] args) {
        SpringApplication.run(UploadApplication.class, args);
    }
}

结果:

upload_progress_2

现在服务端运行正常,还剩个问题就是上传速度有些慢。

发现了个问题,MySQL字符集为UTF- 8时插入不了表情。

参考

  1. SpringBoot+fileUpload获取文件上传进度*
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,658评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,482评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,213评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,395评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,487评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,523评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,525评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,300评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,753评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,048评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,223评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,905评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,541评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,168评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,417评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,094评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,088评论 2 352