header | value |
---|---|
Upload-Offset | 현재 전송된 offset |
Upload-Length | 전체 파일 크기 |
Tus-Version | 지원하는 프로토콜 버전 리스트 |
Tus-Resumable | 현재 사용중인 프로토콜 버전 |
Tus-Extension | 서버에서 지원하는 확장 리스트 |
Tus-Max-Size | 파일 허용 크기 |
X-HTTP-Method-Override | http 메서드 재정의 |
요청
POST /files HTTP/1.1 Host: tus.example.org Content-Length: 0 Upload-Length: 100 Tus-Resumable: 1.0.0 Upload-Metadata: filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==,is_confidential
응답
HTTP/1.1 201 Created Location: https://tus.example.org/files/24e533e02ec3bc40c387f1a0e460e216 Tus-Resumable: 1.0.0
요청
- HEAD 메소드 요청으로 중단된 업로드의 offset을 조회
HEAD /files/24e533e02ec3bc40c387f1a0e460e216 HTTP/1.1 Host: tus.example.org Tus-Resumable: 1.0.0
응답
- 중단된 업로드가 없다면 404응답
HTTP/1.1 200 OK Upload-Offset: 70 Tus-Resumable: 1.0.0
요청
- PATCH 메소드 요청으로 업로드 재개
PATCH /files/24e533e02ec3bc40c387f1a0e460e216 HTTP/1.1 Host: tus.example.org Content-Type: application/offset+octet-stream Content-Length: 30 Upload-Offset: 70 Tus-Resumable: 1.0.0
응답
HTTP/1.1 204 No Content Tus-Resumable: 1.0.0 Upload-Offset: 100
요청
OPTIONS /files HTTP/1.1 Host: tus.example.org
응답
HTTP/1.1 204 No Content Tus-Resumable: 1.0.0 Tus-Version: 1.0.0,0.2.2,0.2.1 Tus-Max-Size: 1073741824 Tus-Extension: creation,expiration
자바로된 서버 구현한 라이브러리는 다음과 같다.
스타수 제일 많고 설명도 잘해놓은 3번째 라이브러리를 선택했다.
build.gradle
implementation "me.desair.tus:tus-java-server:1.0.0-2.0"
옵션 | 설명 |
---|---|
withUploadURI | 업로드 엔드포인트로 사용할 uri |
withMaxUploadSize | 업로드 최대 바이트 수(기본값 Long.MAX_VALUE) |
withStoragePath | 업로드 정보를 보관하는 경로 |
withChunkedTransferDecoding | 분할된 http 요청의 디코딩 활성화 여부(기본값은 false) |
withThreadLocalCache | ThreadLocal 캐싱 활성화 여부 |
withUploadExpirationPeriod | 만료시간(ms) |
TusConfig
@Configuration
public class TusConfig {
@Value("${tus.data.path}")
private String tusDataPath;
@Value("${tus.data.expiration}")
Long tusDataExpiration;
@Bean
public TusFileUploadService tus() {
return new TusFileUploadService()
.withStoragePath(tusDataPath)
.withDownloadFeature()
.withUploadExpirationPeriod(tusDataExpiration)
.withThreadLocalCache(true)
.withUploadURI("/tus/upload");
}
}
TusController
@Slf4j
@Controller
@RequiredArgsConstructor
public class TusController {
private final TusService tusService;
// 업로드 페이지
@GetMapping("/tus")
public String tusUploadPage() {
return "tus";
}
// 업로드 엔드포인트
@ResponseBody
@RequestMapping(value = {"/tus/upload", "/tus/upload/**"})
public ResponseEntity<String> tusUpload(HttpServletRequest request, HttpServletResponse response) {
return ResponseEntity.ok(tusService.tusUpload(request, response));
}
}
TusService
@Slf4j
@Service
@RequiredArgsConstructor
public class TusService {
private final TusFileUploadService tusFileUploadService;
@Value("${tus.save.path}")
private String savePath;
public String tusUpload(HttpServletRequest request, HttpServletResponse response) {
try {
// 업로드
tusFileUploadService.process(request, response);
// 현재 업로드 정보
UploadInfo uploadInfo = tusFileUploadService.getUploadInfo(request.getRequestURI());
// 완료 된 경우 파일 저장
if (uploadInfo != null && !uploadInfo.isUploadInProgress()) {
// 파일 저장
createFile(tusFileUploadService.getUploadedBytes(request.getRequestURI()), uploadInfo.getFileName());
// 임시 파일 삭제
tusFileUploadService.deleteUpload(request.getRequestURI());
return "success";
}
return null;
} catch (IOException | TusException e) {
log.error("exception was occurred. message={}", e.getMessage(), e);
throw new RuntimeException(e);
}
}
// 파일 업로드 (날짜별 디렉토리 하위에 저장)
private void createFile(InputStream is, String filename) throws IOException {
LocalDate today = LocalDate.now();
String uploadedPath = savePath + "/" + today;
String vodName = getVodName(filename);
File file = new File(uploadedPath, vodName);
FileUtils.copyInputStreamToFile(is, file);
}
// 파일 이름은 랜덤 UUID 사용
private String getVodName(String filename) {
String[] split = filename.split("\\.");
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
return uuid + "." + split[split.length - 1];
}
}
tusFileUploadService.cleanup()
메소드를 통해 쉽게 정리할 수 있다.TusCleanUpScheduler
@Slf4j
@Component
@RequiredArgsConstructor
public class TusCleanUpScheduler {
private final TusFileUploadService tus;
@Scheduled(fixedDelay = 10000)
public void cleanup() throws IOException {
log.info("clean up");
tus.cleanup();
}
}
@EnableScheduling
어노테이션을 사용하여 스케줄러 활성화UploadApplication
@EnableScheduling
@SpringBootApplication
public class UploadApplication {
public static void main(String[] args) {
SpringApplication.run(UploadApplication.class, args);
}
}
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>chunk file upload with tus upload</title>
</head>
<body>
<input id="video-file" type="file" name="file">
<button id="upload-btn">업로드</button>
<button id="pause-btn">일시중지</button>
<div id="result"></div>
</body>
<script src="https://cdn.jsdelivr.net/npm/tus-js-client@latest/dist/tus.min.js"></script>
<script type="text/javascript">
const uploadBtn = document.querySelector('#upload-btn')
uploadBtn.addEventListener('click', () => {
const pauseBtn = document.querySelector("#pause-btn");
const file = document.querySelector("#video-file").files[0];
const result = document.querySelector("#result");
const chunkSize = 1024 * 1024 * 5; // 5MB
let response;
// 업로드 객체 생성
const upload = new tus.Upload(file, {
endpoint: "/tus/upload",
chunkSize,
retryDelays: [0, 1000, 3000, 5000],
metadata: {
filename: file.name,
filetype: file.type
},
// 에러 발생시 콜백
onError: (error) => {
console.log("Failed because: " + error);
},
// 업로드 진행중 콜백
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2)
result.textContent = percentage + "%"
},
// 업로드 완료 후 콜백
onSuccess: () => {
result.innerHTML = `
<div>업로드 완료!</div>
<div>${response}</div>
`;
},
// 업로드 중 응답 콜백
onAfterResponse: (req, res) => {
response = res.getBody();
}
});
// 중단된 업로드 조회
upload.findPreviousUploads().then( (previousUploads) => {
// 중단 된 업로드가 있으면 재개
if(previousUploads.length) {
upload.resumeFromPreviousUpload(previousUploads[0]);
}
// 업로드 시작
upload.start();
});
// 업로드 일시 중지
pauseBtn.addEventListener("click", function() {
upload.abort();
});
});
</script>
</html>
http://localhost:8080/tus
업로드 리소스 생성
업로드 재개를 할 수 있도록 로컬 스토리지에 메타데이터 저장
해당 UploadId 하위 폴더에 영상 데이터와 업로드 정보가 저장된다.
- 만료시간이 지나면 스케줄러가 tus/upload 하위에 저장된 영상 데이터와 업로드 정보를 삭제 한다.
- 따라서 이후 업로드를 재개 하려고 하면 해당 데이터가 없으므로 offset 조회시 404응답
- 404응답이 오면 업로드 리소스 생성 요청을 하고 업로드를 시작한다.