【tus】動画などの大容量ファイルアップロードに嬉しい「Resumable Upload」が簡単に実現できる。

# はじめに

動画などのサイズが大きいファイルのアップロード機能を開発することになった場合、以下のような課題が出てくるんじゃないかなと思います。

- ユーザがファイルをアップロードする際、ネットワークエラーなどで最初からやり直しになったりすると辛い。たとえ頻度が少なかったとしても、可能ならなんとかしたい。
- Webサーバにサイズ制限があり、大きいファイルのアップロードは「413 Request Entity Too Large」エラーが発生する等、問題が発生してしまう。

「tus」を利用することで、上記のような課題を比較的簡単にクリアすることができたので、今回は「tus」を使ったファイルアップロード機能を紹介します。

# tus とは

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179364/59f707ea-9175-087a-974c-966c42acf693.png" width="350">

tus は、再開可能なファイルアップロードのためのオープンプロトコルです。

## 主な特徴

- **HTTP-based**
    - 通常利用される HTTP の上位層に構築されているため、既存のライブラリやプロキシ、ファイアウォールを使用したアプリケーションに簡単に統合でき、あらゆるWebサイトから直接使用することができる。
- **Production-ready**
    - Vimeo や Google のフィードバックを受け改良が重ねられたものであり、リリース可能な状態。
- **Minimalistic design...**
    - Client と Server に少し実装をするだけで利用可能。
- **... yet still extensible**
    - 並列アップロード、チェックサム、有効期限などの拡張機能を開発者が選択できる。
- **OSS**
    - MITライセンスでの利用が可能。

# tus の使い方

tus を利用したファイルアップロード機能のサンプルを作成しました。
https://github.com/amtkxa/tus-upload-sample

<img width="350" alt="Screen Shot 2019-04-20 at 13.45.14.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179364/a09d9689-0449-6eb1-c957-a4b536ae9c8a.png">

ファイルアップロード途中でブラウザを再読み込みするなどして、強制中断した後に、同じファイルをアップロードすると、中断時点からアップロード処理を再開することができます。

今回は、これをベースに説明していきます。

## Technology stack

### Client-Side

以下のような技術要素を使っています。

- [tus-js-client](https://github.com/tus/tus-js-client)
- [Vue.js](https://github.com/vuejs/vue)
- [Nuxt.js](https://github.com/nuxt)
- [Element](https://github.com/ElemeFE/element)

適度に最適化された使いやすい環境が欲しかったので、Nuxt.js を使っています。

そのため、Nuxt 成分はそんなになくて、`yarn create nuxt-app tus-client` で作成したプロジェクトの雛形に、`tus-js-client` を使った簡易な実装を追加しているだけです。Vue も必要最低限レベルだと思います。

### Server-Side

Server-Side は　Spring Boot を簡単に使った構成にしています。

- [tus-java-server](https://github.com/tomdesair/tus-java-server)
- [Spring Boot](https://github.com/spring-projects/spring-boot)

こちらは、Spring Initializr で作成したプロジェクトの雛形に `tus-java-server` を使った簡易な実装を追加しています。

## Client-Side

雛形作成時に存在する `index.vue` に、Element の [el-upload](https://element.eleme.io/#/en-US/component/upload) と、簡易な実装を追加しています。

```vue:tus-client/pages/index.vue
<template>
  <section class="container">
    <div>
      <logo />
      <h1 class="title">tus-client</h1>
      <h2 class="subtitle">Tus.io File Upload Sample</h2>
      <el-upload
        action
        :auto-upload="false"
        :on-change="handleChange"
        :on-remove="handleRemove"
        multiple
        :file-list="fileList"
      >
        <el-button slot="trigger" class="button--green">
          Select Files
        </el-button>
        <el-button class="button--grey" @click="upload">
          Upload
        </el-button>
      </el-upload>
    </div>
  </section>
</template>

<script>
import Logo from '~/components/Logo.vue'

export default {
  components: {
    Logo
  },
  data() {
    return {
      fileList: [],
      params: {
        message: 'this is debug message.'
      }
    }
  },
  methods: {
    handleChange: function(file, fileList) {
      this.fileList = fileList
    },
    handleRemove: function(file, fileList) {
      this.fileList = fileList
    },
    upload: async function() {
      if (this.fileList.length === 0) {
        this.showMessage('File has not been selected', 'warning')
        return
      }
      await Promise.all(
        this.fileList.map(file => {
          return this.$store.dispatch('upload', {
            file: file.raw,
            params: this.params
          })
        })
      )
      this.showMessage('File uploaded successfully', 'success')
      this.fileList = []
    },
    showMessage: function(message, type) {
      this.$message({
        message: message,
        type: type
      })
    }
  }
}
</script>
```

Vuex Store に `tus-js-client` を使ったファイルアップロードを行うための実装を追加しています。
Client-Side に実装した tus のための記述は、ここがメインです。

```js:tus-client/store/index.js
import Vuex from 'vuex'
import * as Tus from 'tus-js-client'

const store = {
  state: {},
  getters: {},
  mutations: {},
  actions: {
    upload({ commit }, { file, params }) {
      return new Promise((resolve, reject) => {
        // Option setting
        const options = {
          endpoint: `http://localhost:8080/api/file/upload`,
          fingerprint: file => file.name,
          chunkSize: 1024 * 1024 * 1,
          metadata: {
            filename: file.name
          },
          retryDelays: [0, 3000, 5000, 10000, 20000],
          resume: true,
          onError: function(error) {
            console.log('Failed because: ' + error)
            reject(error)
          },
          onProgress: function(bytesUploaded, bytesTotal) {
            const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2)
            console.log(bytesUploaded, bytesTotal, percentage + '%')
          },
          onSuccess: function() {
            console.log('Download from %s complete', upload.url)
            resolve(upload.url)
          }
        }
        // Create a new tus upload
        const upload = new Tus.Upload(file, options)
        // Start the upload
        upload.start()
      })
    }
  }
}

export default () => new Vuex.Store(store)
```

ここの実装を簡単に説明すると、以下のようなことをやっています。

- 大容量ファイルを分割アップロードする際の Chunk Size の設定
- アップロード中断後に再実行されたら、中断時点からアップロードを再開するための設定
- Server に追加で送りたいメタデータの設定
- ファイルアップロード処理失敗時のリトライ制御の設定
- ファイルアップロード処理の開始

Chunk Size により、一回で送られるリクエストのサイズ、全てのリクエストを送りきるまでに必要なリ回数が変わってくるため、ここはWebサーバ側のサイズ上限などから検討して決める必要があると思います。

tus-js-client に関する詳細な説明は以下にまとまっています。
https://github.com/tus/tus-js-client

## Server-Side

dependencies に `tus-java-server` を追加しています。

```gradle:build.gradle
ext {
    springBootVersion = '2.1.4.RELEASE'
    lombokVersion = '1.18.6'
    tusVersion = '1.0.0-2.0'
}
dependencies {
    // Spring Boot
    implementation "org.springframework.boot:spring-boot-starter-web:${springBootVersion}"
    testImplementation "org.springframework.boot:spring-boot-starter-test:${springBootVersion}"

    // Lombok
    implementation "org.projectlombok:lombok:${lombokVersion}"
    annotationProcessor "org.projectlombok:lombok:${lombokVersion}"

    // Tus Java Server
    implementation "me.desair.tus:tus-java-server:${tusVersion}"
}
```

`tus-java-server` の `TusFileUploadService ` を利用するために必要なクラスを作成しています。

```java:com.amtkca.tusserver.config.TusConfig
@Configuration
public class TusConfig {
    /**
     * アップロードされたデータを一時的に保存するディレクトリのパス
     */
    @Value("${tus.server.data.directory}")
    protected String tusStoragePath;

    /**
     * アップロードを期限切れと見なすまでの期間（milliseconds）
     */
    @Value("${tus.server.data.expiration}")
    protected Long tusExpirationPeriod;

    @PreDestroy
    public void exit() throws IOException {
        // cleanup any expired uploads and stale locks
        tus().cleanup();
    }

    @Bean
    public TusFileUploadService tus() {
        return new TusFileUploadService()
                .withStoragePath(tusStoragePath)
                .withDownloadFeature()
                .withUploadExpirationPeriod(tusExpirationPeriod)
                .withUploadURI("/api/file/upload");
    }
}
```

```properties:application.properties
tus.server.data.directory=${java.io.tmpdir}/tus
tus.server.data.expiration=600000
```



```java:com.amtkca.tusserver.controller.FileUploadController
@RestController
public class FileUploadController {
    @NotNull
    private final FileUploadService fileUploadService;

    @Autowired
    FileUploadController(FileUploadService fileUploadService) {
        this.fileUploadService = fileUploadService;
    }

    @CrossOrigin
    @RequestMapping(value = {"/api/file/upload", "/api/file/upload/**"}, method = {
            RequestMethod.POST, RequestMethod.PATCH, RequestMethod.HEAD, RequestMethod.DELETE,
            RequestMethod.OPTIONS, RequestMethod.GET
    })
    public ResponseEntity upload(HttpServletRequest request, HttpServletResponse response) {
        // Process a tus upload request
        fileUploadService.process(request, response);

        // Generate HTTP Response Headers
        HttpHeaders headers = new HttpHeaders();
        headers.set(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, "*");
        return ResponseEntity.status(HttpStatus.OK).headers(headers).build();
    }
}
```

Client-Side からの分割ファイルアップロードリクエストを全て取得できたら、順番通りに結合する必要があります。
POST 以外の要求にも対応する必要があるため、上記のような実装になっています。

```java:com.amtkca.tusserver.service.FileUploadService
@Slf4j
@Service
public class FileUploadService {
    @NotNull
    private final TusFileUploadService tusFileUploadService;

    @Autowired
    FileUploadService(TusFileUploadService tusFileUploadService) {
        this.tusFileUploadService = tusFileUploadService;
    }

    /**
     * ファイルのアップロードリクエストを処理する。
     *
     * @param request
     * @param response
     */
    public void process(HttpServletRequest request, HttpServletResponse response) {
        try {
            // Process a tus upload request
            tusFileUploadService.process(request, response);

            // Get upload information
            UploadInfo uploadInfo = tusFileUploadService.getUploadInfo(request.getRequestURI());

            if (uploadInfo != null && !uploadInfo.isUploadInProgress()) {
                // Progress status is successful: Create file
                createFile(tusFileUploadService.getUploadedBytes(request.getRequestURI()), uploadInfo.getFileName());

                // Delete an upload associated with the given upload url
                tusFileUploadService.deleteUpload(request.getRequestURI());
            }
        } catch (IOException | TusException e) {
            log.error("exception was occurred. message={}", e.getMessage(), e);
            throw new RuntimeException(e);
        }
    }

    /**
     * ファイルを作成する。
     *
     * @param is
     * @param filename
     * @throws IOException
     */
    private void createFile(InputStream is, String filename) throws IOException {
        File file = new File("dest/", filename);
        FileUtils.copyInputStreamToFile(is, file);
    }
}
```

ここでは、`tus-java-server` の `TusFileUploadService ` を利用して、以下のようなことをやっています。

- Client-Side からの分割ファイルアップロードリクエストを全て取得かを判定
- 全て取得できていたら、出力先ディレクトリにファイルを作成し、不要になった一時データを削除する。

UploadInfo で Client から追加で送られてきたメタデータを取得することも可能です。

今回紹介したサンプルは、 tus の動きを知ることが目的なので、サーバ側のディレクトリにファイルを生成して終わりになっています。
良くありそうなのは、この後に Amazon S3 などにアップロードとかだと思いますが、ここまでできれば、そういった要件で利用する AWS SDK for Java などに苦労せず引き継げると思います。

# さいごに

今回は、tus を使ったサンプルでの紹介でしたが、特に難しいことは何もやらずに、やりたいことを実現できたと考えています。

単純なファイルアップロード機能であっても、より良い方法を追求しようとするのは難しいですが、今後もうまいやり方を模索したり、tus 以外のテクノロジーも探して技術検証をして、気ままに追求していきたいと思います。また何か良さそうなものを見つけたら、ご紹介したいと思います。

最後まで読んで頂き、ありがとうございました。

# 参考にしたもの

- [Tus.io](https://tus.io/)