Skip to content

第十二章 网络编程

12.1 客户端—服务器编程模型

每个网络应用都是基于客户端—服务器模型的。釆用这个模型,一个应用是由一个服务器进程和一个或者多个客户端进程组成。服务器管理某种资源,并且通过操作这种资源来为它的客户端提供某种服务。

例如,一个 Web 服务器管理着一组磁盘文件,它会代表客户端进行检索和执行。一个 FTP 服务器管理着一组磁盘文件,它会为客户端进行存储和检索。相似地,一个电子邮件服务器管理着一些文件,它为客户端进行读和更新。

客户端—服务器模型中的基本操作是事务(transaction)

一个客户端—服务器事务由以下四步组成。

  • 当一个客户端需要服务时,它向服务器发送一个请求,发起一个事务。
  • 服务器收到请求后,解释它,并以适当的方式操作它的资源。
  • 服务器给客户端发送一个响应,并等待下一个请求。
  • 客户端收到响应并处理它。

认识到客户端和服务器是进程,而不是常提到的机器或者主机,这是很重要的。一台主机可以同时运行许多不同的客户端和服务器,而且一个客户端和服务器的事务可以在同一台或是不同的主机上。无论客户端和服务器是怎样映射到主机上的,客户端—服务器模型都是相同的。

12.2 网络

客户端和服务器通常运行在不同的主机上,并且通过计算机网络的硬件和软件资源来通信。

物理上而言,网络是一个按照地理远近组成的层次系统。最低层是局域网(LAN)。迄今为止,最流行的局域网技术是以太网(Ethernet)。

一个以太网段(Ethernet segment)包括一些电缆(通常是双绞线)和一个叫做集线器的小盒子。每根电缆一端连接到主机的适配器,而另一端则连接到集线器的一个端口上。集线器不加分辨地将从一个端口上收到的每个位复制到其他所有的端口上。

每个以太网适配器都有一个全球唯一的 48 位地址,它存储在这个适配器的非易失性存储器上。一台主机可以发送一段位(称为(frame))到这个网段内的其他任何主机。

使用一些电缆和叫做网桥(bridge)的小盒子,多个以太网段可以连接成较大的局域网,称为桥接以太网。网桥比集线器更充分地利用了电缆带宽。利用一种聪明的分配算法,它们随着时间自动学习哪个主机可以通过哪个端口可达。

在层次的更高级别中,多个不兼容的局域网可以通过叫做路由器(router)的特殊计算机连接起来,组成一个互联网络(internet)。每台路由器对于它所连接到的每个网络都有一个适配器(端口)。路由器也能连接高速点到点电话连接,这是称为广域网 (WAN,Wide-Area Network)的网络示例。

12.3 协议

每台主机和其他每台主机都是物理相连的,但是如何能够让某台源主机跨过所有这些不兼容的网络发送数据位到另一台目的主机呢?

解决办法是一层运行在每台主机和路由器上的协议软件,它消除了不同网络之间的差异。这个软件实现一种协议,这种协议控制主机和路由器如何协同工作来实现数据传输。

这种协议必须提供两种基本能力:

  • 命名机制(IP地址)。不同的局域网技术有不同和不兼容的方式来为主机分配地址。联网络协议通过定义一种一致的主机地址格式消除了这些差异。每台主机会被分配至少一个这种互联网络地址(IP,internetaddress),这个地址唯一地标识了这台主机。
  • 传送机制(数据帧)。在电缆上编码位和将这些位封装成帧方面,不同的联网技术有不同的和不兼容的方式。互联网络协议通过定义一种把数据位捆扎成不连续的片(称为包)的统一方式,从而消除了这些差异。

12.4 全球 IP 因特网

全球 IP 因特网是最著名和最成功的互联网络实现。从 1969 年起,它就以这样或那样的形式存在了。虽然因特网的内部体系结构复杂而且不断变化,但是自从 20 世纪 80 年代早期以来,客户端 - 服务器应用的组织就一直保持着相当的稳定。

1

每台因特网主机都运行实现 TCP/IP 协议(Transmission Control Protocol / Internet Protocol,传输控制协议/互联网络协议)的软件,几乎每个现代计算机系统都支持这个协议。

因特网的客户端和服务器混合使用套接字接口函数和 Unix l/O 函数来进行通信。通常将套接字函数实现为系统调用,这些系统调用会陷入内核态,并调用各种内核模式的 TCP/IP 函数。

从程序员的角度,我们可以把因特网看做一个世界范围的主机集合,满足以下特性:

  • 主机集合被映射为一组 32 位的 IP 地址
  • 这组 IP 地址被映射为一组称为因特网域名(Internet domain name)的标识符。
  • 因特网主机上的进程能够通过连接(connection)和任何其他因特网主机上的进程通信。

12.5 套接字接口

套接字接口(socket interface)是一组函数,它们和 Unix I/O 函数结合起来,用以创建网络应用。大多数现代系统上都实现套接字接口,包括所有的 Unix 变种、Windows 和 Macintosh 系统。

套接字地址结构

从 Linux 内核的角度来看,一个套接字就是通信的一个端点。从 Linux 程序的角度来看,套接字就是一个有相应描述符的打开文件。

c
/* IP socket address structure */
struct sockaddr_in {
    uint16_t       sin_family;   /* Protocol family (always AF_INET) */
    uint16_t       sin_port;     /* Port number in network byte order */
    struct in_addr sin_addr;     /* IP address in network byte order */
    unsigned char  sin_zero[8];  /* Pad to sizeof(struct sockaddr) */
};

/* Generic socket address structure (for connect, bind, and accept) */
struct sockaddr {
    uint16_t  sa_family;    /* Protocol family */
    char      sa_data[14];  /* Address data */
};

socket 函数

客户端和服务器使用 socket 函数来创建一个套接字描述符(socket descriptor)。

c
#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain, int type, int protocol);

connect 函数

客户端通过调用 connect 函数来建立和服务器的连接。

c
#include <sys/socket.h>

int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);

connect 函数试图与套接字地址为 addr 的服务器建立一个因特网连接,其中 addrlen 是 sizeof(sockaddr_in)。

connect 函数会阻塞,一直到连接成功建立或是发生错误。

bind 函数

剩下的套接字函数——bind、listen 和 accept,服务器用它们来和客户端建立连接。

c
#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

bind 函数告诉内核将 addr 中的服务器套接字地址套接字描述符 sockfd 联系起来。

listen 函数

客户端是发起连接请求的主动实体。服务器是等待来自客户端的连接请求的被动实体。默认情况下,内核会认为 socket 函数创建的描述符对应于主动套接字(active socket),它存在于一个连接的客户端。

c
#include <sys/socket.h>

int listen(int sockfd, int backlog);

listen 函数将 sockfd 从一个主动套接字转化为一个监听套接字(listening socket),该套接字可以接受来自客户端的连接请求。

accept 函数

服务器通过调用 accept 函数来等待来自客户端的连接请求。

c
#include <sys/socket.h>

int accept(int listenfd, struct sockaddr *addr, int *addrlen);

accept 函数等待来自客户端的连接请求到达侦听描述符 listenfd,然后在 addr 中填写客户端的套接字地址,并返回一个已连接描述符(connected descriptor),这个描述符可被用来利用 Unix I/O 函数与客户端通信。

监听描述符 VS 已连接描述符

监听描述符和已连接描述符之间的区别使很多人感到迷惑。监听描述符是作为客户端连接请求的一个端点。它通常被创建一次,并存在于服务器的整个生命周期。已连接描述符是客户端和服务器之间已经建立起来了的连接的一个端点。服务器每次接受连接请求时都会创建一次,它只存在于服务器为一个客户端服务的过程中。

主机和服务的转换

Linux 提供了一些强大的函数(称为 getaddrinfo 和 getnameinfo)实现二进制套接字地址结构和主机名、主机地址、服务名和端口号的字符串表示之间的相互转化。

当和套接字接口一起使用时,这些函数能使我们编写独立于任何特定版本的 IP 协议的网络程序。

  • getaddrinfo 函数
c
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

int getaddrinfo(const char *host, const char *service,
                const struct addrinfo *hints,
                struct addrinfo **result);

getaddrinfo 函数将主机名、主机地址、服务名和端口号的字符串表示转化成套接字地址结构。

  • getnameinfo 函数
c
#include <sys/socket.h>
#include <netdb.h>

int getnameinfo(const struct sockaddr *sa, socklen_t salen,
                char *host, size_t hostlen,
                char *service, size_t servlen, int flags);

getnameinfo 函数和 getaddrinfo 是相反的,将一个套接字地址结构转换成相应的主机和服务名字符串

套接字接口的辅助函数

初学时,getnameinfo 函数和套接字接口看上去有些可怕。用高级的辅助函数包装一下会方便很多,称为 open_clientfd 和 open_listenfd,客户端和服务器互相通信时可以使用这些函数。

  • open_clientfd 函数

客户端调用 open_clientfd 建立与服务器的连接。

c
#include "csapp.h"

int open_clientfd(char *hostname, char *port);

open_clientfd 函数建立与服务器的连接,该服务器运行在主机 hostname 上,并在端口号 port 上监听连接请求。它返回一个打开的套接字描述符,该描述符准备好了,可以用 Unix I/O 函数做输入和输出。

  • open_listenfd 函数

服务器调用 open_listenfd 函数,创建一个监听描述符,准备好接收连接请求。

c
#include "csapp.h"

int open_listenfd(char *port);

open_listenfd 函数打开和返回一个监听描述符,这个描述符准备好在端口 port_h 接收连接请求。

echo 客户端和服务器的示例

学习套接字接口的最好方法是研究示例代码。下面展示了一个 echo 客户端的代码。

c
#include "csapp.h"

int main(int argc, char **argv)
{
    int clientfd;
    char *host, *port, buf[MAXLINE];
    rio_t rio;

    if (argc != 3) {
        fprintf(stderr, "usage: %s <host> <port>\n", argv[0]);
        exit(0);
    }
    host = argv[1];
    port = 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);
}

在和服务器建立连接之后,客户端进入一个循环,反复从标准输入读取文本行,发送文本行给服务器,从服务器读取回送的行,并输出结果到标准输出。

c
#include "csapp.h"

void echo(int connfd);

int main(int argc, char **argv)
{
    int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr; /* Enough space for any address */
    char client_hostname[MAXLINE], client_port[MAXLINE];

    if (argc != 2) {
        fprintf(stderr, "usage: %s <port>\n", argv[0]);
        exit(0);
    }
    
    listenfd = Open_listenfd(argv[1]);
    while (1) {
        clientlen = sizeof(struct sockaddr_storage);
        connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
        Getnameinfo((SA *) &clientaddr, clientlen, client_hostname, MAXLINE,
                    client_port, MAXLINE, 0);
        printf("Connected to (%s, %s)\n", client_hostname, client_port);
        echo(connfd);
        Close(connfd);
    }
    exit(0);
}

在打开监听描述符后,它进入一个无限循环。每次循环都等待一个来自客户端的连接请求,输出已连接客户端的域名和 IP 地址,并调用 echo 函数为这些客户端服务。

在 echo 程序返回后,主程序关闭已连接描述符。一旦客户端和服务器关闭了它们各自的描述符,连接也就终止了。

12.6 Web 服务器

Web 基础

Web 客户端和服务器之间的交互用的是一个基于文本的应用级协议,叫做 HTTP(hypertext Transfer Protocol,超文本传输协议)。

HTTP 是一个简单的协议。一个 Web 客户端(即浏览器)打开一个到服务器的因特网连接,并且请求某些内容。

服务器响应所请求的内容,然后关闭连接。浏览器读取这些内容,并把它显示在屏幕上。

Web 内容

对于 Web 客户端和服务器而言,内容是与一个 MIME(Multipurpose Internet Mail Extensions,多用途的网际邮件扩充协议)类型相关的字节序列。

bash
http {
  include mime.types;
  default_type application/octet-stream;
  # ...
}

github.com jshttp mime-db

Web 服务器以两种不同的方式向客户端提供内容:

  • 取一个磁盘文件,并将它的内容返回给客户端。磁盘文件称为静态内容(static content),而返回文件给客户端的过程称为服务静态内容(serving static content)。
  • 运行一个可执行文件,并将它的输出返回给客户端。运行时可执行文件产生的输出称为动态内容(dynamic content),而运行程序并返回它的输出到客户端的过程称为服务动态内容(serving dynamic content)。

每条由 Web 服务器返回的内容都是和它管理的某个文件相关联的。这些文件中的每一个都有一个唯一的名字,叫做 URL(Universal Resource Locator,通用资源定位符)。

HTTP 事务

因为 HTTP 是基于在因特网连接上传送的文本行的,我们可以使用 Linux 的 TELNET 程序来和因特网上的任何 Web 服务器执行事务。对于调试在连接上通过文本行来与客户端对话的服务器来说,TELNET 程序是非常便利的。例如,图 11-24 使用 TELNET 向 AOL Web 服务器请求主页。

  • HTTP 请求
http
method URI version
header-name: header-data
...

这里是正文,注意上面有一个空行
http
GET /static/5d8ce472/scripts/yui/dom/dom-min.js HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,es;q=0.7,la;q=0.6,zh-TW;q=0.5,so;q=0.4,ja;q=0.3
  • HTTP 响应

HTTP 响应和 HTTP 请求是相似的。一个 HTTP 响应的组成是这样的:

  • 一个响应行(response line)
  • 后面跟随着零个或更多的响应报头(response header)
  • 再跟随一个终止报头的空行
  • 再跟随一个响应主体(response body)
http
version status-code status-message
header-name: header-data
...
这里是正文,注意上面有一个空行
http
HTTP/1.1 200 OK
Date: Fri, 19 Aug 2022 14:28:04 GMT
X-Content-Type-Options: nosniff
Last-Modified: Wed, 17 Aug 2022 07:29:21 GMT
Expires: Sat, 19 Aug 2023 14:28:04 GMT
Accept-Ranges: bytes
Content-Type: application/javascript
Content-Encoding: gzip
Content-Length: 5146
Server: Jetty(9.4.45.v20220203)

12.7 小结