Socket 是做什麼的?
雖然 socket 接口理論上還允許訪問除 IP 以外的協議系列,然而在實際上,socket應用程序中使用的每個網絡層都將使用
IP。對於本教程來說,我們僅介紹 IPv4;將來 IPv6 也會變得很重要,但是它們在原理是相同的。在傳輸層,socket
支持兩個特殊協議:TCP (transmission control protocol,傳輸控制協議) 和 UDP (user
datagram protocol,用戶數據報協議)。
Socket不能用來訪問較低(或較高)的網絡層。例如,socket 應用程序不知道它是運行在以太網、令牌環網還是撥號連接上。Socket 的僞層(pseudo-layer)也不知道高層協議(比如 NFS、HTTP、FTP等)的任何情況(除非您自己編寫一個 socket 應用程序來實現那些高層協議)。
在很多情況下,socket接口並不是用於網絡編程 API 的最佳選擇。特別地,由於存在很多很優秀的庫可以直接使用高層協議,您不必關心 socket 的細節;那些庫會爲您處理 socket 的細節。例如,雖然編寫您自己的 SSH 客戶機並沒有什麼錯,但是對於僅只是爲了讓應用程序安全地傳輸數據來說,就沒有必要做得這樣複雜。低級層比 socket 所訪問的層更適合歸入設備驅動程序編程領域。
IP、TCP 和 UDP
正如上一小節所指出的,當您編寫 socket 應用程序的時候,您可以在使用 TCP 還是使用 UDP 之間做出選擇。它們都有各自的優點和缺點。
TCP 是流協議,而UDP是數據報協議。換句話說,TCP 在客戶機和服務器之間建立持續的開放連接,在該連接的生命期內,字節可以通過該連接寫出(並且保證順序正確)。然而,通過 TCP 寫出的字節沒有內置的結構,所以需要高層協議在被傳輸的字節流內部分隔數據記錄和字段。
另一方面,UDP 不需要在客戶機和服務器之間建立連接,它只是在地址之間傳輸報文。UDP 的一個很好特性在於它的包是自分隔的(self-delimiting),也就是一個數據報都準確地指出它的開始和結束位置。然而,UDP 的一個可能的缺點在於,它不保證包將會按順序到達,甚至根本就不保證。當然,建立在 UDP 之上的高層協議可能會提供握手和確認功能。
對於理解 TCP 和 UDP 之間的區別來說,一個有用的類比就是電話呼叫和郵寄信件之間的區別。在呼叫者用鈴聲通知接收者,並且接收者拿起聽筒之前,電話呼叫不是活動的。只要沒有一 方掛斷,該電話信道就保持活動,但是在通話期間,他們可以自由地想說多少就說多少。來自任何一方的談話都按臨時的順序發生。另一方面,當你發一封信的時 候,郵局在投遞時既不對接收方是否存在作任何保證,也不對信件投遞將花多長時間做出有力保證。接收方可能按與信件的發送順序不同的順序接收不同的信件,並 且發送方也可能在他們發送信件是交替地接收郵件。與(理想的)郵政服務不同,無法送達的信件總是被送到死信辦公室處理,而不再返回給發送者。
對等方、端口、名稱和地址
除了 TCP 和 UDP 協議以外,通信一方(客戶機或者服務器)還需要知道的關於與之通信的對方機器的兩件事情:IP 地址或者端口。IP
地址是一個 32 位的數據值,爲了人們好記,一般用圓點分開的 4 組數字的形式來表示,比如:64.41.64.172。端口是一個 16
位的數據值,通常被簡單地表示爲一個小於 65536 的數字。大多數情況下,該值介於 10 到 100 的範圍內。一個 IP
地址獲取送到某臺機器的一個數據包,而一個端口讓機器決定將該數據包交給哪個進程/服務(如果有的話)。這種解釋略顯簡單,但基本思路是正確的。
上面的描述幾乎都是正確的,但它也遺漏了一些東西。大多數時候,當人們考慮 Internet 主機(對等方)時,我們都不會記憶諸如 64.41.64.172這樣的數字,而是記憶諸如 gnosis.cx 這樣的名稱。爲了找到與某個特定主機名稱相關聯的 IP 地址,一般都使用域名服務器(DNS),但是有時會首先使用本地查找(經常是通過 /etc/hosts 的內容)。對於本教程,我們將一般地假設有一個 IP 地址可用,不過下一小節將討論編寫名稱/地址查找代碼。
主機名稱解析
在 C 中,標準庫調用 gethostbyname() 用於名稱查找。下面是 nslookup 的一個簡單的命令行工具實現;要改編它以用於大型應用程序是一件簡單的事情。當然,使用 C 要比使用 Python 稍微複雜一點。
/* Bare nslookup utility (w/ minimal error checking) */
#include <stdio.h> /* stderr, stdout */
#include <netdb.h> /* hostent struct, gethostbyname() */
#include <arpa/inet.h> /* inet_ntoa() to format IP address */
#include <netinet/in.h> /* in_addr structure */
int main(int argc, char **argv) {
struct hostent *host; /* host information */
struct in_addr h_addr; /* Internet address */
if (argc != 2) {
fprintf(stderr, "USAGE: nslookup <inet_address>/n");
exit(1);
}
if ((host = gethostbyname(argv[1])) == NULL) {
fprintf(stderr, "(mini) nslookup failed on '%s'/n", argv[1]);
exit(1);
}
h_addr.s_addr = *((unsigned long *) host->h_addr_list[0]);
fprintf(stdout, "%s/n", inet_ntoa(h_addr));
exit(0);
}
注意,gethostbyname() 的返回值是一個 hostent 結構,它描述該名稱的主機。該結構的成員 host->h_addr_list 包含一個地址表,其中的每一項都是一個按照“網絡字節順序”排列的 32 位值;換句話說,字節順序可能是也可能不是機器的本機順序。爲了將這個 32 位值轉換成圓點隔開的四組數字的形式,請使用 inet_ntoa() 函數。
編寫 socket 客戶機的步驟
編寫客戶機應用程序所涉及的步驟在 TCP 和 UDP 之間稍微有些區別。對於二者來說,您首先都要創建一個 socket;單對 TCP
來說,下一步是建立一個到服務器的連接;向該服務器發送一些數據;然後再將這些數據接收回來;或許發送和接收會在短時間內交替;最後,在 TCP
的情況下,您要關閉連接。
TCP 回顯客戶機(客戶機設置)
首先,我們來看一個 TCP 客戶機。在本教程系列的第二部分,我們將做一些調整,用 UDP 來(粗略地)做同樣的事情。我們首先來看前面幾行:一些 include 語句,以及創建 socket 的語句。
#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#define BUFFSIZE 32
void Die(char *mess) { perror(mess); exit(1); }
這裏沒有太多的設置,只是分配了特定的緩衝區大小,它限定了每個過程中回顯的數據量(但如果必要的話,我們可以循環通過多個過程)。我們還定義了一個小的錯誤函數。
TCP 回顯客戶機(創建 socket)
socket()調用的參數決定了 socket 的類型:PF_INET 只是意味着它使用 IP(您將總是使用它); SOCK_STREAM 和 IPPROTO_TCP 配合用於創建 TCP socket。
int main(int argc, char *argv[]) {
int sock;
struct sockaddr_in echoserver;
char buffer[BUFFSIZE];
unsigned int echolen;
int received = 0;
if (argc != 4) {
fprintf(stderr, "USAGE: TCPecho <server_ip> <word> <port>/n");
exit(1);
}
/* Create the TCP socket */
if ((sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) {
Die("Failed to create socket");
}
說返回的值是一個 socket 句柄,它類似於文件句柄。特別地,如果 socket 創建失敗,它將返回 -1 而不是正數形式的句柄。
TCP 回顯客戶機(建立連接)
現在我們已經創建了一個 socket 句柄,還需要建立與服務器的連接。連接需要有一個描述服務器的 sockaddr
結構。特別地,我們需要使用echoserver.sin_addr.s_addr 和 echoserver.sin_port
來指定要連接的服務器和端口。我們正在使用 IP 地址這一事實是通過echoserver.sin_family 來指定的,但它總是被設置爲
AF_INET。
/* Construct the server sockaddr_in structure */
memset(&echoserver, 0, sizeof(echoserver)); /* Clear struct */
echoserver.sin_family = AF_INET; /* Internet/IP */
echoserver.sin_addr.s_addr = inet_addr(argv[1]); /* IP address */
echoserver.sin_port = htons(atoi(argv[3])); /* server port */
/* Establish connection */
if (connect(sock,
(struct sockaddr *) &echoserver,
sizeof(echoserver)) < 0) {
Die("Failed to connect with server");
}
與創建 socket 類似,在嘗試建立連接時,如果失敗,則返回-1,否則 socket 現在就準備好發送或接收數據了。
TCP回顯客戶機(發送/接收數據)
現在連接已經建立起來,我們準備好可以發送和接收數據了。send() 調用接受套接字句柄本身、要發送的字符串、所發送的字符串的長度(用於驗證)和一個標記作爲參數。一般情況下,表記的默認值爲 0。send() 調用的返回值是成功發送的字節的數目。
/* Send the word to the server */
echolen = strlen(argv[2]);
if (send(sock, argv[2], echolen, 0) != echolen) {
Die("Mismatch in number of sent bytes");
}
/* Receive the word back from the server */
fprintf(stdout, "Received: ");
while (received < echolen) {
int bytes = 0;
if ((bytes = recv(sock, buffer, BUFFSIZE-1, 0)) < 1) {
Die("Failed to receive bytes from server");
}
received += bytes;
buffer[bytes] = '/0'; /* Assure null terminated string */
fprintf(stdout, buffer);
}
rcv() 調用不保證會獲得某個特定調用中傳輸的每個字節。在接收到某些字節之前,它只是處於阻塞狀態。所以我們讓循環一直進行,直到收回所發送的全部字節。很明顯,不同的協議可能決定以不同的方式(或許是字節流中的分隔符)決定何時終止接收字節。
TCP 回顯客戶機(包裝)
對 send() 和 recv()
的調用在默認的情況下都是阻塞的,但是通過改變套接字的選項以允許非阻塞的套接字是可能的。然而,本教程不會介紹創建非阻塞套接字的細節,也不介紹在生產
服務器中使用的諸如分支、線程或者一般異步處理(建立在非阻塞套接字基礎上)之類的細節。這些問題將在本教程的第二部分介紹。
在這個過程的末尾,我們希望在套接字上調用 close() ,這很像我們對文件句柄所做的那樣:
fprintf(stdout, "/n");
close(sock);
exit(0);
}