Closed8

Sans I/O 系ライブラリのお作法まとめ

ピン留めされたアイテム
まちゅけんまちゅけん

Conclusion

スクラップにまとめてから半年したらサッパリ忘れていたので、一目瞭然な表にまとめました (拡大推奨)。

HTML 表バージョン (改行で見づらい、コピペ用)
Package イベントを入力する バイトを出力する バイトを入力する イベントを出力する
h11 send(event) -> bytes send(event) -> bytes
Same as left
receive_data(data) -> None next_event() -> Event
h2 send_headers(stream_id, headers) -> None
send_data(stream_id, data) -> None
etc
data_to_send() -> bytes receive_data(data) -> list[Event] receive_data(data) -> list[Event]
Same as left
wsproto send(event) -> bytes send(event) -> bytes
Same as left
receive_data(data) -> None events() -> Generator[Event]
websockets send_request(request) -> None
send_text(data) -> None
etc
data_to_send() -> list[bytes] receive_data(data) -> None events_received() -> list[Event]

それぞれプロトコルのセマンティクスが違うと言えど、Sans I/O としてのシンタックスが地味に異なっており理解するのが難しいです (websockets は別にしても python-hyper 系の間でも API が統一されていません)。

API に関しては h11 が最も直感的で分かりやすいと思いました。 wsproto もほぼ同じですが、受信したイベントがない (ネットワークからのバイトが必要な) 時に関して h11next_event()h11.NEED_EVENT が出力されます。 wsprotoevents()StopIteration が挙がるだけです。 ネットワークコード側としては前者の方が制御し易いです。

h2 は先駆者なパッケージでこの中では最も古いのもあるか、または HTTP/2 プロトコルの自体が複雑であるからなのか、 API 的にも他よりフレンドリーに感じませんでした。

websockets の Sans I/O layer は最後発ながらも難しいと感じていたのですが、表にしてまとめると綺麗な API に整えれていることが分かります。 イベントとオブジェクトの入出力メソッドそれぞれの役割が被っていません。

Reference

まちゅけんまちゅけん

Sans I/O とは

https://sans-io.readthedocs.io/

ネットワーク I/O を抜いたプロトコルの実装。 HTTP/1.1 プロトコルを例にすると、h11 という Sans I/O ライブラリはバイトデータをを HTTP メッセージとしてパースしてヘッダーやボディのバイトを作ってくれる。 なので HTTP クライアント開発者であれば Sans I/O ライブラリのお作法だけ知っていれば、HTTP プロトコルを任意のネットワーク I/O、例えば同期 I/O socket や非同期 I/O の asynciotrio などに結合することができる。

お作法まとめ

  • h11(HTTP/1.1)
  • h2 (HTTP/2)
  • wsproto (WebSocket)
  • websockets Sans I/O layer (WebSocket)

上記の Sans I/O 系ライブラリのお作法を調べてまとめてみる。 実際にコードを書いて、バイトをパースする為の手順や、どのような名称のメソッドを利用するかを知りたい。

HTTP クライアントの httpx はオプションで HTTP/1.1 と HTTP/2 を切り替えることができるが、内部で利用している h11 h2 の利用方法の違いを理解したい。 両者は python-hyper からリリースされているが元の開発者が違うらしい [1] のでどのような違いがあるのか気になっている。

また WebSocket プロトコルの Sans I/O ライブラリとして wsproto は以前からあるみたいだが、asyncio に実装を結合していたwebsockets が最近 Sans I/O 機能のあるモジュールを追加した。 この両者についてもどんな違いがあるか気になるので調べたい。

ネットワーク I/O はとりあえず socketを利用してシンプル HTTP / WebSocket リクエストをする最小のスニペットを書いてみる。

脚注
  1. https://github.com/python-hyper/h11/commit/752d230bce73abb1cc4fef3675c0a7665ac4e42d ↩︎

まちゅけんまちゅけん

h11

import socket
import ssl

import h11

hostname = "httpbin.org"
context = ssl.create_default_context()

with (
    socket.create_connection((hostname, 443)) as sock,
    context.wrap_socket(sock, server_hostname=hostname) as sock,
):
    # ----------------------------------------------------------------------------------
    # Request
    # ----------------------------------------------------------------------------------

    # h11 Connection
    h11_connection = h11.Connection(our_role=h11.CLIENT)

    # h11 Request
    h11_request = h11.Request(
        method="GET", target="/get", headers=[("Host", "httpbin.org")]
    )
    h11_eof = h11.EndOfMessage()

    # h11 objects to bytes
    bytes_request = h11_connection.send(h11_request)
    bytes_eof = h11_connection.send(h11_eof)

    # bytes to I/O
    sock.sendall(bytes_request)
    sock.sendall(bytes_eof)

    # ----------------------------------------------------------------------------------
    # Response
    # ----------------------------------------------------------------------------------

    response: h11.Response | None = None
    body = b""
    while True:
        # bytes to h11 objects
        event = h11_connection.next_event()

        match event:
            case h11.NEED_DATA():
                # bytes from I/O
                bytes_received = sock.recv(8192)

                # Buffering bytes
                h11_connection.receive_data(bytes_received)
            case h11.Response():
                # Response object
                response = event
            case h11.Data():
                # Data object
                body += event.data
            case h11.EndOfMessage():
                # End object
                break

    print(response.headers)
    print(body.decode())

リクエスト

  • h11.Connection: コネクションクラス

  • h11.Request: リクエストクラス

  • h11.EndOfMessage: 終了メッセージクラス

  • コネクションクラスインスタンスの send メソッドの引数に、リクエストクラスや終了メッセージクラスのインスタンスを与えるとインスタンスの内容を bytes で出力する。

  • socketbytes を送信する

レスポンス

  • コネクションクラスインスタンスの next_event メソッドでイベントを受け取る
  • イベントが h11.NEED_DATA だったら socket から bytes 受信して、コネクションクラスインスタンスの receive_data メソッドで bytse をバッファリングする
  • h11.Responseh11.Data クラスインスタンスを受け取る
  • h11.EndOfMessage クラスインスタンスだったら HTTP 通信を終了する
まちゅけんまちゅけん

h2

import socket
import ssl

import h2.connection
import h2.events

hostname = "httpbin.org"
context = ssl.create_default_context()
context.set_alpn_protocols(["h2"])

with (
    socket.create_connection((hostname, 443)) as sock,
    context.wrap_socket(sock, server_hostname=hostname) as sock,
):
    # ----------------------------------------------------------------------------------
    # Request
    # ----------------------------------------------------------------------------------

    # h2 Connection
    h2_connection = h2.connection.H2Connection()
    h2_connection.initiate_connection()

    # h2 Request
    h2_connection.send_headers(
        1,
        [
            (":method", "GET"),
            (":path", "/get"),
            (":authority", hostname),
            (":scheme", "https"),
        ],
        end_stream=True,
    )

    # h2 objects to bytes
    bytes_request = h2_connection.data_to_send()

    # bytes to I/O
    sock.sendall(bytes_request)

    # ----------------------------------------------------------------------------------
    # Response
    # ----------------------------------------------------------------------------------

    response: h2.events.ResponseReceived | None = None
    body = b""
    response_stream_ended = False
    while not response_stream_ended:
        # bytes from I/O
        bytes_received = sock.recv(8192)

        # bytes to h2 objects
        events = h2_connection.receive_data(bytes_received)

        for event in events:
            match event:
                case h2.events.ResponseReceived():
                    # Response object
                    response = event
                case h2.events.DataReceived():
                    # Data objct
                    h2_connection.acknowledge_received_data(
                        event.flow_controlled_length, event.stream_id
                    )
                    body += event.data
                case h2.events.StreamEnded():
                    # End object
                    response_stream_ended = True
                    break

        # send any pending data to the server
        sock.sendall(h2_connection.data_to_send())

    print(response.headers)
    print(body.decode())

リクエスト

  • コネクションクラス h2.connection.H2Connection のインスタンスを生成する
  • コネクションクラスの initiate_connection メソッドを呼び出す
  • コネクションクラスの send_headers メソッドでヘッダーを送信する
  • コネクションクラスの data_to_send メソッドでバイトを生成する
  • ネットワークにバイトを送信する

レスポンス

  • ネットワークからバイトを取得する
  • コネクションクラスの receive_data メソッドでバイトを読み込んでイベントリストを取得する
  • イベントリストをイテレートしてイベントを区別する
    • レスポンスイベント
    • データイベント
      • コネクションクラスの acknowledge_received_dataメソッドを呼び出してサーバー側に通知する
    • エンドストリームイベント
  • コネクションクラスの data_to_send メソッドでバイトを生成して、何か双方向的に送信するデータがあればネットワークで送信する
まちゅけんまちゅけん

h11 / h2 お作法まとめ

リクエスト

  • h11
    • リクエストクラスやエンドメッセージクラスのインスタンスを生成する
    • コネクションクラスの send メソッドでリクエストクラスやエンドメッセージクラスをバイトに変換する
    • バイトをネットワークに送信する
  • h2
    • コネクションクラスの initiate_connection メソッドで初期化する
    • コネクションクラスの send_headers メソッドで送信するヘッダーを指定する
    • コネクションクラスの data_to_send メソッドでバイトを生成する
    • バイトをネットワークに送信する

リクエストは、h11 はリクエストクラスなどを作成して send メソッドでバイトを出力するのに対して、h2 は send_headers メソッドでなどでコネクションクラスに指示を溜めてから data_to_send メソッドでバイトを出力している。

レスポンス

  • h11
    • コネクションクラスの next_event メソッドでイベントを取得する
    • レスポンスを判定する
      • h11.NEED_DATAなら、ネットワークからバイトを受信してコネクションクラスの receive_data でバイトをバッファリングする
  • h2
    • ネットワークからバイトを受信する
    • コネクションクラスの receive_data メソッドでイベントリストを取得する
    • イベントリストのイベントを判定する
    • もし必要な送信データがあれば送信する

h11 は next_event メソッドで簡易的なイテレーションができる設計になっている。 receive_data メソッドの名前が共通しているが、h11 はバイトをバッファリングするのに対して、h2 はバイトをイベントリストに変換している。

まちゅけんまちゅけん

wsproto

import socket
import ssl
from collections import deque

from wsproto import ConnectionType, WSConnection
from wsproto.events import (
    AcceptConnection,
    CloseConnection,
    Message,
    Request,
)

hostname = "echo.websocket.events"
context = ssl.create_default_context()

with (
    socket.create_connection((hostname, 443)) as sock,
    context.wrap_socket(sock, server_hostname=hostname) as sock,
):
    # ----------------------------------------------------------------------------------
    # Handshake
    # ----------------------------------------------------------------------------------

    ws = WSConnection(ConnectionType.CLIENT)
    events = deque()

    request = Request(host=hostname, target="/")
    data = ws.send(request)

    sock.send(data)

    print("handshake", len(events))
    handshake_finished = False
    while not handshake_finished:
        if not len(events):
            data = sock.recv(8192)
            print(data)
            ws.receive_data(data)
            events.extend(ws.events())

        while len(events):
            event = events.popleft()
            print(event)
            match event:
                case AcceptConnection():
                    handshake_finished = True
                    break

    # ----------------------------------------------------------------------------------
    # Receive message
    # ----------------------------------------------------------------------------------

    print("receive first message", len(events))
    message_finished = False
    while not message_finished:
        if not len(events):
            data = sock.recv(8192)
            print(data)
            ws.receive_data(data)
            events.extend(ws.events())

        while len(events):
            event = events.popleft()
            print(event)
            match event:
                case Message(message_finished=True):
                    message_finished = True
                    break

    # ----------------------------------------------------------------------------------
    # Send message
    # ----------------------------------------------------------------------------------

    print("sending message", len(events))
    data = ws.send(Message(data="hello wsproto 123456789123456789123456789123456789"))

    sock.send(data)

    # ----------------------------------------------------------------------------------
    # Receive message
    # ----------------------------------------------------------------------------------

    print("receive echo message", len(events))
    message_finished = False
    while not message_finished:
        if not len(events):
            data = sock.recv(8192)
            print(data)
            ws.receive_data(data)
            events.extend(ws.events())

        while len(events):
            event = events.popleft()
            print(event)
            match event:
                case Message(message_finished=True):
                    message_finished = True
                    break

    # ----------------------------------------------------------------------------------
    # Close connection
    # ----------------------------------------------------------------------------------

    sock.send(ws.send(CloseConnection(code=1000)))

リクエスト

  1. コネクションクラス wsproto.WSConnection のインスタンスを生成する
  2. リクエストクラス wsproto.events.Request のインスタンスを生成する
  3. コネクションクラスの send メソッドで、リクエストクラスからバイトを出力する
  4. ネットワークにバイトを送信する

レスポンス

  1. ネットワークからバイトを受信する
  2. コネクションクラスの receive_data メソッドでバイトを読み込む
  3. コネクションクラスの events メソッドで WebSocket イベントを生成する
  4. コネクションアクセプトクラス wsproto.events.AcceptConnectionが生成される

WebSocket メッセージ - 受信

  1. ネットワークからバイトを受信する
  2. コネクションクラスの receive_data メソッドでバイトを読み込む
  3. コネクションクラスの events メソッドで WebSocket イベントを生成する
  4. メッセージクラス wsproto.events.Messageが生成される
    • message_finished 属性が False 場合、ネットワークから受信したバイトままの途切れたメッセージとなっているので注意が必要

WebSocket メッセージ - 送信

  1. メッセージクラス wsproto.events.Messageのインスタンスを生成する
  2. コネクションクラスの send メソッドで、メッセージクラスからバイトを出力する
  3. ネットワークにバイトを送信する
まちゅけんまちゅけん

websockets Sans I/O layer

import socket
import ssl
from collections import deque

from websockets.client import ClientProtocol
from websockets.frames import Frame
from websockets.http11 import Response
from websockets.uri import parse_uri

wsuri = parse_uri("wss://echo.websocket.events")
context = ssl.create_default_context()

with (
    socket.create_connection((wsuri.host, wsuri.port)) as sock,
    context.wrap_socket(sock, server_hostname=wsuri.host) as sock,
):
    # ----------------------------------------------------------------------------------
    # Handshake
    # ----------------------------------------------------------------------------------

    protocol = ClientProtocol(wsuri)
    request = protocol.connect()
    protocol.send_request(request)

    for data in protocol.data_to_send():
        print(">", data)
        sock.sendall(data)

    events = deque()
    print("handshake", len(events))
    handshake_finished = False
    while not handshake_finished:
        if not len(events):
            data = sock.recv(8192)
            print("<", data)
            if data:
                protocol.receive_data(data)
            else:
                protocol.receive_eof()

        events.extend(protocol.events_received())
        while len(events):
            event = events.popleft()
            print(event)

            match event:
                case Response():
                    handshake_finished = True
                    break

    # ----------------------------------------------------------------------------------
    # Receive message
    # ----------------------------------------------------------------------------------

    print("receive first message", len(events))
    message_finished = False
    while not message_finished:
        if not len(events):
            data = sock.recv(32)
            print("<", data)
            if data:
                protocol.receive_data(data)
            else:
                protocol.receive_eof()

        events.extend(protocol.events_received())
        while len(events):
            event = events.popleft()
            print(repr(event))
            match event:
                case Frame():
                    message_finished = True
                    break

    # ----------------------------------------------------------------------------------
    # Send message
    # ----------------------------------------------------------------------------------

    print("sending message", len(events))
    protocol.send_text(b"hello wsproto 123456789123456789123456789123456789")

    for data in protocol.data_to_send():
        print(">", data)
        sock.sendall(data)

    # ----------------------------------------------------------------------------------
    # Receive message
    # ----------------------------------------------------------------------------------

    print("receive first message", len(events))
    message_finished = False
    while not message_finished:
        if not len(events):
            data = sock.recv(32)
            print("<", data)
            if data:
                protocol.receive_data(data)
            else:
                protocol.receive_eof()

        events.extend(protocol.events_received())
        while len(events):
            event = events.popleft()
            print(repr(event))
            match event:
                case Frame():
                    message_finished = True
                    break

リクエスト

  1. プロトコルクラス websockets.client.ClientProtocol のインスタンスを生成する
  2. プロトコルクラスの connect メソッドでリクエストクラスのインスタンスを生成する
  3. プロトコルクラスの send_request メソッドでリクエストクラスを内部にバッファする
  4. プロトコルクラスの data_to_send メソッドでバイトを出力する
  5. ネットワークにバイトを送信する

レスポンス

  1. ネットワークからバイトを受信する
  2. プロトコルクラスの receive_data メソッドでバイトを内部にバッファする
  3. プロトコルクラスの events_received メソッドでイベントを生成する
  4. レスポンスクラス websockets.http11.Response が生成される

WebSocket メッセージ - 受信

  1. ネットワークからバイトを受信する
  2. プロトコルクラスの receive_data メソッドでバイトを内部にバッファする
  3. プロトコルクラスの events_received メソッドでイベントを生成する
  4. レスポンスクラス websockets.frames.Frame が生成される

WebSocket メッセージ - 送信

  1. プロトコルクラスの send_text メソッドでバイトを内部にバッファする
  2. プロトコルクラスの data_to_send メソッドでバイトを出力する
  3. ネットワークにバイトを送信する
まちゅけんまちゅけん

wsproto / websockets Sans I/O layer お作法まとめ

リクエスト、メッセージ送信

  • wsproto
    1. リクエストオブジェクトまたはメッセージオブジェクトを、コネクションオブジェクトの send メソッドに渡してバイトを出力する
  • websockts Sans I/O layer
    1. リクエストオブジェクトはプロトコルオブジェクトの send_request メソッドで、メッセージのバイトはプロトコルオブジェクトの send_text メソッドで内部にバッファする
    2. プロトコルオブジェクトの data_to_send メソッドで内部のバッファからバイトを出力する

レスポンス、メッセージ受信

  • wsproto
    1. コネクションオブジェクトの receive_data メソッドでバイトを内部にバッファする
    2. コネクションオブジェクトの events メソッドで内部のバッファからイベントを出力する
  • websockets Sans I/O layer
    1. プロトコルオブジェクトの receive_data メソッドでバイトを内部にバッファする
    2. プロトコルオブジェクトの events_received メソッドで内部のバッファからイベントを出力する
このスクラップは2024/01/08にクローズされました