网络应用随处可见。任何时候浏览Web、发送e-mail信息都是在使用网络应用程序。网络应用程序基本是基于相同的基本编程模型的,有着相似的整体逻辑结构,并且依赖相同的编程接口。
网络应用依赖许多在系统研究中已经学习过的概念。例如,进程、信号、字节顺序、内存映射以及动态内存分配,都扮演着重要的角色。还有一些新概念需要掌握。我们需要理解基本的*客户端-服务器编程模型,以及如何编写使用因特网提供的服务的客户端-服务器程序。
1. 客户端-服务器编程模型
每个网络应用都是基于客户端-服务器模型的。采用这个模型,一个应用是由一个服务器进程和一个或多个客户端进程组成的。服务器管理着某种资源,并且通过操作这种资源来为它的客户端提供某种服务。例如FTP服务器管理着一组磁盘文件,它会为客户端提供存储和检索服务。
客户端-服务器模型中的基本操作是事务(transaction)。一个客户端-服务器事务由以下四步组成:
- 当一个客户端需要服务时,它向服务器发送一个请求,发起一个事务。
- 服务器收到请求后,解释它,并以适当的方式操作它的资源。
- 服务器给客户端发送一个响应,并等待下一个请求。
- 客户端收到响应并处理它。
客户端和服务器是进程,而不是常提到的机器或者主机。一台主机可以同时运行许多不同的客户端和服务器,而且一个客户端和服务器的事务可以在同一台或者是不同的主机上。
2. 网络
客户端和服务器通常运行在不同的主机上,并且通过计算机网络的硬件和软件资源来通信。这里将从程序员的视角给出一个切实可行的思维模型。
对于主机而言,网络只是一种I/O设备,是数据源和数据接收方。
物理上,网络是一个按照地理远近组成的层次结构系统。最低层是LAN(local Area Network,局域网),在一个建筑或者校园范围内。
每个以太网适配器都有一个全球唯一的48位地址,它存储在这个适配器的非易失存储器上。一台主机可以发送一段位(称为帧(frame))到这个网段内的其他任何主机。每个帧包括一些固定数量的头部(header)位,用来标识此帧的源和目的地址以及此帧的长度,此后紧随着的就是数据位的有效载荷(payload)。每个主机适配器都能看到这个帧,但是只有目的主机实际读取它。
多个以太网段组成的局域网可以通过网桥的方式组成较大的局域网。
在更高级别中,多个不兼容的局域网可以通过叫做路由器(router)的特殊计算机连接起来,组成一个internet(互联网络)。路由器可以提供WAN(wide-Area Network,广域网)的网络示例。
通常使用小写字母的internet描述互联网络这个一般概念,而用大写字母Internet来描述一种具体实现。
互联网的一个重要的特性是,它能由采用完全不同和不兼容技术的各种局域网和广域网络组成。通过运行在每台主机和路由器上的协议软件,来消除不同网络之间的差异。这种协议必须提供两种基本能力:
- 命名机制。协议通过定义一种一致的主机地址格式消除这些差异。每个主机会被分配至少一个这种互联网络地址(internet address)。这个地址唯一的标识了这台主机。
- 传送机制。互联网协议通过定义一种将数据位打包成不连续的片的统一方式,从而消除这些差异。一个包是由包头和有效载荷组成的。包头一般包括包的大小以及源主机和目的主机的地址,有效载荷包括从源主机发出的数据位。
下面是一个例子说明这种数据的传送:
- 运行在主机A上的客户端进行一个系统调用,从客户端的虚拟地址空间复制数据到内核缓冲区中。
- 主机A上的协议软件通过在数据前附加互联网络包头和LAN1帧头,创建一个LAN1的帧。互联网络的包头寻址到互联网主机B。LAN1帧头寻址到路由器。然后它传送此帧到适配器。(LAN1帧的有效载荷是一个互联网络包,而互联网络包的有效载荷是实际用户数据)
- LAN1适配器复制该帧到网络上。
- 当此帧到达路由器,路由器的LAN1适配器从电缆上读取它,并将这帧数据传送到协议软件。
- 路由器从包头中提取目的地址,并用它作为路由表的索引,确定向哪里转发这个包。
- 路由器的LAN2适配器复制该帧到网络上。
- 当此帧到达主机B时,它的适配器从电缆上读到此帧,并将它传送到协议软件。
- 主机B上的协议软件剥离包头和帧头。将最终得到的数据复制到服务器的虚拟地址空间。
3. 全球IP因特网
在全球IP因特网结构中,每台主机都运行实现TCP/IP协议的软件。因特网的客户端和服务器混合使用套接字接口函数和Unix I/O函数来进行通信。
TCP/IP实际上是一个协议族,其中每一个都提供不同的功能。
IP协议提供基本的命名方法和递送机制,这种递送机制能够从一台因特网主机往其他主机发送包,也叫做*数据包(datagram)*。IP机制从某种意义上而言是不可靠的,因为,如果数据包在网络中丢失或者重复,它并不会试图恢复。UDP(Unreliable Datagram Protocol, 不可靠数据包协议)稍微扩展了IP协议,这样一来,包可以在进程间而不是在主机间传送。TCP是一个构建在IP之上的复杂协议,提供了进程间可靠的全双工(双向的)连接。
从程序员的角度来看,我们可以把因特网看作一个世界范围的主机集合,满足以下特性:
- 主机集合被映射为一组32位的IP地址。
- 这组IP地址被映射成为一组称为因特网域名(Internet domain name)的标识符。
- 因特网主机上的进程能够通过连接(connection)和任何其他因特网主机上的进程通信。
IPv4 使用32位地址; IPv6使用128位地址。
3.1 IP地址
一个IP地址是一个32位无符号整数。同时因为因特网主机有不同的主机字节顺序,TCP/IP为任意数据项定义了统一的网络字节顺序(network byte order)(大端字节顺序),即使主机字节顺序(host byte order)是小端法。Unix提供了下面这样的函数在网络和主机字节顺序间实现转换。
1 | //返回按照网络字节顺序的值 |
Linux系统中可以使用HOSTNAME命令来确定你自己的主机地址。
应用程序可以使用inet_ptone
和inet_ntop
函数来实现IP地址和点分十进制串之间的转换。
1 | int inet_pton(AF_INET, const char *src, void *dst); |
3.2 因特网域名
因特网客户端和服务器互相通信的时候使用的是IP地址。然而对于人们而言,IP地址难以记住,所以因特网也定义了一组更加人性化的域名(domain name),以及一种将域名映射到IP地址的机制。
一级域名包括’com’, ‘edu’, ‘gov’, ‘org’;二级域名包括‘baidu’,‘amazon’等等;三级域名包括‘www’, ‘cs’等等。
因特网定义了域名集合和IP地址集合之间的映射,直到1988年,这个映射都是通过一个叫做HOST.TXT的文本文件来手工维护的。从那以后,这个映射是通过分布在世界范围内的DNS(Domain Name System, 域名系统)来维护的。
可以使用Linux的nslookup
程序来探究DNS映射的一些属性,如:
1 | linux> nslookup localhost |
最简单的情况是域名和IP之间是一一映射的;但是也可以多个域名映射到同一个IP地址;也可以一个域名映射到同一组的多个IP地址;最后也可以存在某些合法域名没有映射到任何IP地址。
3.3 因特网连接
因特网客户端和服务器通过在连接上发送和接收字节流来通信。从连接一对进程的意义上而言,连接是点对点的。从数据可以同时双向流动的角度来说,它是全双工且可靠的。
一个套接字是连接的一个端点。每个套接字都有相应的套接字地址,是由一个因特网地址和一个16位的整数端口组成,用“地址:端口”来表示。
当客户端发起一个连接请求时,客户端套接字地址中的端口是内核自动分配的,称为临时端口(ephemeral port)。然而,服务器套接字地址中的端口通常是某个知名端口,是和这个服务对应的。如Web服务器使用80号端口,电子邮件使用25号端口。每个具有知名端口的服务都有一个对应的知名的服务名。文件/etc/services
包含了一张这台机器提供的知名的名字和端口之间的映射。
一个连接是由它两端的套接字地址唯一确定的。这对套接字地址叫做套接字对(socket pair),由下列元组来表示:
1 | (cliaddr:cliport, servaddr:servport) |
4. 套接字接口
*套接字接口(socket interface)*是一组函数,它们和Unix I/O函数结合起来,用于创建网络应用。
1 | +- 客户端 服务器 |
4.1 套接字地址结构
从Linux内核的角度来看,一个套接字就是通信的一个端点。从Linux程序的角度来看,套接字就是一个有相应描述符的打开文件。
Linux 套接字存放在类型为sockaddr_in
的数据结构中。
这里
_in
后缀是互联网(internet)的缩写。
4.2 socket函数
客户端和服务器使用socket
函数来创建一个套接字描述符(socket descriptor)
。
1 | int socket(int domain, int type, int protocol); |
这里返回的描述符仅是部分打开的,还不能用于读写。如何完成打开套接字的工作,取决于我们是客户端还是服务器。
4.3 connect函数
客户端通过调用connect
函数来建立和服务器的连接。
1 | int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen); |
connect
函数试图与套接字地址为addr
的服务器建立一个因特网连接。connect函数会阻塞,一直到连接成功或者是发生错误。
4.4 bind/listen/accept函数
bind
,listen
,accept
函数,服务器用它们来和客户端建立连接。
1 | int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); |
bind
函数告诉内核将addr中的服务器套接字地址和套接字描述符sockfd联系起来。
客户端是发起连接请求的主动实体。服务器是等待来自客户端的连接请求的被动实体。
1 | int listen(int sockfd, int backlog); |
listen
将sockfd从一个主动套接字转化成为一个监听套接字(listening socket),该套接字可以接受来自客户端的连接请求。backlog参数表示排队未完成的连接请求数量。
服务器通过调用accept
函数来等待来自客户端的连接请求。
1 | int accept(int listenfd, struct sockaddr *addr, int *addrlen); |
4.5 主机和服务的转换
Linux同时还提供了一些强大的函数(称为getaddrinfo
和getnameinfo
)实现二进制套接字地址结构和主机名,主机地址,服务名和端口号的字符串表示之间的互相转化。
1 | int getaddrinfo(const char *host, const char *service, |
1 | int getnameinfo(const struct socketaddr *sa, socklen_t salen, |
4.6 套接字接口的辅助函数
客户端还可以通过调用open_clientfd
建立与服务器的连接。
1 | int open_clientfd(char *hostname, char *port); //成功则返回描述符,若出错则为-1 |
服务器可以通过调用open_listenfd
函数来创建一个监听描述符,并准备好连接请求。
1 | int open_listenfd(char *port); //成功则返回描述符,若出错则为-1 |
EOF是由内核检测到的一种条件。应用程序在他接收到一个由read函数返回的零返回码的时候,它就会发出EOF条件。对于磁盘文件,出现这种情况通常的原因是文件位置超出文件长度。
5. Web服务器
Web客户端和服务器之间的交互的是一个基于文本的应用级协议,叫做HTTP。一个Web客户端(即浏览器)打开一个到服务器的因特网连接,并且请求某些内容。服务器响应所请求的内容,然后关闭连接。浏览器读取这些内容,并把它显示在屏幕上。
Web内容可以用一种叫做HTML的语言来编写。一个HTML程序页包含指令,它们告诉浏览器如何显示这页中的各种文本和图形对象。
对于Web客户端和服务器而言,内容是与一个MIME(Multipurpose Internet Mail Extensions, 多用途的网际邮件扩充协议)类型相关的字节序列。下面是一些常用的MIME类型。
MIME类型 | 描述 |
---|---|
text/html | HTML页面 |
text/plain | 无格式文本 |
application/postscript | Postscript文档 |
image/gif | GIF格式编码的二进制图像 |
image/png | PNG格式编码的二进制图像 |
image/jpeg | JPEG格式编码的二进制图像 |
Web服务器以两种不同的方式向客户端提供内容
- 取一个磁盘文件,并将它的内容返回给客户端。这里的磁盘文件被称为静态内容(static content),而返回文件给客户端的过程称为服务静态内容(serving static content).
- 运行一个可执行文件,并将它的输出返回给客户端。运行时可执行文件产生的输出称为动态内容(dynamic content)。
每条由Web服务器返回的内容都是和它管理的某个文件相关联的。这些文件中的每一个都有一个唯一的名字叫做URL(Universal Resource Locator, 通用资源定位符)。同时URL中可以使用?
分隔文件名和参数,而且每个参数都可以使用&
字符分隔开。
HTTP事务主要有下面几种:
- HTTP请求。如GET,POST,OPTIONS等等。
- HTTP响应。通常包括响应状态码。
当服务器接收到客户端请求后,会fork一个子进程来处理请求。