ネットワークプログラミングの世界では、一つの設計パターンが静かに注目を集めています。それが「Sans-IO」です。この記事では、Sans-IOの基本概念から実装例、そしてなぜこれが注目されているのかを解説します。
Sans-IOとは何か?
「Sans-IO」(フランス語で「IOなし」の意)は、ネットワークプロトコルの実装からI/O処理を完全に分離するプログラミングアプローチです。
従来のネットワークライブラリでは、プロトコルロジック(HTTPやWebSocketなどの仕様に基づいた処理)とI/O処理(実際にソケットからデータを読み書きする部分)が密接に結合していました。これに対して、Sans-IOアプローチではこれらを明確に分離します。
Sans-IOライブラリの特徴
Sans-IOに基づいたライブラリには、以下のような特徴があります:
- I/O操作を実行しない: バイト列の生成と解析のみを行い、実際のネットワーク通信は行わない
- 純粋な状態機械として実装: プロトコルを状態遷移の集合として表現する
- プラットフォーム非依存: 特定のI/Oフレームワークに依存しない
簡単に言えば、Sans-IOライブラリは「入力されたバイト列を解析してイベントを生成する」と「イベントからバイト列を生成する」という2つの基本機能に集中し、実際にネットワーク上でバイト列を送受信する責任はユーザーコードに委ねます。
なぜSans-IOなのか?
これまでのアプローチと比べて、Sans-IOにはどのような利点があるのでしょうか?
1. 再利用性の高さ
Sans-IOパターンの最大の強みは、再利用性です。同じプロトコル実装を、異なるI/Oモデル(同期・非同期・スレッドベース)で利用できます。
例えば、HTTP/1.1クライアントを実装する場合:
- 標準ライブラリの
socket
モジュールと組み合わせて同期的に使うことも -
asyncio
と組み合わせて非同期で使うことも -
trio
やtwisted
などの別のフレームワークで使うことも可能です
このアプローチは、Python 3.5でasync/await
構文が導入され、非同期プログラミングが主流になってきた現在、特に価値があります。以前は同期I/Oのために書かれたプロトコル実装を非同期に移植する作業が必要でしたが、Sans-IOならそのような手間はかかりません。
2. テストの容易性
Sans-IOライブラリのもう一つの大きな利点は、テストが非常に容易なことです。
ネットワークI/Oを伴うコードをテストするのは通常、非常に困難です。ネットワーク接続の確立、タイムアウト、接続エラーなどをすべてモック化する必要があります。
Sans-IOアプローチでは、プロトコル処理は純粋にメモリ内で行われるため、単にバイト列を入力として与え、期待する出力やイベントが得られるかをテストするだけで済みます。実際のネットワーク接続は必要ありません。
3. 複雑さの閉じ込め
ネットワークプロトコルとI/O処理は、それぞれ独自の複雑さを持っています。これらを分離することで、各部分の責任を明確にし、それぞれの複雑さを管理しやすくなります。
Sans-IOライブラリは、プロトコル処理の複雑さに集中し、I/O処理の複雑さ(非同期処理、タイムアウト、接続エラーなど)は、I/Oフレームワークに任せることができます。
Python Sans-IOエコシステム
Pythonには、Sans-IO原則に基づいた優れたライブラリが揃っています。特に「python-hyper」グループによって開発されているライブラリが有名です。
h11 - HTTP/1.1プロトコル
h11は、HTTP/1.1プロトコルのSans-IO実装です。シンプルながら完全なHTTP/1.1実装を提供し、RFC 7230に準拠しています。
基本的な使用例を見てみましょう:
import h11
# クライアント側のコネクション作成
conn = h11.Connection(our_role=h11.CLIENT)
# リクエスト作成
request = h11.Request(
method="GET",
target="/",
headers=[("Host", "example.com")]
)
# リクエストをバイト列に変換
request_bytes = conn.send(request)
# 実際のアプリケーションではrequest_bytesをネットワークに送信する
# EndOfMessageも送信する必要がある
end_message_bytes = conn.send(h11.EndOfMessage())
# 実際のアプリケーションではend_message_bytesもネットワークに送信する
# レスポンスデータを受信した場合の処理
# 実際のアプリケーションではネットワークからデータを受信する
received_data = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello"
conn.receive_data(received_data)
# イベント処理
event = conn.next_event()
if isinstance(event, h11.Response):
print(f"ステータスコード: {event.status_code}")
# ボディデータを取得
event = conn.next_event()
if isinstance(event, h11.Data):
print(f"レスポンスボディ: {event.data}")
# メッセージ終了を確認
event = conn.next_event()
if isinstance(event, h11.EndOfMessage):
print("レスポンス完了")
この例では、h11はHTTPプロトコルの処理(リクエストの構築、レスポンスの解析)を担当しますが、実際のネットワーク通信は行いません。
hyper-h2 - HTTP/2プロトコル
hyper-h2は、HTTP/2プロトコルのSans-IO実装です。HTTP/2の複雑な機能(ストリーム多重化、ヘッダー圧縮、フロー制御など)をサポートしています。
import h2.connection
# HTTP/2コネクションの作成
conn = h2.connection.H2Connection()
# コネクション開始シーケンスを生成
data_to_send = conn.initiate_connection()
# 実際のアプリケーションではdata_to_sendをネットワークに送信する
# リクエストを送信
stream_id = conn.get_next_available_stream_id()
conn.send_headers(
stream_id=stream_id,
headers=[
(':method', 'GET'),
(':path', '/'),
(':authority', 'example.com'),
(':scheme', 'https'),
],
end_stream=True
)
# 生成されたフレームを取得
data_to_send = conn.data_to_send()
# 実際のアプリケーションではdata_to_sendをネットワークに送信する
# データ受信のシミュレーション
# 実際のアプリケーションではネットワークからデータを受信する
received_data = b"..." # HTTP/2フレームデータ
events = conn.receive_data(received_data)
# イベント処理
for event in events:
if isinstance(event, h2.events.ResponseReceived):
headers = event.headers
print(f"ヘッダー受信: {headers}")
elif isinstance(event, h2.events.DataReceived):
print(f"データ受信: {event.data}")
elif isinstance(event, h2.events.StreamEnded):
print(f"ストリーム終了: {event.stream_id}")
wsproto - WebSocketプロトコル
wsprotoは、WebSocketプロトコル(RFC 6455)のSans-IO実装です。WebSocket接続の確立(ハンドシェイク)からメッセージの送受信、接続の終了までをサポートしています。
from wsproto import WSConnection, ConnectionType
from wsproto.events import Request, AcceptConnection, TextMessage, BytesMessage
# クライアント接続の作成
ws = WSConnection(ConnectionType.CLIENT)
# ハンドシェイクリクエストの生成
request_bytes = ws.send(Request(host='example.com', target='/'))
# 実際のアプリケーションではrequest_bytesをネットワークに送信する
# サーバーからのレスポンス受信をシミュレーション
# 実際のアプリケーションではネットワークからデータを受信する
received_data = b"..." # WebSocketハンドシェイクレスポンスデータ
ws.receive_data(received_data)
# イベント処理
for event in ws.events():
if isinstance(event, AcceptConnection):
print("WebSocket接続が確立されました")
# テキストメッセージの送信
message_bytes = ws.send(TextMessage(data="Hello WebSocket!"))
# 実際のアプリケーションではmessage_bytesをネットワークに送信する
elif isinstance(event, TextMessage):
print(f"テキストメッセージ受信: {event.data}")
elif isinstance(event, BytesMessage):
print(f"バイナリメッセージ受信: {event.data}")
Sans-IOとステートマシン
Sans-IO実装の中核にあるのは「状態機械(ステートマシン)」の考え方です。プロトコルの動作を一連の「状態」と「遷移」として表現します。
例えば、h11では、クライアントとサーバーの状態が以下のように管理されています:
HTTPクライアントの状態遷移:
- IDLE: 初期状態、リクエスト送信可能
- SEND_BODY: リクエストヘッダーを送信済み、ボディ送信中
- DONE: リクエスト完了、レスポンス待ち
- MUST_CLOSE: レスポンス受信完了、接続を閉じる必要あり
HTTPサーバーの状態遷移:
- IDLE: 初期状態、リクエスト待ち
- SEND_RESPONSE: リクエスト受信済み、レスポンス送信中
- SEND_BODY: レスポンスヘッダー送信済み、ボディ送信中
- DONE: レスポンス完了
このような状態機械により、プロトコルの複雑な振る舞いを管理し、不正な操作(例えば、リクエスト送信前にボディを送信しようとする)を防ぐことができます。
実践的なSans-IOの使い方
Sans-IOライブラリを実際のアプリケーションで使うには、I/O処理と統合する必要があります。ここでは、h11を使った同期・非同期両方のHTTPクライアント実装例を示します。
同期版HTTPクライアント
import socket
import h11
def sync_http_get(host, port, path):
# ソケット接続
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
# h11コネクション作成
conn = h11.Connection(our_role=h11.CLIENT)
# リクエスト作成と送信
request = h11.Request(
method="GET",
target=path,
headers=[("Host", host)]
)
# リクエストヘッダーとボディ終了マーカーを送信
sock.sendall(conn.send(request))
sock.sendall(conn.send(h11.EndOfMessage()))
# レスポンス受信
response_body = b""
while True:
# ソケットからデータ受信
data = sock.recv(1024)
if not data: # 接続が閉じられた
break
# h11にデータを渡す
conn.receive_data(data)
# 生成されたイベントを処理
while True:
event = conn.next_event()
if event is h11.NEED_DATA: # もっとデータが必要
break
if isinstance(event, h11.Response):
print(f"ステータスコード: {event.status_code}")
elif isinstance(event, h11.Data):
response_body += event.data
elif isinstance(event, h11.EndOfMessage):
# レスポンス完了
sock.close()
return response_body
sock.close()
return response_body
# 使用例
if __name__ == "__main__":
response = sync_http_get("httpbin.org", 80, "/get")
print(response.decode())
非同期版HTTPクライアント(asyncio)
import asyncio
import h11
async def async_http_get(host, port, path):
# 非同期ソケット接続
reader, writer = await asyncio.open_connection(host, port)
# h11コネクション作成
conn = h11.Connection(our_role=h11.CLIENT)
# リクエスト作成と送信
request = h11.Request(
method="GET",
target=path,
headers=[("Host", host)]
)
# リクエストヘッダーとボディ終了マーカーを送信
writer.write(conn.send(request))
writer.write(conn.send(h11.EndOfMessage()))
await writer.drain()
# レスポンス受信
response_body = b""
while True:
# 非同期でデータ受信
data = await reader.read(1024)
if not data: # 接続が閉じられた
break
# h11にデータを渡す
conn.receive_data(data)
# 生成されたイベントを処理
while True:
event = conn.next_event()
if event is h11.NEED_DATA: # もっとデータが必要
break
if isinstance(event, h11.Response):
print(f"ステータスコード: {event.status_code}")
elif isinstance(event, h11.Data):
response_body += event.data
elif isinstance(event, h11.EndOfMessage):
# レスポンス完了
writer.close()
await writer.wait_closed()
return response_body
writer.close()
await writer.wait_closed()
return response_body
# 使用例
if __name__ == "__main__":
response = asyncio.run(async_http_get("httpbin.org", 80, "/get"))
print(response.decode())
これらの実装では、h11の同じプロトコル処理コードを使いながら、I/O部分だけが同期と非同期で異なることがわかります。これこそがSans-IOアプローチの強みです。
Sans-IOのベストプラクティス
Sans-IOライブラリを効果的に使うためのアドバイスをいくつか紹介します:
1. 責任の明確な分離
プロトコル処理(Sans-IOライブラリ)とI/O処理(ネットワークコード)の責任を明確に分けます。Sans-IOライブラリはバイト列の生成と解析のみを担当し、I/Oコードはそれらのバイト列をネットワーク上で送受信する責任を持ちます。
2. エラー処理の区別
エラーには、プロトコルエラー(不正なメッセージ形式など)とI/Oエラー(接続タイムアウトなど)の2種類があります。これらは異なる方法で処理する必要があります。
Sans-IOライブラリはプロトコルエラーのみを処理し、通常は以下のような例外を提供します:
-
LocalProtocolError
: ローカル側のプロトコル違反 -
RemoteProtocolError
: リモート側のプロトコル違反
I/Oエラーは、I/Oフレームワーク側で処理します。
3. バッファリングの管理
Sans-IOライブラリは通常、不完全なメッセージを内部バッファに保存します。これは、TCP/IPの性質上、メッセージが複数のパケットに分割されて届く可能性があるためです。
過剰なメモリ使用を避けるためには、受信バッファのサイズに制限を設けるのがよいでしょう。多くのSans-IOライブラリでは、最大バッファサイズを設定するオプションが提供されています。
Sans-IOの課題と欠点
Sans-IOアプローチにも、もちろん課題や欠点があります:
1. 直感に反する設計
多くの開発者は、ネットワークプログラミングを「ソケットからデータを読み書きする」という視点で理解しています。I/Oとプロトコル処理が分離されているという考え方は、初めは受け入れにくいかもしれません。
2. コードの増加
Sans-IOライブラリを使うには、I/O処理のコードを自分で書く必要があります。これにより、全体のコード量が増える可能性があります。特に単純なユースケースでは、オールインワンのライブラリの方が簡潔なコードになるかもしれません。
3. エコシステムの発展途上
Sans-IOはまだ比較的新しいアプローチであり、すべてのプロトコルに対応するライブラリが揃っているわけではありません。また、ドキュメントやチュートリアルも、より成熟したアプローチと比べると限られていることがあります。
まとめ
Sans-IOは、ネットワークプロトコル実装とI/O処理を明確に分離することで、再利用性、テスト容易性、メンテナンス性を大幅に向上させるアプローチです。
特にPythonの非同期プログラミングが主流になりつつある現在、このアプローチの価値はますます高まっています。h11、hyper-h2、wsprotoなどのライブラリは、Sans-IOの原則に基づいた優れた実装例です。
Sans-IOには学習曲線やコード量の増加といった課題もありますが、複雑なネットワークアプリケーションや、さまざまなI/Oモデルをサポートする必要がある場合には、その利点が欠点を大きく上回ると思います。
独自プロトコルを実装する際にも参考になる設計パターンだと思います。
参考リンク
- Sans-IO解説ドキュメント - Sans-IOの概念と実装例
- h11ドキュメント - HTTP/1.1の純粋なPython実装
- hyper-h2ドキュメント - HTTP/2のPython実装
- wsprotoドキュメント - WebSocketのPython実装
- Sans-IO programming: what, why and how(PyCon UKトーク) - Sans-IOの解説動画