はじめに
動画などのサイズが大きいファイルのアップロード機能を開発することになった場合、以下のような課題が出てくるんじゃないかなと思います。
- ユーザがファイルをアップロードする際、ネットワークエラーなどで最初からやり直しになったりすると辛い。たとえ頻度が少なかったとしても、可能ならなんとかしたい。
- Webサーバにサイズ制限があり、大きいファイルのアップロードは「413 Request Entity Too Large」エラーが発生する等、問題が発生してしまう。
「tus」を利用することで、上記のような課題を比較的簡単にクリアすることができたので、今回は「tus」を使ったファイルアップロード機能を紹介します。
tus とは
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
ファイルアップロード途中でブラウザを再読み込みするなどして、強制中断した後に、同じファイルをアップロードすると、中断時点からアップロード処理を再開することができます。
今回は、これをベースに説明していきます。
Technology stack
Client-Side
以下のような技術要素を使っています。
適度に最適化された使いやすい環境が欲しかったので、Nuxt.js を使っています。
そのため、Nuxt 成分はそんなになくて、yarn create nuxt-app tus-client
で作成したプロジェクトの雛形に、tus-js-client
を使った簡易な実装を追加しているだけです。Vue も必要最低限レベルだと思います。
Server-Side
Server-Side は Spring Boot を簡単に使った構成にしています。
こちらは、Spring Initializr で作成したプロジェクトの雛形に tus-java-server
を使った簡易な実装を追加しています。
Client-Side
雛形作成時に存在する index.vue
に、Element の el-upload と、簡易な実装を追加しています。
<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 のための記述は、ここがメインです。
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
を追加しています。
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
を利用するために必要なクラスを作成しています。
@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");
}
}
tus.server.data.directory=${java.io.tmpdir}/tus
tus.server.data.expiration=600000
@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 以外の要求にも対応する必要があるため、上記のような実装になっています。
@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 以外のテクノロジーも探して技術検証をして、気ままに追求していきたいと思います。また何か良さそうなものを見つけたら、ご紹介したいと思います。
最後まで読んで頂き、ありがとうございました。