LoginSignup
882
659

More than 3 years have passed since last update.

WebSocketの次の技術!?WebTransportについての解説とチュートリアル

Last updated at Posted at 2020-12-02

概要

こんにちは。NTTコミュニケーションズのyuki uchidaです。普段はSkyWayというWebRTCプラットフォームの開発やWebRTCリサーチャー(見習い)をしています。

この記事は NTTコミュニケーションズ Advent Calendar 2020 の3日目の記事です。
昨日はMasaki Shimuraさんの記事、 「Threat Intelligenceの活用を促進するMISPの紹介」でした。

この記事は、WebSocketの次の技術ではないかと噂される、WebTransportの概要や双方向通信の歴史をまとめつつ、WebTransportのdatagram形式でデータを送信してみるチュートリアル記事です。

対象読者

  • WebTransportっていう技術を初めて聞いた人
  • WebSocketを使ったことがあり、不満がある人
  • 双方向通信・リアルタイム通信について興味がある人

WebTransportとは?

Web界隈で長く使われてきたWebSocketの欠点を解消し、適用できるユースケースを増やした夢の双方向通信フレームワークです。現在は仕様策定中で、Googleによって広くフィードバックが募集されています。
過去のいろいろなプロトコルの反省を踏まえたプロトコルになっており、WebSocketを使用していたサービスだけではなく、通話サービス・配信サービス・クラウドゲーミングなど、多方面から期待されている技術です。

双方向通信の歴史

WebSocketの弱点とWebTransportの素晴らしさに言及する前に、双方向通信の歴史を振り返ってみましょう。

おおよそ歴史の流れとしてはポーリング方式→セッション指向方式へと移行してきており、おおよそ以下のような流れになります。それぞれの技術が出てきた背景や欠点を見ながら「どうしてWebTransportが出てきたのか?」を考えていきましょう。

  • ポーリング(ajaxなど)
  • ロングポーリング(Cometなど)
  • Server-Sent Event(SSE)
  • WebSocket
  • (WebRTC)
  • WebTransport

ポーリング(&Ajax)

サーバーとの接続を保つのに最も簡単な方法です。

ポーリングは、定期的にサーバーにリクエストを送り、定期的に画面を更新することで、サーバー側で更新があった際にクライアントの画面にも早く反映することができます。

().jpg

しかし、ポーリングは画面を定期的に更新するため、画面のデータ全てを再度取得します。「差分だけ取得するようにすれば効率的なのでは!?」ということで生まれたのがAjaxです。

Ajaxは、ポーリングと同様に定期的にサーバーにリクエストを投げる部分は同じですが、更新したい部分のデータだけをJSONやXMLで受け取ることで取得するデータの量を削減することができます。(JavaScriptで受け取ったデータをHTMLに反映させます)

(Ajax).jpg

Ajax含め、ポーリングは非常に単純で実装も簡単ですが、定期的にサーバーにリクエストを投げる仕組みであるため、最新情報がなくてもリクエストしてしまいます。

また、ポーリングの間隔もアプリケーションによって様々で、その間隔が長いと画面が更新するまでに時間がかかってしまいますし、その間隔が短いとサーバー側に負荷がかかってしまうという問題もあります。

ロングポーリング(Cometなど)

ロングポーリングは、ポーリングの「更新がないのにリクエストが飛んでしまう」という問題を解決したものになります。

ロングポーリングでは、リクエストを送った後、サーバー側は更新があるまでレスポンスを返さず、更新が出た時に初めてレスポンスを返します(接続は張られたまま保持されます)。
その後、データを受け取ったクライアントは再度ロングポーリングのためのリクエストを通信を張り直します。

(Comet)_(1).jpg

これによって、「更新がないのにリクエストが飛んでしまう」という問題が解決されます。

一方で、ロングポーリングは、サーバー側で更新があった時にレスポンスを受け取り、再度ロングポーリングのためのリクエストを飛ばして通信を張り直すため、「サーバー側で短期間に更新が複数回あると通信を張り直すまで次の更新を受け取れない」「再度通信を張るコストがかかる」という問題があります。

Server Sent Events(SSE)

ServerSentEvents(以下SSE)は、ロングポーリングであった「サーバー側で短期間に更新が複数回あると通信を張り直すまで次の更新を受け取れない」「再度通信を張るコストがかかる」という問題を解決したものです。

SSEでは、ロングポーリングと同様に、サーバー側にリクエストを送り、通信を保持しておきます。ロングポーリングと違うのは、SSEは「レスポンスがあっても通信を張り直すことはせず、その後も通信を使い続ける」ことです。

サーバーは、更新があった際にはchunk(分割データ)としてレスポンスを返し、接続を保持し続けます。(通常のHTTP通信では、レスポンスを返すと通信が閉じられますが、SSEではレスポンスをchunkとして返すことによって通信を閉じないようにしています)

(Server_Sent_Events).jpg

このくらいまでくると、かなり効率的な通信方法にも見えますが、SSEにも問題がありました。

  • 「サーバー→クライアント方向には使えるが、クライアント→サーバー方向には使えない」
  • 「HTTP通信を張り続けるため、CPUの使用量が多い」

といった問題です。

WebSocket

WebSocketは、SSEが抱えていた問題である「サーバー→クライアント方向には使えるが、クライアント→サーバー方向には使えない」「HTTP通信を張り続けるため、CPUの使用量が多い」といった問題を解決するものです。

WebSocketは、SSEとは違い、HTTPプロトコルではなく、WebSocketプロトコルというWebSocketのために考案された通信方式を使用しています。SSEは既存の技術を組み合わせて作ったのに対して、WebSocketは既存の技術の制約に捉われることなく最適な方式を使用しているためSSEに比べて高いパフォーマンスを出すことができました。

また、クライアント→サーバー、サーバー→クライアントの両方に使うことができるようになったため、

リアルタイムかつ双方向通信がWebSocketによって実現されました。

(WebSocket).jpg

ここまでくるとかなり洗練されてきているように感じますね。WebSocketに頼っているプロジェクトは非常に多い、今も最前線で使われている技術ですね。

WebRTC

これはWebSocketの次の技術というわけではなく、別のユースケースを満たすために考案されたプロトコルです。(WebSocketは2011年に仕様が策定され、WebRTCは最初に出たのが2011年で仕様が固まってきたのが2014年です)

WebSocketがサーバー↔クライアントの双方向通信を叶えるサバクラ型のプロトコルであるのに対して、WebRTCはクライアント↔クライアントの双方向通信を叶えるP2P型のプロトコルです。

P2Pで通信するため、通信経路のやり取りでサーバーを介して行われることが多いですが、通信経路が決まり、WebRTCコネクションが確立した後はサーバーを介さず通信が行えます

(WebRTC).jpg

サーバーを介さず通信を確立することができるため、サーバーを経由する時間を削減できたりサーバーにかかる負荷を削減できたりするのが特徴です。

また、特定のアプリケーションやプラグインを導入する必要がなく、最新のブラウザさえあれば良いというのもWebRTCの特徴です。

特にメディアをリアルタイムで送受信したい場合に使われるプロトコルで、ZoomやGoogle Meet、DiscordなどのWeb会議にもこのWebRTCが使われています。

こちらもWebSocketと同じく2020年現在も最前線で使われている技術です。

WebSocket と WebRTC

このWebRTCはWebSocketとは違い、TCPではなくUDPを採用しています。TCPは「相手に確実にデータを届けたい」という方針で策定したプロトコルですが、UDPは「確実じゃなくてもいいから高速にデータを届けたい」という方針で策定したプロトコルであるため、サバクラモデルとP2Pモデルという違いもありますが、採用しているプロトコルからも、WebRTCとWebSocketではユースケースが大きく違います。

両方とも双方向通信を叶える技術ですが、TCPでサバクラ型なWebSocketと、UDPでP2P型なWebRTCと違いがあります。

WebTransport

本記事のメインです。

WebSocketとWebRTCは非常に普及した技術ですが、どちらの技術にも欠点はあります。

  • WebSocketの欠点
    • TCPを利用しているためUDPのようなユースケースでは利用できない(映像配信・クラウドゲーミングなど)
    • Head of Line Blockingなどのパフォーマンス問題に引っ張られてしまう
      • Head of Line Blockingとは「先発のパケットがロストしてしまうと、再送制御が入り、後のパケットが詰まってしまうこと」ことです。このような問題があるため、常に低遅延を維持したいアプリケーションには不向きです
  • WebRTCの欠点
    • WebRTCで利用されるプロトコルが非常に複雑で柔軟性がない

WebTransportでは、QUICというTCPとUDPのいいとこ取りをしたプロトコルを用いて、WebSocketやWebRTCの欠点を解消しています。

  • QUICとは
    • 高速にデータをやりとりするためのプロトコルです。UDP上にTCPのような再送制御や輻輳制御を実装することによって、TCPとUDPのいいとこどりをしています

Untitled.png

引用 https://datatracker.ietf.org/meeting/98/materials/slides-98-edu-sessf-quic-tutorial/

WebTransportのコア技術であるQUICについては、本記事の末尾のQ&Aコーナーにて軽く説明しています。

WebSocket WebRTC WebTransport
Reliable(TCPのような) Reliable&UnReliable(TCP/UDPのような) Reliable&UnReliable(TCP/UDPのような)
Client ↔ Serverモデル Peer ↔ Peer モデル Client ↔ Server モデル
柔軟性が高い(低レベルAPI) 柔軟性が低い(高レベルAPI) 柔軟性が高い(低レベルAPI)
広く普及 広く普及 開発中(現在はChromeのみ)

まとめると、WebTransportは、「WebSocketのような柔軟性を持ちつつWebRTCのようにTCP/UDPの両方のユースケースに対応できるようなClient ↔ ServerモデルなAPI」になります。

これにより、WebSocketのパフォーマンス問題に対応しつつ、WebSocketでは対応しきれなかったユースケースに対応することができます。

クラウドゲーミングや映像配信では、HLSやWebRTCを利用して対応していましたが、今後はWebTransportで対応できるようになるかもしれません。たくさんの技術を学ぶのは大変なのでWebTransportだけ学べば十分になったら開発者としても楽でいいですね。

以下のスライドはW3CのWorkshopで発表された内容です。クラウドゲーミングや映像配信というユースケースでWebTransportがなぜ良いのかを確認することができます。

https://www.w3.org/2018/12/games-workshop/slides/21-webtransport-webcodecs.pdf

余談ですが、現在TwitchではWebTransportを使った配信システムのプロトタイプが開発中のようです。まだChromeでしか使えない技術ですが、かなり期待されていることが分かりますね。GoogleもWebRTCを用いたクラウドゲーミングシステム(GoogleStadia)を開発していますが、そのGoogleがWebTransportに乗り気であることから、クラウドゲーミングへの応用もかなり期待されています。

カーソル位置をdatagramで送信してお絵かきアプリを作ってみる

長い双方向通信のまとめがおわったので、実際にWebTransportを使用してみます。

WebTransportには、streamとdatagramという二つの送信方式があります。

  • stream: TCPのように到達保証・順番保証がされる
  • datagram: UDPのように高速だが到達保証・順番保証がない

今回は、WebSocketではできなかった、UDP likeな送信方式であるdatagramを利用して、お絵かきアプリを作ってみます。

以下のGIFのようなアプリケーションが完成物です。

test.gif

完成物のコードはこちらのリポジトリに置いてあります

仕組みとしては単純で、WebTransportサーバーにdatagramを送り、WebTransportサーバーは接続している他のクライアントにそのdatagramを配信します。

mouse_point_share.jpg

QuicTransportとは、WebTransportの仕様の中から、重要なものだけを取り出して実装したLiteな実装です。このQuicTransportというLiteな実装でフィードバックを集め、WebTransportという仕様に反映していくためのものです。

今回の記事ではこのQuicTransportを使っていきます。

現在、QuicTransportはフィードバックをあつめるためのOrigin Trial期間中であるため、QuicTransportをGoogle Chromeで使用するための手順がいくつかあります。

1. ChromeM85以降をダウンロードする

QuicTransportはEdgeな技術であるため、最近のGoogle Chromeでなければ動きません。

左上のメニューバーから「Google Chromeについて」を選択し、現在のバージョンを確認してください。バージョン85以降になっていれば問題ありません。それ以前のものになっている場合はupdateしましょう

Untitled 1.png

2. Origin Trialに登録し、Tokenを取得する

以下のリンクのActive Trialsのタブから、QuicTransportを探し、REGISTERボタンを押しましょう

https://developers.chrome.com/origintrials/#/trials/active

_2020-12-02_11_45_30.png

以下のように、WebOriginの項目に http://localhost:8000 を入力、Expected Usageの項目に 0 to 10,000 を入力しましょう。(どちらも任意のものなので参考までに。)

その後、利用規約に同意してREGISTERボタンを押せば登録完了です。

Untitled 2.png

登録できたら、以下のようにTokenが取得できます。このTokenは後ほどGoogle Chromeの起動時に引数として与えてあげるために必要です。

3. QuicTransportサーバー用の秘密鍵・公開鍵を作成する

以下のコマンドをコピペして certificate.keycertificate.pem を用意しましょう

openssl req -newkey rsa:2048 -nodes -keyout certificate.key -x509 -out certificate.pem -subj '/CN=Test Certificate' -addext "subjectAltName = DNS:localhost"

では次に、以下のコマンドで署名を行います。(出力される文字列をメモしておきましょう)

openssl x509 -pubkey -noout -in certificate.pem | openssl rsa -pubin -outform der | openssl dgst -sha256 -binary | base64

4. Google Chromeを引数付きで起動する

Google Chromeに、「QuicTransportを利用できるようにするフラグ」と「その際に出るエラーを無視するためのフラグ」をつけて起動します。その際、メモした文字列を使用してください

open -a "Google Chrome" --args --origin-to-force-quic-on=localhost:4433 --ignore-certificate-errors-spki-list="<3.QuicTransportサーバー用の秘密鍵・公開鍵を作成するでメモした文字列>"

起動すると、Google Chromeのメニューバーの下に以下のような注意書きが出るのを確認しましょう。もしこの注意書きが出なかった場合には一度Google Chromeを完全に終了してから再度上記のコマンドを入力して注意書きが出るか確認してください。

_2020-12-02_12_14_47.png

5. HTML/JavaScriptを書く

  1. Origin Trialに登録し、Tokenを取得する

で取得したQuicTransportのTokenを以下のHTMLの

<meta http-equiv="origin-trial" content="<ORIGIN TRIALのTOKEN>" /> の部分を置き換えてください。これによってQuicTransportがそのページで利用可能になります。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta
      http-equiv="origin-trial"
      content="<ORIGIN TRIALのTOKEN>"
    />
    <style>
      .box {
        display: flex;
        flex-direction: row;
      }
      .viewer {
        width: 720px;
        height: 620px;
        text-align: center;
        border: 1px solid gray;
        margin: 3px;
      }
    </style>
  </head>
  <body>
    <main>
      <hr />
      <div>
        WebCodecs supported:
        <span id="web-codecs-supported">detecting...</span>
      </div>
      <input
        type="button"
        id="prepareConnectionButton"
        value="prepareConnection"
        onclick="prepareConnection()"
      />
      <textarea
        id="QuicTransportID"
        type="text"
        name="QuicTransportID"
        value="?????"
        cols="50"
        readonly
      ></textarea>
      <div>
        <button id="getUserMediaButton">getUserMedia</button>
      </div>
      <div>
        <button id="sendVideoWithDatagramButton">sendVideoWithDatagram</button>
        <button id="sendVideoWithBidirectionalStreamButton">
          sendVideoWithBidirectionalStream
        </button>
        <button id="sendKeyFrameRequestButton">sendKeyFrameRequest</button>
      </div>
      <div class="box">
        <div class="encoder viewer">
          <h2>encoder</h2>
          <video autoplay></video>
          <div>
            timestamp: <span class="timestamp"></span><br />
            type: <span class="type"></span><br />
            byteLength: <span class="byte-length"></span>
          </div>
        </div>
        <div class="decoder viewer">
          <h2>decoder</h2>
          <canvas></canvas>
        </div>
      </div>
    </main>
  </body>
  <script>
    const checkSupported = () => {
      return !!window.VideoEncoder;
    };
    const supported = checkSupported();
    if (!supported) {
      alert(
        [
          'Your browser does not support WebCodecs.',
          'use Chrome with M87 and enable `#enable-experimental-web-platform-features`',
          'for experiencing this experimental web app.',
        ].join(' ')
      );
    }
    document.querySelector('#web-codecs-supported').innerHTML = supported ? 'yes' : 'no';
  </script>
  <script src="../cbor.js"></script>
  <script src="./script.js"></script>
</html>

次に、JavaScriptのコードを書いていきます。(index.jsを作成しておいてください)

QuicTransportServerと接続する関数

まずは、QuicServerと接続を確立する部分です。

接続したいQuicTransportServerのURLを取得して、 var transport = new WebTransport(url); するだけです!簡単ですね。

URLは、 quic-transport://localhost:4433/mouse_point_share のような形になります。先頭が quic-transport というプロトコルの文字列になってるのに注意してください。

async function prepareConnection() {
  // QuicTransportServerと接続するためのURLです。
  // HTML側で`quic-transport://localhost:4433/mouse_point_share` をデフォルトにしています。
  let url = document.getElementById("url").value;
  var transport = new WebTransport(url);
  console.log(`initializing WebTransport Instance`);
  // QuicTransportServerとのコネクションが切れた時の処理を記述します
  transport.closed
    .then(() => {
      console.log(`The QUIC connection to ${url} closed gracefully`);
    })
    .catch((error) => {
      console.error(`the QUIC connection to ${url} closed due to ${error}`);
    });
  // QuicTransportServerとのコネクションを開始!!!
  await transport.ready;
  console.log("startReceivingDatagram");
  startReceivingDatagram(transport); // datagramを受け取る準備です。後ほど定義します
  console.log("startReceivingStream");
  startReceivingStream(transport); // streamを受け取るための準備です。後ほど定義します
  globalThis.currentTransport = transport;
  globalThis.streamNumber = 1;
}

datagramを受け取って処理する関数

次に、datagramを受け取って処理する関数を書いていきます。datagramを受け取って、カーソルの軌跡を描画するお絵かきアプリにしたいので、描画する部分も含めて記述しちゃいましょう。

datagramを受け取るためには、 prepareConnection() 関数で用意した transport オブジェクトを利用します。以下のような形でdatagramを読み込んでくれる reader を生成します。

そして、繰り返し文を用いて読み込みをすればdatagramを読み込むことが可能です。

async function startReceivingDatagram(transport) {
  const reader = transport.datagramReadable.getReader();
    while (true) {
        // valueが受け取ったdatagram。
        const { value, done } = await reader.read();
        // やりたい処理
        hogehoge()

        // doneがtrueの場合、送信が終了したということなのでbreakして読み込みを終了する
        if (done) {
      break;
    } 
    }
}

描画するための諸々の変数や処理などを含めたコードが以下になります。

HTMLのcanvasに、前回のカーソル位置から受け取ったカーソル位置まで線を引くような処理になっています。

let prev_mouse_point_x = null;
let prev_mouse_point_y = null;

async function startReceivingDatagram(transport) {
  const reader = transport.datagramReadable.getReader();
  while (true) {
    const { value, done } = await reader.read();
    let result = new TextDecoder("ascii").decode(value);
    // resultの中身は文字列で"mouse_point=x,y"といった形になる予定
    if (result.startsWith("mouse_point=")) {
      const index = result.indexOf("=");
      const [mouse_point_x, mouse_point_y] = result.slice(index + 1).split(",");
      const myPics = document.getElementById("myPics");
      const context = myPics.getContext("2d");
      if (prev_mouse_point_x && prev_mouse_point_y) {
        drawLine(
          context,
          prev_mouse_point_x,
          prev_mouse_point_y,
          mouse_point_x,
          mouse_point_y
        );
      }
      prev_mouse_point_x = mouse_point_x;
      prev_mouse_point_y = mouse_point_y;
    }
    if (done) {
      break;
    }
  }
}

Untitled 3.png

streamを受けて処理する関数

では次にstreamを受け取る処理を書いていきます。

QuicTransportServerに接続している自分のコネクションIDと他のコネクションIDを知るために用意しています。

以下のGIFのように、コネクションが成立すると、左側に自分のコネクションID、右側に他に接続しているコネクションIDを表示するようにしています。

test1.gif

async function startReceivingStream(transport) {
  let reader = transport.incomingUnidirectionalStreams.getReader();
  while (true) {
    let result = await reader.read();
    if (result.done) {
      console.log("Done accepting unidirectional streams!");
      return;
    }
    let stream = result.value;
    let number = globalThis.streamNumber++;
    readDataFromStream(stream, number);
  }
}

async function readDataFromStream(stream, number) {
  let decoder = new TextDecoderStream("utf-8");
  let reader = stream.readable.pipeThrough(decoder).getReader();
  while (true) {
    let result = await reader.read();
    if (result.done) {
      console.log("Stream #" + number + " closed");
      return;
    }

    // 受け取った中身を解釈する
    // 今回のアプリケーションでは、quic_transport_id=で始まっている場合、自分のquic_transport_idとするように設計しています
    if (result.value.startsWith("quic_transport_id=")) {
      const index = result.value.indexOf("=");
      document.getElementById("QuicTransportID").value = result.value.slice(
        index + 1
      ); 
    // 今回のアプリケーションでは、joined=で始まっている場合、他のquic_transport_idとするように設計しています
    } else if (result.value.startsWith("joined")) {
      const index = result.value.indexOf("=");
      document.getElementById("OtherQuicTransportID").value +=
        result.value.slice(index + 1) + "\n";
    }
  }
}

datagramで自分のカーソル位置を送信する関数

canvas上でクリックを押しながらカーソルを動かすとdatagramで相手にカーソル位置を送るような処理を書いていきます。

送信処理に関しては単純で、Webtransportオブジェクトからwriterを取得して、 writer.write() で送信するだけです。

// globalThis.currentTransportはQuicConnectionを張っているWebtransportオブジェクトのことです。
// 基本的にquictransportでは、このWebtransportオブジェクトから送信するwriterや受信するreaderを取得します
const ws = globalThis.currentTransport.datagramWritable; // datagramを送信するwriterを取得
const writer = ws.getWriter();
globalThis.writer = writer;
const text = 'hogehoge';
const encoded_text =  new TextEncoder().encode(text);
writer.write(encoded_text); // encodeした文字列をdatagramで送信します

自身のカーソル位置を取得したりする処理も含めたコードが以下のものです。

async function sendMousePointDatagram() {
  let QuicTransportID = document.getElementById("QuicTransportID").value;
  QuicTransportID = new TextEncoder().encode(QuicTransportID);
  const transport = globalThis.currentTransport;
  mouse_point_share();
}
let isDrawing = false;
function drawLine(context, x1, y1, x2, y2) {
  context.beginPath();
  context.strokeStyle = "black";
  context.lineWidth = 1;
  context.moveTo(x1, y1);
  context.lineTo(x2, y2);
  context.stroke();
  context.closePath();
}

var timeoutId;
function mouse_point_share() {
  const myPics = document.getElementById("myPics");
  const context = myPics.getContext("2d");
  myPics.addEventListener("mousedown", (e) => {
    x = e.offsetX;
    y = e.offsetY;
    isDrawing = true;
  });

  myPics.addEventListener("mousemove", (e) => {
    if (timeoutId) return;
    timeoutId = setTimeout(function () {
      timeoutId = 0;

      if (isDrawing === true) {
        drawLine(context, x, y, e.offsetX, e.offsetY);
        x = e.offsetX;
        y = e.offsetY;
        const text = `mouse_point=${e.offsetX},${e.offsetY}`;
        const encoded_text = new TextEncoder().encode(text);

        if (globalThis.writer) {
          writer.write(encoded_text);
        } else {
          const ws = globalThis.currentTransport.datagramWritable;
          const writer = ws.getWriter();
          globalThis.writer = writer;
          writer.write(encoded_text);
        }
      }
    }, 10);
  });

  window.addEventListener("mouseup", (e) => {
    if (isDrawing === true) {
      drawLine(context, x, y, e.offsetX, e.offsetY);
      x = 0;
      y = 0;
      isDrawing = false;
    }
  });
}

これで、HTML/JavaScriptの準備は完了です。

6. QuicTransportServerの処理を書く

datagramやstreamを受け取り対向に送信するようなQuicTransportServerを実装していきます。

今回は googleがgithubにあげているサンプルを少し改修していきます。

https://github.com/GoogleChrome/samples/blob/gh-pages/quictransport/quic_transport_server.py

今回のアプリケーションで、QuicTransportServerがやる処理を以下に挙げます。

  • QuicTransportServerと接続した際に接続したconnectionのIDをstreamで伝える
  • QuicTransportServerと接続している他のconnectionのIDをstreamで伝える
  • マウスのカーソル位置をdatagramを受け取ると送信者以外の全てのconnectionにそのデータをdatagramで伝える

QuicTransportServerと接続した際に、sendDataHandlerというハンドラをアタッチする

ブラウザ側で new WebTransport(url) を行うと、QuicTransportServerでは、そのクライアント情報をチェックし、接続してきたURLのpathによって処理をルーティングします。

今回は、 quic-transport://localhost:4433/mouse_point_share のように、mouse_point_shareというpathに接続するようになっています。そのため以下のコードでは、pathがmouse_point_shareのときはsendDataHandlerというハンドラをアタッチしています。

def process_client_indication(self) -> None:
    """
    ProtocolNegotiated/HandshakeCompletedのeventが送られてきた後に送られるClient_indicationを処理する。
    stream_idが2(クライアント開始・単方向)で、handlerがまだ渡されていない(開始していないstreamである)場合にこの関数が呼ばれる。
    (その時はend_stream=Trueで、self.is_closing_or_closed()がFalseになっているはず)
    new QuicTransport(url);の段階で、clientの情報が送られてくる。その情報はまず`quic_event_received()` で処理される
    """
    KEY_ORIGIN = 0
    KEY_PATH = 1
    indication = dict(
        self.parse_client_indication(io.BytesIO(self.client_indication_data))
    )

    origin = urllib.parse.urlparse(indication[KEY_ORIGIN].decode())
    path = urllib.parse.urlparse(indication[KEY_PATH]).decode()
    # Verify that the origin host is allowed to talk to this server.  This
    # is similar to the CORS (Cross-Origin Resource Sharing) mechanism in
    # HTTP.  See <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS>.
    if origin.hostname != "localhost":
        raise Exception("Wrong origin specified")
    # Dispatch the incoming connection based on the path specified in the
    # URL.
    if path.path == "/mouse_point_share":
        self.handler = sendDataHandler(self, self._quic)
        print("handler attached!!!!!!!")
    else:
        raise Exception("Unknown path")

def is_closing_or_closed(self) -> bool:
    return self._quic._close_pending or self._quic._state in END_STATES

sendDataHandlerは以下のような設計になっています。

  • 初期化の際に、addConnections関数を呼び出しており、「自身のconnectionIDをstreamで伝える」「他に接続しているconnectionのIDをstreamで伝える」という処理を行う
  • connectionが切断された時にremoveConnections関数を呼び出しており、諸々の抜ける処理をおこなう
  • datagramを受け取ると、自分以外のconnectionに対して send_datagram_frame(payload) で送信する

受け取ったdatagramを送信する処理は、

connection_dict["connection"].send_datagram_frame(payload)

というような形でconnectionを選び、 send_datagram_frame() 関数によって送信するだけです。こちらもそこまで難しくはないですね。(connectionの管理をサーバー側できちんとやる必要はありますが、それはWebSocketなども同様です)

class sendDataHandler:
    def __init__(self, protocol, connection) -> None:
        self.protocol = protocol
        self.connection = connection
        self.counters = defaultdict(str)
        # 接続した際にはaddConnections関数を呼び出し、自身のconnectionIDの取得と、その他に接続しているconnectionのIDの取得を行います
        self.quic_transport_id = addConnections(self.connection, self.protocol)

    def removeFromConnections(self) -> None:
        removeConnections(self.connection, self.proto)

    def quic_event_received(self, event: QuicEvent) -> None:
        if isinstance(event, DatagramFrameReceived):
            payload = event.data
            # self.connection.send_datagram_frame(payload)
            for quic_transport_id, connection_dict in connections_list.items():
                if self.quic_transport_id == quic_transport_id:
                    continue
                connection_dict["connection"].send_datagram_frame(payload)
                connection_dict["protocol"].transmit()

接続した時の addConnections() や切断した時の removeConnections() は以下のようにしてあります。たくさん接続してきてもきちんと管理できるように、 connections_list という辞書を用意しておき、それぞれのconnectionのuuidでユニークなIDを付与しています。

connections_list = {}

def addConnections(new_connection, new_protocol) -> None:
    print(f"quic_transport_server addConnections{connections_list}")
    new_quic_transport_id = str(uuid.uuid4())
    # 入室情報を他の人に教えてあげる(streamで)
    for quic_transport_id, connection_dict in connections_list.items():
        response_id = connection_dict["connection"].get_next_available_stream_id(
            is_unidirectional=True
        )
        payload = str(f"joined={new_quic_transport_id}").encode("ascii")
        connection_dict["connection"].send_stream_data(response_id, payload, True)
        # connection_dict["protocol"].transmit()

    connections_list[new_quic_transport_id] = {
        "connection": new_connection,
        "protocol": new_protocol,
    }
    # 自分のQuicTransportIDを教えてあげる(streamで)
    response_id = new_connection.get_next_available_stream_id(is_unidirectional=True)
    payload = str(f"quic_transport_id={new_quic_transport_id}").encode("ascii")
    new_connection.send_stream_data(response_id, payload, True)
    new_protocol.transmit()
    for quic_transport_id, connection_dict in connections_list.items():
        # if new_connection == connection_dict["connection"]:
        #     continue
        if new_quic_transport_id == quic_transport_id:
            continue
        response_id = new_connection.get_next_available_stream_id(
            is_unidirectional=True
        )
        payload = str(f"joined={quic_transport_id}").encode("ascii")
        new_connection.send_stream_data(response_id, payload, True)
        new_protocol.transmit()
    return new_quic_transport_id

def removeConnections(connection, protocol) -> None:
    connections_list.remove({"connection": connection, "protocol": protocol})
    print(
        f"quic_transport_server removeConnections removed :{connection},{protocol}, remain: {connections_list} "
    )

これでQuicTransportServerの準備が完了です。

7. 起動!

4. Google Chromeを引数付きで起動する でGoogle Chromeが起動できているので、QuicTransportServerと、http.serverを起動します。

## QuicTransportServerの起動 
python quic_transport_server.py certificate.pem certificate.key
## http.serverを8000で立
python -m http.server 8000

http://localhost:8000 にアクセスしてGoogle ChromeからQuicTransportサーバーに接続して通信ができるかを確認しましょう。

完成物のコードはこのリポジトリにおいてありますのでご確認ください。

以上でカーソル位置をdatagramで送信してお絵かきアプリを作ってみる チュートリアルは終了です。
このように、WebSocketと同じような使用感で使えそうなことがわかったのではないでしょうか?

Q&Aコーナー

WebTransportをきちんと理解するためには、「他の技術では何が足りないのか?」「何が嬉しいのか?」ということを把握する必要があります。その理解の一助となるため、出てきそうな疑問と筆者なりの答えをQ&A形式でまとめてみました。補足やコメント等もお待ちしております!

WebSocketに置き換わるか?

TCPではなくUDPで送りたいようなユースケースをWebSocketで満たすのは難しいため、まずはWebSocketでは満たせず、HLSやWebRTCなどで頑張って対応していたユースケースから置き換わっていくと思われます。(映像配信やクラウドゲーミングなどなど)

(Twitchはもう社内でWebTransportを用いて配信を試しているらしい)

UDPのような形で送らなくてもいいようなユースケースにおいては、わざわざWebTransportを使う特別大きな理由はない。ただ、WebTransportはWebSocketが抱えていたHead of Line Blocking問題を解決しているため、パフォーマンス面でWebSocketを上回る可能性は十分にあります。

また、WebTransportはコア技術としてQUICを採用しているため、UDPが通らないようなNWの場合には使えない可能性があります。(企業内NWだとFWによって弾かれてしまう場合が稀にあります)

そういう時には別の技術をつかってどうにかフォールバックする必要がありますが、WebTransportの関連技術として HTTP2Transportというものがあります。これもまだ仕様策定段階ではありますが、WebTransportとほぼ同じ使用感で使えるように策定される予定です。そのため、「WebTransportは使えなくてWebSocketなら使える」という状況の時にも、HTTP2Transportが使えます。使用方法がWebTransportとほぼ同じになる予定なので、別の技術を習得する必要がなく、エンジニアにとって嬉しい技術ですね。

これらの関連技術も含めて、WebSocketを代替していく可能性はありそうです。

WebRTCに置き換わるか?

WebRTCであっても、大人数通話などではサバクラモデルで提供されている場合が多いです。そのようなサバクラモデルで提供されているWebRTCはWebTransportに置き換わる可能性がありそうです。

ただし、WebTransportはあくまでサバクラモデルであり、P2Pのユースケースは依然として残ります。完全な代替にはならないでしょう。また、WebRTCからWebTransportに移行するのも、今までの財産を捨てることになるのでそこまで容易ではないでしょう。

WebTransport(QuicTransport)で使われるQUICプロトコルをWebRTCにも適用しようという動きはあり、RTCQuicTransportが実装されていく可能性は十分にあります。WebTransportで置き換えるというわけではなく、WebTransportのコア技術を適用する形ですね。

WebRTCにQUICを適用しようと考えた理由は、「WebRTCで使われているICEプロトコルやDTLSプロトコルが非常に複雑である」ためです。

なんでQUICがでてきたの?

TCPは40年前に策定されたプロトコルで、長く使われてきました。相手にきちんと届くことを念頭において策定されているため、到達保証・順番保証の仕組みが洗練されています。(再送制御・輻輳制御など)

しかし、技術の進歩に応じて、TCPでは対応できないケースが増えてきました。

そこで新しいプロトコルを考えてそういうケースに対応してきました。ですが、TCPに変更を加えたり、新しいプロトコルを全てのデバイスで利用できるようにするのはデバイスのOS・カーネルレベルで変更を加えなければいけないので困難を極めました。

そこで考え出されたのが、UDP上にいろいろな仕組みを乗せることです。

UDPはTCPと違い、良くも悪くも制約が少ないため、UDP上でいろいろな仕組みを再現すればTCPと同様のことを行いつつ、カスタマイズすることもできます。

こうすれば、デバイスのOS・カーネルレベルで変更をする必要がなくなるため、UDPが使えるデバイスならどれでもQUICが利用できます。

ReliableUDPやSCTPといったプロトコルも、UDP上に仕組みを作っています。QUICはこれらの過去のプロトコルの反省も踏まえて作られたプロトコルです。

Untitled 4.png

引用 https://datatracker.ietf.org/meeting/98/materials/slides-98-edu-sessf-quic-tutorial/

Reliable UDPについて詳しく知りたい方は以下のスライドがおすすめです

https://speakerdeck.com/fadis/xiang-jie-reliable-udp

SCTPについて詳しく知りたい方は以下の記事がおすすめです

https://qiita.com/urakarin/items/dd1cb2f25e2c80178e5d

SCTPではなく、なぜQUICがよいのか?という部分に関しては以下の記事がおすすめです

https://http3-explained.haxx.se/ja/why-quic/why-tcpudp

QUICについては以下の記事が詳しいです。

https://qiita.com/flano_yuki/items/251a350b4f8a31de47f5

より詳細に知りたい方は以下のドラフトを読むと良いです。 (重要なキーワードはWebTransport QuicTransport HTTP3 QUICです。これらを抑えられればかなりのレベルで理解できると思います)

終わりに

本記事では、WebSocketの次の技術ではないかと噂されるWebTransport について解説・チュートリアルを行いました。まだまだEdgeな技術ではありますが、各方面からかなり期待されている技術です。

NTTコミュニケーションズ Advent Calendar 2020 10日目の記事では、本記事で解説したWebTransportとWebCodecsを組み合わせてビデオチャットを作ってみるチュートリアルを執筆予定です。(WebCodecsもかなりEdgeな技術ですが、映像や音声の処理の柔軟性が大きく増すため、注目されている技術です。)
また、12/11に開催されるWebTransport & WebCodecs meetup vol.0でもWebTransportとWebCodecsについて話します!
こちらも是非ご確認いただければ幸いです。

それでは NTTコミュニケーションズ Advent Calendar 2020 の3日目の記事の記事はここまでとさせて頂きます。

明日は @TAR_O_RIN さんのKubernetesの記事です。お楽しみに!

882
659
8

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
882
659