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が必要なの?
- ブラウザで双方向通信がしたい
- けどWebSocketはTCPベースだからリアルタイム通信とか向いてないよね?
- え? WebRTCのデータチャネルがあるじゃないかって? あれはP2Pだし使いにくいんだよねー
- けどUDPだけだとコネクションとかストリームの概念もないし、TCPとUDPのいいとこ取りのプロトコルが欲しいよね。
- そんな問題を解決するためにGoogleがQUICを作っていたよ! IETFで標準化するからそれを使おう!
- あ、直接QUICを使おうと思ったけど、標準化するしHTTP/3使うよ!
- 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セッションがクローズされたら出力するようにします。
# ロガーをインポート
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では:protocol
をwebtransport
にする必要があります。
(残りの三つのデータは、、よくわかりません😭)
('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'}),
ストリームなので受信したらack
でpacket_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' がサーバーに送られてしまいます。