MinIO分片上传完整实现

概述

对于大文件上传,这是一个非常普遍的需求。分片上传就是一个实现路径,也叫分块上传。

下面直接给出MinIO和腾讯云COS的实现逻辑代码。

不过这种几乎快要烂大街的代码根本不值一分钱:

  • 是的,我就是在吐槽,最近两家公司的CTO拿着屎山代码当成传家宝;
  • 仅两年来,Coding Agent能力完全可以代替5-8年经验的开发者。

重点在后面记录的几个问题。

MinIO

根据官方文档,分片上传有3个步骤:

  • 初始化
  • 上传分片
  • 合并分片

初始化

直接上代码,Controller层接口定义如下:

java 复制代码
@PostMapping("/init")
@ApiOperation(value = "初始化分片上传", notes = "创建分片上传任务,返回上传ID和分片信息")
public MultipartUploadInitResponse initMultipartUpload(@Valid @RequestBody MultipartUploadInitRequest request) {
	return service.initMultipartUpload(request);
}

MultipartUploadInitRequest:

java 复制代码
@Data
@ApiModel("分片上传初始化请求")
public class MultipartUploadInitRequest {
	@ApiModelProperty(value = "文件名", required = true)
	@NotBlank(message = "文件名不能为空")
	private String fileName;
	@ApiModelProperty(value = "文件大小(字节)", required = true)
	@NotNull(message = "文件大小不能为空")
	@Min(value = 1, message = "文件大小必须大于0")
	private Long fileSize;
	@ApiModelProperty(value = "文件哈希值", required = true)
	@NotBlank(message = "文件哈希值不能为空")
	private String fileHash;
	@ApiModelProperty(value = "哈希算法", example = MD5)
	private String hashAlgorithm = MD5;
	@ApiModelProperty(value = "存储桶名称", required = true)
	@NotBlank(message = "存储桶名称不能为空")
	private String bucketName;
	@ApiModelProperty(value = "业务场景", required = true)
	@NotBlank(message = "业务场景不能为空")
	private String bizScene;
	@ApiModelProperty(value = "存储平台", example = "MINIO")
	private String platform = "MINIO";
	// 默认 5MB
	@ApiModelProperty(value = "分片大小(字节)", example = "5242880")
	private Long chunkSize = 5242880L;
}

MultipartUploadInitResponse:

java 复制代码
@Data
@ApiModel("分片上传初始化响应")
public class MultipartUploadInitResponse {
	@ApiModelProperty("上传任务ID")
	private String uploadId;
	@ApiModelProperty("文件存储ID")
	private Long fileId;
	@ApiModelProperty("总分片数")
	private Integer totalParts;
	@ApiModelProperty("分片大小(字节)")
	private Long chunkSize;
	@ApiModelProperty("已上传的分片列表(用于断点续传)")
	private List<PartInfo> uploadedParts;
	
	@Data
	@ApiModel("分片信息")
	public static class PartInfo {
		@ApiModelProperty("分片序号")
		private Integer partNumber;
		@ApiModelProperty("分片大小")
		private Long partSize;
		@ApiModelProperty("分片ETag")
		private String etag;
		@ApiModelProperty("是否已上传")
		private Boolean uploaded;
		@ApiModelProperty("预签名上传URL")
		private String uploadUrl;
		@ApiModelProperty("URL过期时间(毫秒时间戳)")
		private Long expireTime;
	}
}

核心Service方法:

java 复制代码
public MultipartUploadInitResponse initMultipartUpload(MultipartUploadInitRequest request) {
	log.info("初始化分片上传,文件名: {}, 大小: {}", request.getFileName(), request.getFileSize());
	String uploadId = CmsUtil.getUuid();
	// 计算分片信息
	long chunkSize = request.getChunkSize() != null ? request.getChunkSize() : DEFAULT_CHUNK_SIZE;
	int totalParts = (int) Math.ceil((double) request.getFileSize() / chunkSize);
	// 生成文件路径
	String filePath = commonService.generateFilePath(minioProperties.getBasePath(), request.getFileName(), request.getBizScene());
	// 创建主表记录
	ObjectStorageDO storage = commonService.buildObjectStorage(request, filePath, uploadId, totalParts);
	Long id = objectStorageService.addAndReturnId(storage);
	// 创建分片记录(分片编号从0开始,与前端保持一致)
	List<MultipartUploadDO> parts = new ArrayList<>();
	OffsetDateTime expireTime = OffsetDateTime.now().plusHours(URL_EXPIRE_HOURS);
	for (int i = 0; i < totalParts; i++) {
	    // 最后一片大小 = fileSize - 前面所有分片占用的大小
	    long partSize = (i == totalParts - 1) ? request.getFileSize() - i * chunkSize : chunkSize;
	    MultipartUploadDO part = new MultipartUploadDO();
	    part.setUploadId(uploadId);
	    part.setPartNumber(i);
	    part.setPartSize(partSize);
	    part.setUploadStatus(false);
	    part.setExpireTime(expireTime);
	    part.setCreateTime(OffsetDateTime.now());
	    if (request.getEnableDirectUpload()) {
	        // 生成预签名上传URL
	        String partObjectName = filePath + PART + i;
	        try {
	            String url = minioTemplate.getPresignedObjectUrl(request.getBucketName(), partObjectName, URL_EXPIRE_HOURS, TimeUnit.HOURS, Method.PUT);
	            part.setUploadUrl(url);
	        } catch (Exception e) {
	            log.error("生成分片上传URL失败:", e);
	            throw new BackendBizException(GENERATE_MULTIPART_UPLOAD_URL_FAIL);
	        }
	    }
	    parts.add(part);
	}
	multipartUploadService.batchInsert(parts);
	// 构建响应
	MultipartUploadInitResponse resp = new MultipartUploadInitResponse();
	resp.setUploadId(uploadId);
	resp.setFileId(id);
	resp.setTotalParts(totalParts);
	resp.setChunkSize(chunkSize);
	
	List<MultipartUploadInitResponse.PartInfo> partInfos = parts.stream()
	        .map(item -> {
	            MultipartUploadInitResponse.PartInfo info = new MultipartUploadInitResponse.PartInfo();
	            info.setPartNumber(item.getPartNumber());
	            info.setPartSize(item.getPartSize());
	            info.setUploaded(false);
	            info.setUploadUrl(item.getUploadUrl());
	            info.setExpireTime(item.getExpireTime().toInstant().toEpochMilli());
	            return info;
	        })
	        .collect(Collectors.toList());
	resp.setUploadedParts(partInfos);
	log.info("分片上传初始化完成,uploadId: {}, 总分片数: {}", uploadId, totalParts);
	return resp;
	}

初始化核心逻辑:

  • 前后端协商一致分开大小,默认5M,这也是MinIO推荐的分块阈值;
  • 前后端协商一致分片起始索引。不能各玩各的,前端如果从0开始,后端从1开始,则后续的5M分片文件上传校验无法通过
  • 返回uploadId给前端,uploadId可以使用自定义UUID,也可直接使用平台返回的Id。

分片上传

controller方法定义:

java 复制代码
@PostMapping("/upload")
@ApiOperation(value = "上传分片", notes = "上传单个分片文件")
public String uploadPart(
		@ApiParam(value = "分片文件", required = true) @NotNull @RequestPart("file") MultipartFile file,
		@ApiParam(value = "上传任务ID", required = true) @NotBlank @RequestParam String uploadId,
		@ApiParam(value = "分片序号", required = true) @NotNull @Min(value = 0, message = "分片序号不能为负数") @RequestParam Integer partNumber,
		@ApiParam(value = "分片哈希值", required = true) @RequestParam @NotBlank String partHash,
		@ApiParam(value = "分片大小") @NotNull @RequestParam Long partSize,
		@ApiParam(value = "存储平台", example = "MINIO") @RequestParam(defaultValue = "MINIO") String platform) {
	return service.uploadPart(file, uploadId, partNumber, partHash, partSize, platform);
}

其中partSize可有可无,属于冗余传参,对于前N-1个分片文件,其大小一般固定为5M,即5242880,最后一个分片文件小于5242880。调用接口时,需要上传part文件,后端可根据MultipartFile.getSize()方法获取分片大小。

核心Service方法如下:

java 复制代码
public String uploadPart(MultipartFile file, MultipartUploadDTO req) {
	log.info("上传分片,uploadId: {}, partNumber: {}", req.getUploadId(), req.getPartNumber());
	try {
	    MutableTriple<Boolean, MultipartUploadDO, ObjectStorageDO> result = commonService.checkDbInfo(req.getUploadId(), req.getPartNumber(), StorageTypeEnum.MINIO.name());
	    if (!result.getLeft()) {
	        return "";
	    }
	    Boolean check = commonService.checkPartAndHash(file, req);
	    if (!check) {
	        return "";
	    }
	    ObjectStorageDO storage = result.right;
	    // 上传分片到MinIO
	    String partObjectName = storage.getFilePath() + ".part." + req.getPartNumber();
	    ObjectWriteResponse resp = minioTemplate.putObject(storage.getBucketName(), file, partObjectName);
	    // 更新分片状态
	    boolean updated = multipartUploadService.updatePartStatus(req.getUploadId(), req.getPartNumber(), resp.etag(), req.getPartHash());
	    if (updated) {
	        // 更新主表的已完成分片数
	        Integer completedParts = multipartUploadService.countUploadedParts(req.getUploadId());
	        storage.setCompletedParts(completedParts);
	        objectStorageService.update(storage);
	        log.info("分片上传成功,uploadId: {}, partNumber: {}, 已完成: {}/{}", req.getUploadId(), req.getPartNumber(), completedParts, storage.getTotalParts());
	    }
	    return resp.etag();
	} catch (Exception e) {
	    log.error("分片上传失败: uploadId={}, partNumber={}", req.getUploadId(), req.getPartNumber(), e);
	    return "";
	}
}

CommonService.java两个校验方法:

java 复制代码
public MutableTriple<Boolean, MultipartUploadDO, ObjectStorageDO> checkDbInfo(String uploadId, Integer partNumber, String platform) {
    MutableTriple<Boolean, MultipartUploadDO, ObjectStorageDO> result = new MutableTriple<>(false, null, null);
    // 获取主表信息
    ObjectStorageDO storage = objectStorageService.findByUploadIdAndPlatform(uploadId, platform);
    if (storage == null) {
        log.error("上传任务不存在: uploadId={}", uploadId);
        return result;
    }
    // 验证分片信息
    MultipartUploadDO part = multipartUploadService.findByUploadIdAndPartNumber(uploadId, partNumber);
    if (part == null) {
        log.error("分片信息不存在: uploadId={}, partNumber={}", uploadId, partNumber);
        return result;
    }
    result.setLeft(true);
    result.setMiddle(part);
    result.setRight(storage);
    return result;
}

public Boolean checkPartAndHash(MultipartFile file, MultipartUploadDTO req) throws IOException {
    // 验证分片大小,可有可无
    if (!req.getPartSize().equals(file.getSize())) {
        log.warn("分片大小不匹配: expected={}, actual={}", req.getPartSize(), file.getSize());
        return false;
    }
    // 验证分片哈希
    String actualHash;
    try (InputStream inputStream = file.getInputStream()) {
        actualHash = DigestUtils.md5Hex(inputStream);
    }
    if (!req.getPartHash().equals(actualHash)) {
        log.error("分片哈希不匹配: expected={}, actual={}", req.getPartHash(), actualHash);
        return false;
    }
    return true;
}

核心业务逻辑:

  • 校验DB记录是否存在
  • 校验接口传参的fileHash是否与文件真实hash相同。后端代码计算方式是读取文件流,然后使用Apache Commons-Codec工具库,当然支持使用其他类库
  • 更新分片上传状态

分片合并

controller接口:

java 复制代码
@PostMapping("/complete")
@ApiOperation(value = "完成分片上传", notes = "合并所有分片,完成文件上传")
public FileDTO completeMultipartUpload(
		@RequestParam String uploadId,
		@ApiParam(value = "存储平台", example = "MINIO") @RequestParam(defaultValue = "MINIO") String platform) {
    return service.completeMultipartUpload(uploadId, platform);
}

Service核心代码:

java 复制代码
public FileDTO completeMultipartUpload(String uploadId) {
	log.info("完成分片上传,uploadId: {}", uploadId);
	try {
		// 获取上传任务信息
		ObjectStorageDO storage = objectStorageService.findByUploadIdAndPlatform(uploadId, StorageTypeEnum.MINIO.name());
		if (storage == null) {
			throw new BackendBizException(UPLOAD_TASK_NOT_EXIST);
		}
		// 检查所有分片是否都已上传
		Integer completedParts = multipartUploadService.countUploadedParts(uploadId);
		if (!completedParts.equals(storage.getTotalParts())) {
			throw new BackendBizException(CHUNK_NOT_UPLOAD_COMPLETE);
		}
		// 获取所有分片信息(按分片编号升序排列,确保合并顺序正确)
		List<MultipartUploadDO> parts = multipartUploadService.findByUploadId(uploadId);
		List<String> filePaths = parts.stream()
		        .sorted(Comparator.comparingInt(MultipartUploadDO::getPartNumber))
		        .map(i -> storage.getFilePath() + PART + i.getPartNumber())
		        .collect(Collectors.toList());
		minioTemplate.composeObject(storage.getBucketName(), storage.getFilePath(), filePaths);
		// 验证最终文件哈希
		try {
		    // 获取合并后的文件信息
		    StatObjectResponse finalFileStats = minioTemplate.statObject(storage.getBucketName(), storage.getFilePath());
		    // 计算合并后文件的哈希值
		    List<String> list = parts.stream().map(MultipartUploadDO::getPartHash).collect(Collectors.toList());
		    String actualFileHash = commonService.getFileHash(list);
		    if (!actualFileHash.equals(finalFileStats.etag())) {
		        log.error("文件哈希验证失败: uploadId={}, expected={}, actual={}", uploadId, storage.getFileHash(), actualFileHash);
		        // 删除不完整的文件
		        minioTemplate.removeFile(storage.getBucketName(), storage.getFilePath(), false);
		        throw new BackendBizException(FILE_HASH_VERIFY_FAIL);
		    }
		    log.info("文件哈希验证通过: uploadId={}, hash={}", uploadId, actualFileHash);
		    // 验证文件大小
		    if (!storage.getFileSize().equals(finalFileStats.size())) {
		        log.error("文件大小验证失败: uploadId={}, expected={}, actual={}", uploadId, storage.getFileSize(), finalFileStats.size());
		        // 删除不完整的文件
		        minioTemplate.removeFile(storage.getBucketName(), storage.getFilePath(), false);
		        throw new BackendBizException(FILE_SIZE_VERIFY_FAIL);
		    }
		    // 更新文件的最终ETag
		    storage.setFileHash(finalFileStats.etag().contains(DASH) ? finalFileStats.etag().substring(0, finalFileStats.etag().indexOf(DASH)) : finalFileStats.etag());
		} catch (Exception e) {
		    log.error("文件验证失败: uploadId={} ", uploadId, e);
		    throw new BackendBizException(FILE_VERIFY_FAIL);
		}
		// 清理分片文件
		for (MultipartUploadDO part : parts) {
		    try {
		        minioTemplate.removeFile(storage.getBucketName(), storage.getFilePath() + PART + part.getPartNumber(), false);
		    } catch (Exception e) {
		        log.warn("清理分片文件失败: partNumber={}, error={}", part.getPartNumber(), e.getMessage());
		    }
		}
		// 更新主表状态
		storage.setUploadStatus(UploadStatusEnum.COMPLETED.getCode());
		storage.setModifyTime(LocalDateTime.now().atOffset(ZoneOffset.UTC));
		objectStorageService.update(storage);
		// 删除分片记录
		multipartUploadService.deleteByUploadId(uploadId);
		// 构建响应
		FileDTO fileDTO = new FileDTO();
		fileDTO.setId(storage.getId());
		fileDTO.setName(storage.getFileName());
		fileDTO.setUrl(minioTemplate.getPresignedObjectUrl(storage.getBucketName(), storage.getFilePath()));
		log.info("分片上传完成,uploadId: {}, fileId: {}", uploadId, storage.getId());
		return fileDTO;
	} catch (Exception e) {
	    log.error("完成分片上传失败: uploadId={}", uploadId, e);
	    throw new BackendBizException(COMPLETE_MULTIPART_UPLOAD_FAIL);
	}
}

自测

为了通过Postman模拟自测,需要借助一些工具。分片阈值是5M,那就选择一个(5M, 10M]区间内的文件,如何切割文件呢?

对于macOS,使用系统自带的命令行即可实现:

bash 复制代码
split -b 5242880 e99f6ba278654d588baa171968ceac57.mp4 part_1

执行成功后,查看文件

然后使用在线网站计算文件Hash,当然这类网站有很多,按需使用。

COS

限于篇幅过长,腾讯云COS分片上传实现请参考腾讯云COS分片上传完整实现

另外,关于表设计,也请移步COS。

问题

size must be greater than 5242880

3个分片都成功上传,在MinIO Server端也能查看到,但是合并分片时遇到的报错

报错信息:java.lang.IllegalArgumentException: source xxx/yyy/2c3a762704754aeaaabc1a00af94324c.mp4.part.3: size 1286209 must be greater than 5242880

注意看红色框标记的部分,稍加分析,不难得出:合并时没有按照partNumber升序排列,错把part.3当做第一个分片。

分片哈希不匹配

遇到的问题,分片上传时,日志提示分片哈希不匹配

稍加分析,再看看代码片段:

java 复制代码
String actualHash;
	try (InputStream inputStream = file.getInputStream()) {
		actualHash = DigestUtils.md5Hex(inputStream);
	}

服务端在每一个分片文件上传时,使用读取文件流的方式,计算分片的Hash;

与此同时,使用Postman自测时,使用spilt命令行切割文件,使用在线网站工具计算文件Hash,这两者是一样的,问题只能是前端:

分片起始索引

其实这根本不值一提,根本算不上什么问题。但是,工作上工程上的事情,不是一个人能完成的,也就是必须依赖于团队协作。团队协作,自然离不开沟通;沟通成本高的团队,就很浪费时间,影响效率。

上面的代码片段有分片校验。也就是说,前端定义的分片起始索引和后端定义的起始索引,必须要一致。否则报错:分片信息不存在

初始化分片时,会将分片信息存储于DB:

java 复制代码
for (int i = 0; i < totalParts; i++) {
	// 最后一片大小 = fileSize - 前面所有分片占用的大小
	long partSize = (i == totalParts - 1) ? request.getFileSize() - i * chunkSize : chunkSize;
	MultipartUploadDO part = new MultipartUploadDO();
	part.setUploadId(uploadId);
	part.setPartNumber(i);
	part.setPartSize(partSize);
}

假如前端定义的partNumber从0开始,后端从1开始,怎么调试对接,肯定都不能联调成功的。

相关地,最后一片大小也与起始索引息息相关,仅仅只是对齐起始索引,没有调整最后一个分片大小,最后的合并分片还是会失败。

合并分片时Hash校验

初始化上传时,前端需要传参Hash;分片上传若干个5M大小分片文件,前端也会传参Hash;最后合并分片,调用第三方SDK。作为服务端,逻辑校验非常有必要。

问题:如何确认MinIO Server合并文件是真的合并成功,而不仅仅只是判断client没有抛出异常。

有了校验的意识,仅仅只是第一步。如何校验才是关键,很多开发者第一直觉是读取文件流,这其中就包括我接手的屎山代码。

继续看看私有方法

这是在干啥??

读取一个超大文件,读到内存流里,然后使用JDK自带的API:MessageDigest.getInstance("MD5");,虽然有小批量处理md5.update,但是性能损坏和时间计算成本,不能不考虑。

优化思路:既然能拿到初始文件Hash,以及N个分片文件的Hash,即1+N个Hash。那是否存在一种算法,根据N个分片文件Hash,通过计算等价于初始文件Hash?如果相等,说明文件合并真的成功。

GPT答复:存在,即私有方法getFileHash

java 复制代码
/**
 * MinIO/S3 分片合并后文件的Hash/Etag计算逻辑
 */
public String getFileHash(List<String> list) throws Exception {
	// 将 Hex 字符串转回 16 字节的二进制数组并拼接
	byte[] allBytes = new byte[list.size() * 16];
	for (int i = 0; i < list.size(); i++) {
		byte[] partBytes = Hex.decodeHex(list.get(i));
		System.arraycopy(partBytes, 0, allBytes, i * 16, 16);
	}
	// 对拼接后的二进制流再做一次 MD5
	return DigestUtils.md5Hex(allBytes) + DASH + list.size();
}

分片大小阈值

当分片上传的文件小于5M时,MinIO Server端,支持合并为正常文件。也就是说,MinIO支持通过分片上传的方式,上传小于5M的文件。

但是,不管是在MinIO Server端,还是断点调试,都会发现合并后的文件的ETag并没有-n后缀:

而且,刚好和原始文件的MD5算法得到的Hash值一样。

作为对比,看看大于5MB的文件,合并后的ETag值带有-n后缀,n表示由多少个分片文件组成:

针对小于5M的文件的分片上传的场景,既然MinIO支持,那代码层也应该支持,不修改,怎合并文件比较Hash值的地方会有问题:

java 复制代码
public String getFileHash(List<String> list) throws Exception {
	if (list.size() == 1) {
		return list.get(0);
	}
	// 省略后续代码
}

来看看AI是怎么解读的?

初始化分片上传Hash

写到最后,秃然发现一个"致命"问题。本地使用Postman自测时,主要是在验证代码逻辑正确性,选择大于5M小于10M的文件即可。因为文件非常小,使用在线工具网站,能快速拿到文件Hash。后端Java代码里获取文件Hash,一般情况都是读取文件流,使用Commons Codec类库DigestUtils.md5Hex(inputStream);方法。前端在初始化分片文件上传时,必须得带上Hash!!

问题:前端是如何快速计算几G大文件的Hash值的?

GPT解读:

前端计算超大文件的Hash,确实不能像后端那样一次性读取整个文件流------几个G的文件直接读到内存里,浏览器直接崩。

核心思路是:分块读取+增量计算,把大文件切成一个个小片,边读边喂给Hash算法,最后再得出完整的结果。

目前主要有这几种方案,从简单到复杂给你排个序:

方案一:spark-md5(最常用,推荐)

这是最主流的方案,专门为增量计算设计的。

安装:npm install spark-md5

js 复制代码
import SparkMD5 from 'spark-md5';

function calculateFileMD5(file) {
    return new Promise((resolve, reject) => {
        const chunkSize = 5 * 1024 * 1024; // 5MB 分片
        const chunks = Math.ceil(file.size / chunkSize);
        let currentChunk = 0;
        const spark = new SparkMD5.ArrayBuffer();
        const fileReader = new FileReader();
        fileReader.onload = (e) => {
            spark.append(e.target.result); // 增量追加
            currentChunk++;
            if (currentChunk < chunks) {
                loadNext(); // 继续读下一片
            } else {
                resolve(spark.end()); // 最终 MD5
            }
        };
        fileReader.onerror = () => reject(fileReader.error);
        function loadNext() {
            const start = currentChunk * chunkSize;
            const end = Math.min(start + chunkSize, file.size);
            fileReader.readAsArrayBuffer(file.slice(start, end));
        }
        loadNext();
    });
}
// 使用
const file = fileInput.files[0];
const md5 = await calculateFileMD5(file);
console.log(md5);

优点:成熟稳定,简单可靠,兼容性好。

缺点:纯JS实现,大文件计算时间较长(几个G可能要几十秒)。

方案二:WebAssembly + Web Worker(最快)

追求极致性能的话,这个方案是天花板。用Rust/C++写Hash算法编译成Wasm,再加上多线程Worker并行计算。

js 复制代码
// 以 fast-md5-web 为例
import { Md5CalculatorPool, WasmInit } from 'fast-md5-web';
// 初始化
await WasmInit();
const pool = new Md5CalculatorPool(4); // 4个Worker线程
// 计算
const hash = await pool.calculateMd5(file, 32, 60000, (progress) => {
    console.log(`进度: ${progress}%`);
});
console.log('MD5:', hash);
pool.destroy(); // 用完销毁

优点:速度极快,官方声称比spark-md5快8-16倍,且不阻塞UI。

缺点:依赖WebAssembly和SharedArrayBuffer,需要配置COOP/COEP响应头才能启用多线程(单线程模式则不需要)。

方案三:Web Crypto API(原生支持)

浏览器内置的加密API,不需要额外库。

js 复制代码
async function hashWithWebCrypto(file) {
    const chunkSize = 2 * 1024 * 1024; // 2MB 分片
    let position = 0;
    let hashBuffer = await crypto.subtle.digest('SHA-256', new ArrayBuffer(0));
    while (position < file.size) {
        const chunk = file.slice(position, position + chunkSize);
        const arrayBuffer = await chunk.arrayBuffer();
        // 注意:Web Crypto 的 digest 方法不能增量更新,需要自己拼接
        // 实际上更简单的方式是一次性读取(小文件)或用 spark-md5
        position += chunkSize;
    }
    // crypto.subtle.digest不支持真正的增量MD5,它每次digest都是重新计算整个数据块,所以大文件场景不如spark-md5的增量模式
}

注意:Web Crypto API的digest方法是一次性计算,不是真正的增量,所以对大文件不太友好。真正的增量计算还是得用spark-md5

其他开箱即用的封装库

  • file-chunk-worker:基于Web Worker+spark-md5,支持进度回调,开箱即用。
  • fluxforge:企业级分片处理库,内置重试、并发控制、完整性验证。
相关推荐
sg_knight1 天前
MinIO 进阶:文件下载、批量获取与打包压缩全攻略
文件管理·minio·ftp·cos·oss·文件服务器
分布式存储与RustFS5 天前
MinIO迎来“恶龙”?RustFS这款开源存储简直“不讲武德”
架构·rust·开源·对象存储·minio·企业存储·rustfs
sg_knight9 天前
如何实现“秒传”与“断点续传”?MinIO + Java 实战进阶篇
java·开发语言·文件管理·minio·ftp·oss·文件传输
分布式存储与RustFS9 天前
AI 数据湖最佳实践:RustFS 支撑大模型训练的存储架构与性能优化
人工智能·性能优化·架构·对象存储·minio·企业存储·rustfs
分布式存储与RustFS13 天前
Windows原生版RustFS:无需Docker,1分钟本地对象存储环境搭建
windows·docker·容器·对象存储·minio·企业存储·rustfs
阿杜杜不是阿木木13 天前
authentik开源身份认证与管理平台-与 MinIO 集成(8)
minio·authentik
sg_knight17 天前
Docker环境下的MinIO安装,以及如何正确配置数据持久化(避坑篇)
运维·docker·容器·minio·ftp·cos·oss
sg_knight17 天前
MinIO自带的Web Console管理后台怎么用?日常管理操作全解
前端·文件管理·minio·ftp·cos·oss
分布式存储与RustFS17 天前
RustFS永久开源承诺深度解读:Apache 2.0协议、商业化边界、社区可信度
开源·apache·数据安全·对象存储·minio·企业存储·rustfs