java实现大文件分片上传

背景:

        公司后台管理系统有个需求,需要上传体积比较大的文件:500M-1024M;此时普通的文件上传显然有些吃力了,加上我司服务器配置本就不高,带宽也不大,所以必须考虑多线程异步上传来提速;所以这里就要用到文件分片上传技术了。

技术选型:

        直接问GPT实现大文件分片上传比较好的解决方案,它给的答案是webUploader(链接是官方文档);这是由 Baidu FEX 团队开发的一款以 HTML5 为主,FLASH 为辅的现代文件上传组件。在现代的浏览器里面能充分发挥 HTML5 的优势,同时又不摒弃主流IE浏览器,沿用原来的 FLASH 运行时,兼容 IE6+,iOS 6+, android 4+。采用大文件分片并发上传,极大的提高了文件上传效率;功能强大且齐全,支持对文件内容的Hash计算分片上传,可实现上传进度条等功能。

实现原理:

        文件分片上传比较简单,就不画图了,前端(webUploader)将用户选择的文件根据开发者配置的分片参数进行分片计算,将文件分成N个小文件多次调用后端提供的分片文件上传接口(webUploader插件有默认的一套参数规范,文件ID及分片相关字段,后端将对保存分片临时文件),后端记录并判断当前文件所有分片是否上传完毕,若已上传完则将所有分片合并成完整的文件,完成后建议删除分片临时文件(若考虑做分片下载可以保留)。

前端引入webUploader:

这里推荐去CDN下载静态资源:

记得要先引入JQuery,webUploader依赖JQuery;前端页面引入CSS和JS文件即可,Uploader.swf文件在创建webUploader对象时指定,貌似用来做兼容的。

前端(笔者前端用的layui)核心代码:


        //百度文件上传插件 WebUploader
        let uploader = WebUploader.create({
            // 选完文件后,是否自动上传。
            auto: true,
            // swf文件路径
            swf: contextPath + '/static/plugin/webuploader/Uploader.swf',
            pick: {
                id: '#webUploader',
                multiple: false
            },
            // 文件接收服务端。
            server: contextPath + '/common/file/shard/upload',
            // 文件分片上传相关配置
            chunked: true,
            chunkSize: 5 * 1024 * 1024, // 分片大小为 5MB
            chunkRetry: 3, // 上传失败最大重试次数
            threads: 5, // 同时最大上传线程数
        });


        //文件上传临时对象
        let fileUpload = {
            idPrefix: '' //文件id前缀
            , genIdPrefix: function () {
                this.idPrefix = new Date().getTime() + '_';
            }
            , mergeLoading: null //合并文件加载层
            , lastUploadResponse: null // 最后一次上传返回值
            , chunks: 0 // 文件分片数
            , uploadedChunks: 0 // 已上传文件分片数
            , sumUploadChunk: function () {
                if (this.chunks > 0) {
                    this.uploadedChunks++;
                }
            }
            , checkResult: function () {
                if (this.uploadedChunks < this.chunks) {
                    layer.open({
                        title: '系统提示'
                        , content: '文件上传失败,请重新上传!'
                        , btn: ['我知道了']
                    });
                }
            }
        };

        // 某个文件开始上传前触发,一个文件只会触发一次
        uploader.on('uploadStart', function (file) {
            $('#uploadProgressBar').show();
            // 生成文件id前缀
            fileUpload.genIdPrefix();
        });

        // 当某个文件的分块在发送前触发,主要用来询问是否要添加附带参数,大文件在开起分片上传的前提下此事件可能会触发多次
        uploader.on('uploadBeforeSend', function (object, data, header) {
            // 重写文件id生成规则
            data.id = fileUpload.idPrefix + data.name;
            fileUpload.chunks = data.chunks != null ? data.chunks : 0;
        });


        uploader.on('uploadProgress', function (file, percentage) {
            // 更新进度条
            let value = Math.round(percentage * 100);
            element.progress('progressBar', value + '%');
            if (value == 100) {
                fileUpload.mergeLoading = layer.load();
            }
        });

        // 获取最后上传成功的文件信息,每个分片文件上传都会回调
        uploader.on('uploadAccept', function (file, response) {
            if (response == null || response.code !== '0000') {
                return;
            }
            fileUpload.sumUploadChunk();
            if (response.data != null && response.data.fileAccessPath != null) {
                fileUpload.lastUploadResponse = response.data;
            }
        });

        // 文件上传成功时触发
        uploader.on('uploadSuccess', function (file, response) {
            console.log('File ' + file.name + ' uploaded successfully.');
            layer.msg('文件上传成功!');
            $('#fileName').val(fileUpload.lastUploadResponse.fileOriginalName);
            $('#fileRelativePath').val(fileUpload.lastUploadResponse.fileRelativePath);
        })

        uploader.on('uploadComplete', function (file) {
            console.log('File' + file.name + 'uploaded complete.');
            console.log('总分片:' + fileUpload.chunks + ' 已上传:' + fileUpload.uploadedChunks);
            fileUpload.checkResult();
            $('#uploadProgressBar').hide();
            layer.close(fileUpload.mergeLoading);
        });

其中几个关键的节点的事件回调都提供了,使用起来很方便;其中“uploadProgress”事件实现了上传的实时进度条展示。

后端Controller代码:

    /**
     * 文件分片上传
     * 
     * 
     * @param file
     * @param fileUploadInfoDTO
     * @return
     */
    @PostMapping(value = "shard/upload")
    public Layui<FileUploadService.FileBean> uploadFileByShard(@RequestParam("file") MultipartFile file,
            FileUploadInfoDTO fileUploadInfoDTO) {
        if (null == fileUploadInfoDTO) {
            return Layui.error("文件信息为空");
        }
        if (null == file || file.getSize() <= 0) {
            return Layui.error("文件内容为空");
        }
        log.info("fileName=[{}]", file.getName());
        log.info("fileSize=[{}]", file.getSize());
        log.info("fileShardUpload=[{}]", JSONUtil.toJsonStr(fileUploadInfoDTO));
        FileUploadService.FileBean fileBean = fileShardUploadService.uploadFileByShard(fileUploadInfoDTO, file);
        return Layui.success(fileBean);
    }


/**
 * @Author: XiangPeng
 * @Date: 2023/12/22 12:01
 */

@Getter
@Setter
public class FileUploadInfoDTO implements Serializable {

    private static final long serialVersionUID = -1L;

    /**
     * 文件 ID
     */
    private String id;

    /**
     * 文件名
     */
    private String name;

    /**
     * 文件类型
     */
    private String type;

    /**
     * 文件最后修改日期
     */
    private String lastModifiedDate;

    /**
     * 文件大小
     */
    private Long size;

    /**
     * 分片总数
     */
    private int chunks;

    /**
     * 当前分片序号
     */
    private int chunk;
}


@Getter
@Setter
public class FileUploadCacheDTO implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 文件 ID
     */
    private String id;

    /**
     * 文件名
     */
    private String name;

    /**
     * 分片总数
     */
    private int chunks;

    /**
     * 当前已上传分片索引
     */
    private List<Integer> uploadedChunkIndex;

    public FileUploadCacheDTO(FileUploadInfoDTO fileUploadInfoDTO) {
        this.id = fileUploadInfoDTO.getId();
        this.name = fileUploadInfoDTO.getName();
        this.chunks = fileUploadInfoDTO.getChunks();
        this.uploadedChunkIndex = Lists.newArrayList();
    }

    public FileUploadCacheDTO() {

    }
}

后端Service层代码:

    /**
     * 文件分片上传
     * 
     * @param fileUploadInfoDTO
     * @param file
     * @return
     */
    public FileBean uploadFileByShard(FileUploadInfoDTO fileUploadInfoDTO, MultipartFile file) {
        if (fileUploadInfoDTO == null || file == null) {
            throw new ServiceException("文件上传失败!");
        }
        // 无需分片的小文件直接上传
        if (fileUploadInfoDTO.getChunks() <= 0) {
            return super.commonUpload(file);
        }
        String fileId = fileUploadInfoDTO.getId();
        // 生成分片临时文件,文件名格式:文件id_分片序号
        FileBean fileBean = super.commonUpload(fileId + StrUtil.UNDERLINE + fileUploadInfoDTO.getChunk(), file);
        // redis缓存数据
        FileUploadCacheDTO fileUploadInfo = null;
        synchronized (this) {
            // 查询文件id是否存在,不存在则创建,存在则更新已上传分片数
            fileUploadInfo = (FileUploadCacheDTO) redisService.get(genRedisKey(fileId));
            // 第一个分片文件上传
            if (fileUploadInfo == null) {
                fileUploadInfo = new FileUploadCacheDTO(fileUploadInfoDTO);
            }
            fileUploadInfo.getUploadedChunkIndex().add(fileUploadInfoDTO.getChunk());
            redisService.set(genRedisKey(fileId), fileUploadInfo);
            // 判断所有分片文件是否上传完成
            if ((fileUploadInfo.getUploadedChunkIndex().size()) < fileUploadInfo.getChunks()) {
                return fileBean;
            }
        }
        // 合并文件
        return mergeChunks(fileUploadInfo);
    }

    /**
     * 分片文件全部上传完成则合并文件,清除缓存并返回文件地址
     * 
     * @param fileUploadCache
     * @return
     */
    private FileBean mergeChunks(FileUploadCacheDTO fileUploadCache) {
        String mergeFileRelativePath = super.getCommonPath().getFileRelativePath() + fileUploadCache.getId();
        String mergeFilePath = super.getCommonPath().getBasePath() + mergeFileRelativePath;
        RandomAccessFile mergedFile = null;
        File chunkTempFile = null;
        RandomAccessFile chunkFile = null;
        try {
            mergedFile = new RandomAccessFile(mergeFilePath, "rw");
            for (int i = 0; i < fileUploadCache.getChunks(); i++) {
                // 读取分片文件
                chunkTempFile = new File(
                        super.getCommonPath().getFileFullPath() + fileUploadCache.getId() + StrUtil.UNDERLINE + i);
                byte[] buffer = new byte[1024 * 1024];
                int bytesRead;
                chunkFile = new RandomAccessFile(chunkTempFile, "r");
                // 合并分片文件
                while ((bytesRead = chunkFile.read(buffer)) != -1) {
                    mergedFile.write(buffer, 0, bytesRead);
                }
                chunkFile.close();
            }
        } catch (IOException e) {
            log.error("merge file chunk error, fileId=[{}]", fileUploadCache.getId(), e);
        } finally {
            try {
                if (mergedFile != null) {
                    mergedFile.close();
                }
            } catch (IOException e) {

            }
            redisService.remove(genRedisKey(fileUploadCache.getId()));
            // 删除分片文件
            removeChunkFiles(super.getCommonPath().getFileFullPath(), fileUploadCache);
        }
        return FileBean.builder().fileOriginalName(fileUploadCache.getName()).fileRelativePath(mergeFileRelativePath)
                .fileAccessPath(super.getNginxPath() + mergeFileRelativePath).build();
    }


    private void removeChunkFiles(String fileFullPathPrefix, FileUploadCacheDTO fileUploadCache) {

        taskExecutor.execute(() -> {
            try {
                // 延迟1秒删除
                TimeUnit.SECONDS.sleep(1);
                String fileFullPath;
                for (int i = 0; i < fileUploadCache.getChunks(); i++) {
                    try {
                        fileFullPath = fileFullPathPrefix + fileUploadCache.getId() + StrUtil.UNDERLINE + i;
                        FileUtil.del(fileFullPath);
                        log.info("file[{}] delete success.", fileFullPath);
                    } catch (Exception e) {
                        log.error("delete temp file error.", e);
                    }
                }
            } catch (Exception e) {
                log.error("delete temp chunk file error.", e);
            }
        });

    }

    private String genRedisKey(String id) {
        return FILE_SHARD_UPLOAD_KEY + id;
    }

最后:

        以上还只是简单的分片合并,想做好一点,还可以考虑加入断点续传;笔者这个版本未根据文件内容做唯一ID的计算,若是大量文件上传的场景,可以考虑实现个相同文件秒传等等。

  • 20
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值