TCP/IP协议族(第4版) 第17章 试读

 

  第四部分
应 用 层
17   应用层简介
18   主机配置: DHCP
19   域名系统( DNS
20   远程登录: TELNET SSH
21   文件传送: FTP TFTP
22   **网和 HTTP
23   电子邮件: SMTP POP IMAP MIME
24   网络管理( SNMP
25   多媒体
 
 

 
 
 
 
 
章是对应用层的总体概述。在接下来8章的内容中,我们将介绍一些因特网中常用的客户 服务器应用程序。在这一章,我们要呈现的是客户 服务器程序设计的概貌,并给出它们实现的一些简单的代码。网络应用程序领域内容极其丰富且复杂,因此不可能用一章的篇幅来涵盖。我们要做的只能是鸟瞰这一领域的全景,使后面8章的内容更加容易理解。
目标
本章有以下几个目标:
q 介绍客户 服务器范式。
q 介绍套接字接口并列举其中的一些常用函数。
q 讨论使用了 UDP提供的无连接服务的客户 服务器通信。
q 讨论使用了 TCP提供的面向连接服务的客户 服务器通信。
q 给出使用 UDP服务的客户程序的一个例子。
q 给出使用 UDP服务的服务器程序的一个例子。
q 给出使用 TCP服务的客户程序的一个例子。
q 给出使用 TCP服务的服务器程序的一个例子。
q 简单地讨论 P2P范式及其应用。
网络或者说互联网的作用就是向用户提供服务。本地站点的一个用户希望能够接收远程站点的一台计算机所提供的服务。要想实现此目标的一种方法就是运行两个程序。本地计算机上运行一个程序,它向远程计算机请求服务,远程计算机上也运行一个程序,它向请求者提供服务。也就是说,通过互联网相连的两台计算机上必须各运行一个程序,其中一个程序提供服务,另一个程序请求服务。
乍看之下,允许一个运行在本地,一个运行在远程的两个应用程序之间互相通信貌似很简单。但是当我们要实现这种方式时就会引出很多问题。这些问题包括:
1.这两个应用程序应当既能请求服务又能提供服务,还是只能二者选一?一种答案是让一个称为客户的运行在本地主机上的应用程序向另一个称为服务器的运行在远程主机上的应用程序请求服务。换言之,请求服务的责任与提供服务的责任互相独立。一个应用程序不是请求者(客户),就是提供者(服务器)。也就是说,应用程序必须以客户和服务器的形式成对出现,且两者同名。
2.服务器应当只向某个特定的客户提供服务,还是说服务器应当向任何请求了它所能提供的服务类型的客户提供服务?最普遍的答案就是让一个服务器向任何需要该类型服务的客户提供服务,而不是为某个特定的客户提供服务。换言之,服务器和客户之间的关系是一对多的。
3.一台计算机是否只能运行一个程序(客户或者服务器)?答案是连接到因特网上的任何计算机都应当能够运行任意多的客户程序,只要安装了相应的软件。服务器程序需要运行在能够连续不断地运行的计算机上,关于这一点我们将在稍后详细了解。
4.一个应用程序应当在何时运行?是所有时间,还是仅当它们需要服务时?通常,请求服务的客户程序应当仅在需要时才运行。而提供服务的服务器程序则应当在所有时间都运行,因为它无法知道什么时候它的服务会被请求。
5.应当用一个通用的应用程序来提供用户希望的所有类型的服务,还是应当为每种类型的服务使用一个应用程序?在TCP/IP中,那些使用频率高且用户数量大的服务都有特定的客户 服务器程序。例如,我们有独立的客户 服务器应用程序来允许用户访问文件、发送电子邮件等等。而对于比较个性化一些的服务,我们应当有一个通用的应用程序以允许用户能够接入到远程计算机所提供的服务。例如,我们应当有一个客户服务器应用程序能够允许用户登录到一台远程计算机上,然后再使用由该计算机提供的服务。
17.1.1 服务器
服务器(server)就是运行在远程主机上向客户提供服务的程序。当它启动时,它敞开大门迎接来自客户的请求,但是它从来不会主动启动一个服务,只有当它被请求时才会如此做。
服务器程序是一个无限程序。当它启动后就一直运行,除非出现故障。它一直在等待着来自客户的请求到达。当一个请求到达时,它就响应这个请求,可能是顺序的,也可能是并发的,这一点我们稍后再详细解释。
17.1.2  客户
客户(client)是运行在本地主机上的一个程序,它请求得到服务器的服务。客户程序是有限的,也就是说它是由用户启动(或由另一个应用程序启动),并且在服务完成后终止。通常,客户利用远程主机的IP地址以及在该主机上运行的某个特定服务器程序的熟知端口地址来打开通信信道。在通信信道被打开后,客户就可以发送请求并接收响应。虽然这种请求 响应可能会往返重复多次,但整个过程是有限的,最终一切都会结束。
17.1.3 并发
客户和服务器都可以运行在并发模式下。
客户的并发
客户可以在一台主机上顺序地或者并发地运行。客户顺序地(iteratively)运行表示它们一个接一个地运行。一个客户程序必须启动,运行,并在终止之后,主机才能启动另一个客户。不过,今天的绝大多数计算机都允许并发(concurrent)的客户。也就是说,两个或多个客户可同时运行。
服务器的并发
顺序服务器一次只能处理一个请求。它接收一个请求,处理该请求,并向请求者发送响应,在此之后,它才能接着处理另一个请求。与此相反的是,并发服务器能够同时处理多个请求,因而需要在多个请求之间分享它的时间。
服务器或者使用无连接的运输层协议UDP,或者使用面向连接的运输层协议TCP/SCTP。因此,服务器的操作取决于两项因素:运输层协议和服务方式。理论上讲,我们可以有四种类型的服务器:无连接顺序型、无连接并发型、面向连接顺序型和面向连接并发型(参见图17.1)。
图17.1 服务器类型
无连接顺序服务器
使用UDP的服务器通常都是顺序的,正如我们曾经提到的,它是指服务器一次只处理一个请求。服务器从接收到的UDP数据报中获得一个请求,处理该请求,并把响应递交给UDP以便返回给客户。在此期间服务器对其他数据报不予理睬,这些数据报被保存在一个队列中等待服务。它们可能全部来自同一个客户,也可能分别来自不同的客户。无论哪种情况,它们按照到达的顺序一个接一个地被处理。
为了完成这项任务,服务器只需要使用一个端口,即熟知端口。所有到达该端口的数据报排队等待着被服务,如图17.2所示。
图17.2 无连接顺序服务器
面向连接的并发服务器
使用TCP(或SCTP)的服务器通常是并发的。这就表示该服务器能够一次为多个客户服务。因为通信是面向连接的,也就是说请求是一个字节流,它可能在多个报文段中到达,并且返回的响应也可能要占用多个报文段。服务器与每个客户之间都建立了一条连接,并且这条连接一直保持打开,直至整个字节流处理完成且连接终止。
这种类型的服务器不能只用一个端口,因为有可能多个连接同时打开,而每条连接都需要一个端口。服务器需要多个端口,但能够被它使用的只有一个熟知端口。解决的办法是使用一个熟知端口和多个临时端口。服务器从熟知端口处接受连接请求。客户可以在发起连接时访问这个熟知端口。而在连接建立后,服务器就为该连接指派一个临时端口,以释放它自己的熟知端口。此后,数据的传送发生在两个临时端口之间,一个在客户端,另一个在服务器端。此时熟知端口就空闲出来可以为另一个客户建立连接了。要为若干个客户同时提供服务,服务器需要创建子进程,也就是原始进程(父进程)的复本。
服务器还必须为每条连接建立一个队列。来自客户的报文段被保存在相应的队列中,并由服务器并发地提供服务。图17.3描绘了此配置。
图17.3 面向连接的并发服务器
17.1.4 套接字接口
客户进程如何与服务器进程通信呢?一个计算机程序就是一组预先定义的指令,它们告诉计算机要做什么。一个计算机程序中有一个用于数**算的指令集合,还有一个用于处理字符的指令集合,另外还有一个用于输入/输出访问的指令集合。如果要让一个程序与运行在另一台主机上的程序之间能够通信,我们就需要一个新的指令集合来告诉运输层打开连接,向另一端发送数据,接收来自另一端的数据,以及关闭连接。此种类型的指令集合通常被称为接口(interface)。
接口就是为了两个实体之间的交互而设计的指令集合。
人们已经为计算机通信设计了若干接口。其中有三个接口是通用的:套接字接口(socket interface)、运输层接口(transport layer interface)以及 STREAM。虽然网络编程人员需要对这三个接口都很熟悉,但是在本章我们只简单地讨论套接字接口,以便展示应用层网络通信的总体思想。

 

图17.4 操作系统和TCP/IP协议族之间的关系
 
 
套接字接口起源于 20 世纪 80 年代的 Berkeley 大学,当时是作为 UNIX 环境中的一部分。为了更好地理解套接字接口的概念,我们需要考虑到底层操作系统(如 UNIX Windows )与 TCP/IP 协议族之间的关系。这是一个到目前为止都被我们忽略的问题。图 17.4 从概念上描绘了操作系统和 TCP/IP 协议族之间的关系。

 

作为指令集合的套接字接口位于操作系统和应用程序之间。应用程序如果想要接入由TCP/IP协议族提供的服务,就必须使用在套接字接口中定义的指令。
17.1
大多数编程语言都有一个文件接口,即一组允许程序员打开文件,从文件中读取,写入到文件,并对该文件执行一些其他操作,最后关闭该文件的指令集合。当程序需要打开某个文件时,就要使用由操作系统掌握的该文件的文件名。当该文件被打开后,操作系统会返回对该文件的一个引用(整数或指针),它被用于其他指令中,如读操作或写操作。
套接字
套接字 socket )是模拟了我们在生活中常见的硬件插口的软件抽象。要想使用通信信道,应用程序(客户或服务器)需要请求操作系统创建一个套接字。然后,应用程序就能够 插入 该套接字以发送和接收数据。为了使数据通信得以顺利进行,就需要一对套接字,通信的两端各一个。图 17.5 用我们日常生活中使用的插口和插头(例如,电话机的)来模拟这个抽象的概念。在因特网中,套接字就是一个软件数据结构,稍后我们将会进一步讨论。
图17.5 套接字的概念
数据结构
定义了套接字的数据结构的格式取决于进程所使用的底层语言。在本章剩余的部分中,我们都假定进程是用C语言来编写的。在C语言中,套接字被定义为五字段的结构(结构或记录),如图17.6所示。
图17.6 套接字的数据结构
请注意,程序员不应重新定义这个结构,它是已经定义好的。程序员只需要使用包含了这个定义的头文件即可(稍后再讨论)。让我们简单地定义一下这个结构中使用的各个字段:
q 族 这个字段定义了一个协议组:IPv4、IPv6、UNIX主域协议等等。在TCP/IP中我们使用的族类型的定义是:用常量IF_INET来表示IPv4协议,用常量IF_INET6来表示IPv6协议。
q 类型 这个字段定义了四种类型的套接字:SOCK_STREAM(用于TCP),SOCK_DGRAM(用于UDP),SOCK_SEQPACKET(用于SCTP)和SOCK_RAW(用于直接使用IP服务的应用)。这些类型如图17.7所示。
q 协议 这个字段定义了接口使用的协议。对于TCP/IP协议族,它被设置为0。
q 本地套接字地址 这个字段定义了本地套接字地址。正如我们在第13章中所讨论的,套接字地址就是IP地址和端口地址的组合。
q 远程套接字地址 这个字段定义了远程的套接字地址。
图17.7 套接字类型
图17.8 IPv4的套接字地址
 
套接字地址的结构
在我们能够使用套接字之前,还需要了解套接字地址的结构,它是 IP 地址和端口地址的组合。虽然网络编程已经定义了很多种套接字地址,但我们在这里只定义用于 IPv4 的套接字地址。在这个版本中,套接字地址是一个复杂的数据结构( C 语言中的结构类型),如图 17.8 所示。
请注意,结构sockaddr_in有五个字段,其中的sin_addr字段本身又是一个in_addr类型的结构,这个结构只有一个字段s_addr。我们先来定义结构in_addr如下:
 
struct in_addr
{
      in_add_t        s_addr;          //一个32 位的IPv4地址
}
 
现在我们来定义结构sockaddr_in:
 
struct sockaddr_in
{
     uint8_t         sin_len;         //结构的长度(16字节)
     sa_family_t    sin_family;     //设置为AF_INET
     in_port_t       sin_port;       //一个16 位的端口号
      struct in_addr sin_addr;       //一个32 位的IPv4地址
      char             sin_zero[8];    //未使用
}
 
函数
进程与操作系统之间的交互是通过一组预先定义的函数来完成的。在这一小节,我们要介绍这些函数,而在下面的小节中,我们将展示这些函数如何组合起来以建立进程。
qsocket函数
操作系统定义的套接字结构如图17.6所示。但是除非收到进程的命令,操作系统是不会创建套接字的,因此进程需要调用socket函数来创建一个套接字。这个函数的原型如下所示:
 
int socket (int family, int type, int protocol);
 
调用这个函数会创建一个套接字,但是在新创建的套接字结构中只填写了三个字段(族,类型和协议)。如果调用成功,这个函数返回唯一的套接字描述符sockfd(非负整数),它可以被用在其他调用中来指向该套接字。如果调用不成功,操作系统返回−1。
qbind函数
socket函数只填写了该套接字的部分字段。要将本地计算机和本地端口绑定到该套接字,就需要调用bind函数。bind函数填写本地套接字地址的值(本地IP地址和本地端口号)。如果绑定失败,则返回−1。它的原型如下所示:
 
int bind (int sockfd, const struct sockaddress* localAddress, socklen_t* addrLen);
 
在这个原型中,sockfd是调用socket函数时返回的套接字描述符的值,localAddress是指向一个套接字地址的指针,它需要已经被定义好(通过系统或程序员),addrLen是套接字地址的长度。在后面我们将会了解何时需要使用bind函数,何时不需要使用。
qconnect函数
connect函数用于向套接字结构中添加远程套接字地址。如果连接失败,它返回−1。它的原型如下所示:
 
int connect (int sockfd, const struct sockaddress* remoteAddress, socklen_t* addrLen);
 
这个原型中的参数与前面的相同,只是第二个和第三个参数定义的不是本地套接字地址,而是远程套接字地址。
qlisten函数
listen函数只能被TCP服务器调用。在TCP创建并绑定好套接字后,它必须通知操作系统说套接字已经准备好,可以接收客户的请求了。通过调用listen函数就能完成这个任务。参数backlog是连接请求的最大数量。如果调用失败,函数返回−1。它的原型如下所示:
 
int listen (int sockfd, int backlog);
 
qaccept函数
accept函数是服务器进程用来通知TCP它已经准备好接收来自客户的请求。如果调用失败,函数返回−1。它的原型如下所示:
 
int accept (int sockfd, const struct sockaddress* clientAddr, socklen_t* addrLen);
 
后两个参数分别是指向地址和长度的指针。accept函数是一个阻塞函数,也说是说当它被调用后,它会阻塞自己(指进程停在此处不再往下执行 —译注),直至客户的连接建立起来。然后accept函数才能获得客户的套接字地址和地址长度,并将它们传递给服务器进程,以便用于访问客户。有以下几点值得注意:
a.accept函数的调用会使服务器检查缓存中是否还有任何正在等待的客户连接请求。如果没有,accept函数让进程进入睡眠状态。当该队列中至少有一个请求时,进程被唤醒。
b. 在成功调用 accept 之后,新的套接字被创建,在客户套接字与服务器的这个新的套接字之间的通信建立。
c.accept函数接收到的地址被填入新套接字的远程套接字地址字段。
d. 收到的客户地址用指针返回给进程。如果程序员不需要这个地址,可以用 NULL来替换。
e.返回的地址长度被传递给该函数,同样以指针的形式返回。如果不需要这个地址长度,可以用NULL来替换。
qfork函数
fork函数被进程用来复制自己。调用fork函数的进程称为父进程,创建出来的进程(也就是那个复本)称为子进程。它的原型如下所示:
 
pid_t fork (fork);
 
很有意思的一件事是fork进程调用一次,却要返回两次。在父进程中,返回值是一个正整数(调用它的父进程的进程ID)。在子进程中,返回值是0。如果出现错误,fork函数返回−1。在调用fork函数之后,两个进程并发运行,CPU交替地给每个进程分配运行时间。
qsend和recv函数
进程调用send函数以便向运行在远程主机上的另一个进程发送数据。进程调用recv函数以便接收从运行在远程主机上的另一个进程发过来的数据。它们都假定在这两台主机之间已经有一条打开的连接,因此,它们只能用于TCP(或SCTP)。这两个函数返回的是发送或接收的字节数。
 
int send (int sockfd, const void* sendbuf, int nbytes, int flags);
int recv (int sockfd, void* recvbuf, int nbytes, int flags);
 
这里的sockfd是套接字描述符;sendbuf是指向一个缓存的指针,在这个缓存中保存的是将要发送的数据;recvbuf也是指向一个缓存的指针,在这个缓存中保存的是已接收的数据;nbytes是将要发送或已接收的数据的大小。如果调用成功,这两个函数返回实际发送或接收的字节数,如果出现错误,则返回−1。
qsendto和recvfrom函数
进程调用sendto函数以便使用UDP服务向远程进程发送数据。进程调用recvfrom函数以便使用UDP服务接收来自远程进程的数据。因为UDP是无连接协议,所有其中的一个参数要定义远程套接字地址(目的地址或源地址)。
 
int sendto (int sockfd, const void* sendbuf, int buflen, int flags
                 struct sockaddr* destinationAddress, socklen_t* addrLen);
 
int recvfrom (int sockfd, void* recvbuf, int buflen, int flags
               struct sockaddr* sourceAddress, socklen_t* addrLen);
 
这里的sockfd是套接字描述符;buf是指向缓存的指针,在这个缓存中保存的是将要发送的数据或接收到的数据;buflen是缓存的大小。虽然flag的值可以非零,但在本章我们例举的简单程序中它们都被置为0。如果调用成功,这两个函数返回发送或接收的字节数,如果出现错误,则返回−1。
qclose函数
进程调用close函数来关闭一个套接字。
 
int close (int sockfd);
 
在调用此函数之后,该sockfd就无效了。这个函数返回一个整数。如果成功,则返回0;如果出现错误,则返回−1。
q字节排序函数
计算机中的信息是以主机字节序来保存的,它可能是小数在前(little-endian),也就是最低位字节被保存在开始的地址中,也可能是大数在前(big-endian),也就是最高位字节被保存在开始的地址中。而在网络编程中,数据和其他各种信息都是网络字节序的,它是大数在前的。因为当我们编写程序时,并不能确定像IP地址和端口号这样的信息在计算机中是如何保存的,所以我们需要把它们转换为网络字节序。人们为此而专门设计了两个函数:htons(host to network short)把一个短值(16 位)转换为网络字节序;htonl(host to network long)把一个长值(32 位)转换为网络字节序。另外还有两个完全相反的函数:ntohs和ntohl。这些函数的原型如下所示:
 
uint16_t htons (uint16_t shortValue);
 
uint32_t htonl (uint32_t longValue);
 
uint16_t ntohs (uint16_t shortValue);
 
uint32_t ntohs (uint132_t longValue);
 
q内存管理函数
最后我们需要一些函数来管理保存在内存中的值。在这里要介绍三个常用的内存函数,不过在本章它们并不是都用得到。
 
void* memset (void* destination, int chr, size_t len);
 
void* memcpy (void* destination, const void* source, size_t nbytes);
 
int memcmp (const void* ptrl, const void* ptr2, size_t nbytes);
 
第一个函数memset(内存设置)用于在destination指针(起始地址)指定的内存中设置(保存)特定数量(len的值)的字节。第二个函数memcpy(内存拷贝)用于从内存中的某处(source)复制特定数量(nbyte的值)的字节到内存的另一处(destination)。第三个函数memcmp(内存比较)用于比较以ptr1和ptr2开始的两组(nbytes)字节。若这两组字节相同,则结果为0;若第一组小于第二组,则结果小于0;若第一组大于第二组,则结果大于0。这个比较基于C语言中的字符串字节的比较。
q地址转换函数
通常,我们喜欢使用点分十进制格式的32位IP地址。但是,当我们想把这个地址保存到套接字中时,就需要将其转换为一个数值。有两个函数可分别用于将地址从点分十进制表示转换为一个数值,或从一个数值转换为点分十进制表示:inet_pton(从记法到数值)和inet_ntop(从数值到记法)。我们要使用的family的值是常量AF_INET。这两个函数的原型如下所示:
 
int inet_pton (int family, const char* stringAddr, void* numericAddr);
 
char* inet_ntop (int family, const void* numericAddr, char* stringAddr, int len);
 
头文件
要使用前面所描述的函数,就需要一组头文件。我们在一个独立的文件中定义头文件,这个文件被命名为“headerFiles.h”。我们将在后面的程序中包括这个文件,以避免每次都要包括长长的头文件列表。不是在所有的程序中所有这些头文件都需要,但是最好还是把它们都包括了,以免有遗漏。
 
// "headerFiles.h"
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <errno.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
#include <arpa/innet.h>
#include <sys/wait.h>
 
17.1.5 使用UDP的通信
图17.9所示为此类通信的一个简化了的流程图。
图17.9 使用UDP的无连接的顺序通信
服务器进程
服务器进程先启动。服务器进程调用 socket 函数以创建套接字。然后它再调用 bind 函数将自己的熟知端口号和运行这个服务器进程的计算机的 IP 地址绑定到该套接字。然后服务器调用 recvfrom 函数,它会进入阻塞状态,直至有一个数据报到达。当数据报到达时, recvfrom 函数解除阻塞,并从接收到的数据报中提取客户套接字地址及其地址长度,然后把这两个信息返回给进程。进程保存这两个信息,接着调用一个过程(函数)来处理该请求。当结果准备好后,服务器进程调用 sendto 函数,并利用前面保存的信息将结果发送到发出此请求的客户。服务器使用了无限循环,以响应来自同一个客户或不同客户的所有请求。
客户进程
客户进程更简单一些。客户调用socket函数以创建套接字。然后它调用sendto函数,并向其传递服务器的套接字地址和缓存位置。UDP能够从这个缓存中获取到数据并生成数据报。然后,客户调用recvfrom函数,这个函数进入阻塞状态,直至服务器返回的响应到达。当响应到达时,UDP把数据交付给客户进程,也就是recvfrom函数解除阻塞,然后把收到的数据递交给客户进程。请注意,我们假设客户报文小得足以装进一个数据报中。如果情况不是这样,那么就要反复调用sendto和recvfrom这两个函数。但是,服务器并不知道这是一次多个数据报的通信,它只是独立地对待每一个请求。
17.2
作为例子,让我们来看看应当如何设计并编写以下两个程序:echo服务器和echo客户。该客户向服务器发送了一行文本,服务器将相同的文本行返回给客户。虽然这个客户/服务器程序看起来好像没有什么用处,但实际上它还是有一些应用的。例如,当某台计算机想测试网络上的另一台计算机是否仍然在工作时就可以使用它。为了更好地理解程序中的代码,我们先用图17.10来描绘这两个程序所使用的变量的总体情况。
图17.10 在使用了UDP服务的echo服务器和echo客户中所用到的变量
表17.1所示为echo服务器的程序。为了简化整个程序,我们忽略了很多细节和差错处理。在练习中,我们会要求读者提供更多的细节。
17.1  使用UDP服务的echo服务器程序
01
//UDP echo服务器程序
02
#include “headerFiles.h”
03
 
04
int main (void)
05
{
06
    //变量的声明和定义
07
    int sd;                                                                //套接字描述符
08
    int nr;                                                                //接收的字节数
09
    char buffer [256];                                              //数据缓存
10
    struct sockaddr_in serverAddr;                          //服务器地址
11
    struct sockaddr_in clientAddr;                           //客户地址
12
    int clAddrLen;                                                   //客户地址长度
13
    //创建套接字
14
    sd = socket (PF_INET, SOCK_DGRAM, 0);
15
    //将本地地址和端口绑定到套接字
16
    memset (&serverAddr, 0, sizeof(serverAddr));
17
    serverAddr.sin_family = AF_INET;
18
    serverAddr.sin_addr.s_addr = htonl (INADDR_ANY);       //默认地址
19
    serverAddr.sin_port = htons (7)                                          //我们假设端口为7
20
    bind (sd, (struct sockaddr*) &serverAddr, sizeof(serverAddr));
21
    //接收并回送数据报
22
    for ( ; ; )                                                                               //一直运行
23
     {
24
         nr = recvfrom (sd, buffer, 256, 0, (struct sockaddr*)&clientAddr, &clAddrLen);
25
         sendto (sd, buffer, nr, 0, (struct sockaddr*)&clientAddr, sizeof(clientAddr));
26
     }
27
}                 //echo服务器程序结束
 
14 行创建了套接字。第 16 19 行创建了本地套接字地址,远程套接字地址在后面的第 24 行创建,稍后再解释。第 20 行将本地套接字地址绑定到该套接字。第 22 26 行接收和发送数据报,它们可能来自多个客户。当服务器接收到一个来自客户的数据报时,该客户的套接字地址及其长度被返回给服务器进程。这两个信息用于第 25 行向相应的客户返回回送报文。请注意,在这个程序中我们忽略了很多错误检查代码,它们被留作练习。
表17.2所示为echo进程的客户程序。我们假设该客户只发送了一个需要服务器回送的报文。如果我们要发送多个报文,数据发送代码段就应当使用一个循环语句来重复执行。
17.2  使用UDP服务的echo客户程序
01
//UDP echo客户程序
02
#include “headerFiles.h”
03
 
04
int main (void)
05
{
06
    //变量的声明和定义
07
    int sd;                                                                //套接字描述符
08
    int ns;                                                                //发送的字节数
09
    int nr;                                                                //接收的字节数
10
    char buffer [256];                                              //数据缓存
11
    struct sockaddr_in serverAddr;                          //套接字地址
12
    //创建套接字
13
    sd = socket (PF_INET, SOCK_DGRAM, 0);
14
    //建立服务器套接字地址
15
    memset (&serverAddr, 0, sizeof(serverAddr));
16
    serverAddr.sin_family = AF_INET;
17
    inet_pton (AF_INET, “server address”, &serverAddr.sin_addr);
18
    serverAddr.sin_port = htons (7)                       
19
    //发送和接收数据报
20
    fgets(buffer, 256, stdin);
21
    ns = sendto (sd, buffer, strlen(buffer), 0,
22
         (struct sockaddr)&serverAddr, sizeof(serveraddr))
23
    recvfrom (sd, buffer, strlen (buffer), 0, NULL, NULL);
24
    buffer [nr] = 0;
25
    printf (“Received from server:”);
26
    fputs (buffer, stdout);
27
    //关闭和退出
28
    close (sd);
29
    exit (0);
30
}                 //echo客户程序结束
 
第13行创建了一个套接字。第15~18行所示为我们如何建立服务器套接字地址,在这里不需要建立客户套接字地址。第20~26行从键盘读取一个字符串,然后将其发送到服务器,最后接收服务器返回的字符串。第24行在接收到的字符串后增加了一个空字符,以便它在第26行中显示。第28行关闭了这个套接字。在这个程序中我们忽略了很多错误检查代码,它们被留作练习。
服务器和客户程序都还需要增加一些错误检查代码,才能使之完整。
17.1.6 使用TCP的通信
现在我们来讨论使用TCP服务的面向连接的并发通信(SCTP的情况类似)。图17.11描绘了这种类型的通信的一般流程图。
服务器进程
服务器进程首先启动。它调用socket函数以创建套接字,这个套接字被我们称为监听套接字。监听套接字仅用于连接建立阶段。然后,服务器进程调用bind函数将运行该服务器的计算机的套接字地址绑定到连接。服务器进程接着调用accept函数。这个函数是一个阻塞函数,当被调用时,它就进入阻塞状态,直至TCP接收到来自客户的连接请求(SYN报文段)。然后accept函数解除阻塞,并创建新的套接字,我们称为连接套接字,其中包含了发送该SYN报文段的客户的套接字地址。在accept函数解除阻塞后,服务器进程就知道有客户需要它的服务。为了提供并发性,服务器进程(父进程)调用fork函数。这个函数创建一个新的进程(子进程),它与父进程一模一样。在调用了fork函数之后,就有两个进程在同时运行,但是它们各做各的事。现在每个进程都有两个套接字:监听套接字和连接套接字。父进程把服务客户的任务完全转交给子进程,并再次调用accept函数以等待另一个客户来请求连接。现在,子进程已准备好为客户服务。它首先关闭监听套接字,然后调用recv函数接收来自客户的数据。像recvfrom函数一样,recv函数也是一个阻塞函数,在报文段到来之前它一直处于阻塞状态。子进程使用了一个循环并重复调用recv函数,直至它接收到由客户发来的所有报文段。接着,子进程把完整的数据传递给一个函数(我们称它为handleRequest),由它来处理请求并返回结果。然后,通过一次调用send函数,把这个结果发送给客户。在这里我们需要强调几点。首先,我们使用的流程图是有可能出现的一种最简化的情况。服务器还可能会使用多个函数来接收和发送数据,以便为特定的应用程序选择一个合适的函数。其次,我们假设向客户发送的数据尺寸足够小,只需要调用一次send函数就能发送完,如果不是这样,我们就需要用一个循环来重复地调用send函数。第三,虽然服务器进程可以只调用一次send函数来发送数据,但是TCP在发送数据时可能需要多个报文段。因此,客户进程可能不只接收到一个报文段,这一点我们将在介绍客户进程时再解释。
图17.11 面向连接的并发通信流程图
图17.12所示为从套接字的角度看父进程和子进程的状态。图中的a部分描绘了accept函数返回前的状态。父进程使用监听套接字等待来自客户的请求。当accept函数阻塞并返回后(b部分),父进程就有两个套接字:监听套接字和连接套接字。客户被连接到连接套接字。在调用fork函数后(c部分),我们有了两个进程,每个进程都有两个套接字。这两个进程都要与客户连接。父进程需要关闭它的连接套接字,使之不再与客户相连,以便能够监听来自其他客户的请求(d部分)。在子进程开始向连接客户提供服务之前,它需要先关闭自己的监听套接字,以便后面的请求不会影响到它(e部分)。最后,当子进程完成对连接客户的服务后,它需要关闭自己的连接套接字,使自己断开与提供服务的客户之间的联系(f部分)。
图17.12 从套接字的角度看父进程和子进程的状态
子进程在向相应的客户提供了服务之后必须被销毁,虽然我们在程序中并没有这么做(为了简单性)。子进程在完成自己的职责后就进入安眠状态,这在UNIX系统环境中通常被称为僵尸进程(zombie)。子进程可以在它不再被需要时立即销毁。另外还有一种方式,系统可以每过一段时间运行一个特殊的程序,以销毁系统中所有的zombie。这些 zombie占用了系统空间,并且会影响到系统的性能。
客户进程
客户进程比较简单。客户调用socket函数来创建socket。然后它调用connect函数以请求与服务器的连接。connect函数是阻塞函数,它在两个TCP之间的连接建立之前一直处于阻塞状态。当connect函数返回后,客户调用send函数向服务器发送数据。我们仅调用了一次send函数,这是假设一次调用就能够发送所有的数据。根据应用程序的不同,我们可能会需要重复地调用这个函数(循环)。然后,客户调用recv函数,开始时这个函数也处于阻塞状态,直至有报文段到达且TCP将这些数据交付给了进程。请注意,虽然这些数据是服务器进程通过一次调用send函数发送过来的,但是服务器端的TCP可能使用了多个报文段来传送这些数据。也就是说,我们可能需要重复调用recv函数才能接收到所有的数据。这个循环是由recv函数的返回值来控制的。
17.3
我们希望编写两个程序来说明使用TCP服务的echo客户和echo服务器是什么样的。图17.13描绘了我们在这两个程序中使用的变量。因为数据可能在不同的块中到达,所以需要两个指向缓存的指针。第一个指针是固定的,总是指向缓存的起始位置。第二个指针是移动的,以便到达的字节能够紧跟在前一个数据区的后面。
图17.13 例17.3中使用的变量
表17.3所示为使用TCP服务的echo服务器程序。我们精简掉了很多细节,将它们留给有关网络编程的书籍。我们只是想给出此程序总体面貌。
17.3  使用TCP服务的echo服务器程序
01
//TCP echo服务器程序
02
#include “headerFiles.h”
03
 
04
int main (void)
05
{
06
    //变量的声明和定义
07
    int listensd;                                                       //监听套接字描述符
08
    int connectsd;                                                    //连接套接字描述符
09
    int n;                                                                //每一次接收的字节数
10
    int bytesToRecv;                                              //接收的总字节数
11
    int processID;                                                   //子进程的ID
12
    char buffer [256];                                              //数据缓存
13
    char* movePtr;                                                  //指向缓存的指针
14
    struct sockaddr_in serverAddr;                          //服务器地址
15
    struct sockaddr_in clientAddr;                           //客户地址
16
    int clAddrLen;                                                   //客户地址长度
17
    //创建监听套接字
18
    listensd = socket (PF_INET, SOCK_STREAM, 0);
19
    //为监听套接字绑定本地地址和端口
20
    memset (&serverAddr, 0, sizeof (serverAddr));
21
    serverAddr.sin_family = AF_INET;
22
    serverAddr.sin_addr.s_addr = htonl (INADDR_ANY);
23
    serverAddr.sin_port = htons (7);            //我们假设端口是7
24
    bind (listensd, &serverAddr, sizeof (serverAddr));
25
    //监听连接请求
26
    listen (listensd, 5);
27
    //处理连接
28
    for ( ; ;)                                  //永远运行
29
    {
30
        connectsd = accept (listensd, &clientAddr, &clAddrLen);
31
        processID = fork ();
32
        if (processID == 0)                    //子进程
33
        {
34
            close (listensd);
35
            bytesToRecv = 256;
36
            movePtr = buffer;
37
            while ( (n = recv (connectfd, movePtr, bytesToRecv, 0)) > 0)
38
            {
39
                movePtr = movePtr + n;
40
                bytesToRecv = movePtr - n;
41
             }                 //while结束
42
             send (connectsd, buffer, 256, 0);
43
             exit (0);
44
         }      //if结束
45
         close (connectsd);                 //回到父进程
46
     }       //for循环结束
47
}     //结束echo服务器程序
 
这段程序以图 17.11 所示的流程图为蓝本。每当 recv 函数解除阻塞时,它就获得一些数据,并将这些数据保存在缓存的尾端。于是 movePtr 移动到指向下一个数据块将被保存的位置上(行 39 )。将要读入的字节数也从初始值( 256 )不断减少,以防止缓存的溢出(行 40 )。在所有数据都被接收后,服务器调用 send 函数把这些数据全部发送给客户。正如我们在前面所提到的,在这里只调用了一次 send 函数,但是 TCP 可能要用若干个报文段来发送这些数据。然后,子进程调用 close 函数以销毁该连接套接字。
表17.4所示为使用TCP服务的echo客户程序。它使用了与表17.2中所描述的一样的策略来创建服务器套接字地址。然后这个程序通过键盘获取需要被回送的字符串,把这个字符串保存到sendBuffer中,并发送它。服务器返回的结果可能在几个报文段中。这个程序使用了一个循环以重复调用recv函数,直至所有数据都到达。与往常一样,我们忽略了差错检查的代码,目的是为了保存程序的简洁性。如果要实际地使用这段代码来发送数据到服务器,那些代码还是需要添加的。
17.4  使用TCP服务的echo客户程序
01
//TCP echo客户程序
02
#include “headerFiles.h”
03
 
04
int main (void)
05
{
06
    //变量的声明和定义
07
    int sd;                                                                 //套接字描述符
08
    int n;                                                                //已接收的字节数
09
    int bytesToRecv;                                              //接收的总字节数
10
    char sendBuffer [256];                                        //发送缓存
11
    char recvBuffer [256];                                        //接收缓存
12
    char* movePtr;                                                  //指向缓存的指针
13
    struct sockaddr_in serverAddr;                          //服务器地址
14
 
15
    //创建套接字
16
    sd = socket (PF_INET, SOCK_STREAM, 0);                                    
17
    //创建服务器套接字地址
18
    memset (&serverAddr, 0, sizeof (serverAddr));
19
    serverAddr.sin_family = AF_INET;
20
    inet_pton (AF_INET, “server address”, &serverAddr.sin_addr);
21
    serverAddr.sin_port = htons (7);            //我们假设端口是7
22
    //连接
23
    connect (sd, (struct sockaddr*)&serverAddr, sizeof(serverAddr));
24
    //发送和接收数据
25
    fgets (sendBuffer, 256, stdin);
26
    send (fd, sendBuffer, strlen (sendbuffer), 0);
27
    bytesToRecv = strlen (sendbuffer);
28
    movePtr = recvBuffer;
29
    while ( (n = recv (sd, movePtr, bytesToRecv, 0) ) > 0)
30
        {
31
            movePtr = movePtr + n;
32
            bytesToRecv = bytesToRecv − n   
33
        }    //while循环结束
34
        recvBuffer[bytesToRecv] = 0;
35
        printf (“Received from server:”);
36
        fputs (recvBuffer, stdout);
37
        //关闭并退出
38
        close (sd);
39
        exit (0);
40
}              //结束echo客户程序
 
17.1.7 预先定义的客户–服务器应用
因特网有一组已定义的使用了客户–服务器范式的应用程序。它们是一些成熟的程序,能够直接安装和使用。其中,有一些应用是为了提供某种特定的服务而设计的(如FTP),还有一些是为了允许用户登录到服务器上以执行所需的任务而设计的(如TELNET),另外还有一些是为了辅助别的应用程序而设计的(如DNS)。我们将在第18~24章中详细讨论这些应用程序。
在附录F中,我们给出了表17.1~17.4的Java版本的简单程序。
虽然目前因特网上正在使用的绝大多数应用程序都使用了客户 服务器范式,但是使用称为 P2P 范式( peer-to-peer paradigm )的思想最近越来越多地受到人们的关注。在这种范式中,两个对等的计算机(便携机、台式机或大型主机)可以彼此之间互相通信以交换服务。这种范式对某些领域是很有吸引力的,譬如文件传送,当客户想传送一个很大的文件(如音频或视频文件)时,如果使用客户 服务器范式将会给服务器带来很重的负担。另外如果两个对等主机需要不经过服务器来互相交换一些文件或信息,也会对使用 P2P 范式感兴趣。但是,我们必须说明的是, P2P 范式并没有完全抛弃客户 服务器范式。实际上,它所做的只是把服务器的负担让希望加入这一过程的若干个用户共同分担而已。例如,如果有多个客户要下载同一个大文件,服务器可以让每个客户先下载一部分文件,然后客户彼此之间共享,而不是让每个客户都建立一条连接并各自下载这个文件。不过,在下载部分文件或共享已下载的文件的过程中,某一台计算机扮演的是客户的角色,而其他的计算机则扮演服务器的角色。换言之,对某个特定的应用来说,一台计算机在这一时刻可能是一个客户,而在另一时刻又会成为一个服务器。此类应用目前在商业应用上还是受控制的,并没有正式加入到因特网中。我们把对这些应用的介绍与说明留给针对各种具体应用的书籍。
有几本书全面覆盖了网络编程的内容。特别地,我们推荐[Ste et al. 04]、[Com 96]、[Rob & Rob 96]和[Don & Cal 01]。

客户–服务器范式
接口
P2P范式
套接字
套接字接口
流(STREAM)
运输层接口(TLI)

q在因特网中的绝大多数应用程序都被设计为使用客户–服务器范式,其中有一个应用程序称为服务器,它提供服务,而另外一个应用程序称为客户,它接收服务。服务器程序是一个无限的程序。当它启动后就一直运行下去,除非出现故障。它等待来自客户的请求。客户程序是有限的,也就是说它由用户启动,并在服务完成后终止。客户和服务器都可以运行于并发模式。
q客户可以在一台主机上顺序地或者并发地运行。顺序服务器一次只处理一个请求。相反地,并发服务器能够同时处理多个请求。
q 客户 - 服务器范式基于一组预先定义的称为接口的函数。在本章,我们讨论了一些最常用的接口,称为套接字接口。套接字接口就是一个指令集合,它位于操作系统和应用程序之间。套接字是模拟了我们在生活中常见的硬件插口的软件抽象。要想使用通信信道,应用程序(客户或服务器)必须请求操作系统创建一个套接字。
q进程和操作系统之间的交互是通过一系列预先定义的函数完成的。在本章,我们介绍了这些函数中的一个子集,具体地说,有socket、bind、connect、listen、accept、fork、send、recv、sendto、recvfrom和close。我们还介绍了一些字节顺序函数和几个内存管理函数。
q一个服务器程序可以被设计为使用UDP服务的无连接的顺序程序,也可以是使用TCP或SCTP服务的面向连接的并发程序。
q虽然目前在因特网中,客户–服务器范式是最常见的,但是另外一种称为P2P的范式也有不少商业应用。
17.6.1 习题
1.使用小数在前,给出一个32位的数字是如何在四个内存位置(字节 xx + 1, x + 2和 x + 3)中保存的。
2.使用大数在前,给出一个32 位的数字是如何在四个内存位置(字节 xx + 1, x + 2和 x + 3)中保存的。
3.编写一段小程序来看看你的计算机使用的是小数在前还是大数在前。
4.在本章中我们使用了几种数据类型。编写一段小程序使用并测试它们。
5.编写一段小程序来测试一下我们在本章中使用到的内存管理函数。
6.在UNIX环境下,有几个函数可用于读取像IP地址和端口地址这样的主机信息,例如,gethostbyname,gethostbyaddress,getservebyname和getservebyport。试着查找一些关于这些函数的信息,并在一段小程序中使用它们。
7.在表17.1中,请添加检查调用socket函数是否出现了错误的代码段。如果出现错误,程序需要退出。提示:使用perror和exit函数。perror函数的原型如下所示:
 
void perror (const char* string);
 
8.在表17.1中,请添加检查调用bind函数是否出现错误的代码段。如果出现错误,程序需要退出。提示:使用perror和exit函数。
9.对表17.2中的程序进行修改,使之允许客户发送多个数据报。
10.对图17.11中的流程图进行修改,以描绘一个无连接的顺序服务器。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 5
    评论
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值