この記事は、Zennにも投稿しています。
はじめに
Raspberry Piで測ったセンサーの値を、ネットワーク経由でPCに送る。IoTの入門でよく作る構成です。PythonならTCPソケットで手軽に実装でき、次のようなコードを見かけることも多いと思います。
sock.send(json.dumps(readings).encode("utf-8"))
data = conn.recv(1024)
readings = json.loads(data.decode("utf-8"))
少ないデータで試すと、このコードは問題なく動きます。ところが、送るデータが増えたり、ネットワークの状態が変わったりすると、ある日突然 JSONDecodeError で止まります。
この記事では、なぜこのコードが壊れるのかと、TCPソケットでデータを確実に送受信するための方法をまとめます。
なぜ壊れるのか:TCPには「メッセージの区切り」がない
TCPは、データを**区切りのないバイトの流れ(ストリーム)**として届けるプロトコルです。送信側が1回の send で送ったデータが、受信側でも1回の recv で届くとは限りません。
- 1回で送ったデータが、何回かに分かれて届くことがある
- 続けて送った2つのデータが、くっついて届くことがある
-
recv(1024)は「最大1024バイトまで受け取る」という意味で、1024バイトより大きいデータは途中までしか受け取れない
実際に、センサーの値60件分(約3.7KB)のJSONを送って、受信側で recv(1024) を1回だけ呼んでみると、次のようになります。
送信バイト数: 3780 結果: [('error', 'JSONDecodeError', 1024)]
最初の1024バイトしか受け取れず、JSONとして途中で切れているため、読み取りに失敗しました。少ないデータで試しているうちは、たまたま1回の recv で全部届いているだけなのです。
送信側の send にも同じ問題があります。send は、渡したデータの一部しか送らずに戻ってくることがあります。全部送り切るまで繰り返してくれる sendall を使いましょう。
解決策:自分で「区切り」を決める
TCPに区切りがない以上、どこからどこまでが1つのメッセージなのかを、送信側と受信側で決めておく必要があります。代表的な方法は2つです。
| 方法 | 仕組み | 向いているケース |
|---|---|---|
| 長さを先に送る | 本体の前に「本体が何バイトか」を送る | 中身に何が入るかわからないデータ、大きいデータ |
| 改行で区切る | 1件のメッセージを1行にして、改行で区切る | JSONのように、中身に改行を含まないテキスト |
方法1:長さを先に送る
本体(JSON)の前に、本体のバイト数を4バイトの数値として付けて送ります。受信側は、まず4バイトを読んで長さを知り、その長さの分だけ受け取るまで recv を繰り返します。
import json
import struct
HEADER = struct.Struct("!I") # 本体の長さを入れる4バイトのヘッダー
def send_message(sock, obj):
body = json.dumps(obj).encode("utf-8")
sock.sendall(HEADER.pack(len(body)) + body) # 長さ → 本体の順に送る
def recv_exact(sock, size):
"""ちょうど size バイト受け取るまで recv を繰り返す"""
data = b""
while len(data) < size:
chunk = sock.recv(size - len(data))
if not chunk: # 相手が接続を閉じた
raise ConnectionError(f"{size}バイト受け取る前に接続が切れました")
data += chunk
return data
def recv_message(sock):
"""メッセージを1件受け取る。相手が正常に接続を閉じたら None を返す"""
first = sock.recv(1)
if not first:
return None
(length,) = HEADER.unpack(first + recv_exact(sock, HEADER.size - 1))
return json.loads(recv_exact(sock, length).decode("utf-8"))
ポイントは、recv_exact で指定したバイト数がそろうまで recv を繰り返すことです。データが何回に分かれて届いても、正しく1件のメッセージを組み立てられます。
日本語(UTF-8)は1文字が3バイトなので、recv の区切りが文字の途中に来ることもあります。そのため、全部受け取ってからデコードするのが安全です。この実装では、メッセージ全体がそろってから decode しています。
相手がメッセージとメッセージの間で接続を閉じたときは None、メッセージの途中で切れたときは ConnectionError にして、正常な切断と異常な切断を区別しています。
方法2:改行で区切る
JSONを1件ずつ1行にして、改行(\n)で区切って送る方法です。JSON Lines(newline-delimited JSON)という形式として仕様がまとめられています。受信側は、ソケットをファイルのように扱える makefile を使うと、改行が来るまでデータをためる処理を自分で書かずに済みます。
import json
import socket
def send_line(sock, obj):
sock.sendall((json.dumps(obj) + "\n").encode("utf-8")) # 1件ごとに改行で区切る
def receive_lines(conn):
with conn, conn.makefile("r", encoding="utf-8") as f:
for line in f: # 改行が来るまで、自動でデータをためてくれる
yield json.loads(line)
JSONの仕様では、文字列の中の改行などの制御文字は必ずエスケープすることになっています。そのため、json.dumps で作ったJSONは、中身に改行が入っていても \n という文字に置き換えられて1行に収まり、区切りが崩れることはありません。シンプルに書きたいときはこちらがおすすめです。
Pythonのドキュメントでは、makefile で作ったファイルオブジェクトは、タイムアウトが発生すると内部のバッファが不整合な状態になることがあると注意されています。受信側でタイムアウトを設定したい場合は、方法1のように recv を自分で呼ぶ実装のほうが扱いやすいです。
実際の送信側・受信側
方法1を使って、IoTで使いやすい形にした送信側と受信側の例です。
受信側(PC)
import socket
import threading
from protocol import recv_message
HOST = "0.0.0.0" # 他の機器からの接続を受け付ける
PORT = 8765
def handle(conn, addr):
with conn:
conn.settimeout(30) # 30秒何も届かなければ切断する
try:
while (message := recv_message(conn)) is not None:
print(addr, message)
print(addr, "相手が接続を閉じました")
except (ConnectionError, TimeoutError) as e:
print(addr, "異常な切断:", e)
def main():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen()
while True:
conn, addr = server.accept()
threading.Thread(target=handle, args=(conn, addr), daemon=True).start()
if __name__ == "__main__":
main()
ここでのポイントは3つです。
-
HOST = "0.0.0.0"にする:"localhost"や"127.0.0.1"にすると、同じPCの中からしか接続できません。Raspberry Piなど他の機器から受け付けるには、"0.0.0.0"(すべてのネットワークインターフェース)にします。Pythonでは空文字列""でも同じ意味になります - 接続ごとにスレッドを分ける:複数のRaspberry Piから同時に接続されても、1台ずつ待たせずに受け付けられます
-
タイムアウトを設定する:送信側の機器の電源が突然切れると、受信側は「まだつながっている」と思ったまま待ち続けることがあります。一定時間データが届かなければ切断するようにしておきます。タイムアウトすると
TimeoutErrorが発生します(Python 3.10より前はsocket.timeout)
送信側(Raspberry Pi)
import random
import socket
import time
from protocol import send_message
SERVER = ("192.168.0.10", 8765) # 受信側(PC)のIPアドレスとポート
def connect_with_retry(address, max_wait=60):
wait = 1
while True:
try:
return socket.create_connection(address, timeout=5)
except OSError as e:
print(f"接続できませんでした({e})。約{wait}秒後に再接続します")
time.sleep(wait + random.uniform(0, 1)) # ばらつきを加えて、同時の再接続を避ける
wait = min(wait * 2, max_wait) # 待ち時間を少しずつ延ばす
def main():
sock = connect_with_retry(SERVER)
while True:
reading = {"device": socket.gethostname(), "temperature": 25.0, "humidity": 60.0}
try:
send_message(sock, reading)
except OSError:
sock.close()
sock = connect_with_retry(SERVER) # 切れたらつなぎ直す
continue
time.sleep(60)
if __name__ == "__main__":
main()
IoT機器は、Wi-Fiが不安定だったり、受信側のPCがまだ起動していなかったりと、つながらないことが当たり前の環境で動きます。そこで、接続に失敗したら待ち時間を1秒・2秒・4秒…と延ばしながら再接続するようにしています(指数バックオフ)。すぐに何度も再接続しようとして、ネットワークや受信側に負担をかけないためです。
さらに、待ち時間にランダムなばらつき(ジッター)を加えています。停電から復旧したときなどに、たくさんの機器が同じタイミングで再接続しようとすると、受信側に接続が集中してしまうからです。
機器の見分け方にも注意
複数の機器からデータを受け取るときは、データに「どの機器から送られたか」を入れておくと便利です。このとき、MACアドレスを取得する目的で uuid.getnode() を使う例をよく見かけますが、注意が必要です。
Pythonのドキュメントによると、uuid.getnode() はMACアドレスを取得できなかった場合、ランダムな値を返します。そのため、環境によっては起動するたびに違う値になり、同じ機器のデータがバラバラの機器のものとして記録されてしまうことがあります。
機器を確実に見分けたい場合は、次のような値をIDとして使うほうが安全です。
- ホスト名(
socket.gethostname())。機器ごとに重ならない名前を付けておく - 設定ファイルに書いた、機器ごとのID
本番で使うなら:セキュリティと他の選択肢
ここで紹介したソケット通信は、データを暗号化しておらず、送信元の認証もしていません。同じネットワークの中なら、誰でもデータを盗み見たり、偽のデータを送り込んだりできてしまいます。自宅や実験用のネットワークの中で使う分には問題になりにくいですが、インターネットを経由する場合は、TLSで暗号化するなどの対策が必要です。
また、IoTのデータを集める用途では、MQTTという軽量な通信プロトコルもよく使われます。送信側は「ブローカー」と呼ばれる中継サーバーにデータを送り(Publish)、受信側はブローカーから必要なデータを受け取ります(Subscribe)。送信側と受信側が直接つながらないので、それぞれを独立して動かせます。ほかにも、次のような特徴があります。
- 配信の確実さを3段階(QoS 0:最大1回、QoS 1:最低1回、QoS 2:ちょうど1回)から選べる
- TLSによる暗号化や、クライアントの認証に対応している
- クライアントが軽量で、小さなマイコンでも動かせる
まずはソケットで通信の仕組みを理解し、台数が増えたり、本番で運用したりする段階になったら、MQTTのような仕組みを検討するとよいと思います。
まとめ
| 落とし穴 | 対策 |
|---|---|
recv(1024) 1回では、データが途中までしか届かないことがある |
「長さを先に送る」か「改行で区切る」で、メッセージの区切りを決める |
send は一部しか送らないことがある |
sendall を使う |
日本語が recv の途中で分割されることがある |
メッセージ全体を受け取ってからデコードする |
localhost で待ち受けると、他の機器から接続できない |
0.0.0.0 で待ち受ける |
| 相手が突然いなくなると、待ち続けてしまう | タイムアウトを設定する |
| つながらないと送信側が止まる | 待ち時間を延ばしながら再接続する |
uuid.getnode() はランダムな値を返すことがある |
ホスト名や設定ファイルのIDで機器を見分ける |
「少ないデータで試したら動いた」通信のコードほど、データが増えたときや、ネットワークが不安定なときに壊れやすいものです。TCPは「区切りのないバイトの流れ」であることを意識して、確実に送受信できる仕組みにしておきましょう。
参考資料
-
socket — 低水準ネットワークインターフェース(Python ドキュメント):
send・sendall・recv・makefile・settimeoutの仕様、空文字列での待ち受け -
ソケットプログラミング HOWTO(Python ドキュメント):
send・recvがすべてのバイトを扱うとは限らないこと、メッセージの区切り方、localhostで待ち受けたときの見え方 -
uuid — UUID objects according to RFC 9562(Python ドキュメント):
uuid.getnode()の仕様 - JSON Lines:改行で区切ったJSONの形式
- RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format:文字列の中でエスケープが必要な文字(7章)
- Exponential Backoff And Jitter(AWS Architecture Blog):指数バックオフとジッター
- MQTT: The Standard for IoT Messaging:MQTTの特徴(Publish/Subscribe、QoS、セキュリティ)