Learning Objectives
- 說明 socket 是程式使用作業系統網路功能的介面,並寫出 TCP 用戶端的 connect、sendall、recv、close
- 在 str 與 bytes 之間正確轉換(encode、decode),並說明為什麼網路上傳的是位元組
- 說明 TCP 是位元組流,recv 不一定一次收完,用迴圈讀到對方關閉或讀到換行
- 處理連線被拒絕、逾時與查不到名稱三種常見的例外
- 寫出只處理一個用戶端的 TCP 伺服器:bind、listen、accept、recv 迴圈
程式怎麼用網路
前面幾課從外面看網路:封包怎麼走、TCP 怎麼建立連線。這一課換個角度,從程式來用網路。 瀏覽器、聊天軟體、遊戲,底層都是同一件事:程式請作業系統開一條連線,把位元組送出去、收回來。
程式和作業系統網路功能之間的介面叫 socket。你可以把它想成一支電話:
| 動作 | 電話的比喻 | Python |
|---|---|---|
| 建立 socket | 拿起一支電話 | socket.socket(AF_INET, SOCK_STREAM) |
| 連線 | 撥號,對方接起來(三向交握) | s.connect((位址, 埠)) |
| 送出 | 說話 | s.sendall(資料) |
| 接收 | 聽對方說 | s.recv(最多幾個位元組) |
| 關閉 | 掛電話(送出 FIN) | s.close() |
AF_INET 表示 IPv4 位址,SOCK_STREAM 表示 TCP(下一課的 UDP 是 SOCK_DGRAM)。
連線對象寫成 (位址, 埠) 這樣的元組(tuple),例如 ("192.168.1.20", 5000)。
這一課的範例都在模擬網路上執行:你的程式跑在 PC1(192.168.1.10),伺服器 SRV 在 192.168.1.20。 每執行一步,畫面會畫出連線的時序圖(SYN、SYN-ACK、ACK、資料、FIN)。 模擬網路用虛擬時間,延遲是示意值;程式碼的寫法和在真的電腦上執行相同。 計算機概論軌第 30 課〈協定分層與 TCP/IP〉介紹過 TCP 的角色,這一課直接動手寫。
逐步看:第一個 TCP 用戶端
伺服器的 5000 埠是一個「回音」服務:收到什麼就原樣送回。注意兩件事:
connect 那一步,時序圖出現三向交握;encode 之後,9 個字元變成 15 個位元組。
str 與 bytes,以及位元組流
socket 只認得 bytes(位元組序列),不認得 Python 的 str。直接 s.sendall("hello") 會得到
TypeError: a bytes-like object is required, not 'str'。轉換的方向要記清楚:
| 方向 | 寫法 | 範例 |
|---|---|---|
| str → bytes(送出前) | 文字.encode("utf-8") |
"你好".encode("utf-8") 是 6 個位元組 |
| bytes → str(收到後) | 資料.decode("utf-8") |
b"OK\n".decode("utf-8") 是 "OK\n" |
| 直接寫 bytes 字面值 | b"..." |
b"GET club\n"(只能放 ASCII 字元) |
兩端要用同一種編碼,事先講好;現在通常是 UTF-8。HTTP 的標頭規定用 ASCII,內容的編碼則寫在標頭裡(charset=utf-8)。
還有一件更重要的事:TCP 送的是一條位元組流(byte stream),不是一則一則的訊息。
你 sendall 兩次,對方可能一次 recv 就收到兩次的內容;你 sendall 一大段,對方也可能要 recv 好幾次才收完。
TCP 只保證位元組的順序和完整,不保留送出時的分段。所以讀資料要用迴圈,讀到下面其中一種情況為止:
- 對方關閉連線:
recv回傳空的b""。加了Connection: close的 HTTP 回應就是這樣結束的。 - 讀到約定好的結尾:例如每則訊息以換行
\n結尾,讀到換行才算一則。 - 讀到約定好的長度:例如 HTTP 的
Content-Length標頭事先說好內容有幾個位元組。
逐步看:recv 迴圈讀到對方關閉
用 socket 自己送出一個 HTTP 請求。伺服器的回應有 144 個位元組,送完就關閉連線。
這個程式故意每次只拿 64 個位元組,看迴圈跑了幾次、最後怎麼知道「收完了」。with 區塊結束時會自動 close。
小測驗:bytes 與位元組流
下面的程式會印出什麼?
下面的程式模擬「收到兩段資料、接起來再切行」,會印出什麼?
用戶端連續呼叫 sendall(b"A\n") 和 sendall(b"B\n")。伺服器呼叫一次 recv(1024),可能拿到什麼?
recv 回傳空的 b"" 代表什麼?
逐步看:連線被拒絕、逾時與查不到名稱
網路程式一定會遇到失敗。這個程式依序連四個目標,各自示範一種結果。看時序圖上每一種結果長什麼樣子: 正常的一來一往、收到 RST、送出後等不到回應,以及連一個封包都還沒送出就失敗。
三種常見的失敗
| 例外 | 時序圖上看到 | 代表什麼 | 常見原因 |
|---|---|---|---|
ConnectionRefusedError |
SYN 送出,對方回 RST | 對方主機在,但那個埠沒有程式在聽 | 伺服器程式沒啟動、埠號寫錯 |
TimeoutError(socket.timeout) |
送出後等不到回應 | 在設定的時間內沒有收到東西 | 對方沒有回應、防火牆把封包丟掉、主機不存在 |
socket.gaierror |
沒有任何封包 | 名稱查不到 IP | 名稱打錯、DNS 設定有問題(第 18 課) |
這三個例外都是 OSError 的子類別。另外有一個 connect_ex:失敗時不丟例外,而是回傳錯誤代碼(0 表示成功),適合「先試試看連不連得上」的檢查程式。寫程式時的習慣:
- 一定要設逾時:
socket.create_connection((位址, 埠), timeout=秒數)或s.settimeout(秒數)。沒有設定時,recv會無限期等待,程式看起來像當掉。 - 用
with或try … finally管理 socket:不管成功或丟出例外,都會關閉,不會留下沒關的連線。 - 先接住特定的例外,再接一般的
OSError:錯誤訊息才能告訴使用者該怎麼處理(檢查埠號?檢查網路?檢查名稱?)。
TCP 伺服器:bind、listen、accept
用戶端主動撥號;伺服器則是先在某個埠等著,有人打來才接。伺服器多了三個步驟:
| 步驟 | 寫法 | 做什麼 |
|---|---|---|
| 綁定 | srv.bind(("0.0.0.0", 8000)) |
佔用本機的 8000 埠;0.0.0.0 表示所有介面都收 |
| 監聽 | srv.listen() |
開始接受連線;作業系統替你完成三向交握,排進佇列 |
| 接受 | conn, addr = srv.accept() |
從佇列拿出一條連線;沒有人連進來就一直等 |
accept 回傳一個新的 socket conn,專門和這一個用戶端通訊;原本的 srv 繼續負責等下一個人。
之後用 conn.recv、conn.sendall 收送,和用戶端的寫法一樣。
setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) 讓伺服器程式重新啟動時可以立刻再綁同一個埠。
沒有它,剛關閉的連線還在 TIME_WAIT 狀態(第 16 課)時,bind 可能失敗並出現「Address already in use」。
這一課的伺服器一次只處理一個用戶端;處理多個用戶端的寫法在下一課。
逐步看:處理一個用戶端的伺服器
你的程式跑在 PC1,當伺服器。模擬網路裡有一個用戶端 C1(192.168.1.31),0.3 秒時連進來,
隔 0.2 秒送一行,送完兩行就關閉。注意 accept 那一步,時間直接跳到有人連進來的時候。
小測驗:例外與伺服器
用戶端連到 192.168.1.20 的 5001 埠,時序圖顯示送出 SYN 後立刻收到 RST。Python 會丟出哪個例外?
伺服器的 conn, addr = srv.accept() 回傳的 conn 是什麼?
下面的程式會印出什麼?(模擬一個「讀到換行為止」的緩衝區)
重點整理
- socket 是程式使用網路的介面。TCP 用戶端:
socket(AF_INET, SOCK_STREAM)→connect→sendall→recv→close;用with確保關閉。 - socket 收送的是 bytes:送出前
encode("utf-8"),收到後decode("utf-8"),兩端要用同一種編碼。 - TCP 是位元組流,不保留送出時的分段:
recv可能只拿到一部分,也可能一次拿到好幾則。用迴圈讀到對方關閉(b"")、讀到換行,或讀到約定的長度。 - 三種常見失敗:
ConnectionRefusedError(RST,沒有程式在聽)、TimeoutError(等不到回應,一定要設逾時)、socket.gaierror(名稱查不到)。 - TCP 伺服器:
bind→listen→accept;accept回傳一個新的 socket 專門服務這個用戶端。
下一課用 UDP 寫程式,並同時服務多個用戶端,最後自己設計一個簡單的協定:訊息怎麼分段、錯誤怎麼回報。