もしユーザーにOAuth 認証してもらい、Youtubeに動画ファイルをアップロードさせるサービスを作るとしたらどうするでしょうか。まずアップロードされたファイルをディスクに保存、完了したら次いでYoutubeに投稿を開始・・・という実装方法もあるかも知れません。
しかし、ディスクへの保存とYoutubeへのファイル送信を逐次処理すると、エンドユーザーから見たアップロード時間が長くなってしまいす。またファイルの保存・削除にもきちんと責任を持たないと、一時ファイルの漏洩に繋がったり、ディスクを圧迫するリスクにも繋がる可能性があります。
これらアップロード時間とロジック短縮の観点から、本稿ではファイルアップロードストリームをそのままYoutubeへの投稿ストリームに載せる実装と気づいた点を紹介したいと思います。実装サンプルをherokuで稼働させていますので、ぜひ試してみてください。
デモ: http://stark-chamber-3100.herokuapp.com/
ソース: https://github.com/piglovesyou/youtube_stream_upload_sample
処理の順序
- ユーザーがブラウザでGoogle OAuth 認証します(説明を省略します)
- ブラウザでタイトルを入力、動画ファイルを添付したのち、multipartでPOSTします
- サーバはファイルデータの受信が完了する前にYoutubeへの投稿を開始します
- ユーザーのリクエストが終わり、Youtubeへの投稿も終わり、Youtubeからのレスポンスがあった時、ユーザーに投稿成功のレスポンスを返します
POST "multipart/form-data" の動き
初めに少しmultipart/form-dataの仕組みを振り返りたいと思います。ブラウザではファイルを送信するとき、次のようなHTMLを使います。
<form method="POST" action="/upload" enctype="multipart/form-data">
<input type="text" name="nice_title" />
<input type="file" name="nice_attachment" />
<input type="submit" name="アップロード" />
</form>
このformが送信されると、サーバには下のようなフォーマットのデータが、何回かに分断されて送られて来ます。どこで分断されるかは分かりません(インフラとOS設定に依存します)。
------WebKitFormBoundaryLPrAi8q0p6KV85SB
Content-Disposition: form-data; name="nice_title"
これはタイトルです・・
------WebKitFormBoundaryLPrAi8q0p6KV85SB
Content-Disposition: form-data; name="nice_attachment"; filename="video.mp4"
Content-Type: video/mp4
(バイナリ)
(バイナリ)
(バイナリ)
(バイナリ)
------WebKitFormBoundaryLPrAi8q0p6KV85SB--
WebKitFormBoundaryLPrAi8q0p6KV85SB
はブラウザがランダムに作った「区切り文字(boundary)」です。HTTP header に予め宣言してあります。パーサーはこの区切り文字のおかげでHTTP body を正確に分割することができます。
上記のHTTP body には2つのフィールドが含まれています。一つは<input type="text" />
のタイトル文字列で、もう一方は<input type="file" />
のアップロードファイルデータです。実際には、テキストよりも動画ファイルのデータ量がずっと大きくなるでしょう。このファイルの部分が特に何回にも分かれてサーバに到着することが想像できるかと思います。
この大きなバイナリデータの終端を待たずに、届いたものからYoutubeへのリクエストに受け渡すのが今回の目的です。HTTP body パーサーには busboy を使います。busboyは、次々にやってくるmultipart/form-data のHTTP body 断片をパースし、早いタイミングで開発者が扱いやすいデータを提供してくれるnpmモジュールです。
HTTP body パーサーbusboy のインターフェース
busboyについては本家busboyのExamplesページが分かりやすいです。expressを利用する場合、このような使い方になります。
app.post('/upload', (req, res) => {
let busboy = new Busboy({ headers: req.headers });
busboy.on('file', (fieldname, fileReadableStream, filename, encoding, mimetype) => {
// ここでファイルフィールドの値をストリーム形式で利用します
});
busboy.on('field', (fieldname, val, fieldnameTruncated, valTruncated) => {
// ここでフィールドの値を利用します
});
busboy.on('finish', function() {
res.send('done.');
});
req.pipe(busboy);
})
Node.jsが提供してくれる引数req
は、ユーザーから送られて来るHTTP body の ReadableStream です。先ほど紹介したHTTP body の断片を次々と処理できるインターフェースです。これをbusboy にパイプします。busboy は区切り文字を見つけながら、multipart のHTTP body をパースしていきます。
busboy が提供する最初の2つのイベントに注目してください。ファイルが見つかればfile
イベントを、それ以外のフィールドのときにはfield
イベントを発火します。file
イベントの引数には fileReadableStream が渡ってくるので、開発者は適切にデータを消費(ディスクに保存する or Youtubeにアップロードする等)する必要があります。field
イベントには、フィールドの完全な値が渡ってきます。
このことから、busboyはファイルデータの始端を見つけた時にfile
イベントを、それ以外の時はフィールドの終端を見つけたときにfield
イベントを、HTTP bodyに並ぶ順番どおりに発火することが分かります。
Youtube に渡す
Youtubeへの投稿にはyoutube-api モジュールを使います。下のようなインターフェースなので、これに合わせて値を用意します。
Youtube.videos.insert({
resource: {
snippet: {
title: 'タイトル',
description: '説明文'
},
status: {
privacyStatus: 'private'
}
},
part: 'snippet,status',
media: {
body: readableStream
}
}, function(err, data) { ... })
この関数がreadableStreamを本当にリクエストストリームに使っているのか調べたところ、reqest というモジュールを使って確かにmultipartをストリーム送信していました。
今回ユーザーからはタイトルとファイルの2つを入力として許容しますから、タイトルの値とファイルストリームの2つが揃った直後にYoutube.videos.insert
を呼ぶのが近道ということになります。順番に依存せず「2つが手に入ったら次へ進む」という処理は、Promise.all
を使うと短く書くことが出来るでしょう。下の q はプロミスを便利にするモジュールです。
function parseBody(req) {
let detectAttachment = Q.defer();
let detectTitle = Q.defer();
let busboy = new Busboy({ headers: req.headers });
busboy.on('file', (fieldname, fileReadableStream) =>
detectAttachment.resolve(fileReadableStream));
busboy.on('field', (fieldname, val) =>
detectTitle.resolve(val));
req.pipe(busboy);
return Q.all([detectAttachment.promise, detectTitle.promise]);
}
タイトルを見つけるdetectTitle
と、ファイルストリームを手に入れるdetectAttachment
のどちらも上手く行く約束(プロミス)をするのがこのparseBody
関数です。また説明は省略しますが、いくら待っても何らかの理由で2つ揃わない場合のためにタイムアウト処理も入れておくべきでしょう。
さて、必要な2つのデータが手に入りました。先ほどのYoutube.videos.insert
は扱いやすいようにpromiseを返すuploadToYoutube
関数にしておきました。下のreq.flash()
は、UIにメッセージを出すためのものなので、あまり気にしないでください。
app.post('/upload', (req, res) => {
parseBody(req)
.then(([fileStream, title]) => {
return uploadToYoutube(req.session.auth_token, fileStream, title);
})
.then(([result]) => {
req.flash('message', 'uploading succeeded!');
res.redirect('/');
})
.catch(reason => {
req.flash('error-message', reason.stack ? reason.stack : reason);
res.set({Connection: 'close'});
res.redirect('/');
});
});
これで、アップロードが完了するとブラウザに「uploading succeeded!」のメッセージが表示されるはずです。以上でやりたいことは大体できたと思います。
考察
いくつか気付いた点を書いて行きます。
multipart/form-data
のフィールドの順番
今回はHTTP body の ReadableStream である req
と、それを消費するbusboyが更に fileStream を作るという2段構えでYoutube への受け渡しができました。ただ HTTP body はあくまで先頭から少しずつストリームに流れて来ます。この点について、busboyが生成するfileStreamには気をつけなければいけない点があります。
もしも次のような HTTP body をbusboy で解析するとどうなるでしょうか。
------WebKitFormBoundaryLPrAi8q0p6KV85SB
Content-Disposition: form-data; name="nice_attachment"; filename="video.mp4"
Content-Type: video/mp4
(バイナリ)
(バイナリ)
(バイナリ)
------WebKitFormBoundaryLPrAi8q0p6KV85SB
Content-Disposition: form-data; name="nice_title"
default title...
------WebKitFormBoundaryLPrAi8q0p6KV85SB--
ファイルのフィールドが最初に来ているのが分かるかと思います。busboyはこのbodyであっても問題なくパースできますが、今回のYoutube投稿を目的とした場合、問題が起こります。
Youtube投稿する関数は、タイトル文字列とreadableStreamの2つを求めていました。最初のHTTP bodyの例では、ファイルが後でした。タイトルを見つけた後、ファイルの先頭を見つけることができたので、そこから到着して来るバイナリの断片を効率よくYoutubeへの送信ストリームに乗せることができました。
しかし、ファイルが先の例では、初めにファイルにぶつかるので、ファイルストリームを消費しない限りタイトルを準備できません。本稿で紹介した「2つ揃ったら消費を開始する」というコードでは、消費が開始せず、その結果アップロード自体がストップしてしまいます。field
イベントの発火に至りません。つまり、multipartのフィールドの順番が肝心なのです。
また、この大事な順番を左右するのがUI側であることも注意しなければなりません。
各フィールドは、ドキュメントに現れる入力ウィジェットと同じ順番で、処理エージェントに送信される。
http://www.w3.org/TR/html4/interact/forms.html#h-17.13.4
W3C勧告には上のようにあり1、確かに<input />
の並び順のとおりにmultipartに並ぶことを手元のChromeで確認しました。サーバのロジックかHTMLの順序に依存することにより、メンテナンス性に影響が出ることが考えられます。例えば、タイトルだけでなく「説明」も入力できるようにする場合、UI側だけでなくサーバ側のパースロジックも忘れずに修正する必要が出てきます。
アップロードストリーム処理は、multipartとの密接な連携が不可欠です。
アップロードストリームの背圧制御
ここまで書いたとおり、Node.jsのリクエストストリームはとても制御しやすくできていますが、安全にも作られています。
HTTP body ストリームは、断片の利用(消費)に滞るとブラウザからのアップロードを待機させてくれます。残念ながらコードは追えていませんが、おそらくソケットからACK(断片を受け取りましたという返事)をクライアントに送らないなどして、TCPフロー制御により待機状態を作っているのでしょう。
これにより、万が一Youtubeサーバへの接続にもたついたり、送信が受信より極端に遅かった場合でも、ユーザーからのストリームが消費されない限り安全に待機状態になるため、サーバ側のメモリを圧迫することはありませんし、処理を失敗させることもありません。同時に、消費が再開されればすぐにYoutube への送信を続行できます。
逆に言えば、ストリームを消費しないとまずいことになります。先にあげた「multipartの順番問題」はひとつの例です。また別のケースとして、これも先ほど触れましたが、ストリームは安全なぶんいくらでも待つので、適切なタイムアウト処理も不可欠でしょう。単にユーザーにメッセージを出すだけでなく、Youtubeへの送信の途中ならキャンセル処理もしないとリソースの消費に繋がるかも知れません。
結び
multipartとストリーム処理の良い組み合わせは、安全で効率のよい中継処理を作ります。イベント駆動であるNode.js のパワーをいっそう引き出すものになるでしょう。