Java Web简化版本大文件分片上传(含前端+后端)

目录

一、前言

二、前端WebUploader

三、后端

四、总结


一、前言

原先接口支持1G文件上传,但是最近客户需求变了,需要支持2G文件上传。本着能调参数就不改代码的原则,尝试通过调整JVM来实现大文件上传,最后发现不可行,因为虽然上传的是2G文件,但是实际占用的内存却远远大于2G。后来想着有没有web端的流式文件上传,一边上传的同时可以一边写入文件,也不会占用多大的内存。于是花了不少时间找资料,也没找到我觉得可行或可用的方案(WebSocket可能可以,还没尝试,试过的小伙伴可以回来告诉我一下)。

迫于无奈,只能对文件上传接口进行改造,使用较为通用的文件分片上传。

通过查找了不少文章,发现都很复杂。复杂不说,要不就是代码不完整,要不就是结构不清晰,难以明白其真意。

于是,我按照自己的想法,写了一个简单版本的文件分片上传,简单主要是简单在后台处理上面进行了各种简化,使得文件分片上传更加简单明了。至于需要更加完美的版本,也在此基础上进行完善。或者说,明白了简单版本的文件分片上传,也可以根据自己的想法,做出一版新的,以适应不同的业务需求。

以下将展开对前端和后端的具体操作

二、前端WebUploader

前端的文件分片上传组件,我这里使用的是WebUploader,使用成熟组件的好处是:不用自己写文件切割的相关逻辑,所有分片文件请求发送完毕之后,会触发一个最终确认的请求。

前端相关的代码我是参考以下这篇文章的:

WebUploader - 分片上传_风神修罗使的博客-CSDN博客_webuploader分片上传

WebUpoader具体详细可以参考以下网址:

快速开始 - Web Uploader

资源的引入这里我就不说了,想要了解可以参考其他地方

<link th:href="@{/webuploader/webuploader.css}" rel="stylesheet" />

<script th:src="@{/webuploader/webuploader.js}"></script>

以下是我的前端相关代码(非前端工程师,代码看看就好哈),具体含义可以参考注释:

// 文件分片上传
function sliceUpload(addFormData, options) {

    // default options
    options = $.extend({
        folder: '',
        success: $.noop,
        error: $.noop,
        progress: $.noop
    }, options);

    var errorHandler = options.error;
    var successHandler = options.success;
    var progressHanlder = options.progress;
    //var fileMd5 = uuidv4();
    //var runtimeForRuid = new WebUploader.Runtime.Runtime();
    var file = addFormData.get("file")
    var wuFile = new WebUploader.File(new WebUploader.Lib.File(WebUploader.guid('rt_'), file)); // 待上传文件
    var chunkSize = 50 * 1024 * 1024;                   // 分片大小
    var chunked = file.size > chunkSize;                // 文件是否需要分片
    var chunks = Math.ceil(file.size / chunkSize);   // 文件分片块数

    //
    var events = $.extend({
        uploadBeforeSend: function(_, data) {
            // 当是分片上传时
            if (chunked) {
                // 上传前的准备,todo
            }
        },
        uploadProgress: progressHanlder
    }, file.type.indexOf('image') >= 0 || !chunked ? {
        uploadSuccess: function(_, data) {
            // 非分片上传成功回调,todo
        },
        uploadError: function() {
            // 非分片上传失败回调,todo
        }
    } : {
        uploadFinished: function() {

            // 所有分片都上传完毕,最终执行逻辑,后台确认文件并且进行合并
            var file = uploader.getFiles()[0];
            if (!file || file.type.indexOf('image') >= 0) {
                return;
            }
            $.ajax({
                method: "POST",
                url: "/checkUpload",
                contentType: 'application/json; charset=UTF-8',
                data: JSON.stringify({
                    fileName: addFormData.get("fileName"),  // 文件名称
                    size: addFormData.get("size"),          // 文件大小
                    chunks: chunks                          // 总分片数
                }),
                success: function (data) {
                    // 文件确认上传调用成功
                },
                error: function (){
                    // 文件确认上传调用失败
                }
            })
        }
    });

    // 配置webuploader,指定每个分片文件的上传接口等
    var uploader = WebUploader.create({
        fileNumLimit: 1,
        chunked: chunked,
        chunkSize: chunkSize,
        prepareNextFile: true,
        server: '/sliceUpload',
        fileVal: 'file',
        formData: {
            md5: addFormData.get("md5"), // 此处是计算好的文件的md5,作为文件唯一的标识
            fileName: addFormData.get("fileName"),
            stationId: addFormData.get("stationId")
        },
        threads: 3, // 开启3个线程并发上传分片,根据自己系统能力调整
    });

    // 给Uploader绑定上传事件
    $.each(events, function(name, handler) {
        uploader.on(name, handler);
    });

    // 开始上传
    uploader.addFiles(wuFile);
    uploader.upload();
    return uploader;
}

三、后端

1、每一个分块文件调用的接口

创建一个文件对象接收类:

/**
 * 分片文件接收类
 * @date 2022/8/25
 */
@ToString
@Data
public class FileUploadVO {

    // 文件唯一标识,并不是分片文件的,而是大文件的
    private String md5;

    // 文件名称
    private String fileName;

    // 当前分片序号
    private Integer chunk;

    // 分片对象,文件对象
    private MultipartFile file;
}

 创建一个controller,加一个sliceUpload接口,用于分片文件的上传:

@RestCOntroller
public class UploadConroller
{

    @Reource
    private UploadService uploadService;

    /**
    * 文件分片上传
    * @date 2022/8/29
    * @param fileUploadVO 文件分片对象
    * @return 返回分片上传结果
    */
    @PostMapping("/sliceUpload")
    public R<Boolean> sliceUpload(FileUploadVO fileUploadVO){
        try{
            return uploadService.saveSliceFile(fileUploadVO);
        }catch (Exception e){
            return new R<>(false, "分片文件保存失败", R.FAIL);
        }
    }
}

 创建一个service类:

public interface UploadService {

    /**
     * 分片文件上传
     * @date 2022/8/29
     * @param fileUploadVO 分片文件对象
     * @return 返回上传结果
     */
    R<Boolean> saveSliceFile(FileUploadVO fileUploadVO) throws Exception;
}

创建上传实现类: 

@Slf4j
@Service
public class UploadServiceImpl implements UploadService{

    @Value("${save-location}")
    private String saveDir;

    @Resource
    private RedisTemplate<String, String> redisTemplate;

    private final static String KEY_PREFIX = "uploader-server";

    /**
     * 分片文件上传
 * @date 2022/8/29
 * @param fileUploadVO 分片文件对象
 * @return 返回上传结果
 */
@Override
public R<Boolean> saveSliceFile(FileUploadVO fileUploadVO) throws Exception{
    // 获取分片文件临时存放位置
    File dir = Paths.get(saveDir, "temp").toFile();

    // 创建文件夹,同步,避免并发创建
    synchronized (this) {
        if (!dir.exists() && !dir.isDirectory()) {
            boolean mkdirs = dir.mkdirs();
            if (!mkdirs) {
                log.error("文件保存目錄創建失敗");
                String key = KEY_PREFIX + ":slice-file-upload:" + fileUploadVO.getMd5() + ":" + "fail";
                // 将失败的文件数进行记录
                redisTemplate.boundValueOps(key).increment();
                return new R<>(false, "分片文件保存失败", R.FAIL);
            }
        }
    }

    InputStream inputStream = null;
    FileOutputStream fos = null;
    String tempFileName = fileUploadVO.getFileName();
    try{
        log.info("进行分片文件保存,fileUploadVO = " + fileUploadVO);
        inputStream = fileUploadVO.getFile().getInputStream();
        if(fileUploadVO.getChunk() != null){
            // 存入到临时文件中的文件改为 当前块_文件名
            tempFileName = fileUploadVO.getChunk() + "_" + tempFileName;
        }
        fos = new FileOutputStream(Paths.get(dir.getPath(), tempFileName).toFile());
        byte[] bytes = new byte[1024];
        int len;
        while((len = inputStream.read(bytes)) != -1){
            fos.write(bytes, 0, len);
        }
        // 无异常,向缓存中记录文件成功块数
        String key = KEY_PREFIX + ":slice-file-upload:" + fileUploadVO.getMd5() + ":" + "success";
        redisTemplate.boundValueOps(key).increment();
        log.info("文件分片保存成功,fileUploadVO = " + fileUploadVO);
        return new R<>(true, "分片文件保存成功", R.SUCCESS);
    }catch (Exception e){
        // 出现异常,向缓存中记录文件失败情况
        log.error("文件分片保存失败,fileUploadVO = " + fileUploadVO);
        log.error("异常堆栈信息:", e);
        String key = KEY_PREFIX + ":slice-file-upload:" + fileUploadVO.getMd5() + ":" + "fail";
        redisTemplate.boundValueOps(key).increment();
        return new R<>(false, "分片文件保存失败", R.FAIL);
    }finally {
        if(inputStream != null){
            inputStream.close();
        }
        if(fos != null){
            fos.close();
        }
    }
}

将一个文件分片上传之后,你可以在temp文件夹夹中看到所有的分片文件:

0_xxx.filetype

1_xxx.filetype

2_xxx.filetype

...

2、所有分块文件调用完毕之后,将触发该接口,用于合并所有文件与确认文件最终上传情况

创建一个文件确认类:

@Data
@ToString
public class CheckFileUploadVO {

    // 文件唯一标识
    private String md5;

    // 文件名称
    private String fileName;

    // 文件大小
    private String size;

    // 总分片数
    private Integer chunks;
    
}

在controller中添加如下接口:

/**
 * 检查文件上传情况
 * @date 2022/8/29
 * @param checkFileUploadVO 文件信息
 * @return 返回文件检查情况
 */
@PostMapping("/checkUpload")
public R<Boolean> checkUpload(@RequestBody CheckFileUploadVO checkFileUploadVO){
    try{
        return uploadService.checkUpload(checkFileUploadVO);
    }catch (Exception e){
        log.error("文件合并异常,checkFileUploadVO = " + checkFileUploadVO);
        log.error("异常堆栈: ", e);
        return new R<>(false, "文件上传失败", R.FAIL);
    }
}

在service中添加方法声明:

/**
     * 检查分片文件上传情况
     * @date 2022/8/29
     * @param checkFileUploadVO 分片文件检查对象
     * @return 返回上传检查结果
     */
    R<Boolean> checkUpload(CheckFileUploadVO checkFileUploadVO) throws Exception;

在实现类中添加如下方法:

/**
 * 检查分片文件上传情况(检查并且合并文件)
 * @date 2022/8/29
 * @param checkFileUploadVO 分片文件对象
 * @return 返回上传检查结果
 */
public R<Boolean> checkUpload(CheckFileUploadVO checkFileUploadVO) throws Exception{
    // 记录分片文件上传成功个数的key
    String successKey = KEY_PREFIX + ":slice-file-upload:" + checkFileUploadVO.getMd5() + ":" + "success";
    // 记录分片文件上传失败个数的key
    String failKey = KEY_PREFIX + ":slice-file-upload:" + checkFileUploadVO.getMd5() + ":" + "fail";
    // 缓存中记录的成功分片数
    String successNum = redisTemplate.boundValueOps(successKey).get();
    // 缓存中记录的失败分片数
    String failNum = redisTemplate.boundValueOps(failKey).get();

    // 分片上传成功数等于总分片数,进行文件合并,合并之后删除文件所有分片文件,并且进行记录
    File[] files = new File[checkFileUploadVO.getChunks()];
    for (int i = 0; i < checkFileUploadVO.getChunks(); i++) {
        String tempFileName = i + "_" + checkFileUploadVO.getFileName();
        // 临时分片文件
        files[i] = Paths.get(saveDir, "temp", tempFileName).toFile();
    }
    // 最终分片文件
    File resultFile = Paths.get(saveDir, checkFileUploadVO.getFileName()).toFile();

    // 成功个数为空或者失败个数不为空,则文件上传失败
    if (StringUtils.isEmpty(successNum) || Integer.parseInt(successNum) < checkFileUploadVO.getChunks() || !StringUtils.isEmpty(failNum)) {
        log.error("部分分片文件上传失败,结束文件合并, checkFileUploadVO = " + checkFileUploadVO);
        return new R<>(false, "文件上传失败", R.FAIL);
    }
    
    try {
        log.info("开始进行分片文件合并, checkFileUploadVO = " +  checkFileUploadVO);
        FileChannel resultFileChannel = new FileOutputStream(resultFile, true).getChannel();
        for (int i = 0; i < checkFileUploadVO.getChunks(); i++) {
            FileChannel sliceFileChannel = new FileInputStream(files[i]).getChannel();
            // 将分片文件写入到最终文件
            resultFileChannel.transferFrom(sliceFileChannel, resultFileChannel.size(), sliceFileChannel.size());
            sliceFileChannel.close();
        }
        resultFileChannel.close();
        
        //记录文件上传情况,todo
        boolean insert = true;
        return new R<>(false, "文件上传成功", R.FAIL);
    } catch (Exception e){
        log.error("分片文件合并失败, checkFileUploadVO = " + checkFileUploadVO);
        log.error("异常堆栈: ", e);
        insert = false;
        return new R<>(false, "文件上传失败", R.FAIL);
    } finally {
        // 删除所有分片文件
        for (int i = 0; i < checkFileUploadVO.getChunks(); i++) {
            if(files[i].exists() && files[i].isFile()){
                files[i].delete();
            }
        }
        // 如果记录失败,删除最终文件
        if(!insert){
            // 如果合并文件存在。也删除
            if(resultFile.exists() && resultFile.isFile()){
                resultFile.delete();
            }
        }
        // 删除key
        redisTemplate.delete(successKey);
        redisTemplate.delete(failKey);
    }
}

最终将

0_xxx.filetype

1_xxx.filetype

2_xxx.filetype

...

合并为xxx.filetype,并且返回合并结果

3、如果文件不是一个分块文件,是一个没有达到分块条件的文件,就按正常文件上传,此处省略,拿1修改完善下即可。

四、总结

以上只是将大文件分片上传进行了简单化,目的是为了快速实现大文件分片上传,以及充分了解其过程。当然,其中肯定存在不少问题的,需要根据实现业务场景进行优化。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值