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);
}