网络编程
所有的网络应用都是基于相同的基本编程模型,有着相似的整体逻辑结构,并且依赖相同的编程接口。我们需要理解基本的客户端-服务器编程模型,以及如何编写使用因特网提供的客户端-服务器程序。最后,我们开发一个小的但功能齐全的Web服务器,能够为真实的Web浏览器提供静态和动态的文本和图形内容。
客户端-服务器编程模型
- 每个网络应用都是基于客户端-服务器模型的。一个应用是由一个服务器进程和一个或者多个客户端进程组成。服务器管理某种资源,并且通过操作这种资源来为它的客户端提供某种服务。例如,一个Web服务器管理了一组磁盘文件,它会代表客户端进行检索和执行。一个FTP服务器就管理了一组磁盘文件,它会为客户端进行存储和检索。一个电子邮件服务器管理了一些文件,它为客户端进行读和更新。
- 客户端-服务器模型中的基本操作是事务。一个客户端-服务器事务由四步组成:
- 当一个客户端需要服务时,它向服务器发送一个请求,发起一个事务。例如,当Web浏览器需要一个文件时,它就发送一个请求给Web服务器。
- 服务器收到请求后,解释它,并以适当的方式操作它的资源。例如,当Web服务器收到浏览器发出的请求后,它就读一个磁盘文件。
- 服务器给客户端发送一个响应,并等待下一个请求。例如,Web服务器将文件发送回客户端。
- 客户端收到响应并处理它。例如,当Web浏览器收到来自服务器的一页后,它就在屏幕上显示此页。
认识到客户端和服务器是进程,而不是常常提到的机器或者主机,这是很重要的。一台主机可以同时运行许多不同的客户端和服务器,而且一个客户端和服务器的事务可以在同一台或是不同的主机上运行。无论客户端和服务器是怎样映射到主机上的,客户端-服务器模型是相同的。
客户端-服务器事务不是数据库事务,没有数据库事务的任何特性,例如原子性。在我们的上下文中,事务仅仅是客户端和服务器执行的一系列步骤。
网络
客户端和服务器通常运行在不同的主机上,并且通过计算机网络的硬件和软件资源来通信。
对于一个主机而言,网络只是又一种I/O设备,作为数据源和数据接收方。下图,一个插到I/O总线扩展槽的适配器提供了到网络的物理接口。从网络上接收到的数据从适配器经过I/O和存储器总线拷贝到存储器,典型地是通过DMA传送。相似的,数据也能从存储器拷贝到网络。
物理上而言,网络是一个按照地理远近组成的层次系统。最低层是LAN(Local Area Network,局域网),在一个建筑或者校园范围内。最流行的局域网技术使以太网(Ethernet)。
一个以太网段包括一些电缆和一个集线器。
一端连接到主机的适配器,另一端连接到集线器的一个端口上。集线器不加分辨地将从一个端口上收到的每个位复制到其他所有的端口上。因此,每台主机都能看到每个位。
每个以太网适配器都有一个全球唯一的48位地址,它存储在这个适配器的非易失性存储器上。一台主机可以发送一段位,称为帧,到这个网段内其他任何主机。这个帧包括固定数量的头部位,用来标识此帧的源和目的地址以及此帧的长度,此后紧随的就是数据位的有效载荷。每个主机适配器都能看到这个帧,但是只有目的主机实际读取它。
使用一些电缆和叫做网桥的小盒子,多个以太网段可以连接成较大的局域网,称为桥接以太网。
在层次的更高级别中,多个不兼容的局域网可以通过叫做路由器的特殊计算机连接起来,组成一个互联网络(internet)。
每台路由器对于它所连接到的每个网络都有一个适配器(端口)。路由器也能连接高速点到点电话连接,这是称为WAN(Wide-Area Network,广域网)的网络示例,之所以这么叫是因为它们覆盖的地理范围比局域网的大。一般而言,路由器可以用来由各种局域网和广域网构建互联网络。
互联网至关重要的特性是,它能由采用完全不同和不兼容技术的各种局域网和广域网组成。每台主机和其他每台主机都是物理相连的,但是如何能够让某台源主机跨过所有这些不兼容的网络发送数据到另一台目的主机呢?
解决办法是一层运行在每台主机和路由器上的协议软件,它消除了不同网络之间的差异,这个软件实现一种协议,这种协议控制主机和路由器如何协同工作来实现数据传输。这种协议必须提供两种基本能力:
- 命名机制:不同的局域网技术有不同和不兼容的方式来为主机分配地址。互联网协议通过定义一种一致的主机地址格式消除了这些差异。每台主机会被分配至少一个这种互联网络地址,这个地址唯一地标识了这台主机
- 传送机制:互联网协议通过定义一种把数据位捆扎成不连续的片(称为包)的统一方式,从而消除了这些差异。
下图展示了一个主机和路由器如何使用互联网协议在不兼容的局域网间传送数据的示例。这个互联网示例由两个局域网通过一台路由器连接而成。一个客户端运行在主机A上,主机A与LAN1相连,它发送一串数据字节到运行在主机B上的服务器端,主机B则连接在LAN2上。这个过程包括8个基本步骤:
- 运行在主机A上的客户端进行一个系统调用,从客户端的虚拟地址空间拷贝数据到内核缓冲区。
- 主机A上的协议软件通过在数据前附加互联网包头和LAN1帧头,创建了一个LAN1的帧。互联网包头寻址到互联网主机B。LAN1帧头寻址到路由器。然后它传送此帧到适配器。注意,LAN1帧的有效载荷是一个互联网络包,而互联网络包的有效载荷是实际的用户数据。
- LAN1适配器拷贝该帧到网络上
- 当此帧到达路由器时,路由器的LAN1适配器从电缆上读取它,并把它传送到协议软件
- 路由器从互联网络包头提取出目的互联网地址,并用它作为路由表的索引,确定向哪里转发这个包,本例中是LAN2。路由器剥落旧的LAN1的帧头,加上寻址到主机B的新的LAN2帧头,并把得到的帧传送到适配器
- 路由器的LAN2适配器拷贝该帧到网络上
- 当此帧到达主机B时,它的适配器从电缆到读取到此帧,并将它传送到协议软件
- 最后,主机B上的协议软件剥落包头和帧头,当服务器进行一个读取这些数据的系统调用时,协议软件最终将得到的数据拷贝到服务器的虚拟地址空间
全球IP因特网
每台因特网主机都运行实现TCP/IP协议的软件,几乎每个现代计算机系统都支持这个协议。因特网的客户端和服务器混合使用套接字接口和Unix I/O函数来进行通信。套接字函数典型地是作为会陷入内核的系统调用来实现的,并调用各种内核模式的TCP/IP函数
IP地址:一个IP地址就是一个32位无符号整数。网络程序将IP地址存放在下图的IP地址结构中
struct in_addr{
unsigned int s_addr;
};
因特网主机可以有不同的主机字节顺序,TCP/IP为任意整数数据项定义了统一的网络字节顺序(大端字节顺序)。在IP地址结构中存放的地址总是以(大端法)网络字节顺序存放的,即使主机字节顺序是小端法。Unix提供了下面这样的函数在网络和主机字节顺序间实现转换:
#include <netinet/in.h>
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htonl(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地址通常是以一种称为点分十进制表示法来表示的,每个字节由它的十进制值表示,并用句点和其他字节分开
因特网程序使用inet_ aton和inet_ntoa函数来实现IP地址和点分十进制串之间的转换
#include <arpa/inet.h>
int inet_aton(const char* cp,struct in_addr* inp);
返回:若成功则为1,若出错则为0
char* inet_ntoa(struct in_addr in);
返回:指向点分十进制字符串的指针
“n”表示网络,“a”表示应用
因特网域名
因特网客户端和服务器互相通信时使用的是IP地址。大整数是很难记住的,所以因特网也定义了一组更加人性化的域名,以及一种将域名映射到IP地址的机制。域名是一串用句点分隔的单词(字母、数字和破折号),例如
kittyhawk.cmcl.cs.cmu.edu
域名集合形成了一个层次结构,每个域名编码了它在这个层次中的位置。下图展示了域名层次结构的一部分。层次结构可以表示为一棵树,树的节点表示域名,反向到根的路径形成了域名。子树称为子域。层次结构中的第一层是一个未命名的根节点,下一层是一组一级域名。下一层是二级域名,例如cmu.edu。一旦一个组织得到了一个二级域名,那么它就可以在这个子域中创建任何新的域名了。
因特网定义了域名集合和IP地址集合之间的映射。通过分布世界范围内的数据库(DNS(域名系统))来维护的。从概念上而言,DNS数据库由上百万的主机条目结构组成的,其中每条定义了一组域名和一组IP地址之间的映射。
因特网应用程序通过调用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地址的字节长度,第三个参数总是零。
这个程序从命令行读取一个域名或点分十进制地址,并显示相应的主机条目。每台因特网主机都有本地定义的域名localhost,这个域名总是映射为本地回送地址127.0.0.1;
localhost名字为引用运行在同一台机器上的客户端和服务器提供了一种便利和可移植的方式,这对调试相当有用。
因特网连接
因特网客户端和服务器通过在连接上发送和接收字节流来通信,从连接一对进程的意义上而言,连接是点对点的。从数据可以同时双向流动来说,它是全双工的。
一个套接字是连接的一个端点。每个套接字都有相应的套接字地址,是由一个因特网地址和一个16位的整数端口组成的,用“地址:端口”来表示。当客户端发起一个连接请求时,客户端套接字地址中的端口是由内核自动分配的,称为临时端口。然而。服务器套接字地址中的端口通常是某个知名的端口,是和这个服务相对应的。例如,Web服务器通常使用端口80。
一个连接是由它两端的套接字地址唯一确定的。这对套接字地址叫做套接字对,由下列元组来表示:
(cliaddr:cliport, servaddr:servport)
cliaddr是客户端的IP地址,cliport是客户端的端口,servaddr是服务器的IP地址,而servport是服务器的端口。
展示了一个Web客户端和一个Web服务器之间的连接:
套接字接口是一组函数,它们和Unix I/O函数结合起来,用以创建网络应用。
给出了一个典型的客户端-服务器事务的上下文中的套接字接口概述
套接字地址结构
从Unix内核的角度来看,一个套接字就是通信的一个端点,从Unix程序的角度来看,套接字就是一个有相应描述符的打开文件。
因特网的套接字地址存放在类型为sockaddr_in的16字节结构中。对于因特网应用,sin _family成员是AF _INET,sin_port成员是一个16位的端口号,而sin_addr成员就是一个32位的IP地址。
connect、bind和accept函数要求一个指向与协议相关的套接字地址结构的指针。
socket函数
客户端和服务器使用socket函数来创建一个套接字描述符。
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain,int type, int protocol);
返回:若成功则为非负描述符,若出错则为-1
clientfd = socket(AF_INET, SOCK_STREAM, 0);
AF_INET表明我们正在使用因特网,而SOCK_STREAM表示这个套接字是因特网连接的一个端点。socket返回的clientfd描述符仅是部分打开的,还不能用于读写。
connect函数
客户端通过调用connect函数来建立和服务器的连接。
#include <sys/socket.h>
int connect(int sockfd,struct sockaddr* serv_addr,int addrlen);
若成功则为0,若出错则为-1
connect函数试图与套接字地址为serv_addr的服务器建立一个因特网连接,其中addrlen是sizeof(sockaddr_in).connect函数会阻塞,一直到连接成功建立或是发生错误。如果成功sockfd描述符现在就可以读写了,并且得到的连接是由套接字对(x:y, serv_addr:sin_addr:serv_addr.sin_port)刻画的;x表示客户端的IP地址,而y表示临时端口,它唯一地确定了客户端主机上的客户端进程。
open_clientfd函数
我们发现将socket和connect函数包装成一个叫做open_clientfd的辅助函数是很方便的,客户端可以用它来和服务器建立连接。
#include "csapp.h"
int open_clientfd(char* hostname,int port);
bind函数
剩下的套接字函数bind、listen和accept被服务器用来和客户端建立连接。
#include <sys/socket.h>
int bind(int sockfd,struct sockaddr* my_addr,int addrlen);
返回:若成功则为0,若出错则为-1
bind函数告诉内核将my_addr中的服务器套接字地址和套接字描述符sockfd联系起来。addrlen就是sizeof(sockaddr_in)。
listen函数
客户端是发起连接请求的主动实体。服务器是等待客户端的连接请求的被动实体。默认情况下,内核会认为socket函数创建的描述符对应于主动套接字,它存在于一个连接的客户端。服务器调用listen函数告诉内核,描述符是被服务器而不是客户端使用的。
#include <sys/socket.h>
int listen(int sockfd, int backlog);
返回:若成功则为0,若出错则为-1
listen函数将sockfd从一个主动套接字转换为一个监听套接字,该套接字可以接受来自客户端的连接请求。backlog参数暗示了内核在开始拒绝连接请求之前,应该放入队列中等待的未完成连接请求的数量。通常会设置为一个较大的值,比如1024
open_listenfd函数
我们发现将socket、bind和listen函数结合成一个叫做open_listenfd的辅助函数是很有帮助的,服务器可以用它来创建一个监听描述符。
#include "csapp.h"
int open_listenfd(int port);
返回:若成功则为描述符,若Unix出错则为-1
open_listenfd函数打开和返回一个监听描述符,这个描述符准备好在知名端口port上接收连接请求。
accept函数
#include <sys/socket.h>
int accept(int listenfd, struct sockaddr* addr, int* addrlen);
返回:若成功则为非负连接描述符,若出错则为-1
accept函数等待来自客户端的连接请求到达侦听描述符listenfd,然后在addr中填写客户端的套接字地址,并返回一个已连接描述符,这个描述符可被用来利用Unix I/O函数与客户端通信。
监听描述符和已连接描述符之间的区别。监听描述符是作为客户端连接连接请求的一个端点。典型地,它被创建一次,并存在于服务器的整个生命周期。已连接描述符是客户端和服务器之间已经建立起来了的连接的一个端点。服务器每次接受连接请求时都会被创建一次,它只存在于服务器为一个客户端服务的过程中。
第一步,服务器调用accept,等待连接请求到达监听描述符。
第二步,客户端调用connect函数,发送一个连接请求到listenfd。
第三步,accept函数打开了一个新的已连接描述符connfd,在clientfd和connfd之间建立连接,并且随后返回connfd给应用程序。在这一点以后,客户端和服务器局可以分别通过读和写clientfd和connfd来回传送数据了。
Web服务器
创建虽然小但是功能齐全的Web服务器
Web客户端和服务器之间的交互用的是一个基于文本的应用级协议,叫做HTTP(超文本传输协议)。HTTP是一个简单的协议。一个Web客户端(即浏览器)打开一个到服务器的因特网连接,并且请求某些内容。服务器响应所请求的内容,然后关闭连接。浏览器读取这些内容,并把它显示在屏幕上。
Web服务和常规的文件检索服务(FTP)有什么区别呢?主要的区别是Web内容可以用一种叫做HTML(超文本标记语言)的语言来编写。一个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的HTML文件。如果用户单击了这个高亮文本对象,浏览器就会从CMU服务器中请求相应的HTML文件并显示它。
Web内容
对于Web客户端和服务器而言,内容是与一个MIME类型相关的字节序列。
Web服务器以两种不同的方式向客户端提供内容:
- 取一个磁盘文件,并将它的内容返回给客户端。磁盘文件称为静态内容,而返回文件给客户端的过程称为服务静态内容。
- 运行一个可执行文件,并将它的输出返回给客户端。运行时可执行文件产生的输出称为动态内容,而运行程序并返回它的输出到客户端的过程称为服务动态内容。
每条由Web服务器返回的内容都是和它管理的某个文件相关联的。这些文件中的每一个都有一个唯一的名字,叫做URL(通用资源定位符)。例如,URL
http://www.google.com:80/index.html
表示因特网主机www.google.com上一个称为/index.html的HTML文件,它由一个监听端口80的Web服务器管理的。HTTP默认的端口就是80。可执行文件的URL可以在文件名后包括程序参数。“?”字符分割文件名和参数,而且每个参数都用”&”字符分隔开。例如,URL
http://bluefish.ics.cs.cmu.edu:8000/cgi-bin/adder?15000&213
标识了一个叫做/cgi-bin/adder的可执行文件,会带两个参数字符串15000和213来调用它。在事务过程中,客户端和服务器使用的是URL的不同部分。例如,客户端使用前缀http://www.google.com:80来决定与哪类服务器联系,服务器在哪里,以及它监听的端口号是多少。服务器使用后缀/index.html来发现在它文件系统中的文件,并确定请求的是静态内容还是动态内容。
HTTP事务
因为HTTP是基于在因特网连接上传送的文本行的,我们可以使用Unix的TELNET程序来和因特网上的任何Wb服务器执行事务。对于调试在连接上通过文本行来与客户端对话的服务器来说,TELNET是非常方便的。
使用TELNET向AOL Web服务器请求主页
第一行,我们从Unix外壳运行TELNET,要求它打开一个到AOL Web服务器的连接。TELNET向终端打印三行输出,打开连接,然后等待我们输入文本(第五行)。每次我们输入一个问本行,并键入回车键,TELNET会读取该行,在后面加上回车和换行符号(“\r\n”),并且将这一行发送到服务器。这是和HTTP标准相符的,HTTP标准要求每个文本行都由一对回车和换行符来结束。为了发起事务,我们输入一个HTTP请求(5~7行)。服务器返回HTTP响应(8~17行),然后关闭连接(18行)。
HTTP请求
一个HTTP请求的组成是这样的:一个请求行(第5行),后面跟随另个或更多个请求报头(第6行),再跟随一个空的文本行来终止报头列表。一个请求行的形式是
< method > < uri > < version >
HTTP支持许多不同的方法,包括GET、POST、OPTIONS、HEAD、PUT、DELETE和TRACE。GET方法指导服务器生成和返回URI标识的内容。URI是相应的URL的后缀,包括文件名和可选的参数。
请求行中的< version >字段表明了该请求遵循的HTTP版本。
总的来说,第5行的请求行要求服务器取出并返回HTML文件/index.html。它也告知服务器请求剩下的部分是HTTP/1.1格式的。
请求报头为服务器提供了额外的信息,例如浏览器的商标名或者浏览器理解的MIME类型。请求报头的格式为:
< header name >:< header data>
Host报头(第6行),这个报头在HTTP/1.1请求中是需要的,而在HTTP/1.0请求中是不需要的。
第7行的空文本行终止了报头,并指示服务器发送被请求的HTML文件。
HTTP响应
HTTP响应和HTTP请求是相似的。一个HTTP响应的组成是这样的:一个响应行(第8行)后面跟随零个或更多的响应报头(9~13行),再跟随一个终止报头的空行(第14行),再跟随一个响应主体(第15~17行),一个响应行的格式是:
< version > < status code > < status message >
版本字段描述的是响应所遵循的HTTP版本。状态码是一个三位的正整数,指明对请求的处理。状态消息给出与错误代码等价的英文描述。
服务动态内容
- 客户端如何将程序参数传递给服务器
服务器如何将参数传递给子进程
在服务器接收一个如下的请求时,GET /cgi-bin/adder?15000&213 HTTP/1.1。它调用fork来创建一个子进程,并调用execve在子进程的上下文中执行/cgi-bin/adder程序。像adder这样的程序,常常称为CGI程序,因为它们遵守CGI标准的规则。在调用execve之前,子进程将CGI(通用网关接口)环境变量QUERY_STRING设置为“15000&213”。服务器如何将其他信息传递给子进程
CGI定义了大量的其他环境变量,一个CGI程序在它运行时可以设置这些环境变量。
- 子进程将它的输出发送到哪里
一个CGI程序将它的动态内容发送到标准输出。在子进程加载并运行CGI程序之前,它使用Unix dup2函数将标准输出重定向到和客户端相关联的已连接描述符。因此,任何CGI程序写到标准输出的东西都会直接到达客户端。
注意,因为父进程不知道子进程生成的内容的类型或大小,所以子进程就要负责生成Content-Type和Conent-Length响应报头,以及终止报头的空行。
sprintf指的是字符串格式化命令,主要功能是把格式化的数据写入某个字符串中。sprintf是个变参函数。使用sprintf对于写入buffer的字符数是没有限制的,这就存在buffer溢出的可能性。
TINY Web服务器
TINY是一个有趣的程序,在短短250行代码中,它结合了许多我们已经学习到的思想,例如进程控制、Unix I/O、套接字接口和HTTP。虽然它缺乏一个实际服务器所具备的功能性、健壮性和安全性,但是它足够同来为实际的Web浏览器提供静态和动态的内容。
TINY是一个迭代服务器,监听在命令行中传递来的端口上的连接请求。在通过调用open_listenfd函数打开一个套接字以后,TINY执行典型的无限服务器循环,不断地接受连接请求(31行),执行事务(32行),并关闭连接它的那一端(33行)。
doit函数
处理一个HTTP事务。首先我们读和解析请求行(11~12行)。注意,我们使用rio_readlineb函数读取请求行。TINY只支持GET方法。如果客户端请求其他方法(比如POST),我们发送一个错误信息,并返回主程序(13~17行),主程序随后关闭连接并等待下一个连接请求。否则,我们读并且忽略任何请求报头。
sscanf读取格式化的字符串中的数据。sscanf与scanf类似,都是用于输入的,只是后者以键盘(stdin)为输入源,前者以固定字符串为输入源。
取仅包含指定字符集的字符串。下例中,取仅包含1到9和小写字母的字符串
sscanf(“123456adcdedBCDEF”,”%[1-9a-z]”,buf);
printf(“%s\n”,buf);
然后,我们将URL解析为一个文件名和一个可能为空的CGI参数字符串。并且我们设置一个标志,表明请求的是静态内容还是动态内容(21行)。如果文件在磁盘上不存在,我们立即发送一个错误信息给客户端并返回。
最后,如果请求的是静态内容,我们就验证文件是一个普通文件,而我们是有读权限的(29行)。如果是,我们就向客户端提供静态内容(34行)。相似的,如果请求的是动态内容,我们就验证该文件是可执行文件(37行),如果是这样,我们就继续,并且提供动态内容(42行)。
clienterror函数
clienterror函数发送一个HTTP响应到客户端,在响应行中包含状态码和状态消息,响应主体中包含一个HTML文件,向浏览器的用户解释这个错误。HTML响应应该指明主体中内容的大小和类型。因此我们选择创建HTML内容为一个字符串,可以简单的确定它的大小。还有,请注意我们为所有的输出使用的都是健壮的rio__writen函数。
read_requesthdrs函数
TINY不使用请求包头中的任何信息。它仅仅调用read_requesthdrs函数来读取
parse_uri函数
TINY假设静态内容的主目录就是它的当前目录,而可执行文件的主目录是./cgi-bin。任何包含字符串cgi-bin的URI都会被认为表示的是对动态内容的请求。默认的文件名是./home.html。
strstr(str1,str2)函数用于判断字符串str2是否是str1的子串,如果是,则该函数返回str2在str1中首次出现的地址;否则,返回NULL。
strcpy从src地址开始且含有’\0’结束符的字符串复制到以dest开始的地址空间,返回值的类型为char*。
char strcpy(char dest, const char *src);返回指向dest的指针。
strcat:将两个char类型连接。
parse_uri函数实现了这些策略。它将URI解析为一个文件名和一个可选的CGI参数字符串。如果请求的是静态内容(5行),我们将清除CGI参数串(6行),然后将URI转换为一个相对的Unix路径名,例如./index.html(7~8行)。如果URI是用“/”结尾的(9行),我们将把默认的文件名加在后面(10行)。另一方面,如果请求的是动态内容(13行),我们就会抽取出所有动态CGI参数(14~20行),并将URI剩下的部分转换为一个相对的Unix文件名(21~22行)。
serve_static函数
TINY提供四种不中类型的静态内容:HTML文件、无格式的文本文件,以及编码为GIF和JPEG格式的图片。
serve_static函数发送一个HTTP响应,其主体包含一个本地文件的内容。首先通过检查文件名的后缀来判断文件类型(7行),并且发送响应行和响应报头给客户端(8~12行)。注意用一个空行终止报头。
接着,我们将被请求文件的内容拷贝到已连接描述符fd来发送响应主体。第15行以读方式打开filename,并获得它的描述符。第16行,Unix mmap函数将被请求文件映射到一个虚拟存储器空间。调用mmap将文件srcfd的前filesize个字节映射到一个从地址srcp开始的私有只读虚拟存储器区域。
一旦将文件映射到存储器,我们就不再需要它的描述符了,所以我们关闭这个文件(17行)。执行这项任务失败将导致一种潜在的致命的存储器泄露。第18行执行的是到客户端的实际文件传送。rio_writen()函数拷贝从srcp位置开始的filesize个字节(它们当然已经被映射到了所请求的文件)到客户端的已连接描述符。最后,第19行释放了映射的虚拟存储器区域。这对于避免潜在的致命的存储器泄露是很重要的。
mmap(一种内存映射文件的方法)
mmap将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。
void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset); void munmap(void* start, size_t length);
成功执行时,mmap()返回被映射区的指针,munmap()返回0。
serve_dynamic函数
TINY通过派生一个子进程并在子进程的上下文中运行一个CGI程序,来提供各种类型的动态内容。
一开始就向客户端发送一个表明成功的响应行,同时还包括带有信息的Serve报头。CGI程序负责发送响应的剩余部分。它没有考虑到CGI程序会遇到某些错误的可能性。
在发送响应的第一部分后,会派生一个新的子进程(11行)。子进程用来自请求URI的CGI参数初始化QUERY_STRING环境变量(13行)。
接下来,子进程重定向它的标准输出到已连接文件描述符(14行),然后加载运行CGI程序。因为CGI程序运行在子进程的上下文中,它能够访问所有在调用execve函数之前就存在的打开文件和环境变量。因此,CGI程序写到标准输出上的任何东西都将直接送到客户端进程,不会受到任何来自父进程的干涉。
其间,父进程阻塞在对wait的调用中,等待子进程终止的的时候,,回收操作系统分配给子进程的资源。
如果一个服务器写一个已经被客户端关闭了的连接(比如,因为你在浏览器上单击了“stop”按钮),那么第一次这样的写会正常返回,但是第二次写就会引起发送SIGPIPE信号,这个信号的默认行为就是终止这个进程。如果捕获或者忽略SIGPIPE信号,那么第二次写操作会返回值-1,并将errno设置为EPIPE。strerr和perror函数将EPIPE错误报告为“Broken pipe”。