영상 스트리밍 #4. 파일 분할 업로드 - tus protocol

Bobby·2023년 3월 4일
1

streaming

목록 보기
4/9

🎥 Tus Protocol

  • tus protocol은 http기반의 재개 가능한 업로드를 위한 오픈 프로토콜이다.
  • 다양한 기능들을 제공하고 공식/비공식 구현들의 예제들도 잘 나와있고 다양한 언어를 지원하고 있다.
  • 공식이: https://tus.io/

핵심 동작 방식

  • 이외에도 추가적인 확장 기능들이 있다.
  • 자세한 사항 참고
headervalue
Upload-Offset현재 전송된 offset
Upload-Length전체 파일 크기
Tus-Version지원하는 프로토콜 버전 리스트
Tus-Resumable현재 사용중인 프로토콜 버전
Tus-Extension서버에서 지원하는 확장 리스트
Tus-Max-Size파일 허용 크기
X-HTTP-Method-Overridehttp 메서드 재정의

1. 업로드 리소스 생성

  • 첫 업로드시 업로드 리소스 생성

요청

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

2. offset 조회

요청

  • 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

3. 업로드 재개

요청

  • 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

4. 서버 구성 정보 조회

요청

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)
withThreadLocalCacheThreadLocal 캐싱 활성화 여부
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 하위 폴더에 영상 데이터와 업로드 정보가 저장된다.

업로드 재개

  • 업로드 재개를 위한 offset을 조회
  • 응답헤더에 offset 데이터 응답

업로드 완료

  • 만료시간이 지나면 스케줄러가 tus/upload 하위에 저장된 영상 데이터와 업로드 정보를 삭제 한다.
  • 따라서 이후 업로드를 재개 하려고 하면 해당 데이터가 없으므로 offset 조회시 404응답
  • 404응답이 오면 업로드 리소스 생성 요청을 하고 업로드를 시작한다.

코드

profile
물흐르듯 개발하다 대박나기

0개의 댓글