深入理解计算机系统阅读笔记-第十二章
第12章 网络编程
12.1 客户端-服务器编程模型
每个网络应用都是基于客户端-服务器模型的。根据这个模型,一个应用时由一个服务器进程和一个或者多个客户端进程组成。服务器管理某种资源,并且通过操作这种资源来为它的客户端提供某种服务。例如,一个Web服务器管理了一组磁盘文件,它会代表客户端进行检索和执行。一个FTP服务器就管理了一组磁盘文件,它会为客户端进行存储和检索。相似地,一个电子邮件服务器管理了一些文件,它为客户端进行读和更新。
客户端-服务器模型中的基本操作是事务(transaction),如下图所示
一个客户端-服务器事务由4步组成:
1、当一个客户端需要服务时,它向服务器发送一个请求,发起一个事务。例如,当Web浏览器需要一个文件时,它就发送一个请求给Web服务器。
2、服务器收到请求后,解释它,并以适当的方式操作它的资源。例如,当Web服务器收到浏览器发出的请求后,它就读一个磁盘文件。
3、服务器给客户端发送一个响应,并等待下一个请求。例如,Web服务器将文件发送回客户端。
4、客户端收到响应并处理它。例如,当Web浏览器收到来自服务器的一页后,它就在屏幕上显示此页。
注意:客户端和服务器是进程,而不是在本上下文中常被称为的机器或者主机。一台主机可以同时运行许多不同的客户端和服务器,而且客户端和服务器的事务可以在同一台或是不同的主机上。物理客户端和服务器是怎样映射到主机上的,客户端-服务器模型都是相同的。
12.2 网络
对于一个主机而言,网络只是又一种I/O设备,作为数据源和数据接收方,如下图所示。
一个插到I/O总线扩展槽的适配器提供了到网络的物理接口。从网络上接收到的数据从适配器经过I/O和存储器总线拷贝到存储器,典型地是通过DMA传送。相似地,数据也能从存储器拷贝到网络。
物理上而言,网络是一个按照地理远近组成的层次系统。最低层是LAN(Local Area Network,局域网),范围在一个建筑或者校园内。目前最流行的局域网技术是以太网(Ethernet)。
一个以太网段(Ethernet segment)包括一些电路(通常是双绞线)和一个叫做集线器的小盒子,如下图所示。以太网段通常服务于一个小的区域,每根电缆都有相同的最大位带宽,典型的是100Mb/s或者1Gb/s。一端连接到主机的适配器,另一端连接到集线器的一个端口。集线器不加分辨地将从一个端口上收到的每个位复制到其他所有的端口上,因此每台主机都能看到每个位。
每个以太网适配器有全球唯一的48位地址,它存储在这个适配器的永久性存储器上。一台主机可以发送一段位,称为帧(frame),到这个网段内其他主机。每个帧包括一些固定数量的头(header)位,用来标识此帧的源和目的地址以及帧长,此后紧随的就是数据位。每个主机适配器都能看到这个frame,但是只有目的主机实际读取它。
使用一些电缆和叫做网桥(bridge)的小盒子,多个以太网段可以连接成较大的局域网,称为桥接以太网,如下图所示。网桥比集线器更充分地利用了电缆带宽。利用 一种聪明的分配算法,它们随着时间自动学习哪个主机可以通过哪个端口,然后在有必要时,有选择地将一帧从一个端口拷贝到其他端口。
为了简化,可以把集线器和网桥以及电缆画成一个水平线
在层次更高的级别中,多个不兼容的局域网可以通过叫做路由器(router)的特殊计算机连接起来,组成一个internet(互联网络)。每台路由器对于它所连接的每个网络都有一个适配器(端口)。路由器也能连接高速点到点电话连接,这是WAN(Wide-Area Network,广域网)的一种示例。下面展示一个小型的internet。
Internet至关重要的特性是,它能由采样完全不同和不兼容技术的各种局域网和广域网组成。每台主机都是物理相连接的,通过软件协议消除不同网络间的差异,软件必备一下能力:
命名方法:不同的局域网技术有不同和不兼容的方式来为主机分配地址。internet协议通过定义一种一致的主机地址格式,消除这些差异。每台主机至少分配一个internet地址,这个地址唯一地标识了它。
传送机制:在电缆上编码位和将这些位封装成帧,不同的网络互联技术有不同的和不兼容的方式。internet协议通过定义一种把数据位捆扎成不连续的组块(chunk)--也就是包的同一方式,从而消除了这些差异。一个包是由包头(header)和有效载荷(payload)组成的,其中包头包括包的大小以及源主机和目的主机的地址,有效载荷包括从源主机发出的数据位。
下图展示了一个主机和路由器如何使用internet协议在不兼容的局域网间传送数据的示例。
这个示例由两个局域网通过一台路由器连接而成。一个客户端运行在主机A上,A与LAN1相连,它发送了一串数据字节到运行在主机B上的服务器端,主机B则连接在LAN2上。这个过程有8个基本步骤:
1、运行在A上的客户端进行一个系统调用,从客户端的虚拟地址空间拷贝数据到内核缓冲区。
2、主机A上的协议软件通过在数据前附加internet包头和LAN1帧头,创建了一个LAN1的帧。internet包头寻址到internet主机B。LAN1帧头寻址到路由器。然后它传送此帧到适配器。注意,LAN1帧的有效载荷是一个internet包,其有效载荷是实际的用户数据 。这种封装是基本的网络互联方法之一。
3、LAN1适配器拷贝该帧到网络上。
4、当此帧到达路由器时,路由器的LAN1适配器从电缆上读取它,并把它传送到协议软件。
5、路由器从internet包头中提取出目的internet地址,并用它作为路由表的索引,确定向哪里转发这个包,在本例中是LAN2。路由器剥落旧的LAN1的帧头,加上寻址到主机B的新的LAN2帧头,并把得到的帧传送到适配器。
6、路由器的LAN2适配器拷贝该帧到网络上。
7、当此帧到达主机B时,它的适配器从电缆上读到此帧,并将它传送到协议软件。
8、最后,主机B上的协议软件剥落包头和帧头。当服务器进行一个读取这些数据的系统调用时,协议软件最终将得到的数据拷贝到服务器的虚拟地址空间。
当然这里掩盖了许多复杂的问题。如果不同的网络有不同帧大小的最大值,该怎么办呢?路由器如何知道往哪里转发帧呢?当网络拓扑变化时,如何通知路由器?如果一个包丢失了又会如何呢?虽然如此,我们的实力抓住了internet思想的精髓,封装是关键。
12.3 全球IP因特网
全球IP因特网是internet最著名和最成功的实现。从20世纪80年代以来,客户端-服务器应用的组织就一直保持相当的稳定。下图展示了一个因特网客户端-服务器应用程序的基本硬件和软件组织。
每台因特网主机都运行实现TCP/IP(Transmission Control Protocol/Internet Protocol,传输控制协议/互联网络协议)的软件,几乎每个现代计算机系统都支持这个协议。因特网的客户端和服务器混合使用套接字接口函数和Unix I/O函数来进行通信。套接字函数典型地是作为系统调用来实现的,这些系统会陷入内核,并调用各种内核模式的TCP/IP函数。
TCP/IP实际是一个协议族,其中每一个都提供不同的功能。例如,IP协议提供基本的命名方法和递送机制,这种递送机制能够从一台因特网主机往其他主机发送包,也叫做数据报(datagram)。IP机制从某种意义上而言是不可靠的,因为,如果数据报在网络中丢失或者重复,它并不会恢复。UDP(不可靠数据报协议)稍微扩展了IP协议,这样一来,包可以在进程间而不是在主机间传送。TCP是一个建筑在IP之上的复杂协议,提供了进程间可靠的全双工连接。为了简化我们的讨论,我们将TCP/IP看做是一个单独的整体协议。我们将不讨论它的内部工作,只讨论TCP和IP为应用程序提供的某些基本功能。我们将不讨论UDP.
从程序员角度,我们可以把因特网看做一个世界范围的主机集合,满足以下特性:
主机集合被映射为一组32位的IP地址。
这组IP地址被映射为一组称为因特网域名(Internet domain name)的标识。
一个因特网主机上的进程能够通过一个连接(connect)和其他任何因特网主机上的进程通信。
12.3.1 IP地址
一个IP地址就是一个32位无符号整数。网络程序将IP地址存放在下面结构体中
/* Internet address structure */
struct in_addr {
unsigned int s_addr; /* network byte order (big-endian) */
};
因为因特网主机可以有不同的主机字节顺序,TCP/IP为任意整数数据项定义了一致的网络字节排序(network order) (大端字节顺序),例如IP地址,它放在包头中,通过网络。在IP地址结构中存放的地址总是以(大端法)网络字节顺序存放的,即使主机字节顺序(host byte order)是小端法。Unix提供了下面这样的函数在网络和主机字节顺序间实现转换:
#include <netinet/in.h>
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostshort);
unsigned long int ntohl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);
htonl函数将32位整数由主机字节顺序转换为网络字节顺序。ntohl函数将32位整数从网络字节顺序转换为主机字节。htons和ntohs函数 为16位的整数执行相应的转换。
IP地址是以点分十进制法表示,这里,每个字节由它的十进制值表示,并且用句点和其他字节分开。例如,128.2.194.242就是地址0x8002c2f2的点分十进制表示。在Linux系统上,你能够使用HOSTNAME命令来设置自己主机的点分十进制地址:
//show ip
linux> hostname -i
128.2.194.242
因特网程序使用inet_aton和inet_ntoa函数来实现IP地址和点分十进制串之间的转换:
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
char *inet_ntoa(struct in_addr in);
12.3.2 因特网域名
因特网客户端和服务器互相通信时使用的是IP地址,然而,对于人们而言,大整数是很难记住的,所以因特网也定义了一组更加人性化的域名(domain name),以及一种将域名映射到IP地址的基址。域名是一串用句点分隔的单词(字母,数字,破折号),如:www.baidu.com
域名集合形成了一个层次结构,每个域名编码了它在这个层次中的位置。通过一个示例你将很容易理解这点。下图展示了域名层次结构的一部分
层次结构被表示为一棵树。树的节点表示域名,反向到根的路径形成了域名。子树称为子域(subdomain) 。层次结构中的第一层是一个未命名的根节点。下一层是一组第一层域名(first-level domain names),由非营利组织ICANN(Internet Corporation for Assigned Names and Numbers,因特网分配名字数字协会)定义。常见的第一层域名包括com、edu、gov、org和net。
下一层是第二层(second-level)域名,例如,cmu.edu,这些域名是由ICANN的各个授权代理按照先到先服务的基础分配的。一旦一个组织得到了一个第二层域名,那么它就可以在这个子域中创建任何新的域名了。
因特网定义了域名集合和IP地址集合之间的映射。直到1988年,这个映射都是通过一个叫做HOSTS.TXT的文本文件来手工维护的。从那以后,这个映射是通过分部在全世界范围内的数据库--称为DNS(域名系统)来维护的。从概念上而言,DNS数据库由上百万的下面主机条目结构(host entry structure)组成的,其中每条定义了一组域名(一个官方名字和一组别名)和一组IP地址之间的映射。
/* DNS host entry structure */
struct hostent {
char *h_name; /* official domain name of host */
char **h_aliases; /* null-terminated array of domain names */
int h_addrtype; /* host address type (AF_INET) */
int h_length; /* length of an address, in bytes */
char **h_addr_list; /* null-terminated array of in_addr structs */
}
因特网应用程序通过调用gethostbyname和gethostbyaddr函数从DNS数据库中检索任意的主机条目。
#include <netdb.h>
struct hostent *gethostbyname(const char *name);
struct hostent *gethostbyaddr(const char *addr, int len, 0);
gethostbyname函数返回和域名name相关的主机条目。gethostbyaddr函数返回和IP地址addr相关的主机条目,第二个参数给出了一个IP地址的字节长度,对于目前的因特网而言总是4个字节,对于我们的要求来说,第3个参数总是0.
每台因特网主机都有本地定义的域名localhost,这个域名是映射为本地回送地址(loopback address)127.0.0.1
在最简单的情况下,一个域名对应一个IP地址,默写情况下,多个域名可以映射为同一个IP地址。
12.3.3 因特网连接
因特网客户端和服务器通过在连接(connection)上发送和接收字节流来通信。从连接一堆进程的意义上而言,连接是点对点(point-to-point)的。从数据可以同时双向流动的角度来说,它是全双工的,并且是可靠的。
套接字(socket)是连接的断电(end-point)。每个套接字都有相应的套接字地址,是由一个因特网地址和一个16位的整数端口组成的,用“地址:端口”来表示。当客户端发起一个连接请求时,客户端套接字地址中的端口是由内核自动分配的,称为临时端口(ephemeral prot)。然而,服务器套接字地址中的端口通常是某个知名的端口,是和服务对应的。例如,Web服务器通常使用端口80,而电子邮件服务器使用端口25.在Unix机器上,文件/etc/services包含一张这台机器提供的服务以及它们的知名端口号的综合列表。
一个连接是由它两端的套接字地址唯一确定的。这对套接字地址叫做套接字对(socket pair),由下列三元组来表示的:
(cliaddr:clipart, servaddr:servport)
其中cliaddr是客户端的IP地址,cliport是客户端的端口号,servaddr是服务器的IP地址,而servport是服务器的端口。例如,下图展示的Web客户端和Web服务器之间的连接。
Web客户端的套接字地址是128.2.194.242:51213,51213是内核分配的临时端口号
Web服务器的套接字地址是208.216.181.15:80,80是和Web服务器相关联的知名端口号。
12.4 套接字接口
套接字接口(socket interface)是一组用来结合Unix I/O函数创建网络应用的函数。大多数现代系统上都实现它,包括所有的Unix变种、Windows和Macintosh系统。下图是一个典型的客户端-服务器事务的上下文中的套接字接口。当我们讨论各个函数时,可以用这张图作为向导图。
12.4.1 套接字地址结构
从Unix内核的角度看,套接字就是通信的端点(end-point) 。从Unix程序的角度来看,套接字就是一个有相应描述符的打开文件。
因特网的套接字地址存放在struct sockaddr_in中,对于因特网应用,sin_family值为AF_INTE,sin_port值为16位的端口号。sin_addr成员是32位IP地址。IP地址和端口号总是以网络字节顺序(大端法)存放。
12.4.2 socket函数
客户端和服务器使用socket函数来创建一个套接字描述符(socket descriptor)
代码中总是按如下参数调用socket函数:
clientfd = socket(AF_INET, SOCK_STREAM, 0);
其中,AF_INET表示使用因特网,SOCK_STREAM表示套接字是因特网连接的端点(end-point) 。socket返回的clientfd描述符仅仅是部分打开,并且不能用于读写。
12.4.3 connect函数
客户端是通过调用connect函数来建立和服务器的连接的
connect函数试图与套接字地址为serv_addr的服务器建立连接,其中addrlen是sizeof(sockaddr_in) 。connect函数会阻塞,一直到连接成功建立或是发生错误。如果成功,sockfd描述符就准备好读写了,并且,得到的连接是由套接字对
刻画的,其中x表示客户端的IP地址,而y表示临时端口,它唯一的确定了客户端主机上的客户端进程。
12.4.4 open_clientfd函数
我们发现将socket和connect函数包装成一个叫做open_clientfd的辅助函数是很方便的。
open_clientfd函数和服务器建立一个连接,该服务器运行在主机hostname上,并在知名端口port上监听连接请求。它返回一个打开的套接字描述符。
12.4.5 bind函数
bind函数告诉内核将my_addr中的服务器套接字地址和套接字描述符sockfd联系起来。参数addrlen就是sizeof(sockaddr_in)。
12.4.6 listen函数
客户端是发起连接请求的主动实体,服务器是等待来自客户端请求的被动实体。默认情况下,内核会认为socket函数创建的描述符对应于主动套接字(active socket),它存在于一个连接的客户端。服务器调用listen函数告诉内核,描述符是被服务器使用的,而不是客户端使用的。
listen函数将sockfd从一个主动套接字转化为一个监听套接字(listening socket),该套接字可以接受来自客户端的连接请求。backlog参数暗示了内核在开始拒绝连接请求之前,应该放入队列中等待的未完成连接请求的数量。backlog参数的确切含义要求对TCP/IP协议的理解,这超出了讨论范围,通常我们会设置一个较大的值,如1024.
12.4.7 open_listenfd函数
open_listenfd函数是socket、bind、listen函数的结合,服务器端可以使用它来创建一个监听描述符。
12.4.8 accept函数
服务器通过调用accept函数来等待来自客户端的连接请求
accept函数等待来自客户端的连接请求到达侦听描述符listenfd,然后在addr中填写客户端的套接字地址,并返回一个已连接描述符(connected descriptor),这个描述符可被用来利用Unix I/O函数与客户端通信。
监听描述符和已连接描述符之间的区别:监听描述符是作为客户端连接请求的一个端点。典型地,它被创建一次,并存在于服务器的整个生命周期。已连接描述符是客户端和服务器之间已经建立起来了的连接的一个端点。服务器每次接受连接请求时,都会创建一次,只存在于服务器为一个客户端服务的过程中。
12.4.9 echo客户端和服务器的示例
学习套接字接口最好的方法是研究示例代码。
下面客户端代码,在和服务器建立连接后,进入循环,反复从标准输入读取文本行,发送文本行给服务器,从服务器读取响应行,并输出到标准输出。当fgets在标准输入上遇到EOF时,或者因为用户在键盘上输入ctrl-d,或者因为在一个重定向的输入文件中用尽了所有的文本行时,循环终止。
循环终止后,客户端关闭描述符。这会导致发送一个EOF到服务器,当服务器从它的rio_readlineb函数收到一个为0的返回码是,就会检测到这个结果。在关闭它的描述符后,客户端就终止了。
#include "csapp.h"
int main(int argc, char **argv)
{
int clientfd, port;
char *host, buf[MAXLINE];
rio_t rio;
if (argc != 3) {
fprintf(stderr, "usage: %s <host> <port>\n", argv[0]);
exut(0);
}
host = argv[1];
prot = atoi(argv[2]);
clientfd = open_clientfd(host, port);
rio_readinitb(&rio, clientfd);
while (fgets(buf, MAXLINE, stdin) != NULL) {
rio_writen(clientfd, buf, strlen(buf));
rio_readlineb(&rio, buf, MAXLINE);
fputs(buf, stdout);
}
close(clientfd);
exit(0);
}
下面是服务器程序代码,在打开监听描述符后,它进入循环,每次循环都等待一个来自客户端的连接请求,输出已连接客户端的域名和IP地址,并调用echo函数为这些客户端服务。在echo程序返回后,主程序关闭已连接描述符。一旦客户端和服务器关闭了它们各自的描述符,连接也就终止了。
#include "csapp.h"
void echo(int connfd);
int main(int argc, char **argv)
{
int listenfd, connfd, port, clientlen;
struct sockaddr_in clientaddr;
struct hostent *hp;
char *haddrp;
if (argc != 2) {
fprintf(stderr, "usage: %s <port>", argv[0]);
exit(0);
}
port = atoi(argv[1]);
listenfd = open_listenfd(port);
while (1) {
clientlen = sizeof(clientaddr);
connfd = accept(listenfd, (SA *)&clientaddr, &clientlen);
/* determine the domain name and IP address of the client */
hp = gethostbyaddr((const char *)&clientaddr.sin_addr.s_addr, sizeof(clientaddr.sin_addr.s_addr), AF_INET);
haddrp = inet_ntoa(clientaddr.sin_addr);
printf("server connected to %s (%s)\n", hp->h_name, haddrp);
echo(connfd);
close(connfd);
}
exit(0);
}
注意,我们的简单的echo服务器一次只能处理一个客户端。这种类型的服务器一次一个地在客户端间迭代,称为迭代服务器(iterative server)。
下面是echo的代码,该程序反复读写文本行,直到rio_readlineb函数在第10行遇到EOF
#include "csapp.h"
void echo(int connfd)
{
size_t n;
char buf[MAXLINE];
rio_t rio;
rio_readinitb(&rio, connfd);
while ((n = rio_readlineb(&rio, buf, MAXLINE)) != 0) {
printf("server received %d bytes\n", n);
rio_writen(connfd, buf, n);
}
}
EOF的概念:其实并没有EOF字符这样的一个东西,EOF是由内核检测到的一种条件。应用程序在它接收到一个由read函数返回的返回码时,它就会发现出EOF条件。对应磁盘文件,当前文件位置超出文件长度时,会发生EOF。对于因特网连接,当一个进程关闭连接在它的那一端时,会发生EOF.连接另一端的进程在试图读取流中最后一个字节之后,会检测到EOF。
12.5 Web服务器
12.5.1 web基础
Web客户端和服务器之间的交互用的是一个基于文本的应用级协议-HTTP(Hypertext Transfer Protocol, 超文本传输协议)。HTTP是一个简单的协议。一个Web客户端(就是浏览器)打开一个到服务器的因特网连接,并且请求某些内容。服务器响应所有请求的内容,然后关闭连接。浏览器读取这些内容,并把它显式在屏幕上。
Web服务和常规的文件检索服务(例如FTP)的主要区别是Web内容可以用一种叫做HTML(hypertext Markup Language,超文本标记语言)的语言来编写。一个HTML程序(页)包含指令(标记符),它们告诉浏览器如何显示这页中的各种文本和图形对象。例如,代码
<b> Make me bold! </b>
告诉浏览器用粗体字类型输出<b>和</b>标记之间的文本。然而,HTML真正的强大之处在于一个页面可以包含指针(超链接),这些指针可以指向存放在任何因特网主机上的内容。例如
<a href="http://www.cmu.edu/index.html">Carnegie Mellon</a>
告诉浏览器高亮显示文本对象Carnegie Mellon“”,并创建一个超链接,它指向存放在CMU Web服务器上名为index.html的文件。如果用户点击了这个高亮文本对象,浏览器从CMU服务器中请求相应的HTML文件,并显式它。
12.5.2 Web内容
对于Web客户端和服务器而言,内容是与一个MIME(Multipurpose Internet Mail Extensions,多用途的网际邮件扩充协议)类型相关的字节序列。下图展示了一些常用的MIME类型。
Web服务器以两种不同的方式向客户端提供内容:
1、取一个磁盘文件,并将它的内容返回给客户端。磁盘文件称为静态内容(static content),而返回文件给客户端的过程称为服务静态内容(serving static content)。
2、运行一个可执行文件,并将它的输出返回给客户端。运行时,可执行文件产生的输出称为动态内容(dynamic content), 而运行程序并返回 它的输出到客户端的过程称为服务动态内容(serving dynamic content)。
每条由Web服务器返回的内容都是和它管理的某个文件相关联的。这些文件中的每一个都有一个唯一的名字,叫做URL(universal resource Locator,通用资源定位符)。例如
http://www.aol.com:80/index.html
表示因特网主机www.aol.com上一个称为/index.html的HTML文件,它是由一个监听80端口的Web服务器管理的。端口号是可选的,而知名的HTTP默认的端口就是80.可执行文件的URL可以在文件名后包括程序参数。“?”字符分隔文件名和参数,而且每个参数都用“&”字符分隔开,例如
http://kittyhawk.cmcl.cs.cmu.edu:8000/cgi-bin/adder?15000&213
标识了一个叫做/cgi-bin/addr的可执行文件,会带两个参数字符串15000和213来调用它,在事务过程中,客户端和服务器使用的是URL的不同部分。例如客户端使用前缀
http://www.aol.com:80
来决定与哪类服务器联系,服务器在哪,以及它监听的端口号是多少。服务器使用后缀
/index.html
来发现在它文件系统中的文件,并确定请求的是静态内容,还是动态内容。
关于服务器如何解释一个URL的后缀,有三点需要解释:
1、确定一个URL指向的是静态内容还是动态内容没有标准的规则。每个服务器对它所管理的文件都有自己的规则。一种常见的方法是,确认一组目录,例如cgi-bin,所有的可执行性文件都必须存放在这些目录中。
2、后缀中的最开始的那个“/”不表示Unix的根目录。相反,它表示的是被请求内容类型的主目录。例如,可以将一个服务器配置成这样:所有的静态内容存放在目录/usr/httpd/html下,而所有的动态内容都存放在目录/usr/https/cgi-bin下。
3、最小的URL后缀是“/”字符,所有服务器将其扩展为某个默认的主页,例如/indexx.html.这解释了为什么简单地在浏览器中键入一个域名就可以取出一个网站的主页。浏览器在URL后添加缺失的“/”,并将之传递给服务器,服务器又把“/”扩展到某个默认的文件名。
12.5.3 HTTP事务
因为HTTP是基于在因特网连接上传送的文本行的,我们可以使用Unix的TELNET程序来和任何因特网上的Web服务器执行事务。对于调试在连接上通过文本行来与客户端对话的服务器来说,TELNET程序是非常便利的。
HTTP请求
一个HTTP请求的组成:一个请求行(request line)(第5行),后面跟随0个或者多个请求报头(request header)(第6行),,再跟随一个空的文本行来终止报头列表(第7行)。一个请求行的形式是
<method><uri><version>
HTTP支持许多方法,包括GET、POST、OPTIONS、HEAD、PUT、DELETE、TRACE.我们只讨论广为应用的GET方法。GET方法指导服务器生成和返回URI(Uniform Resource Identifier,统一资源标识)标识的内容。URI是相应的URL的后缀,包括文件名和可选的参数。
请求中的<version>字段表明了该请求遵循的HTTP版本。最新的HTTP版本是HTTP/1.1。HTTP/1.0是1996年沿用至今的老版本。HTTP/1.1定义了一些附加的报头,为诸如缓冲和安全等高级特性提供支持,它还支持一种机制,允许客户端和服务器在同一条持久连接(persistent connection)上执行多个事务。在实际中,两根版本是兼容的,因为HTTP/1.0的客户端会简单地忽略HTTP/1.1的报头。
总地来说,第5行的请求行要求服务器取出并返回HTML文件/index.html。它也告知服务器请求剩下的部分是HTTP/1.1格式的。
请求报头位服务器提供了额外的信息,例如浏览器的商标名,或者浏览器理解的MIME类型。请求报头的格式为
<header name>:<header data>
针对我们的目的,唯一需要关注的报头是Host报头(第6行),它在HTTP/1.1请求中是需要的,而在HTTP/1.0请求中是不需要的。代理缓存(proxy cache)会使用Host报头,这个代理缓存有时作为浏览器和管理被请求文件的原始服务器(origin server)的中介。客户端和原始服务器之间可以有多个代理,即 所谓的代理链(proxy chain)。Host报头中的数据,指示了原始服务器的域名,使得代理链只能够的代理能够判断它是否可以拥有一个 被请求内容的本地缓存的副本。
第7行的空文本行(通过我们在键盘上键入回车键生成的)终止了报头,并指示服务器发送被请求的HTML文件。
HTTP响应
HTTP响应和HTTP请求是相似的。一个HTTP响应的组成是这样的:一个响应行(response line)(第8行),后面跟随着0个或者更多的响应报头(response header)(第9-13行),再跟随一个终止报头的空行(第14行),再跟随一个响应主体(response body)(第15-17行)。一个响应行的格式是
<version><status code><status message>
版本字段描述的是响应所遵循的HTTP版本。status code(状态码)是一个3位的正整数,指明对请求的处理。status message(状态消息)给出与错误代码等价的英文描述。下图是常见的状态码以及它们相应的消息
第9-13行的响应报头提供了关于响应的附加信息。针对我们的目的,两个最重要的报头是Content-Type(第12行),它告诉客户端响应主体中内容的MIME类型;以及Content-Length(第13行),用来指示响应主体的字节大小。
12.5.4 服务动态内容
一个称为CGI(Common Gateway Interface,通用网关接口)的实际标准解决如下问题
客户端如何将程序参数传递给服务器?
GET请求的参数在URI中传递。正如我们看到的,一个“?”字符分隔了文件名和参数,而每个参数都用一个“&”字符分隔开。参数中不允许有空格,而必须用字符串%20“”来表示。对其他特殊字符,也存在相似的编码。
服务器如何将参数传递给子进程?
在服务器接收一个如下请求后
GET /cgi-bin/adder?15000&213 HTTP/1.1
它调用fork来创建一个子进程,并调用execve在子进程的上下文中执行/cgi-bin/adder程序。像adder这样的程序,常常被称为CGI程序,因为它们遵守CGI标准的规则。而且,因为许多CGI程序是用Perl脚本写的,所以CGI程序也常被称为CGI脚本(CGI script)。在调用execve之前,子进程将CGI环境变量QUERY_STRING设置为“15000&213”,adder程序在运行时可以用Unix getenv函数来引用它。
服务器如何将其他信息传递给子进程?
CGI定义了大量的其他环境变量,一个CGI程序在它运行时,可以设置这些环境变量。下图给出一部分。
子进程将它的输出发送到哪里?
一个CGI程序将它的动态内容发送到标准输出。在子进程加载并运行CGI程序之前,它使用Unix dup2函数将标准输出重定向到和客户端相关联的的已连接描述符。因此,任何CGI程序写到标准输出的东西都会直接到达客户端。
注意,因为父进程不知道子进程生成的内容的类型或大小,所以子进程就要负责生成Content-type和Content-length响应报头,以及终止报头的空行。
12.6 综合:Tiny Web 服务器
我们通过开发一个小但功能齐全的Web服务器来进行实践
Tiny 的 main 程序
Tiny主程序如下,它是一个迭代服务器,监听在命令行中确定的端口上的连接请求。在通过调用open_listenfd函数打开一个监听套接字后,Tiny执行典型的无限服务器循环,反复地接受一个连接请求(第31行),执行事务(第32行),并关闭连接的它那一端。
#include "csapp.h"
void doit(int fd);
void read_requesthdrs(rio_t *rp);
int parse_uri(char *uri, char *filename, char *cgiargs);
void serve_static(int fd, char *filename, int filesize);
void get_filetype(char *filename, char *filetype);
void serve)dynamic(int fd, char *cause, char *errnum, char *shortmsg, char *longmsg);
int main(int argc, char **argv)
{
int listenfd, connfd, port, clientlen;
struct sockaddr_in clientaddr;
/* Check command line args */
if (argc !=2 ) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(1);
}
prot = atoi(argv[1]);
listenfd = open_listenfd(port);
while(1) {
clientlen = sizeof(clientaddr);
connfd = accept(listenfd, (SA *)&clientaddr, &clientlen);
doit(connfd);
close(connfd);
}
}
doit函数
doit函数处理一个HTTP事务。首先,我们读和解析请求(第11~12行)。注意,我们使用rio_readlineb函数读取请求行
void doit(int fd)
{
int is_static;
struct stat sbuf;
char buf[MAXLINE], methor[MAXLINE], uri[MAXLINE], version[MAXLINE];
char filename[MAXLINE], cgiargs[MAXLINE];
rio_t rio;
/* Read request line and headers */
rio_readinitb(&rio, fd);
rio_readlineb(&rio, buf, MAXLINE);
sscanf(buf, "%s %s %s", method, uri, version);
if (strcasecmp(methor, "GET")) {
clienterror(fd, methor, "501", "Not implemented", "Tiny does not implement this method");
return;
}
read_requesthdrs(&rio);
/* Parse URI from GET request */
is_static = parse_uri(uri, filename, cgiargs);
if(stat(filename, &sbuf) < 0 ) {
if (!S_ISREG(sbuf.st_mode)) || ! (S_IRUSE & sbuf,st_mode)) {
clienterror(fd, filename, "404", "Not found", "Tiny couldn't find this file");
return;
}
serve_static(fd, filename, sbuf.st_size);
} else { /* serve dynamic content */
if (!(S_ISREG(sbuf,st_mode)) || !(S_IXUSR & sbuf,st_mode)) {
clienterror(fd, filename, "403", "Forbidden", "Tiny couldn't run the CGI program");
return;
}
serve_dynamic(fd, filename, cgiargs);
}
}
Tiny 只支持GET方法。如果客户端请求其他方法(比如POST) ,我们发送给它一个错误信息,并返回主程序,主程序随后关闭连接并等待下一个连接请求。否则,我们读并且忽略任何请求报头。
然后,我们将URI解析为一个文件名和一个可能为空的CGI参数串,并且我们设置一个标志,表明请求的是静态内容还是动态内容。如果文件在磁盘上不存在,我们立即发送一个错误信息给客户端,并返回。
最后,如果请求的是静态内容,我们就核实文件是一个普通文件,而我们是有读权限的。如果是这样,我们就向客户端提供静态内容。相似地,如果请求的是动态内容,我们就核实该文件是可执行文件,如果是,我们就继续,并且提供动态内容。
clienterror函数
Tiny缺乏一个实际服务器的许多错误处理特性。然而,它会检查一些明显的错误,并把他们报告给客户端。下面程序clienterror函数发送一个HTTP响应给客户端,在响应中包含响应的状态码和状态消息,以及响应主体中的一个HTML文件,向浏览器的用户解释这个错误。
void clienterror(int fd, char *cause, char *errnum, char *shormsg, char *longmsg)
{
char buf[MAXLINE], body[MAXLINE];
/* Build the HTTP respone body */
sprintf(body, "<html><title>Tiny Error</title>");
sprintf(body, "%s<body bgcolor=""ffffff"">\r\n", body);
sprintf(body, "%s%s: %s\r\n", body", errnum, shortmsg);
sprintf(body, "%s<p>%s: %s\r\n", body", longmsg, cause);
sprintf(body, "%s<hr><em>The Tiny Web server</em>\r\n", body);
/* Print the HTTP respone */
sprintf(buf, "HTTP/1.0 %s %s\r\n", errnum, shortmsg);
rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-type: text/html\r\n");
rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-length: %d\r\n\r\n", strlen(body));
rio_writen(fd, buf, strlen(buf));
rio_writen(fd, body, strlen(body));
}
read_requesthdrs函数
Tiny不使用请求报头中的任何信息。它仅仅调用read_requesthdrs函数来读取并忽略这些报头。注意,终止请求报头的空文本行是由回车行和换行符对组成的。
void read_requesthdrs(rio_t *rp)
{
char buf[MAXLINE];
rio_readlineb(rq, buf, MAXLINE);
while(strcmp(buf, "\r\n"))
rio_readlineb(rp, buf, MAXLINE);
return;
}
parse_uri函数
Tiny假设静态内容的主目录就是它的当前目录,而可执行文件的目录是./cgi-bin。任何包含字符串cgi-bin的URI都会被认为表示的是对动态内容的请求。默认的文件名是./home.html。
下面的parse_uri函数实现了这些策略。它将URI解析为一个文件名和一个可选的CGI参数串。如果请求的是静态内容,我们将清除CGI参数串,然后将URI转换为一个相对的Unix路径名,例如./index.html。如果URI是用"/"结尾的,我们将把默认的文件名加在后面。另一方面,如果请求的是动态内容,我们就会抽取出所有的CGI参数,并将URI剩下的部分转换为一个相对的Unix文件名。
int parse_uri(char *uri, char *filename, char *cgiargs)
{
char *ptr;
if (!strstr(uri, "cgi-bin")) { /* Static content */
strcpy(cgiargs, "");
strcpy(filename, ".");
strcat(filename, uri);
if (uri[strlen(uri)-1] == '/')
strcat(filename, "home.html");
return 1;
}
else { /* Dynamic content */
ptr = index(uri, '?');
if (ptr) {
strcpy(cgiargs, ptr+1);
*ptr = '\0';
}
else
strcpy(cgiargs, "");
strcpy(filename, ".");
strcat(filename, uri);
return 0;
}
}
serve_static函数
Tiny 提供4种不同类型的静态内容:HTML文件、无格式的文本文件,以及编码为GIF和JPG格式的图片。这些文件类型占据Web上提供的绝大部分静态内容。
下面的serve_static函数发送一个HTTP响应,其主体包含一个本地文件的内容。首先,我们通过检查文件名的后缀来判断文件类型,并且发送响应行和响应报头给客户端。注意,用一个空行终止报头。
void serve_static(int fd, char *filename, int filesize)
{
int srcfd;
char *srcp, filetype[MAXLINE], buf[MAXBUF];
/* Send respone headers to client */
get_filetype(filename, filetype);
sprintf(buf, "HTTP/1.0 200 OK\r\n);
sprintf(buf, "%sServer: Tiny Web Server\r\n", buf);
sprintf(buf, "%sContent-length: %d\r\n", buf, filesize);
sprintf(buf, "%sContent-type: %s\r\n\r\n", buf, filetype);
rio_writen(fd, buf, strlen(buf));
/* Send response body to client */
srcfd = open(filename, O_RDONLY, 0);
srcp = mmap(0, filesize, PROT_READ, MAP_PRIVATE, srcfd, 0);
close(srcfd);
rio_writen(fd, srcp, filesize);
munmap(srcp, filesize);
}
/*
* get_filetype-derive file type from file name
*/
void get_filetyupe(char *filename, char *filetype)
{
if (strstr(filename, "html"))
strcpy(filetype, "text/html");
else if (strstr(filename, ".gif"))
strcpy(filetype, "image/gif");
else if (strstr(filename, ".jpg"))
strcpy(filetype, "image/jpeg");
else
strcpy(filetype, "text/plain");
}
接着,我们将被请求的内拷贝到已连接描述符fd,来发送响应主体。这里的代码是比较微妙的,需要仔细研究。第15行为读打开了filename,并获得了它的描述符。在第16行,Unix mmap函数将被请求文件映射到一个虚拟存储器空间。回想我们在第10.8节中对mmap的讨论,调用mmap将文件srcfd的前filesize个字节映射到一个从地址srcp开始的私有只读虚拟存储器区域。
一旦我们将文件映射到存储器,我们就不再需要它的描述符了,所以我们关闭文件。执行这项任务失败将导致一种潜在的致命的存储器泄漏。第18行执行的是到客户端的实际文件传到客户端的已连接的描述符。最后,第19行释放了映射的虚拟存储器区域。这对于避免一个潜在的致命存储器泄漏是很重要的。
serve_dynamic函数
Tiny通过派生一个子进程并在子进程的上下文中运行一个CGI程序 ,来提供各种类型的动态内容。
下面的serve_dynamic函数一开始就向客户端发送一个表明成功的响应行,同时还包括带有信息的server报头。CGI程序负责发送响应的剩余部分。注意,这并不像我们可能希望的那样健壮,因为它没有考虑到CGI程序可能会遇到某些错误的可能性。
void serve_dynamic(int fd, char *filename, char *cgiargs)
{
char buf[MAXLINE], *emptylist[] = {NULL};
/* Return first part of HTTP response */
sprintf(buf, "HTTP/1.0 200 OK\r\n");
rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Server: Tiny Web Server\r\n");
rio_writen(fd, buf, strlen(buf));
if (fork() == 0) { /* child */
/* Real server would set all CGI vars here */
setenv("QUERY_STRING", cgiargs, 1);
dup2(fd, STDOUT_FILENO); /* Rediretc stdout to client */
execve(filename, emptylist, environ); /* Run CGI program */
}
wait(NULL); /*Parent waits for adn reaps child */
}
在发送了响应的第一部分后,我们会派生一个新的子进程。子进程用来自请求URI的CGI参数初始化QUERY_STRING环境变量。注意,一个真正的服务器将还要在此处设置其它的CGI环境变量。为了简短,我们省略了这一步。还有,我们注意到Solaris系统使用的是putenv函数,而不是setenv函数。
接下来,子进程重定向它的标准输出到已连接文件描述符,然后加载并运行CGI程序。因为CGI程序运行在子进程的上下文中,它能够访问在调用execve函数之前就存在的相同的打开文件和环境变量。因此,CGI程序写到标准输出上的任何东西都将直接送到客户端进程,不会经过任何父进程的干涉。
其间,父进程阻塞在对wait的调用中,等待当子进程终止的时候,回收操作系统分配给子进程的资源。