Python'da JSON'u IPv4 TCP Soketleri Üzerinden Göndermek

Selamlar, bu yazımda Python ile iki uç arasında JSON mesajları taşırken nereye dikkat etmemiz gerektiğine bakacağız. Konu sıkıcı gelebilir ama TCP'nin küçük bir detayı var ki, ilk öğrendiğinizde 'biz bunu nasıl gözden kaçırdık' dedirtir. Hadi başlayalım.

Çerçeveleme problemi nedir?

TCP bir bayt akışıdır, mesaj sınırı diye bir kavramı yoktur. Yani siz send ile {'a': 1} gönderirsiniz, karşı taraf bunu tek seferde alacak diye bir garanti yok. Belki iki parça gelir, belki başka bir mesajla yapışık gelir, belki tam ortasından bölünür.

Buradan ne çıkarmalıyız? Mesaj sınırını biz koymak zorundayız. Buna kısaca framing (çerçeveleme) deniyor. En yaygın yol da her mesajın başına o mesajın uzunluğunu yazmaktır:

[4 bayt uzunluk][JSON içeriği]

İlk 4 baytı okuyup payload'un kaç bayt olacağını öğreniriz, sonra tam o kadar bayt okuruz. Bu kadar.

Yardımcı fonksiyonlar

İki ucun da kullanacağı üç küçük fonksiyon yazalım. Ben uzunluğu big-endian unsigned int olarak paketliyorum, ağ protokollerinde gelenek bu yöndedir:

import json
import socket
import struct


def send_json(sock: socket.socket, data: dict) -> None:
    """Veriyi JSON'a çevirip 4 baytlık uzunluk önekiyle gönderir."""
    payload = json.dumps(data).encode('utf-8')
    header = struct.pack('>I', len(payload))
    sock.sendall(header + payload)


def recv_json(sock: socket.socket) -> dict:
    """Soketten uzunluk önekli bir JSON mesaj okur."""
    raw_len = recvn(sock, 4)
    if not raw_len:
        raise ConnectionError('Header okunurken bağlantı kapandı')

    msg_len = struct.unpack('>I', raw_len)[0]
    raw_payload = recvn(sock, msg_len)
    if not raw_payload:
        raise ConnectionError('Payload okunurken bağlantı kapandı')

    return json.loads(raw_payload.decode('utf-8'))


def recvn(sock: socket.socket, n: int) -> bytes:
    """Soketten tam olarak n bayt okur."""
    buf = b''
    while len(buf) < n:
        chunk = sock.recv(n - len(buf))
        if not chunk:
            return b''
        buf += chunk
    return buf

Buradaki kritik nokta recvn. socket.recv(n) size en fazla n bayt döner, ama daha az da dönebilir. Bu yüzden 4 bayt istediğinizde 4 bayt alana kadar döngü kurmak şart. Ben de ilk öğrendiğimde 'tek recv yeter' diye düşünüp epey saatimi yemiştim, sizi aynı tuzaktan korumaya çalışıyorum.

Sunucu tarafı

Echo mantığında basit bir sunucu yazalım, her bağlantıyı ayrı bir thread'de karşılayalım:

import socket
import threading

HOST = '0.0.0.0'
PORT = 9009


def handle_client(conn: socket.socket, addr: tuple) -> None:
    print(f'İstemci bağlandı: {addr}')
    with conn:
        while True:
            try:
                msg = recv_json(conn)
                print(f'{addr} -> {msg}')
                response = {'status': 'ok', 'echo': msg}
                send_json(conn, response)
            except (ConnectionError, json.JSONDecodeError) as e:
                print(f'İstemci {addr} hatası: {e}')
                break


with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind((HOST, PORT))
    srv.listen(10)
    print(f'JSON sunucusu {PORT} portunda')
    while True:
        conn, addr = srv.accept()
        threading.Thread(target=handle_client, args=(conn, addr), daemon=True).start()

SO_REUSEADDR küçük ama hayat kurtaran bir bayrak: sunucuyu öldürüp yeniden açtığınızda 'Address already in use' hatasıyla uğraşmazsınız.

İstemci tarafı

İstemci tarafı çok daha sade. Bağlanırız, gönderirsiniz, okur döneriz:

import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
    client.connect(('127.0.0.1', 9009))
    request = {
        'action': 'create_user',
        'username': 'alice',
        'email': 'alice@example.com',
    }
    send_json(client, request)
    response = recv_json(client)
    print(f'Sunucu cevabı: {response}')

Sık karşılaşılan hatalar

  • recv çağrısının döndüğü bayt sayısını saymamak: 4 bayt istediniz, 2 bayt aldınız. Kalan 2'yi unutursanız bir sonraki mesajın başını uzunluk olarak yorumlarsınız. Sonuç: protokol komple kayar.
  • Uzunluk için sınır koymamak: Bozuk bir istemci 0xFFFFFFFF yollarsa server 4 GB ayırmaya çalışır. Üst limit koyun (if msg_len > 10 * 1024 * 1024: raise ...).
  • Endianlık tutarsızlığı: Bir tarafta >I, diğerinde <I kullanırsanız ilk birkaç mesajda her şey iyi gider, sonra durup dururken patlar. İki tarafı da big-endian'da sabit tutun.
  • Çok büyük payload'ları düz JSON ile yollamak: 50 MB JSON ağdan geçerken hem yavaştır hem belleği zorlar. Büyük veri varsa zlib.compress ile sıkıştırın, header yine 4 bayt kalır.

Kapanış

Bu yazıda TCP üzerinden JSON taşımanın temel hilesi olan uzunluk önekli çerçevelemeye baktık. Bence bu kalıbı bir kez doğru oturttuktan sonra üzerine TLS, sıkıştırma, hatta basit bir RPC katmanı eklemek çok daha kolaylaşıyor; asıl yapılmaması gereken şey 'sınırları nasılsa TCP halleder' diye düşünmek. Umarım faydalı olur, görüşmek üzere.