[工具类] 分片上传下载,MD5校验

参考文章

Spring Boot 实现高效文件上传:分片上传、MD5校验、线程池处理高并发_springboot 分片上传文件-CSDN博客

引言

在现代web应用中,文件上传是一个常见而重要的功能。本文将介绍如何使用Spring Boot实现高效的文件上传,重点关注前端的分片上传技术、后端使用多线程处理高并发文件上传技术。


前端分片上传

分片上传原理

大文件传输过程中,单次请求可能出现网络超时,影响用户体验。分片上传将大文件分成多个小文件(分片),每个分片独立上传,最后合并成完整文件。这种方式可以有效降低单次上传的复杂度,提高用户体验。

分片上传实现

1、用户选择文件后,JavaScript会将文件分成20MB的分片,并逐个上传。

2、每个分片还会计算其MD5值,以便后端进行校验,同时使用已上传的分片比例显示上传进度。

3、前端使用websocket进行监听,如果后端所有分片合并完成,会往前端的socket发送完成,前端进行显示

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>分片上传文件</title>
    <!-- 使用本地路径 -->
    <script src="/js/spark-md5.min.js"></script>
    <!-- 在head中添加WebSocket库 -->
    <script src="/js/sockjs.min.js"></script>
    <script src="/js/stomp.min.js"></script>
</head>
<body>

<form id="uploadForm">
    <!-- 文件选择控件 -->
    文件: <input type="file" id="file" name="file" required><br>

    <!-- 上传进度展示 -->
    <progress id="progressBar" value="0" max="100" style="width: 300px;"></progress>
    <p id="status"></p>
    <button type="submit">提交</button>
</form>

<script>
    // 常量定义:每个分片大小20MB(需与后端协商)
    const chunkSize = 20 * 1024 * 1024;
    //初始化WebSocket的方法
    let stompClient;

    function initWebSocket() {
        const socket = new SockJS('/upload-websocket');
        stompClient = Stomp.over(socket);

        stompClient.connect({}, () => {
            console.log('WebSocket连接成功');

            stompClient.subscribe('/topic/mergeStatus', (message) => {
                try {
                    const result = JSON.parse(message.body);
                    // 验证消息完整性
                    if("COMPLETED" === result.status) {
                        updateStatusUI(result);
                    }
                } catch (e) {
                    console.error('消息处理错误', e);
                }
            });
        }, (error) => {
            console.error('连接失败', error);
        });
    }

    function updateStatusUI(result) {
        document.getElementById("status").innerHTML = `
        <div class="alert alert-success">
            <strong>✅ 文件合并完成!</strong>
            <p>文件名:${result.fileName}</p>
            <p>总耗时:${result.totalTime}</p>
        </div>
    `;
    }

    // 表单提交事件处理
    document.getElementById("uploadForm").onsubmit = async function(event) {
        //阻止默认表单提交行为
        event.preventDefault();
        // 获取文件对象
        const file = document.getElementById("file").files[0];
        if (!file) {
            alert("请选择文件!");
            return;
        }

        //初始化WebSocket
        initWebSocket();

        // 计算总分片数(向上取整)  总分片数=文件总字节数/每片字节数
        const totalChunks = Math.ceil(file.size / chunkSize);
        // 简化计数器
        let chunkCounter = 0;
        try {
            for (let chunkNumber = 1; chunkNumber <= totalChunks; chunkNumber++) {
                    // 切割文件获取分片数据
                    const chunk = file.slice(
                        (chunkNumber - 1) * chunkSize,
                        Math.min(chunkNumber * chunkSize, file.size)
                    );
                    // 计算分片MD5(需引入SparkMD5库)
                    //await 会暂停calculateMd5函数的执行,calculateMd5请求完成并返回一个对象,然后继续执行
                    const md5 = await calculateMd5(chunk);
                    // 构建FormData对象
                    const formData = new FormData();
                    formData.append("file", chunk, file.name);
                    formData.append("md5", md5);
                    formData.append("chunkNumber", chunkNumber);
                    formData.append("totalChunks", totalChunks);
                    // 执行分片上传
                    await uploadChunk(formData);
                    //更新进度条
                    await updateProgress(++chunkCounter, totalChunks);
            }
            document.getElementById("status").innerText = "所有分片上传完成,正在合并文件";
        } catch (error) {
            console.error("上传分片失败:", error);
            document.getElementById("status").innerText = "上传失败,请重试";
        }
    };

    /**
     * 计算文件分片的MD5值
     * @param {Blob} blob - 文件分片数据
     * @returns {Promise<string>} MD5哈希值
     */
    function calculateMd5(blob) {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.readAsBinaryString(blob);
            reader.onloadend = function () {
                const md5 = SparkMD5.hashBinary(reader.result);
                resolve(md5);
            };
            reader.onerror = function (error) {
                reject(error);
            };
        });
    }

    /**
     * 执行分片上传请求
     * @param {FormData} formData - 包含分片数据的表单
     */
    function uploadChunk(formData) {
        return new Promise((resolve, reject) => {
            const xhr = new XMLHttpRequest();
            xhr.open("POST", "/uploadPart/upload", true);
            xhr.onload = function() {
                if (JSON.parse(xhr.responseText).code === 200) {
                    resolve(xhr.responseText);
                } else {
                    reject(new Error("上传失败,错误代码:" + xhr.responseText.msg));
                }
            };
            xhr.onerror = function() {
                reject(new Error("网络错误"));
            };
            xhr.send(formData);
        });
    }

    /**
     * 更新进度显示
     * @param {number} currentCount - 已上传数
     * @param {number} totalChunks - 总分片数
     */
    function updateProgress(currentCount, totalChunks) {
        const percentComplete = Math.round((currentCount / totalChunks) * 100);
        document.getElementById("progressBar").value = percentComplete;
        document.getElementById("status").innerText = `上传进度: ${percentComplete}%`;
    }
</script>

</body>
</html>

后端分片接收

文件上传处理

后端使用Spring Boot接收每个分片,并进行MD5校验:

java 复制代码
@RestController
@RequestMapping("/uploadPart")
@Api(tags = "分片上传下载模块")
@Slf4j
public class UploadPartController {

    @Value("${file.uploadPath}")
    private String uploadPath;

    @Resource
    private ThreadPoolTaskExecutor taskExecutor;

    @Resource
    private SimpMessagingTemplate messagingTemplate;

    long startTime = 0L;

    // 在类中新增成员变量记录已上传分片
    private final ConcurrentMap<String, Set<Integer>> uploadedChunksMap = new ConcurrentHashMap<>();

    /**
     * 分片上传接口
     * @param file 分片文件
     * @param md5 分片校验码
     * @param chunkNumber 当前分片序号
     * @param totalChunks 总分片数
     */
    @PostMapping("/upload")
    public R upload(@RequestParam("file") MultipartFile file,
                         @RequestParam("md5") String md5,
                         @RequestParam("chunkNumber") int chunkNumber,
                         @RequestParam("totalChunks") int totalChunks) throws IOException {
        // 空文件检查
        if (file.isEmpty()) {
            log.error("上传的文件为空");
            throw new ServerException("文件为空,请重新上传");
        }

        // 确保上传目录存在
        File uploadDir = new File(this.uploadPath);
        if (!uploadDir.exists()) {
            uploadDir.mkdirs();
        }

        String originalFilename = file.getOriginalFilename();
        log.info("开始上传文件: {}, 分片: {}/{}", originalFilename, chunkNumber, totalChunks);

        // 构建分片文件名
        String chunkFileName = originalFilename + ".part" + chunkNumber;
        File chunkFile = new File(this.uploadPath, chunkFileName);

        // 先检查内存记录
        if (uploadedChunksMap.getOrDefault(originalFilename, Collections.emptySet())
                .contains(chunkNumber)) {
            // 再检查物理文件
            if (!chunkFile.exists()) {
                log.error("分片记录存在但文件缺失: {}-{}", originalFilename, chunkNumber);
                // 清理无效记录
                uploadedChunksMap.get(originalFilename).remove(chunkNumber);
                throw new ServerException("分片状态异常,请重新上传,分片="+chunkNumber);
            }
            log.info("分片已存在: {}-{}", originalFilename, chunkNumber);
            return R.okMsg("分片:" + chunkNumber + "已存在");
        }

        //写入分片
        file.transferTo(chunkFile);
        log.info("分片文件保存成功: {}", chunkFileName);

        // MD5校验(确保数据传输完整性)
        String calculatedMd5 = FileUtils.calculateMd5(chunkFile);
        if (!calculatedMd5.equals(md5)) {
            chunkFile.delete();
            log.error("MD5校验失败,分片: {}", chunkNumber);
            throw new ServerException("MD5校验失败,请重新上传");
        }
        log.info("MD5校验成功,分片: {}", chunkNumber);

        try {
            // 记录已上传分片(线程安全操作)
            uploadedChunksMap.computeIfAbsent(originalFilename, k -> ConcurrentHashMap.newKeySet())
                    .add(chunkNumber);

            // 记录第一个分片的上传开始时间
            if (uploadedChunksMap.get(originalFilename).size()==1){
                startTime = System.currentTimeMillis();
            }

            // 只有当已上传分片数等于总分片数时才合并
            if (uploadedChunksMap.get(originalFilename).size() == totalChunks){
                log.info("所有分片上传完成,开始合并文件: {}", originalFilename);
                uploadedChunksMap.clear();
                //通过CompletableFuture.runAsync将合并任务提交到线程池异步执行,避免阻塞上传接口的主线程‌
                //taskExecutor作为自定义线程池(如ThreadPoolTaskExecutor)可控制并发资源‌
                CompletableFuture.runAsync(() -> {
                    try {
                        mergeChunks(originalFilename, totalChunks);
                    } catch (Exception e) {
                        throw new CompletionException(e); // 包装为CompletionException抛出
                    }
                }, taskExecutor).exceptionally(ex -> {
                    log.error("合并任务异常终止: {}", ex.getMessage());
                    return null;
                });
                R.okMsg("所有分片上传完成,正在合并文件");
            }
        }catch (Exception e){
            log.error(e.getMessage());
            uploadedChunksMap.clear();
            throw new ServerException("分片上传失败");
        }
        return R.okMsg("分片" + chunkNumber + "上传成功");
    }



    /**
    * 合并分片文件
    * @param fileName 原始文件名
    * @param totalChunks 总分片数
    */
    private void mergeChunks(String fileName, int totalChunks) throws Exception {
        log.info("开始合并文件: {}", fileName);

        File mergedFile = new File(uploadPath, fileName);
        try (FileOutputStream fos = new FileOutputStream(mergedFile)) {
            for (int i = 1; i <= totalChunks; i++) {
                Path chunkPath = Paths.get(uploadPath, fileName + ".part" + i);
                File chunkFile = new File(String.valueOf(chunkPath));
                Files.copy(chunkFile.toPath(), fos);
                Files.deleteIfExists(chunkPath); // 更安全的删除方式
            }
        }

        // 计算总耗时(上传+合并)
        long totalTime = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis() - startTime);
        String totalTimeStr = totalTime + "s";
        log.info("文件合并完成:{},总耗时:{}", fileName,totalTimeStr);

        // 合并逻辑完成后发送WebSocket通知
        Map<String, Object> result = new HashMap<String, Object>() {{
            put("fileName", fileName);
            put("totalTime", totalTimeStr);
            put("status", "COMPLETED");
        }};
        messagingTemplate.convertAndSend("/topic/mergeStatus", result);
    }

}

中途失败重新上传,忽略已上传的分片

支持重新上传,之前上传过的文件会记录到缓存,如果中间断网,重新上传后会检查之前的数据,缓存记录和物理地址都存在分片的话,就不会重复上传该分片。

如果人为删除物理地址上的分片,则会抛异常,同时删除缓存中的文件记录 ,需要重新上传。

WebSocket配置

引入依赖

java 复制代码
 <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

WebSocket建立持久连接实现服务器主动推送,相比轮询方案更高效‌

java 复制代码
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        //启用简单内存消息代理,处理以"/topic"为前缀的消息
        config.enableSimpleBroker("/topic");
        //设置应用消息前缀为"/app"
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        //定义WebSocket端点URL
        registry.addEndpoint("/upload-websocket")
                .withSockJS()           //启用SockJS回退选项
                .setHeartbeatTime(10000); // 心跳间隔
    }
}

线程池与异步合并

这个配置类创建了一个 ThreadPoolTaskExecutor bean,用于管理文件上传的线程池。合并文件的操作在所有分片上传完成后异步执行,减少了主线程的阻塞时间。

线程池详细参数介绍:

corePoolSize: 线程池的核心线程数,即在没有任务时,线程池中保持的线程数。

maxPoolSize: 线程池的最大线程数,即线程池中允许的最大线程数。

queueCapacity: 线程池的队列容量,即线程池中等待执行的任务队列的最大长度。

threadNamePrefix: 线程池中线程的名称前缀,用于区分不同的线程。

java 复制代码
@Configuration
public class ThreadPoolConfig {

    @Bean
    public ThreadPoolTaskExecutor fileUploadExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        int cores = Runtime.getRuntime().availableProcessors();
        executor.setCorePoolSize(cores * 2 + 1); // 弹性核心数
        executor.setMaxPoolSize(cores * 10);     // 10倍扩容空间
        executor.setQueueCapacity(200);          // 平衡队列
        executor.setThreadNamePrefix("FileUploadThread-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

文件合并

文件合并是将所有上传的分片合并成一个完整文件的过程,在这个方法中,后端将所有分片合并为一个完整的文件,并记录合并成功的日志。

这里会每次请求一次,会进行记录


测试总结

测试结果正常

后端日志

判断两个文件是否一致

这里我们通过比较两个文件的MD5值来判断是否一致

通过使用Spring Boot和前端分片上传技术,我们可以实现高效的大文件上传。前端分片上传降低了单次上传的复杂度,提升了用户体验;后端多线程处理和文件合并的优化则大大提高了上传效率,减少了对服务器资源的占用