快轉到主要內容
  1. 教學文章/

Python socket 實戰:TCP client/server、timeout 與簡易通訊協定

·8 分鐘· loading · loading · ·
Python Socket TCP Networking Standard-Library Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 77: 本文

featured

一. 前言:HTTP 很方便,但底下其實是 byte stream
#

平常寫 Python 網路程式,我們很少直接碰 socket。 如果要打 API,用 httpxrequests。 如果要寫 server,用 FastAPI、Django、Flask。 如果要連資料庫、Redis、message queue,通常也有現成 client。 這樣很好。拍拍君不是要你回到石器時代,每天自己手刻 HTTP parser。 但懂一點 socket 很有用。 它會讓你理解幾件平常常踩到的問題:

  • 為什麼 recv() 不一定一次拿到完整訊息
  • 為什麼 timeout 要分成 connect timeout 和 read timeout
  • 為什麼 TCP 沒有所謂「一包 message」
  • 為什麼 server 要處理半斷線和空 bytes
  • 為什麼 protocol framing 比 send() 更重要 今天這篇不是要做 production-grade server。 我們會用標準庫 socket 寫一個很小的 TCP client/server,然後一步步加上:
  • timeout
  • line-based protocol
  • length-prefixed protocol
  • 簡單 echo server 看完以後,你不一定會直接拿 socket 寫服務,但你會更懂上層工具為什麼要那樣設計。 這種知識有點像知道咖啡機怎麼加壓。平常不必拆機,但出問題時你會比較不慌。

二. 安裝:標準庫內建,先開一個練習專案
#

socket 是 Python 標準庫。不用安裝。 但我們還是用 uv 開一個乾淨目錄,方便放範例:

uv init socket-lab
cd socket-lab

今天會建立幾個檔案:

socket-lab/
├── echo_server.py
├── echo_client.py
├── line_server.py
├── line_client.py
├── length_protocol.py

如果你不想用 uv,直接用系統 Python 也可以:

python3 echo_server.py

拍拍君建議先在本機練習,host 都用 127.0.0.1127.0.0.1 是 loopback address,意思是「連回自己這台機器」。 不要一開始就開到公開網路。網路程式很可愛,但直接裸奔到外面就不可愛了。

三. 第一個 TCP server:listen、accept、recv、sendall
#

先建立 echo_server.py

from __future__ import annotations
import socket
HOST = "127.0.0.1"
PORT = 8765
def main() -> None:
    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()
        print(f"listening on {HOST}:{PORT}")
        conn, addr = server.accept()
        with conn:
            print(f"connected by {addr}")
            data = conn.recv(1024)
            print(f"received: {data!r}")
            conn.sendall(data)
if __name__ == "__main__":
    main()

這裡有幾個核心概念。 socket.AF_INET 代表 IPv4。 socket.SOCK_STREAM 代表 TCP。 bind() 把 server 綁到本機 address 和 port。 listen() 開始接受連線。 accept() 會等 client 連進來,回傳一個新的 connection socket 和 client address。 注意:server socket 負責聽門鈴,conn socket 才是和某個 client 對話的連線。 先開一個終端機跑 server:

python echo_server.py

它會停在 accept() 等 client。 這是正常的。不是卡住,只是很有耐心。

四. 第一個 TCP client:connect、sendall、recv
#

另開一個終端機,建立 echo_client.py

from __future__ import annotations
import socket
HOST = "127.0.0.1"
PORT = 8765
def main() -> None:
    message = "hello from pypy\n".encode("utf-8")
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.connect((HOST, PORT))
        sock.sendall(message)
        data = sock.recv(1024)
    print(f"server replied: {data.decode('utf-8')}")
if __name__ == "__main__":
    main()

執行:

python echo_client.py

你應該會看到 client 收到原本送出的字串。 server 那邊會印出 bytes:

received: b'hello from pypy\n'

這裡第一個重要觀念出現了: socket 傳的是 bytes,不是 Python string。 所以送出前要 encode(),收到後要 decode()sendall()send() 也不一樣。 send() 可能只送出一部分 bytes,回傳實際送出的長度。 sendall() 會幫你努力送完整段資料,除非中途出錯。 初學時,優先用 sendall()。 不要跟 partial write 賭運氣。賭輸時 bug 很難看。

五. TCP 是 byte stream,不是 message stream
#

很多人第一次寫 socket 會以為:

sock.sendall(b"hello")
sock.sendall(b"world")

對方就會收到兩次:

hello
world

不一定。 TCP 只保證 bytes 順序正確、可靠送達。 它不保證你每次 sendall() 都會對應到對方一次 recv()。 對方可能收到:

helloworld

也可能先收到:

hel

下一次才收到:

loworld

這不是 Python 壞掉。這是 TCP 的本質。 所以你不能把 recv(1024) 當作「讀一則訊息」。 你只能把它當作「從 stream 裡拿一些 bytes」。 真正的訊息邊界,要由你的 protocol 定義。 常見方法有兩種:

  • 用換行符號當結尾:line-based protocol
  • 先傳長度,再傳內容:length-prefixed protocol 我們兩種都做一次。

六. Line protocol:用 \n 當訊息邊界
#

很多文字型 protocol 都是 line-based。 例如簡化想像下,你可以把一個命令寫成:

PING
ECHO hello
QUIT

每一行是一則訊息。 建立 line_server.py

from __future__ import annotations
import socket
HOST = "127.0.0.1"
PORT = 8766
def handle_line(line: str) -> str:
    command = line.strip()
    if command == "PING":
        return "PONG\n"
    if command.startswith("ECHO "):
        return command.removeprefix("ECHO ") + "\n"
    if command == "QUIT":
        return "BYE\n"
    return "ERR unknown command\n"
def main() -> None:
    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()
        print(f"line server on {HOST}:{PORT}")
        conn, addr = server.accept()
        with conn, conn.makefile("rwb") as stream:
            print(f"connected by {addr}")
            for raw_line in stream:
                line = raw_line.decode("utf-8")
                reply = handle_line(line)
                stream.write(reply.encode("utf-8"))
                stream.flush()
                if line.strip() == "QUIT":
                    break
if __name__ == "__main__":
    main()

這裡用了 conn.makefile("rwb")。 它把 socket 包成 file-like object,讓你可以用 for raw_line in stream 一行一行讀。 這對 line protocol 很方便。 再寫 line_client.py

from __future__ import annotations
import socket
HOST = "127.0.0.1"
PORT = 8766
def send_command(stream, command: str) -> str:
    stream.write((command + "\n").encode("utf-8"))
    stream.flush()
    return stream.readline().decode("utf-8").strip()
def main() -> None:
    with socket.create_connection((HOST, PORT), timeout=3) as sock:
        with sock.makefile("rwb") as stream:
            print(send_command(stream, "PING"))
            print(send_command(stream, "ECHO 拍拍君正在測 socket"))
            print(send_command(stream, "WHAT"))
            print(send_command(stream, "QUIT"))
if __name__ == "__main__":
    main()

執行順序:

python line_server.py

另一個終端機:

python line_client.py

你會看到:

PONG
拍拍君正在測 socket
ERR unknown command
BYE

line protocol 的優點是簡單、好 debug。 缺點是內容如果本身可能有換行,就要 escape 或改用別的 framing。

七. Length-prefixed protocol:先說長度,再送內容
#

如果訊息是 JSON、binary data,或內容可能包含換行,length-prefix 通常比較穩。 概念是: 先用固定長度的 header 表示 body 幾個 bytes。 例如前 4 bytes 是 unsigned integer,接著才是 payload。 建立 length_protocol.py

from __future__ import annotations
import json
import socket
import struct
from typing import Any
HEADER_SIZE = 4
def recv_exactly(sock: socket.socket, size: int) -> bytes:
    chunks: list[bytes] = []
    remaining = size
    while remaining > 0:
        chunk = sock.recv(remaining)
        if chunk == b"":
            raise ConnectionError("socket closed before receiving enough data")
        chunks.append(chunk)
        remaining -= len(chunk)
    return b"".join(chunks)
def send_message(sock: socket.socket, payload: dict[str, Any]) -> None:
    body = json.dumps(payload).encode("utf-8")
    header = struct.pack("!I", len(body))
    sock.sendall(header + body)
def recv_message(sock: socket.socket) -> dict[str, Any]:
    header = recv_exactly(sock, HEADER_SIZE)
    (body_size,) = struct.unpack("!I", header)
    body = recv_exactly(sock, body_size)
    return json.loads(body.decode("utf-8"))

struct.pack("!I", len(body)) 的意思是:

  • !:network byte order,也就是 big-endian
  • I:4-byte unsigned integer 這樣 header 永遠是 4 bytes。 接收端先讀 4 bytes,知道 body 長度,再精準讀那麼多 bytes。 recv_exactly() 是這段的重點。 它處理了 partial read。 如果你只寫:
body = sock.recv(body_size)

那你其實是在假設「一次 recv 就會拿完」。 小範例可能可以。真實網路上不保證。 拍拍君對這種「今天剛好可以」的程式碼比較沒耐心。它不是穩,只是還沒被測到。

八. timeout:不要讓程式永遠等下去
#

網路程式最討厭的一種 bug 是:什麼都沒壞,但程式永遠卡著。 可能是對方 server 沒回。 可能是網路中間斷了。 可能是 protocol 雙方都在等對方先講話。 所以 timeout 很重要。 最簡單的方式是用 socket.create_connection()

import socket
with socket.create_connection(("example.com", 80), timeout=3) as sock:
    sock.sendall(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
    sock.settimeout(3)
    data = sock.recv(4096)
print(data[:200])

create_connection(..., timeout=3) 主要控制連線階段。 sock.settimeout(3) 會影響後續 socket operation,例如 recv()send()。 真實應用裡,connect timeout 和 read timeout 常常要分開思考。 例如:

  • connect timeout:1 到 3 秒
  • read timeout:10 到 30 秒
  • long polling 或 streaming:更長,甚至特別處理 如果你之前用過 httpx.Timeout,就會知道它把 connect、read、write、pool 分開。 底層原因就在這裡。 上層工具不是故意把設定做複雜。網路本來就有不同階段。

九. recv() 回傳空 bytes 代表什麼?
#

這行很重要:

chunk = sock.recv(4096)

如果 chunk == b"",代表對方已經關閉連線。 不是「收到空訊息」。 是「這條 stream 結束了」。 所以常見讀取迴圈會長這樣:

def read_until_closed(sock: socket.socket) -> bytes:
    chunks: list[bytes] = []
    while True:
        chunk = sock.recv(4096)
        if chunk == b"":
            break
        chunks.append(chunk)
    return b"".join(chunks)

但不是所有 protocol 都適合讀到對方關閉。 HTTP/1.1、長連線、聊天服務、資料庫連線,通常一條 connection 會承載多個 request/response。 這時你更需要明確 framing,不能只靠「對方關閉」當訊息結束。 如果 client 等 server 關閉,server 又等 client 下一個命令,恭喜,兩邊就會一起安靜地發呆。 這種 bug 沒有戲劇效果。只有 timeout 之後的尷尬。

十. 常見錯誤與拍拍君的碎念
#

第一,不要忘記處理 bytes/string。 socket 收送的是 bytes。如果 protocol 是文字,明確指定 encoding。

payload = text.encode("utf-8")
text = payload.decode("utf-8")

第二,不要假設一次 recv() 就是一則訊息。 這是 socket 初學者最常見的坑。 你要有 protocol。 換行也好,長度 header 也好,總之要定義邊界。 第三,不要讓程式永遠等。 加 timeout。 即使只是內部工具,也要讓失敗有機會變成錯誤訊息,而不是變成一個永遠不動的終端機。 第四,不要一開始就開公開 host。 練習時用:

HOST = "127.0.0.1"

如果你改成:

HOST = "0.0.0.0"

意思是讓 server 聽所有網路介面。 這在 Docker 或內網服務很常見,但也表示你的服務可能被其他機器連到。 該加防火牆、認證和部署邊界時,不要裝沒看到。 第五,不要自己重寫成熟 protocol。 想打 HTTP API,用 httpx。 想寫 web API,用 FastAPI。 想要 TLS、proxy、redirect、cookie、compression,請交給成熟工具。 學 socket 是為了理解底層,不是為了把所有輪子都重刻一次。 拍拍君很喜歡理解底層,但不喜歡無謂受苦。

結語
#

今天我們用 Python 標準庫 socket 走過 TCP 的基本流程。 你看到了:

  • socket()bind()listen()accept() 的 server 流程
  • connect()sendall()recv() 的 client 流程
  • TCP 是 byte stream,不是 message stream
  • line protocol 和 length-prefixed protocol 的差別
  • timeout 為什麼重要
  • recv() 回傳空 bytes 代表連線關閉 如果你平常只用高階 HTTP client,這篇可能有一點底層。 但這種底層感很值得保留一點。 下次 API request 卡住、server 沒回應、Docker service port 打不通,你會比較知道該問什麼問題。 不是每個人都需要每天寫 socket。 但每個寫網路程式的人,都值得知道 socket 在下面默默做什麼。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 77: 本文

相關文章

Python shutil 實戰:檔案複製、搬移、壓縮與安全清理
·7 分鐘· loading · loading
Python Shutil Filesystem Automation Standard-Library Developer-Tools
Python inspect 實戰:看懂函式簽名、物件結構與開發工具自動化
·6 分鐘· loading · loading
Python Inspect Introspection Standard-Library Developer-Tools
Python tempfile 實戰:安全建立暫存檔案、目錄與測試資料
·9 分鐘· loading · loading
Python Tempfile Filesystem Testing Standard-Library Developer-Tools
Python AnyIO 實戰:TaskGroup、取消管理與跨框架非同步工具
·6 分鐘· loading · loading
Python AnyIO Async Asyncio Trio Developer-Tools
Rich Logging Dashboard 實戰:進度、表格與 Log Console 整合
·7 分鐘· loading · loading
Python Rich Logging Cli Dashboard Developer-Tools
Textual + SQLite 實戰:做一個終端機資料管理小工具
·8 分鐘· loading · loading
Python Textual SQLite TUI Database Developer-Tools