背景
当我们说“可恢复文件上传”时,我们指的是上传可以随时中断,然后从失败开始的状态恢复的能力。这种中断可能是意外的(例如,连接中断或服务器崩溃),也可能是用户决定暂停上传时自愿的。在传统的上传实现中,在这种情况下您的进度会丢失,但是 tus 使您能够从这些中断中恢复并在上传停止的地方继续。
适用场景
- 在部分不可靠的网络上运行,在这些网络中,连接很容易断开或连接可能在一段时间内根本不可用,例如在使用移动数据时。
- 处理大文件并希望避免因为上传中断而不得不重新上传部分文件(注意:“大”是一个相对词。如果您的上行链路速度为 100KB/s,则 10MB 的文件可能很大)。
- 希望为您的用户提供暂停上传并稍后(甚至可能在几天后)恢复上传的功能。
- 不想依赖专有的上传解决方案,而是更喜欢在免费和开源项目的基础上进行构建
工作原理
一个 tus 上传被分解为不同的 HTTP 请求,每个请求都有不同的目的:
- 首先,客户端向POST服务器发送请求以发起上传。这个上传创建请求告诉服务器关于上传的基本信息,例如它的大小或附加元数据。如果服务器接受此上传创建请求,它将返回一个成功的响应,并将Location标头设置为上传 URL。上传 URL 用于唯一标识和引用新创建的上传资源。
- 创建上传后,客户端可以通过向PATCH上传 URL发送请求来开始传输实际的上传内容,如前一个POST请求中返回的那样。理想情况下,此PATCH请求应包含尽可能多的上传内容,以尽量减少上传持续时间。所述PATCH请求还必须包含Upload-Offset报头,它告诉在该字节偏移量的服务器应写入上传的数据服务器。如果PATCH请求成功传输了整个上传内容,那么您的上传就完成了!
- 如果PATCH请求因其他原因中断或失败,客户端可以尝试恢复上传。要恢复,客户端必须知道服务器收到了多少数据。此信息是通过向HEAD上传 URL发送请求并检查返回的Upload-Offset标头来获得的。一旦客户端知道上传偏移量,它可以发送另一个PATCH请求,直到上传完成。
- 或者,如果客户端因为不再需要上传而想要删除上传,DELETE则可以向上传 URL 发送请求。在此之后,上传可以被服务器清理,并且不再可能恢复上传
流程图
请求类型与名字说明
- OPTIONS请求主要是获取协议描述,支持的各种参数,协议细节,其实tus使用Header来进行服务器和客户端信息交互,OPTIONS需要实现两个Action,一个用于总的协议描述,另一个可以获取到当前文件的上传进度Offset。
- POST请求当有新文件需要上传时候,注册文件信息,文件名,文件大小。
- HEAD请求请求当前文件的服务器信息,返回文件大小和当前进度。
- PATCH请求上传文件,写入磁盘系统。
名词解释:
- Upload-Offset:上传偏移,请求和响应header中指定资源的偏移。该值必须是一个非负整数。
- Upload-Length:上传长度,请求和响应header中指定整个上载的大小。该值必须是一个非负整数。
- Tus-Version:协议版本,响应报头必须是逗号分隔的由服务器支持的协议版本的列表。该列表必须按服务器的偏好排序
- Tus-Resumable:报头必须被包含在每个请求中除了响应OPTIONS请求。该值必须是客户端或服务器使用的协议版本。如果客户端指定的版本不被服务器支持,它必须以412 Precondition Failed状态响应并且必须Tus-Version在响应中包含 头。此外,服务器不得处理请求。
- Tus-Max-Size:响应报头必须是一个非负整数,其表示以字节为单位的整个上载所允许的最大值
java demo实现
废话不多说我们开始上手写代码,当我们上传一个大文件时候
1.首先执行OPTIONS请求可以是用于收集有关服务器的当前配置信息
- 前端Request
OPTIONS/filesHTTP/1.1Host: http://localhost:8080/tus
- 服务端需要对该请求做出响应和对应的java代码实现
HTTP/1.1204NoContentTus-Resumable: 1.0.0Tus-Version: 1.0.0,0.2.2,0.2.1Tus-Max-Size: 1073741824Tus-Extension: creation,expiration
method=RequestMethod.OPTIONS) (publicvoidgetTusOptions(HttpServletResponseresponse) { response.setHeader(ACCESS_CONTROL_EXPOSE_HEADER, ACCESS_CONTROL_EXPOSE_OPTIONS_VALUE); response.setHeader(TUS_RESUMABLE_HEADER, TUS_RESUMABLE_VALUE); response.setHeader(TUS_VERSION_HEADER, TUS_VERSION_VALUE); response.setHeader(TUS_MAX_SIZE_HEADER, String.valueOf(tusConfigProperties.getMaxSize())); response.setHeader(TUS_EXTENTION_HEADER, TUS_EXTENTION_VALUE); response.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN_HEADER, ACCESS_CONTROL_ALLOW_ORIGIN_VALUE); response.setHeader(ACCESS_CONTROL_ALLOW_METHODS_HEADER, ACCESS_CONTROL_ALLOW_METHODS_VALUE); response.setStatus(HttpStatus.NO_CONTENT.value()); }
2.执行POST请求获取Location,需要必须指定Upload-FileId、Upload-FileName
- 前端Request
POST/filesHTTP/1.1Host: http://localhost:8080/tusContent-Length: 0Upload-Length: 100Tus-Resumable: 1.0.0Upload-FileId: 8CDFF8AE590F41EF94D322BCE31F5B51Upload-FileName: test
- 服务端需要对该请求做出响应和对应的java代码实现
HTTP/1.1201CreatedLocation: http://localhost:8080/tus/8CDFF8AE590F41EF94D322BCE31F5B51Tus-Resumable: 1.0.0publicvoidprocessPost( (value=UPLOAD_LENGTH_HEADER, required=false) IntegeruploadLength, UPLOAD_FILE_ID_HEADER) StringfileId, (UPLOAD_FILE_NAME_HEADER) StringfileName, (HttpServletRequestrequest, HttpServletResponseresponse) throwsUnsupportedEncodingException { fileName=URLDecoder.decode(fileName, "utf-8"); tusFileUploadService.initUpload(fileId, uploadLength, fileName); response.setHeader(ACCESS_CONTROL_EXPOSE_HEADER, ACCESS_CONTROL_EXPOSE_POST_VALUE); Stringlocation=UriComponentsBuilder.fromUriString(request.getRequestURI() +"/"+fileId).build().toString(); response.setHeader(LOCATION_HEADER, location); response.setHeader(TUS_RESUMABLE_HEADER, TUS_RESUMABLE_VALUE); response.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN_HEADER, ACCESS_CONTROL_ALLOW_ORIGIN_VALUE); response.setHeader(ACCESS_CONTROL_ALLOW_METHODS_HEADER, ACCESS_CONTROL_ALLOW_METHODS_VALUE); response.setStatus(HttpStatus.CREATED.value()); } publicTusFileUploadinitUpload(StringfileId, IntegeruploadLength, StringfileName) { uploadLength=null==uploadLength?0 : uploadLength; if (uploadLength>tusConfigProperties.getMaxSize()) { thrownewBizException("上传的文件大小不超过"+FileUtil.prettySize(tusConfigProperties.getMaxSize())); } OSSossClient=buildOssClient(); InitiateMultipartUploadRequestinitiateMultipartUploadRequest=newInitiateMultipartUploadRequest( ossConfigProperties.getBucket(), "tus/"+fileId+"/"+fileName); InitiateMultipartUploadResultresult=ossClient.initiateMultipartUpload(initiateMultipartUploadRequest); StringuploadId=result.getUploadId(); TusFileUploadtusFileUpload=newTusFileUpload(); tusFileUpload.setFileId(fileId); tusFileUpload.setUploadLength(uploadLength); tusFileUpload.setOffset(0); tusFileUpload.setFileName(fileName); tusFileUpload.setUploadId(uploadId); tusFileUpload.setWrappedPartETags(newArrayList<>()); saveToRedis(tusFileUpload); ossClient.shutdown(); returntusFileUpload; }
3.拿到返回的Location之后就会带上fileId继续PATCH请求
- 前端Request
PATCH/files/8CDFF8AE590F41EF94D322BCE31F5B51HTTP/1.1Host: http://localhost:8080/tusContent-Type: application/offset+octet-streamContent-Length: 30Upload-Offset: 0Tus-Resumable: 1.0.0[remaining30bytes]
- 服务端需要对该请求做出响应和对应的java代码实现
HTTP/1.1204NoContentTus-Resumable: 1.0.0Upload-Offset: 70
method= {RequestMethod.PATCH, RequestMethod.POST}, value="/{fileId}") (publicvoidprocessPatch( (UPLOAD_OFFSET_HEADER) LonguploadOffset, value=CONTENT_LENGTH_HEADER, required=false) LongcontentLength, (CONTENT_TYPE_HEADER) StringcontentType, (StringfileId, InputStreaminputStream, HttpServletResponseresponse) { if (null==uploadOffset||uploadOffset<0) { thrownewBizException("文件上传大小异常"); } if (!TUS_CONTENT_TYPE_VALUE.equals(contentType)) { thrownewBizException("文件类型异常"); } TusFileUploadtusFileUpload=tusFileUploadService.findOne(fileId); log.debug("Tus file offset: [{}]", tusFileUpload.getOffset()); log.debug("Tus file final length: [{}]", tusFileUpload.getUploadLength()); if (tusFileUpload.getUploadLength() <tusFileUpload.getOffset()) { thrownewBizException("文件上传大小异常"); } //successfulif (tusFileUpload.getUploadLength() ==tusFileUpload.getOffset()) { tusFileUploadService.completeUpload(tusFileUpload); response.setHeader("Upload-Offset", Long.toString(tusFileUpload.getOffset())); response.setStatus(HttpStatus.OK.value()); return; } PartListinguploadedParts=tusFileUploadService.listUploadedParts(tusFileUpload); // 真正上传longnewOffset=tusFileUploadService.uploadPart(tusFileUpload, inputStream, contentLength, uploadedParts); // 不需要这次上传完成就通知200状态,到下次 patch 获取状态,否则 python 客户端会验证失败。if (tusFileUpload.getUploadLength() ==newOffset) { tusFileUploadService.completeUpload(tusFileUpload); } response.setHeader(ACCESS_CONTROL_EXPOSE_HEADER, ACCESS_CONTROL_EXPOSE_PATCH_VALUE); response.setHeader(TUS_RESUMABLE_HEADER, TUS_RESUMABLE_VALUE); response.setHeader(UPLOAD_OFFSET_HEADER, Long.toString(newOffset)); response.setStatus(HttpStatus.NO_CONTENT.value()); }
4.如果我们在此期间发生了断网和故意暂停。他会终端上传并且把当前的偏移量缓存起来。我们再次点击继续上传的时候会先调用head请求获取缓存中对应的偏移量之后则重复第3步继续上传直到完成为止.
- 前端Request
HEAD/files/24e533e02ec3bc40c387f1a0e460e216HTTP/1.1Host: http://localhost:8080/tusTus-Resumable: 1.0.0
- 服务端需要对该请求做出响应和对应的java代码实现
HTTP/1.1200OKUpload-Offset: 70Tus-Resumable: 1.0.0
method=RequestMethod.HEAD, value="/{fileId}") (publicvoidprocessHead(StringfileId, HttpServletResponseresponse) { TusFileUploadfile=tusFileUploadService.findOne(fileId); log.debug("File: [{}] upload offset: [{}]", fileId, file.getOffset()); response.setHeader(ACCESS_CONTROL_EXPOSE_HEADER, ACCESS_CONTROL_EXPOSE_HEAD_VALUE); response.setHeader(UPLOAD_OFFSET_HEADER, Long.toString(file.getOffset())); response.setHeader(UPLOAD_LENGTH_HEADER, Long.toString(file.getUploadLength())); response.setHeader(TUS_RESUMABLE_HEADER, TUS_RESUMABLE_VALUE); response.setStatus(HttpStatus.OK.value()); }