JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

撰寫一個 TCP 客戶端

第 1 和第 2 篇給了你通訊端的概念,以及它對話的那些位址;現在我們來寫一個真正的客戶端,撥號連到伺服器、送出一行字、讀回回覆。四個呼叫幾乎包辦了所有工作——而其中一個,recv(),藏著一個幾乎咬中每個初學者的陷阱。

四個呼叫,照順序來

從第 1 篇你帶著這幅畫面:一個 TCP 通訊端是兩支程式之間的雙向管子,而一旦它連上,你讀它、寫它,幾乎就和讀寫一個檔案一模一樣。從第 2 篇你帶著另外一半:一台伺服器由一組 IP 位址與連接埠來命名,而這些數字是以網路位元組序在線路上傳遞的。客戶端是發起對話的那一方——它主動去聯絡一台已經在等待的伺服器。寫一個客戶端,主要就是把四個客戶端呼叫照正確的順序做出來:socket()、connect()、send()、recv(),然後 close()。

把順序平實地走一遍。socket() 向核心要一個全新的、尚未連線的通訊端,並交回給你一個檔案描述符——一個小小的 int,和 open() 給你開檔案時的那種把手是同一類東西。connect() 拿著那個描述符、再加上一個填好的位址,去和伺服器完成三向交握;當它回傳成功時,你就有了一條活的連線。在那之後,send() 把位元組推出去、recv() 把位元組拉進來,照你的協定需要的任何樣式。完事了,close() 就把連線拆掉。一個客戶端的一生,就裝在這五個步驟裡。

把位址填好

在 connect() 能撥號給任何人之前,你必須交給它一個位址,而且要是核心期望的那個精確形狀:給 IPv4 用的 struct sockaddr_in。第 2 篇教過欄位規則;這裡把整件事放在一處。你把結構清零、把家族設成 AF_INET、用 htons() 設連接埠好讓它落在網路位元組序、再用 inet_pton() 設 IP——它會把像 "127.0.0.1" 這樣的文字位址,解析成結構要的那種打包好的二進位。用 "127.0.0.1"——也就是回送位址——意思是「跟這台機器上的一個伺服器講話」,而那正是學習階段你想要的,還沒牽涉到任何真正的網路。

有一個誠實的但書:inet_pton() 只解析數字形式的 IP,不解析像 "example.com" 這樣的名稱。把主機名稱變成位址,是 DNS 的工作,由 getaddrinfo() 來做——那是真實客戶端解析名稱的、現代且同時友善於 IPv4 與 IPv6 的做法。我們這裡用一個字面的回送 IP,是為了讓第一個客戶端一次只面對一個新概念;等光禿禿的 connect() 跑通了,getaddrinfo() 是一個小而值得的下一步。

完整的客戶端,逐一檢查錯誤

這是一個完整的客戶端,它連到這台機器上連接埠 8080 的一台伺服器,送出一行字,把回來的任何東西印出來,然後結束。每一個可能失敗的呼叫都檢查了——一支忽略錯誤的通訊端程式,在你的筆電上會看似正常,卻會在某個連接埠忙碌、或某台伺服器掛掉的第一時間翻車。從頭讀到尾;它正是第一節那個五步骨架,被做成真的。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main(void) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);   /* 1. unconnected TCP socket */
    if (fd < 0) { perror("socket"); return 1; }

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof addr);              /* zero every byte first */
    addr.sin_family = AF_INET;
    addr.sin_port   = htons(8080);              /* port -> network order */
    if (inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) != 1) {
        fprintf(stderr, "bad address\n");
        close(fd);
        return 1;
    }

    /* 2. connect: performs the three-way handshake */
    if (connect(fd, (struct sockaddr *)&addr, sizeof addr) < 0) {
        perror("connect");
        close(fd);
        return 1;
    }

    const char *msg = "hello\n";               /* 3. send one line */
    if (send(fd, msg, strlen(msg), 0) < 0) {
        perror("send");
        close(fd);
        return 1;
    }

    char buf[256];                             /* 4. read one reply chunk */
    ssize_t n = recv(fd, buf, sizeof buf - 1, 0);
    if (n < 0)      { perror("recv"); close(fd); return 1; }
    else if (n == 0) printf("server closed the connection\n");
    else { buf[n] = '\0'; printf("got: %s", buf); }

    close(fd);                                 /* 5. tear it down */
    return 0;
}

/* build:  gcc -O2 -Wall client.c -o client  &&  ./client */
一個完整的 TCP 客戶端。socket() 裡的 SOCK_STREAM 就是讓它成為 TCP、而非 UDP 的關鍵。connect() 呼叫上那個 (struct sockaddr *) 轉型是必要的,因為這套 API 比各協定專屬的位址結構還要老。perror() 會從 errno 印出一個人類看得懂的原因,而上面每一個呼叫在失敗時都會設定 errno。

有兩個細節值回票價。第一,send() 與 recv() 回傳一個 ssize_t——一個有號的大小——正是為了能回傳 -1 表示錯誤;把那個計數當成一個可能為負的數來對待,絕不要把它當成一個溢繞過的無號數。第二,那行 buf[n] = '\0' 很重要:recv() 給你的是原始位元組,不是一個 C 字串,所以它不會替你放一個結尾的零。如果你沒寫那個 '\0' 就 printf("%s", buf),你會越過真正的資料、印進後面跟著的任何垃圾——一個小臭蟲,代價卻很難看。

陷阱:recv() 不是「收一個訊息」

現在來談這篇裡單一最重要的概念,也是幾乎每個初學者都會弄錯的那一個。上面那個客戶端只呼叫 recv() 一次,並假設它收到了整個回覆。在一個小小的回送測試上,它通常真的收到了——而那正是這個臭蟲藏身之處。但 TCP 並不遞送訊息;它遞送的是一道位元組串流。那道串流裡頭沒有訊息邊界。如果伺服器送出 "hello world",單一一次 recv() 可能這次交給你 "hello wor"、下一次呼叫才給 "ld"。兩次各送 "abc" 與 "def" 的 send(),可能會以一次 recv() 收到 "abcdef" 抵達。串流保留位元組的順序,卻完全不保留一次 send 在哪裡結束。

所以 recv() 回傳的那個計數,是它這一次給你的計數——從 1 一直到你的緩衝區大小都有可能,而你必須準備好接受比你期望的還少。這是你在檔案那邊遇過的短讀的網路表親:單一一次呼叫可能只做了部分工作。要讀取一個已知的量、或讀到伺服器關閉為止,誠實的做法是一個迴圈,不斷呼叫 recv() 並累積位元組,直到你湊齊你的協定所定義的、一個完整的回覆為止。

  1. 為你的協定決定「一個完整的回覆」是什麼意思——固定數量的位元組、以 '\n' 結尾的一行,或是「一直到伺服器關閉為止的全部」。
  2. 對著你緩衝區的下一個空位呼叫 recv();它回傳 n,也就是它這一次實際遞送的位元組數。
  3. 若 n > 0,把你的寫入位置往前推 n,並檢查你現在是否握有一個完整的回覆;若還沒有,就回到迴圈再呼叫一次 recv()。
  4. 若 n == 0,表示伺服器乾淨地關閉了連線(串流結束);若 n < 0,那是一個真正的錯誤——檢查 errno 並停止。

send() 也可能只送一部分,以及其他誠實的邊角

鏡像也一樣真實:send() 也可能只送一部分。它回傳它實際排入佇列的位元組數,那可能比你要求的少——尤其是當核心的傳送緩衝區快滿時的一次大寫入。穩健的樣式是用迴圈,把你還沒送出去的那一段送出去、每次依回傳的計數把一個指標往前推,直到全部都出去為止。對回送上一個六位元組的 "hello\n",你基本上永遠不會看到一次短送,而那恰恰是初學者為什麼會寫出只在負載下才壞掉的程式碼的原因。現在就把這個迴圈做出來,你就永遠不會被咬。

還有兩個值得誠實點名的邊角。第一,TCP 通訊端上的 send() 與 recv() 非常接近 write() 與 read()——事實上 write(fd, ...) 和 read(fd, ...) 在一個連上的通訊端上就能用——但具名的這一對帶著一個有用的旗標引數(我們這裡傳 0),且是網路程式碼的慣用選擇。第二,目前為止每一個呼叫都是阻塞呼叫:connect() 等交握、recv() 等資料抵達、send() 可能等緩衝區空間。那是最簡單、最好教的模型,對一個第一支客戶端也恰恰正確。本章節的最後一篇會示範如何讓這些呼叫等待——一旦你必須同時兼顧許多條連線,那就會派上用場。

你現在會做什麼,以及接下來是什麼

盤點一下一項貨真價實的技能。你現在能建立一個 TCP 通訊端、用一個網路序的連接埠和一個由 inet_pton() 解析的 IP 把 sockaddr_in 填好、跨過交握connect()、送出一個請求並收下一個回覆、檢查每一個回傳值,並乾淨地關閉。同樣重要的是,你懂了那件區隔「能用的網路程式碼」與「脆弱的展示品」的事:TCP 是一道沒有訊息邊界的位元組串流,所以你用迴圈來讀、用迴圈來寫,絕不靠單一一個滿懷希望的呼叫。

有一個顯而易見的缺角:沒有任何東西在連接埠 8080 上聆聽,所以現在就跑這個客戶端,connect() 會以「連線被拒」失敗。那正是另外一半登場的暗號。第 4 篇〈撰寫一個 TCP 伺服器〉會把你的客戶端一直想連上的那支程式建起來——也就是做 bind()、listen()、accept()、並等待客戶端上門的那一方。兩半合在一起,就構成你將會從頭到尾組起來並跑一遍的、經典的回聲主從程式。接著第 5 篇會重訪 send() 與 recv()、把它們變成非阻塞的,好讓一支程式能服務許多客戶端,而不在任何單獨一個身上凍住。