Fetch Upload Streaming が Chrome 85 から Origin Trial で使えるようになりました。
何ができるかというと ReadableStream を fetch()
の body に渡すことができるようになります。
getUserMedia
でカメラから取得した映像をブラウザからストリーミング送信したいときに使えそうと考えたので、今回試してみました。
TL;DR
fetch()
で Stream のデータを送れるようになった- WebSocket を使わずに映像などのデータをストリーミング送信できる
- 以下のコードがこの記事の内容
ReadableStream とは
Stream なんて知ってるよという方はこの節は飛ばしてください。
Stream を使えばデータを少しずつ読み込んだり、書き込んだりすることができます。
大きなファイルを読み込むときにすべてを読み込んでから読み込んだデータを処理すると時間がかかります。こういうときに少しずつ読み込みながらデータを処理できると処理時間の短縮に繋がり、メモリにもやさしいです。また、途中で読み込みが途切れても途中まで読み込んだデータは処理することも可能です。
Node.js でも fs.createReadStream()
を使うと上記のようにファイルを Stream で処理することができます。
File system | Node.js v14.6.0 Documentation
ブラウザにもデータを Stream で読み込む API があります。ReadableStream を使えば、fetch したデータが大きな場合でも少しずつ処理することができます。
ReadableStream - Web APIs | MDN
ブラウザの Stream API については以下の記事が参考になります。
fetch() upload streaming を使ったストリーミング送信
Fetch API はモダンブラウザで使うことはできますが、body に Steam データを渡すことはできませんでした。
しかし、Chrome 85 から body に ReadableStream のデータを渡すことができるようになります。まだ OriginTrial で、chrome://flags から enable-experimental-web-platform-features
を有効にすることで使うことができます。
例えば、テキストボックスに入力した値をサーバーに送るだけだと、以下のようにReadableStream 内に処理を書きます。
start()
の中で行っている controller.enqueue(e.data);
が呼ばれると Stream 処理の対象のキューの中に入り随時処理されていきます。
pipeThrough
を使って TextEncoderStream
にパイプしている箇所はデータをエンコードして Uint8Array に変換しています。fetch()
に渡す Stream は Uint8Array である必要があるためです。
const stream = new ReadableStream({ start(controller) { textInput.addEventListener("input", (e) => { controller.enqueue(e.data); }); } }).pipeThrough(new TextEncoderStream());
あとは作った ReadableStream のオブジェクトを fetch()
の body に渡すだけです。
fetch('/send', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: stream, // ReadableStream });
これで fetch()
を使った Stream データの送信ができます。
Stream を送信して DevTools の NetWork タブを見るとリクエストは pending 状態を保ちます。
しかしテキストは入力されればサーバーには送信されます。 リクエスト接続し続けながらデータを送信することができるのです。 Stream が閉じられるとレスポンスが返ってきます。
fetch()
を使ったテキストの Stream 処理については以下が参考になります。
また、fetch()
を使った ReadableStream の送信についても詳しく書かれています。
fetch で映像をストリーミング送信する
前置きが長かったですが、ここからが本題です。
これまで紹介してきた fetch()
を使った Stream データの送信を応用して Web カメラから取得した映像をリアルタイムで送信する方法を紹介していきます。
リポジトリは以下です。public/script.js
が今回の記事の内容になります。
glitch から動作確認ができます。カメラの取得は getUserMedia
の挙動にしたがって確認ダイアログが表示されます。
Recording Start
ボタンを押下すると録画してサーバーに送信します。
getUserMedia を使った映像の取得
まず、ローカルのカメラデバイスにアクセスするためには getUserMedia()
という API を使います。
MediaDevices.getUserMedia() - Web API | MDN
以下の記事が詳しいです。
video タグに流すとカメラで撮影している映像がそのまま画面に出力されます。
getUserMedia()
からは stream
という MediaStream の値が返ってきています。しかし、この変数をそのまま fetch()
に設定することはできません。
const video = document.querySelector("video"); const stream = await navigator.mediaDevices.getUserMedia({ video: true, // 映像のON/OFFや大きさを設定できる audio: true, // 音声のON/OFFを設定できる }); video.srcObject = stream; video.onloadedmetadata = function(e) { video.play(); };
MediaRecorder を使った録画/録音の制御
MediaStream はそのまま fetch()
に渡すことはできません。ReadableStream に変換する前に MediaRecorder を使って録画/録音データの処理制御について先に見てみましょう。これを使うことでユーザーに Stream の処理を操作させることが可能になります。
MediaRecorder はメディアを簡単に記録するための API です。
MediaRecorder を初期化するときに getUserMedia()
で取得した MediaStream を渡します。また、option で mimeType の指摘もできます。
const options = { mimeType: "video/webm; codecs=vp9" }; const recorder = new MediaRecorder(stream, options);
以下のようにイベントごとに記録データを処理できます。
recorder.ondataavailable = (event) => { /* 記録データが処理可能になったとき。event.data に入った細切れのデータを使って処理を行う */ } recorder.onstop = () => { /* 記録終了時の処理 */ } recorder.onerror = () => { /* エラー時の処理 */ }
最後に録画/録音を開始します。以下はボタン押下時に記録開始/終了するコードです。start()
の中の数値は timeslice の値で、ミリ秒を設定できます。
設定した時間ごとに前述の ondataavailable
が呼ばれデータを細切れに処理することができます。
startButton.addEventListener("click", () => recorder.start(1000)); stopButton.addEventListener("click", () => recorder.stop());
MediaRecorder と ReadableStream を組み合わせてストリーミングデータを送信する
これで録画/録音する処理までできました。あとは ReadableStream にしてデータを送信します。
const readableStream = new ReadableStream({ start(controller) { recorder.ondataavailable = (event) => controller.enqueue(event.data.arrayBuffer()); recorder.onstop = () => controller.close(); recorder.onerror = () => controller.error(new Error("The MediaRecorder errored!")); } }).pipeThrough(transformStream);
録画を開始して fetch()
で ReadableStream を送信します。
startButton.addEventListener("click", () => { recorder.start(Number(timesliceInput.value)); fetch(`/send?filename=${filenameInput.value}`, { method: 'POST', body: readableStream, allowHTTP1ForStreamingUpload: true, }).catch(e => console.error(e)); }); stopButton.addEventListener("click", () => { if (recorder.state === "recording") recorder.stop(); });
fetch() upload streaming の利用可否の判定
Origin Trial で Chrome にしか実装されていない機能です。今後、他のブラウザで使えるかはまだわかりません。なので、利用可否の判定が必要です。
以下のように Request の body に ReadableStream を設定し、Content-Type
がヘッダーにあるかどうかで判定が可能です。
const supportsRequestStreams = !new Request('', { body: new ReadableStream(), method: 'POST', }).headers.has('Content-Type'); if (!supportsRequestStreams) { // 利用できない場合の処理 }
fetch() upload streaming は HTTP/2 でしか送れない問題
fetch()
を使った Stream の送信はデフォルトでは HTTP/2 でしか送ることができません。
しかし、Chrome では HTTP/1.1 でも使えるようにオプションを用意しています。以下のように allowHTTP1ForStreamingUpload
を true にすることで HTTP/1.1 でも送信することができます。ただしこのオプションは Chrome 独自の機能です。なので、なるべく避けるようにしましょう。
fetch(`/send?key=${key}`, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: stream, allowHTTP1ForStreamingUpload: true, });
WebSocket との比べた利点
最後に WebSocket と比べた個人的な見解について述べて終わりにしたいと思います。 これまで紹介したストリーミングの送信については WebSocket でもできます。 socket.io を使えば難しくはないです。しかし、ws プロトコルが Proxy で弾かれたり、そもそも WebSocket 用のサーバーを立てたりする必要があったり面倒なことが多いです。既存のウェブアプリケーションにストリーミング機能を追加する場合、既存の HTTP サーバー上で動かせると楽です。
以下の Intents にも WebSocket の代替としての用途を期待している旨の記述がされています。 https://groups.google.com/a/chromium.org/forum/#!msg/blink-dev/hqzIf3gdahs/SzVoOzenBQAJ
ブラウザ間のデータのやりとり
WebSocket の用途の一つでもあるリアルタイムなデータのやりとりについても fetch()
だけで可能になります。
以下サンプルのリポジトリです。
GitHub - shisama/sample-streaming-requests-with-fetch-api
サンプルコードは glitch で使えるようにしています。永続性が無いのでデータの保存はできませんが、タブを2つ開いて文字を入力してみると双方の画面に入力した文字が追記されているはずです。 Sample of streaming requests with fetch API
以下のように送信する fetch と受信する fetch を両方リクエストすることで実現できます。
fetch(`/send?key=${key}`, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: stream, }); fetch(`/receive?key=${key}`).then(async res => { const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); while (true) { const { done, value } = await reader.read(); if (done) return; output.append(value); } });
工夫をすれば様々な用途で利用できそうです。
サーバーサイドの実装の容易さ
HTTP で動く Web アプリケーションサーバーに加えて WebSocket 用のサーバーを用意する必要がなくなるだけでもメリットはあると考えています。
また、コードも慣れ親しんだ Web アプリケーションのコードで実装できます。
以下は Node.js と Express を使った fetch()
の Stream リクエストを受け取ってファイルに書き出すコードです。
もちろん Express を使わずとも簡単に書くことができます。
app.post('/send', (req, res) => { res.status(200); const writeStream = fs.createWriteStream(file_path); req.on('data', (chunk) => { writeStream.write(chunk); }); req.on('end', (chunk) => { if (res.writableEnded) return; res.send('Ended'); }); });
最後に
今回は fetch()
で Stream のデータを送信する新しい機能について紹介しました。また、WebSocket で従来実現可能だったメディアのストリーミング送信やリアルタイムでのデータのやりとりについて実装方法を紹介しました。
まだまだ他の用途にも使えそうです。既存のHTTP サーバーに加えて WebSocket サーバーを建てようと検討している方は覚えておくと良いかもしれません。
WebSocket に対するメリットの認識や、今回の映像をストリーミング送信する方法についてもっと良い方法があれば Twitter - @shisama_にメンションやDM、ブコメなどから教えていただけると幸いです。
最後までお読みいただきありがとうございました。
編集履歴
(2020-07-30) @hasegawayosuke のツイートを見てサーバーサイドに関して追記しました。
サーバー側は楽になりそうですねー。プロキシ通過もそうですし、バックエンド側で後付けでwsをサポートしようとするとセッションと紐づけるのとか結構苦労するので、そのあたりが透過的にできるのは、フレームワーク使わず自作する場合でも楽そう。#サイボウズフロントエンドマンスリー
— Yosuke HASEGAWA (@hasegawayosuke) July 28, 2020