別にしんどくないブログ

技術のことや読書メモを書いています

fetch() upload streaming は WebSocket の代替になるのか。Fetch を使ってカメラから取得した映像をストリーミングで送信する

f:id:Shisama:20200728014445p:plain

Fetch Upload Streaming が Chrome 85 から Origin Trial で使えるようになりました。
何ができるかというと ReadableStream を fetch() の body に渡すことができるようになります。 getUserMedia でカメラから取得した映像をブラウザからストリーミング送信したいときに使えそうと考えたので、今回試してみました。

blog.chromium.org

TL;DR

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 については以下の記事が参考になります。

sbfl.net

fetch() upload streaming を使ったストリーミング送信

Fetch API はモダンブラウザで使うことはできますが、body に Steam データを渡すことはできませんでした。
しかし、Chrome 85 から body に ReadableStream のデータを渡すことができるようになります。まだ OriginTrial で、chrome://flags から enable-experimental-web-platform-features を有効にすることで使うことができます。

www.chromestatus.com

例えば、テキストボックスに入力した値をサーバーに送るだけだと、以下のように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 状態を保ちます。

f:id:Shisama:20200728012808p:plain

しかしテキストは入力されればサーバーには送信されます。 リクエスト接続し続けながらデータを送信することができるのです。 Stream が閉じられるとレスポンスが返ってきます。

fetch() を使ったテキストの Stream 処理については以下が参考になります。 また、fetch() を使った ReadableStream の送信についても詳しく書かれています。

web.dev

fetch で映像をストリーミング送信する

前置きが長かったですが、ここからが本題です。
これまで紹介してきた fetch() を使った Stream データの送信を応用して Web カメラから取得した映像をリアルタイムで送信する方法を紹介していきます。

リポジトリは以下です。public/script.js が今回の記事の内容になります。

github.com

glitch から動作確認ができます。カメラの取得は getUserMedia の挙動にしたがって確認ダイアログが表示されます。
Recording Startボタンを押下すると録画してサーバーに送信します。

glitch.com

getUserMedia を使った映像の取得

まず、ローカルのカメラデバイスにアクセスするためには getUserMedia() という API を使います。

MediaDevices.getUserMedia() - Web API | MDN

以下の記事が詳しいです。

app.codegrid.net

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();
};

Image from Gyazo

MediaRecorder を使った録画/録音の制御

MediaStream はそのまま fetch() に渡すことはできません。ReadableStream に変換する前に MediaRecorder を使って録画/録音データの処理制御について先に見てみましょう。これを使うことでユーザーに Stream の処理を操作させることが可能になります。 MediaRecorder はメディアを簡単に記録するための API です。

MediaRecorder - Web API | MDN

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();
});

Image from Gyazo

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 のツイートを見てサーバーサイドに関して追記しました。