Implementing Resumable Multipart File Upload with MinIO and Spring Boot
This article explains how to design and implement a resumable multipart file upload system using MD5 file identification, MinIO/S3 pre‑signed URLs, and Spring Boot controllers and services, covering database schema, task initialization, part uploading, merging, and cleanup of incomplete uploads.
1. Introduction
In a previous short‑video demo the upload logic was naive: the front‑end directly uploaded files to a cloud service without handling network interruptions, causing a poor user experience when an upload failed.
To improve this we adopt a resumable (break‑point‑continue) upload strategy that relies on MD5 file identification and chunked uploading. The workflow is illustrated in the original diagram.
2. Database Structure
The sys_upload_task table stores each upload task, including bucket name, object key, upload ID, chunk size, total size, chunk count, and file identifier (MD5).
3. Backend Implementation
3.1 Check if a file with the same MD5 already exists
Controller layer
@GetMapping("/{identifier}")
public GraceJSONResult taskInfo(@PathVariable("identifier") String identifier) {
return GraceJSONResult.ok(sysUploadTaskService.getTaskInfo(identifier));
}Service layer
public TaskInfoDTO getTaskInfo(String identifier) {
SysUploadTask task = getByIdentifier(identifier);
if (task == null) {
return null;
}
TaskInfoDTO result = new TaskInfoDTO()
.setFinished(true)
.setTaskRecord(TaskRecordDTO.convertFromEntity(task))
.setPath(getPath(task.getBucketName(), task.getObjectKey()));
boolean doesObjectExist = amazonS3.doesObjectExist(task.getBucketName(), task.getObjectKey());
if (!doesObjectExist) {
ListPartsRequest listPartsRequest = new ListPartsRequest(task.getBucketName(), task.getObjectKey(), task.getUploadId());
PartListing partListing = amazonS3.listParts(listPartsRequest);
result.setFinished(false).getTaskRecord().setExitPartList(partListing.getParts());
}
return result;
}3.2 Initialize an upload task
Controller layer
@PostMapping
public GraceJSONResult initTask(@Valid @RequestBody InitTaskParam param) {
return GraceJSONResult.ok(sysUploadTaskService.initTask(param));
}Service layer
public TaskInfoDTO initTask(InitTaskParam param) {
Date currentDate = new Date();
String bucketName = minioProperties.getBucketName();
String fileName = param.getFileName();
String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
String key = StrUtil.format("{}/{}.{}", DateUtil.format(currentDate, "YYYY-MM-dd"), IdUtil.randomUUID(), suffix);
String contentType = MediaTypeFactory.getMediaType(key).orElse(MediaType.APPLICATION_OCTET_STREAM).toString();
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentType(contentType);
InitiateMultipartUploadResult result = amazonS3.initiateMultipartUpload(new InitiateMultipartUploadRequest(bucketName, key).withObjectMetadata(objectMetadata));
String uploadId = result.getUploadId();
int chunkNum = (int) Math.ceil(param.getTotalSize() * 1.0 / param.getChunkSize());
SysUploadTask task = new SysUploadTask()
.setBucketName(bucketName)
.setChunkNum(chunkNum)
.setChunkSize(param.getChunkSize())
.setTotalSize(param.getTotalSize())
.setFileIdentifier(param.getIdentifier())
.setFileName(fileName)
.setObjectKey(key)
.setUploadId(uploadId);
sysUploadTaskMapper.insert(task);
return new TaskInfoDTO()
.setFinished(false)
.setTaskRecord(TaskRecordDTO.convertFromEntity(task))
.setPath(getPath(bucketName, key));
}3.3 Generate pre‑signed upload URL for each chunk
Controller layer
@GetMapping("/{identifier}/{partNumber}")
public GraceJSONResult preSignUploadUrl(@PathVariable("identifier") String identifier,
@PathVariable("partNumber") Integer partNumber) {
SysUploadTask task = sysUploadTaskService.getByIdentifier(identifier);
if (task == null) {
return GraceJSONResult.error("分片任务不存在");
}
Map
params = new HashMap<>();
params.put("partNumber", partNumber.toString());
params.put("uploadId", task.getUploadId());
return GraceJSONResult.ok(sysUploadTaskService.genPreSignUploadUrl(task.getBucketName(), task.getObjectKey(), params));
}Service layer
public String genPreSignUploadUrl(String bucket, String objectKey, Map
params) {
Date now = new Date();
Date expire = DateUtil.offsetMillisecond(now, PRE_SIGN_URL_EXPIRE.intValue());
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucket, objectKey)
.withExpiration(expire)
.withMethod(HttpMethod.PUT);
if (params != null) {
params.forEach(request::addRequestParameter);
}
URL url = amazonS3.generatePresignedUrl(request);
return url.toString();
}3.4 Merge uploaded chunks
Controller layer
@PostMapping("/merge/{identifier}")
public GraceJSONResult merge(@PathVariable("identifier") String identifier) {
sysUploadTaskService.merge(identifier);
return GraceJSONResult.ok();
}Service layer
public void merge(String identifier) {
SysUploadTask task = getByIdentifier(identifier);
if (task == null) {
throw new RuntimeException("分片任务不存");
}
ListPartsRequest listPartsRequest = new ListPartsRequest(task.getBucketName(), task.getObjectKey(), task.getUploadId());
PartListing partListing = amazonS3.listParts(listPartsRequest);
List
parts = partListing.getParts();
if (!task.getChunkNum().equals(parts.size())) {
throw new RuntimeException("分片缺失,请重新上传");
}
CompleteMultipartUploadRequest completeRequest = new CompleteMultipartUploadRequest()
.withUploadId(task.getUploadId())
.withKey(task.getObjectKey())
.withBucketName(task.getBucketName())
.withPartETags(parts.stream()
.map(p -> new PartETag(p.getPartNumber(), p.getETag()))
.collect(Collectors.toList()));
amazonS3.completeMultipartUpload(completeRequest);
}4. Cleanup of Incomplete Parts
If an upload is aborted, add a status column to sys_upload_task (default false). After a successful merge set it to true and run a scheduled job to delete records with false status. MinIO also automatically cleans temporary chunk data.
Demo Repository
GitHub: https://github.com/robinsyn/MinIO_Demo
Top Architect
Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.