LoginSignup
52
30

WebTransportでもプロトコル理解がしたい!

Last updated at Posted at 2021-11-07

2023/12 この記事はだいぶ古く、理解が足りていないところが多々あります。
最近のWebTransportについては下記の記事にまとめてあります。


2022/03/20 Go実装によるデモサーバーを作りました。


前回のechoサンプルを少し改良しました。

前回はechoサンプルを動くようにしましたが、
今回はHTML側に切断処理を入れ、WebTransportのセッションが閉じられた時にQUICのイベントログを出力するようにしました。
今回はそのログも合わせてプロトコルを理解していきます。

ソースコードはこちら

WebTransport? HTTP/3?? QUIC??? 何それおいしいの?

WebTransportに何となくワクワクと闇の気配を感じている皆様こんにちは。
今回は前回のechoサンプルとドラフトを睨めっこしながらプロトコルについて理解していきたいと思います。
ここではChrome M97に実装されている WebTransport over HTTP/3について調べていきます。

どうしてWebTransportが必要なの?

  1. ブラウザで双方向通信がしたい
  2. けどWebSocketはTCPベースだからリアルタイム通信とか向いてないよね?
  3. え? WebRTCのデータチャネルがあるじゃないかって? あれはP2Pだし使いにくいんだよねー
  4. けどUDPだけだとコネクションとかストリームの概念もないし、TCPとUDPのいいとこ取りのプロトコルが欲しいよね。
  5. そんな問題を解決するためにGoogleがQUICを作っていたよ! IETFで標準化するからそれを使おう!
  6. あ、直接QUICを使おうと思ったけど、標準化するしHTTP/3使うよ!
  7. HTTP/3が何かって? HTTP/2で複数画像とかを通信していてパケロスすると詰まることがあるよね? (これをHead of Line ブロッキングという。ちなみにnetlifyの無料プランが遅いのもこれの影響だったはず??) それをQUICで解決するものさ!

多分、こんな感じかと思います。

まずはレイヤー構造の確認

色々なプロトコルや用語が出てくるので、まずは基本となるレイヤー構造をまとめてみます。
WebTransportはHTTP/3を使い
HTTP/3はTLSで必ず暗号化され、かつQUICを使い
QUICは実装上の理由でUDPを使っています。

名称 WebSocket WebRTC(メディア) WebRTC(データ) HTTP/2 WebTransport over HTTP3/
(標準化年) 2011 2014 2014 2015 2022予定
- - SRTP SCTP - HTTP/3
暗号化 TLS(任意) DTLS DTLS TLS(任意) TLS1.3相当(必須)
トランスポート TCP UDP UDP TCP QUIC(UDP)

さて、こうしてレイヤーを見てみるとWebRTC(メディア)には(S)RTPがあるのにWebTransportにはありません。
ということはビデオチャットをWebTransportでやろうと思ったら自前で処理する必要がありそうですね。

通信の全体像について

まずはコネクションストリームフレームパケットといったそれぞれのレイヤーの基本概念について確認していきましょう。
静的なデータ構造と、実際にパケットとしてやり取りされるデータ構造をそれぞれ図にしてみます。

コネクションとストリーム

WebTransport及びHTTP/3ではQUICのコネクションを一つ作成します。
コネクションにはIDがあり、これで管理することでIPが変わってもストリームを維持できるようです。
ちなみにWebTransportは接続をセッションIDで管理します。(WebTransportをクローズしてもQUICのコネクションは維持されます。実装依存?)

またコネクションの中に複数のストリームがあり、QUICではストリームごとにパケットの順番や整合性を保証します。
そしてHTTP/3では決められたストリームをいくつか作成します。
HTTP/3での接続が完了したら、必要に応じてWebTransportのストリームを作成することができます。
そして、UDPのように順番保証のない(つまりストリームを使わない)datagramがあります。

ストリームについて

QUIC、HTTP/3、WebTransport共にスリトームとはQUIC上のコネクションで複数扱うことのできる通信単位のことを指します。

QUICのストリームタイプ

QUICにはストリームの種類は双方向、単方向、クライアントもしくはサーバのどちらから開始されたかという区別があります。
これはストリームIDの下位4bitで区別されます。
IDが偶数だとクライアントから、奇数だとサーバーから。になります。

IDの下位4bit from type
0x00 client 双方向
0x01 server 双方向
0x02 client 単方向
0x03 server 単方向

実際にechoサンプルでWebTransportのセッションを張ると、以下のストリームIDが振られます。

ストリームID 用途 向き 方向
0 HTTP/3リクエスト・レスポンス clientから 双方向
2 HTTP/3 Control Stream clientから 単方向
6 HTTP/3 QPACK Encoder clientから 単方向
10 HTTP/3 QPACK Decoder clientから 単方向
3 HTTP/3 Control Stream serverから 単方向
7 HTTP/3 QPACK Encoder serverから 単方向
11 HTTP/3 QPACK Decoder serverから 単方向

HTTP/3のストリームタイプ

HTTP/3はQUICのストリームを使用します。
クライアントからの双方向のストリーム(0)はHTTPのリクエストとレスポンス処理に使われます。
WebTransportの単方向と双方向ストリームはHTTP/3のストリームと同じですが、新しく別に作成されます。
そして、単方向ストリームのみストリームタイプを独自に定義しています。

key Value
CONTROL 0x00
PUSH 0x01
QPACK Encoder 0x02
QPACK Decoder 0x03
WEBTRANSPORT 0x54

また、このパラメータはQUICストリームの最初のデータとして送られます。

Unidirectional Stream Header {
  Stream Type (i),
}

WebTransportのストリームタイプ

そろそろ訳が分からなくなってきましたが、echoデモの通り3種類です。
HTTP/3のフレームタイプでストリームタイプを示すようです。

下位レイヤでの表現 Datagram Unidirectional Bidirectional
HTTP/3のstream type なし 0x54 0x54
HTTP/3のframe type 0xffd277 0x54 0x41
QUIC Datagram Unidirectional Bidirectional

パケットとフレーム

ここではざっくりと通信されるデータのまとまりを書きます。
UDPはパケットを送信します。
その中にQUICのパケットがあります。このパケット単位でQUICは管理を行います。
QUICのパケットには複数のフレームがあります。これの用途をframe_typeで識別します(20種類ほど)
で、さらにややこしいのが、QUICフレームの中にHTTP/3フレームが入ります

図にするとこんな感じです。
制御用の短いメッセージはたくさん詰めて、データは分割して並行でガンガン送る感じです。

ちなみにHTTP Datagramの場合は逆に多重化したくないのでQUICのdatagramを使い、最大限データを送信できるようにしているらしいです。

QUICのフレームとフレームタイプ

QUICフレームではACKやPINGなどの小さなメッセージやデータを扱います。
一つのパケットには複数のフレームが入り、パケット番号やヘッダーにオフセット情報があります。(詳しくは後述)
フレームタイプは20種類近くあるのでWebTransportに関係するところだけ抜粋します。

名前 説明
PADDING 0x00 サイズ調整のためのデータ
PING 0x01 応答要求
ACK 0x02 応答確認
CRYPTO 0x06 TLS関連
STREAM 0x08 アプリケーションデータ
HTTP/3とWebTransportのストリームはこれ
DATAGRAM 0x30 ストリームを使わないデータ
WebTransportのdatagramはこれ

HTTP/3のフレームとフレームタイプ

HTTP/3にもフレームがあり、一つのQUICフレームに複数のフレームを含めることができます。
フレームには長さ、データ、ストリームならIDがあります。
単方向ストリームの場合は最初の4バイトがフレームタイプになり、双方向の場合はストリームタイプ(4byte) + フレームタイプ(4byte)になるようです。

ここでもWebTransport関係するものだけ上げておきます。
フレームが大きい場合は分割され、QUICフレームのoffsetなどでよしなに繋げられます。

名前 用途
DATA 0x00 データ
HEADER 0x01 HTTP/3のヘッダー
QUICフレームのヘッダとは別です
SETTING 0x4 最初のハンドシェイクで以下のフラグを確認します
WEBTRANSPORTとH3_DATAGRAM
WEBTRANSPORT_STREAM 0x41 双方向(Bidirectional)の時の値
単方向の時はStreamType.WEBTRANSPORT 0x54が使われます

WebTransportのフレーム

HTTP/3の単方向ストリームにはストリームタイプとフレームタイプがあり、双方向の時はフレームタイプのみとなります。
ややこしいですが、WebTransportではHTTP/3フレームのフォーマットはドラフトの通り、
タイプ(u32)、セッションID(u32)、データとなります。

データレイアウト datagram 単方向 双方向
4byte session_id 0x54 0x41
4byte data session_id session_id
それ以降 ... data data

ハンドシェイク

TCPは3ウェイハンドシェイクですが、TLSがあるとさらに1往復かかります。
そこでHTTP/3は1RTT(1往復)できるTLS1.3相当のハンドシェイクを行うらしいです。

ハンドシェイクやTLS周りは複雑で追いきれないので、
今回はWebTransportで意識するHTTP/3のリクエスト周りから見ていきます。

実際のコードとログを追う

それでは実際にコードとログを見ながら確認して見ましょう。
以下の要点となるところを追っていきたいと思います。

  • WebTransportが接続できるまで(HTTP/3リクエスト)
  • datagram echo
  • unidirectional stream echo
  • bidirectinal echo

ログを見れるようにする

まずはPythonのサンプルでログを見れるようにします。
QuicLoggerというものをサーバー作成時に渡すとイベントログを記録してくれるようです。
ただし、to_dict()で出力しないといけないようなので、WebTransportセッションがクローズされたら出力するようにします。

実際のコードはこちら

server.py

# ロガーをインポート
from aioquic.quic.logger import QuicLogger


# ログ周りを初期化し
quic_logger = QuicLogger()
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.DEBUG)

# セッションがクローズしたらログを出力するようにし
class WebTransportProtocol(QuicConnectionProtocol):
    ...
    def quic_event_received(self, event: QuicEvent) -> None:
        if isinstance(event, ProtocolNegotiated):
        ...
        elif isinstance(event, StreamDataReceived):
            if event.end_stream is True:
                pprint(quic_logger.to_dict())
        # WebTransportをcloseしてもすぐにはQUICコネクションは切断されない
        elif isinstance(event, ConnectionTerminated):
            print("terminated!")

# mainのサーバー起動のconfigに追加します。
if __name__ == '__main__':
  
    configuration = QuicConfiguration(
        alpn_protocols=H3_ALPN,
        is_client=False,
        max_datagram_frame_size=65536,
        quic_logger=quic_logger,
    )


するとこんな感じのログが出力されます。

             'events': [('0',
                         'transport',
                         'datagrams_received',
                         {'byte_length': 1250, 'count': 1}),
                        ('207',
                         'transport',
                         'parameters_set',
                         {'ack_delay_exponent': 3,
                          'active_connection_id_limit': 8,
                          'disable_active_migration': False,
                          'initial_max_data': 1048576,
                          'initial_max_stream_data_bidi_local': 1048576,
                          'initial_max_stream_data_bidi_remote': 1048576,
                          'initial_max_stream_data_uni': 1048576,
(以下略)

こんな感じで接続終了後にログが出力されます。
ただしこのログ、イベントのログであって通信ログではないのです。
また、HTTP/3のイベントも出るには出ますが、SETTINGフレームが出力されないなど痒いところに手が届きません。

ログの見方としては

  • datagramが一番外側 : datagram sent / received
  • 次にパケット単位 : packet sent / received
  • 次にフレームを紐解く : frame_parsed
    のようにデータ構造と階層を念頭において見ていくと良いと思います。

HTTP/3のリクエスト

ではHTTP/3のリクエストとレスポンスを確認してみましょう。
まず最初にdatagram_receivedとあり、219バイトのデータを受信したことがわかります。

                        ('5785',
                         'transport',
                         'datagrams_received',
                         {'byte_length': 219, 'count': 1}),

次にpacket_receivedとあり、二つのフレームを受信したことがわかります。それぞれstream_id 0, 10 です。
headerによるとこれらはパケット番号5として送信されたことがわかります。


                        ('5806',
                         'transport',
                         'packet_received',
                         {'frames': [{'fin': False,
                                      'frame_type': 'stream',
                                      'length': 84,
                                      'offset': '0',
                                      'stream_id': '10'},
                                     {'fin': False,
                                      'frame_type': 'stream',
                                      'length': 103,
                                      'offset': '0',
                                      'stream_id': '0'}],
                          'header': {'dcid': '252925e0ab119a61',
                                     'packet_number': '5',
                                     'packet_size': 219,
                                     'scid': ''},
                          'packet_type': '1RTT'}),

ストリーム10はヘッダ圧縮用のデータなので省きます。
stream_id : 10のフレームを紐解くと一つのheaderフレームと3つのdataフレームが入っています。
headerは普段見慣れたHTTP/1.1とは少し形は違いますが、:method:origin:pathなどのヘッダーがあります。
HTTP/3のヘッダはコロンで始まり全て小文字です。
WebTransportでは:protocolwebtransportにする必要があります。
(残りの三つのデータは、、よくわかりません😭)

                        ('6032',
                         'http',
                         'frame_parsed',
                         {'byte_length': '9',
                          'frame': {'frame_type': 'headers',
                                    'headers': [{'name': ':scheme',
                                                 'value': 'https'},
                                                {'name': ':method',
                                                 'value': 'CONNECT'},
                                                {'name': ':authority',
                                                 'value': 'localhost:4433'},
                                                {'name': ':path',
                                                 'value': '/counter'},
                                                {'name': ':protocol',
                                                 'value': 'webtransport'},
                                                {'name': 'sec-webtransport-http3-draft02',
                                                 'value': '1'},
                                                {'name': 'origin',
                                                 'value': 'http://localhost:8000'}]},
                          'stream_id': '0'}),
                        ('6040',
                         'http',
                         'frame_parsed',
                         {'byte_length': '12',
                          'frame': {'frame_type': 'data'},
                          'stream_id': '0'}),
                        ('6049',
                         'http',
                         'frame_parsed',
                         {'byte_length': '9',
                          'frame': {'frame_type': 'data'},
                          'stream_id': '0'}),
                        ('6053',
                         'http',
                         'frame_parsed',
                         {'byte_length': '64',
                          'frame': {'frame_type': 'data'},
                          'stream_id': '0'}),

レスポンス200

HTTP/3のリクエストに対して200を返します。
(またドラフト中のみバージョンを示すヘッダーを含めます。リリース時には削除される為、ここでは省略します)
クライアントがこれを確認することでWebTransportの接続が管理消します。(WebTransport.readyが解決される)

サーバー側の動作としては受信時とは逆で、フレームを作って他のフレームと共にパケットに詰めて送信しています。
stream_id : 0のデータがレスポンスになります。

                        ('7641',
                         'http',
                         'frame_created',
                         {'byte_length': '3',
                          'frame': {'frame_type': 'headers',
                                    'headers': [{'name': ':status',
                                                 'value': '200'}]},
                          'stream_id': '0'}),
                        ('7806',
                         'transport',
                         'packet_sent',
                         {'frames': [{'ack_delay': '486',
                                      'acked_ranges': [['4', '5']],
                                      'frame_type': 'ack'},
                                     {'fin': False,
                                      'frame_type': 'stream',
                                      'length': 1,
                                      'offset': '1',
                                      'stream_id': '11'},
                                     {'fin': False,
                                      'frame_type': 'stream',
                                      'length': 5,
                                      'offset': '0',
                                      'stream_id': '0'}],
                          'header': {'dcid': '',
                                     'packet_number': '5',
                                     'packet_size': 39,
                                     'scid': ''},
                          'packet_type': '1RTT'}),
                        ('7807',
                         'transport',
                         'datagrams_sent',
                         {'byte_length': 39, 'count': 1}),

datagramをechoしたとき

上記で接続はできたので、実際にデータを送信した時の流れを確認して見ましょう。
datagramをブラウザから送ってみます。
ストリームは使わないため、frame_type : datagramのデータが来ています。('a'と1byteだけ送信したのでパディングでサイズ調整されています)

                        ('7345530',
                         'transport',
                         'datagrams_received',
                         {'byte_length': 33, 'count': 1}),
                        ('7345582',
                         'transport',
                         'packet_received',
                         {'frames': [{'frame_type': 'datagram', 'length': 2},
                                     {'frame_type': 'padding'}],
                          'header': {'dcid': '9e4ed475f6755965',
                                     'packet_number': '7',
                                     'packet_size': 33,
                                     'scid': ''},
                          'packet_type': '1RTT'}),

サーバー側ではすぐにechoしています。
datagramなのでこれだけです。

                        ('7345939',
                         'transport',
                         'packet_sent',
                         {'frames': [{'frame_type': 'datagram', 'length': 2}],
                          'header': {'dcid': '',
                                     'packet_number': '6',
                                     'packet_size': 23,
                                     'scid': ''},
                          'packet_type': '1RTT'}),
                        ('7345942',
                         'transport',
                         'datagrams_sent',
                         {'byte_length': 23, 'count': 1}),

Unidirectional stream の echo

次にUnidirectional Streamでechoした時のログを追って見ます。

                        ('29391394',
                         'transport',
                         'packet_received',
                         {'frames': [{'fin': False,
                                      'frame_type': 'stream',
                                      'length': 3,
                                      'offset': '0',
                                      'stream_id': '14'},
                                     {'frame_type': 'padding'}],
                          'header': {'dcid': '866a80df52137f04',
                                     'packet_number': '12',
                                     'packet_size': 33,
                                     'scid': ''},
                          'packet_type': '1RTT'}),

ストリームなので受信したらackpacket_number : 12を受け取ったことを送信しています。

                      ('29392606',
                         'transport',
                         'packet_sent',
                         {'frames': [{'ack_delay': '1134',
                                      'acked_ranges': [['9', '12']], # ← これ
                                      'frame_type': 'ack'}],
                          'header': {'dcid': '',
                                     'packet_number': '9',
                                     'packet_size': 25,
                                     'scid': ''},
                          'packet_type': '1RTT'}),

そしてすぐにechoの為の逆向きのストリームを作成してデータを送信しています。

                        ('7155743',
                         'transport',
                         'packet_sent',
                         {'frames': [{'ack_delay': '103044',
                                      'acked_ranges': [['6', '9']],
                                      'frame_type': 'ack'},
                                     {'fin': True,
                                      'frame_type': 'stream',
                                      'length': 3,
                                      'offset': '0',
                                      'stream_id': '15'}],
                          'header': {'dcid': '',
                                     'packet_number': '10',
                                     'packet_size': 50,
                                     'scid': ''},
                          'packet_type': '1RTT'}),

そしてクライアントからすぐにpacket_number : 10に対してackが帰ってきます。

                        ('7181473',
                         'transport',
                         'packet_received',
                         {'frames': [{'ack_delay': '25424',
                                      'acked_ranges': [['5', '6']],
                                      'frame_type': 'ack'},
                                     {'frame_type': 'padding'}],
                          'header': {'dcid': '62897e69c651cbb4',
                                     'packet_number': '10',
                                     'packet_size': 33,
                                     'scid': ''},
                          'packet_type': '1RTT'}),

Bidirectional Stream の echo

最後に双方向ストリームのechoです。
概ね単方向と同じですが、ストリームが既に貼られている為、ackとechoデータの送信をまとめて行っています。

                        ('7829309',
                         'transport',
                         'packet_sent',
                         {'frames': [{'ack_delay': '10389',
                                      'acked_ranges': [['6', '9']], # packet_number : 9 が送られてきたパケット番号
                                      'frame_type': 'ack'},
                                     {'fin': True,                  # ストリームを閉じる
                                      'frame_type': 'stream',
                                      'length': 4,                  # 返信するデータ
                                      'offset': '0',
                                      'stream_id': '4'}],           # クライアント開始の双方向なので0の次は4
                          'header': {'dcid': '',
                                     'packet_number': '7',
                                     'packet_size': 33,
                                     'scid': ''},
                          'packet_type': '1RTT'}),

まとめ

まだまだ追いきれていないところが多く、仕様も理解しきれていないですが、
ログとコードを追うことである程度、用語や概念などが理解できたかなと思います。
本当はQUICフレームのデータレイアウトなども理解したいところなのですが、
日が暮れるどころか土日が終わってしまった為また来週にしたく思います。

こんな記事でも最後まで目を通してくれる方がいれば嬉しいです。
ここまでお読みいただきありがとうございました。

次は他言語でもWebTransportがしたい!

変更履歴

※2021/11/7現在、Canaryが98.0になり動作しましせん。
そのためChrome Dev 97.0で動作を確認しています。

レスポンスにヘッダを含める必要があるようです。下記を参照ください。

2021/11/13追加 chromeの公式サンプルが動くようになりました! やったね!

chrome公式サンプルはM97以降で動きます。
HTTP/3リクエストのレスポンスに headers.append((b"sec-webtransport-http3-draft", b"draft02"))というヘッダを返す必要があります。
返さない場合は、net::ERR_PROTOCOL_NOT_SUPPORT エラーがブラウザで発生し、エラ〜コード `0x10c = 268' がサーバーに送られてしまいます。

2021/12/25追加 参考リンクを下記の記事一覧ページにまとめました。

他のWebTransport関連記事はこちら

52
30
0

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
52
30