CSAPP 第十一章 网络编程

主要就是客户端和服务器

客户端-服务器编程模型

每个网络应用都是基于客户端-服务器模型的。采用这个模型,一个应用是由一个服务器进程和一个或者多个客户端组成的。其中,服务器管理了各种资源,并且通过操作这种资源为他们的客户端提供某种服务。

客户端-服务器模型中的基本操作是事务(transaction)。一个客户端-服务器事务由一下四步组成:

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

需要注意的是,客户端和服务器是进程,而不是一个主机或者机器。

一台主机可以同时运行多个服务器和客户端,一个客户端或服务器的事务也可以在同一台主机或者不同的主机上。

网络

客户端和服务器通常运行在不同的主机上,通过计算机网络来通信。

对于主机而言,网络只是一种 I/O 设备,是数据源和数据接收方,如下图:

局域网

物理上而言,网络是一个按照地理远近组成的层次系统。最低层是 LAN(Local Area Network,局域网),大概是在一个建筑或者校园范围内。迄今为止,最流行的局域网技术是以太网(Ethernet),以太网的适应能力极强.

一个以太网段(Ethernet segment)包括一些电缆(通常是双绞线)和一个叫做集线器的小盒子,主机用电缆连接到集线器上。

  • 每根电缆都有相同的最大位带宽,通常是 100Mb/s 或者 1Gb/s。一端连接到主机的适配器,而另一端则连接到集线器的一个端口上。
  • 集线器不加分辨地将从一个端口上收到的每个位复制到其他所有的端口上,因此在对一个集线器发送数据会让与之相连的所有主机看到发送的内容。

每个以太网适配器都有一个全球唯一的 48 位地址,它存储在这个适配器的非易失性存储器上。

一台主机可以发送一个标识了目的 MAC 地址的帧到网段内的任何主机以及其它一些元数据,每个主机都能看到同网段发送的任何消息,但是一般只有 MAC 地址一致的主机会读取这个帧。

使用电缆和网桥可以把多个互联网段连接成较大的局域网,称之为桥接以太网(bridged Ethernet),不同的电缆的宽带可以是不同的。如下图所示:

广域网

在层次的更高级别中,多个不兼容的局域网可以通过叫做路由器的特殊计算机连接起来,组成一个 internet(互联网络)。每台路由器对于它所连接到的每个网络都有一个适配器(端口)。

路由器也能连接高速点到点电话连接,称为 WAN(Wide-Area Network,广域网)。

互联网络至关重要的特性是,它能由采用完全不同和不兼容技术的各种局域网和广域网组成

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

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

  • 命名机制:互联网协议通过定义一种一致的主机地址格式,从而消除了不同或联网技术造成的主机地址的分配差异。每台主机会被分配一个唯一得互联网地址,这个地址唯一标识了这台主机。
  • 传送机制:互联网络协议通过定义一种把数据位捆扎成不连续的片(称为”包“)的统一方式,在用户之间传输信息。一个包是由包头和有效载荷组成的,其中包头包括包的大小以及源主机和日的主机的地址,有效载荷包括从源主机发出的数据位。

上图展示了如何在两个不兼容的局域网之间传送数据,经历了以下步骤:

  1. 运行在主机 A 上的客户端进行一个系统调用,从客户端的虚拟地址空间复制数据到内核缓冲区中。
  2. 主机上的协议软件通过在数据前附加互联网络包头和 LAN1 头,创建了个 LAN1 的帧。LAN1 头寻址到路由器,然后它传送这个帧到适配器。
  3. LAN1 适配器复制该帧到网络上,交给路由器。
  4. 当此帧到达路由器时,路由器的 LAN1 适配器从电缆上读取它,并把它传送到协议软件。
  5. 路由器从互联网络包头中提取出目的互联网络地址,并用它作为路由表的索引,确定向哪里转发这个包。路由器剥落旧的 LAN1 的头,加上寻址到主机 B 的新的 LAN2 头,并把得到的帧传送到适配器。
  6. 路由器把该帧传送到 LAN2 网络上。
  7. 该帧到达主机 B 时,读取这个帧,传送到协议软件。
  8. 主机 B 剥落包头和帧头,读取得到数据。最终,协议软件将会将对应的数据拷贝到对应服务器的虚拟地址空间。

全球 IP 因特网

全球 IP 因特网是最著名和最成功的互联网络实现,如下图展示了一个因特网客户端-服务器应用程序的基本硬件和软件组织:

每台因特网主机都运行实现 TCP/IP 协议,几乎每个现代计算机系统都支持这个协议。因特网的客户端和服务器混合使用套接字接口函数和 Unix l/O 函数来进行通信。

TCP/IP 实际是一个协议族,其中每一个都提供不同的功能。

  • IP 机制从某种意义上而言是不可靠的,因为,如果数据报在网络中丢失或者重复,它并不会试图恢复。
  • UDP(Unreliable Datagram Protocol,不可靠数据报协议)稍微扩展了 IP 协议,这样一来,包可以在进程间而不是在主机间传送。
  • TCP 是一个构建在 IP 之上的复杂协议,提供了进程间可靠的全双工(双向的)连接。

为了简化讨论,我们将 TCP/IP 看做是一个单独的整体协议。

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

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

IP 地址

IP 地址就是一个 32 位的无符号整数。IP 地址数据类型由一个结构体实现:

1
2
3
4
/* IP address structure */
struct in_addr {
uint32_t s_addr; /* Address in network byte order (big-endian) */
};

其实这个地址它并不应该被放在结构体里,为 IP 地址定义为一个标量类型应该更有意义,但是现在更改的话会有大量的网络应用受到波及。

因为因特网主机可以有不同的主机字节顺序,TCP/IP 为任意整数数据项定义了统一的网络字节顺序(network byte order)(大端字节顺序)。例如 IP 地址总是以大端序存放的。但是我们的计算机字节顺序是小端序,Unix 为我们提供了一些函数进行转换:

1
2
3
4
5
6
7
8
9
#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong); //将 32 位整数由主机字节顺序转换为网络字节顺序
uint16_t htons(uint16_t hostshort); //将 32 位整数从网络字节顺序转换为主机字节
// 返回:按照网络字节顺序的值。

uint32_t ntohl(uint32_t netlong); //将 16 位整数由主机字节顺序转换为网络字节顺序
uint16_t ntohs(unit16_t netshort); //将 16 位整数从网络字节顺序转换为主机字节
// 返回:按照主机字节顺序的值。

IP 地址通常是以一种称为点分十进制表示法来表示的。每个字节由它的十进制值表示,并且用句点和其他字节间分开。例如,128.2.194.242 就是地址 0x8002c2f2 的点分十进制表示。

在 Linux 系统上,我们可以使用 HOSTNAME 命令来确定自已主机的点分十进制地址。

应用程序使用 inet ptoninet ntop 丽数来实现 IP 地址和点分十进制之间的转换

1
2
3
4
5
#include <arpa/inet,h>
int inet_pton(AF_INET, const char *src, void *dst);
//返回:若成功则为1,若 src 为非法点分十进制地址则为 0,若出错则为一1。
const char *inet_ntop(AF_INET,const void *src,char *dst,socklen t size);
//返回:若成功则指向点分十进制字符事的指针,若出错则为 NULL。

inet_pton 函数将一个点分十进制串(src)转换为一个二进制的网络字节顺序的IP地址(dst)。如果 src 没有指向一个合法的点分十进制字符串,那么该函数就返回 0。任何其他错误会返回 -1,并设置 errno。相似地,inet_ntop 函数将一个二进制的网络字节顺序的 IP 地址(src)转换为它所对应的点分十进制表示,并把得到的以 null 结尾的字符串的最多 size 个字节复制到 dst

因特网域名

因特网客户端和服务器互相通信时使用的是 IP 地址。然而,对于人们而言,大整数是很难记住的,所以因特网也定义了一组更加人性化的域名(domain name),以及一种将域名映射到 IP 地址的机制。

域名是一串用句点分隔的单词(字母、数字和 -),例如 whaleshark.ics.cs.emu.edu

域名集合形成了一个层次结构,每个域名编码了它在这个层次中的位置。下图展示了域名层次结构的一部分:

层次结构可以表示为一棵树。树的节点表示域名,反向到根的路径形成了域名。子树称为子域(subdomain)。层次结构中的第一层是一个未命名的根节点。下一层是一组一级域名(first-level domainname),由非营利组织ICANN(Internet Corporation for Assigned Names and Numbers,因特网分配名字数字协会)定义。

常见的第一层域名包括 comedugovorgnet

下一层是二级(second-level)域名,例如 cmu. edu,这些域名是由 ICANN 的各个授权代理按照先到先服务的基础分配的。一旦一个组织得到了一个二级域名,那么它就可以在这个子域中创建任何新的域名了。

例如 :我拥有了一个二级域名是 modifier.com ,这个域名解析到我的一个服务器,那么它的所有子域就归这个服务器管了,比如我可以创建一个 beautiful.modifier.com

每台因特网主机都有本地定义的域名 localhost,这个域名总是映射为回送地址(loopback address)127.0.0.1

通常情况下,域名和 IP 地址之间是一一映射的,但是某些情况下域名可以多个 IP 地址,一个 IP 地址也可以有多个域名。

因特网连接

因特网客户端和服务器通过在连接上发送和接收字节流来通信,他们是点对点,全双工,可靠的通信。

一个套接字是连接的一个端点。每个套接字都有相应的套接字地址,是由一个因特网地址和一个16 位的整数端口组成的,用“ 地址:端口 ”来表示。

当客户端发起一个连接请求时,客户端套接字地址中的端口是由内核自动分配的,称为临时端口(ephemeral port)。而服务器套接字地址中的端口通常是某个知名端口,是和这个服务相对应的。例如,Web 服务器通常使用端口 80,它对应的服务的知名名字是 http。

机器提供的知名名字和知名端口之间的映射存储在文件 /etc/services

一个连接是由它两端的套接字地址唯一确定的。这对套接字地址叫做套接字对,由下列元组来表示: (cliaddr;cliport,servaddr:servport)

其中 cliaddr 是客户端的 IP 地址,cliport 是客户端的端口;servaddr 是服务器的 IP 地址,而 servport 是服务器的端口。

如下图是一个Web客户端和服务器之间的连接:

在这个示例中,Web 客户端的套接字地址是:

128.2.194.242:51213

其中端口号 51213 是内核分配的临时端口号。Web 服务器的套接字地址是:

208.216.181.15:80

其中端口号 80 是和 Web 服务相关联的知名端口号。给定这些客户端和服务器套接字地址,客户端和服务器之间的连接就由下列套接字对唯一确定了:

(128.2.194.242:51213, 208.216.181.15:80)

套接字接口

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

套接字地址结构

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

因特网的套接字地址存放在类型为 sockaddr_in 的 16 字节结构中。对于因特网应用,sin_family 成员是 AF_INET,sin_port 成员是一个 16 位的端口号,而 sin_addr 成员就是一个 32 位的 IP 地址。IP 地址和端口号总是以网络字节顺序(大端法)存放的。

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 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地址 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 */
};

这里的 _in 后缀是 inernet 的意思

socket

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

1
2
3
4
5
#include <sys/types.h>
#include <sys/socket,h>

int socket(int domain,int type,int protocol);
//返回:若成功则为非负描述符,若出错则为-1。

如果想要使套接字成为连接的一个端点,就用如下硬编码的参数来调用 socket 函数 clientfd = Socket(AF_INET,SOCK_STREAM,0); 其中,AF_INET 表明我们正在使用 32 位 IP 地址,而 SOCK_STREAM 表示这个套接字是连接的一个端点。

socket 返回的 clientfd 描述符仅是部分打开的,还不能用于读写。如何完成打开套接字的工作,取决于我们是客户端还是服务器。

connect

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

1
2
3
4
#include <sys/socket.h>

int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);
//返回:若成功则为 0,若出错则为-1。

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

connect 函数会阻塞,一直到连接成功建立或是发生错误。如果成功,clientfd 描述符现在就准备好可以读写了,并且得到的连接是由套接字对 (x:y,addr.sin_addr:addr.sin_port)刻画的,其中 x 表示客户端的 IP 地址,而 y 表示临时端口,它唯一地确定了客户端主机上的客户端进程。

bind

bindlistenaccept,服务器用它们来和客户端建立连接

1
2
3
4
5
6
#include <sys/socket.h>

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

// 返回:若成功则为 0,若出错则为 -1。

bind 函数告诉内核将 addr 中的服务器套接字地址和套接字描述符 sockfd 联系起来。参数 addrlen 也是 sizeof(sockaddr in)

就比如我们在这个函数中指定 addr 是 127.0.0.1:8080 ,那么通过这个 ip 地址和端口(在这里就是本地访问)就可以找到并访问服务端啦。

listen

首先我们先了解主动实体和被动实体:

  • 客户端是发起连接请求的主动实体
  • 服务器是等待来自客户端的连接请求的被动实体
1
2
3
4
#include <sys/socket.h>

int listen(int sockfd, int backlog);
// 返回:若成功则为 0,若出错则为 -1。

默认情况下,内核会认为 socket 函数创建的描述符对应于主动套接字,存在于一个连接的客户端。

服务器调用 listen 函数将 sockfd 从一个主动套接字转化为一个监听套接字,告诉内核这个描述符是被服务器使用的,该套接字可以接受来自客户端的连接请求。

accept

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

1
2
3
4
#include <sys/socket.h>

int accept(int listenfd,struct sockaddr *addr,int *addrlen);
//返回:若成功则为非负连接描述符,若出错则为-1。

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

监听描述符和已连接描述符:

  • 监听描述符:作为客户端连接请求的一个端点,被创建一次,服务于整个 socket 周期
  • 已连接描述符:每次请求连接时创建,只服务当前客户端

主机和服务的转换

Linux 提供了 getaddrinfogetnameinfo 函数实现二进制套接字地址结构和主机名、主机地址、服务名和端口号的字符串表示之间的相互转化。

getaddrinfo

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb,h>

int getaddrinfo(const char *host,
const char*service,
const struct addrinfo *hintsstruct addrinfo **result);
//返回;如果成功则为0,如果错误则为非零的错误代码

void freeaddrinfo(struct addrinfo *result);
//返回:无

const char *gai_strerror(int errcode);
//返回;错误消息

数据结构如下:

参数:

  • host:域名,或者是ip地址
  • service:服务名
  • hints:可选参数,传递一些标志位参数
  • result:接收返回结果

如果要传递 hints 参数,只能设置下列字段:ai_family、ai_socktype、ai_protocol 和 ai_flags 字段。其他字段必须设置为 0(或 NULL)。

我们用 memset 将整个结构而清零,然后有选择地设置一些字段:

  • ai_flags 字段:我们可以把各种值组合起来得到该掩码
    • AI_CANONNAME:ai_canonname 字段默认为 NULL。如果设置了该标志,就是告诉 getaddrinfo 将列表中第一个 addrinfo 结构的 ai_canonname 字段指向 host 的权威名字。
    • AI_NUMERICSERV:参数 service 默认可以是服务名或端口号。这个标志强制参数 service 为端口号。
    • AI_PASSIVE:getaddrinfo 默认返回套接字地址,客户端可以在调用 connect 时用作主动套接字。这个标志告诉该函数,返回的套接字地址可能被服务器用作监听套接字。在这种情况中,参数 host 应该为 NULL。得到的套接字地址结构中的地址字段会是通配符地址(wildcard address),告诉内核这个服务器会接受发送到该主机所有 IP 地址的请求。这是所有示例服务器所默认的行为。
  • ai_family:指定返回地址的协议簇,取值范围:AF_INET(IPv4)、AF_INET6(IPv6)、AF_UNSPEC(IPv4 and IPv6)
  • ai_socktype:用于设定返回地址的 socket 类型,常用的有 SOCK_STREAM、SOCK_DGRAM、SOCK_RAW, 设置为 0 表示所有类型都可以。
  • ai_protocol:有 IPPROTO_TCP、IPPROTO_UDP 等,设置为 0 表示所有协议。

getnameinfo

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

1
2
3
4
5
6
7
8
#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);
//返回;如果成功则为 0,如果错误则为非零的错误代码

参数 sa 指向大小为 salen 字节的套接字地址结构,host 指向大小为 hostlen 字节的缓冲区,service 指向大小为 servlen 字节的缓冲区。

如果不想要主机名,可以把 host 设置为 NULL,hostlen 设置为 0。

参数 flags 是一个位掩码,能够修改默认的行为。同样可以把各种值用 OR 组合起来得到该掩码。

  • NI_NUMERICHOST。getnameinfo 默认试图返回 host 中的域名。设置该标志会
    使该函数返回一个数字地址字符串。
  • NI_NUMERICSERV。getnameinfo 默认会检查 /etc/services,如果可能,会返回服务名而不是端口号。设置该标志会使该函数跳过查找,简单地返回端口号。

Web 服务器

Web 基础

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

Web 内容可以用一种叫做 HTML(超文本标记语言)的语言来编写。

我之前有学过一点 HTML!这里!

Web 内容

对于 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 服务器以两种不同的方式向客户端提供内容:

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

每条由 Web 服务器返回的内容都是和 URL(Universal Resource Locator,通用资源定位符)有关的。例如:

例如,http://www.google.com:80/index.html

把 URL 进行拆分其实能得到以下结构组成:

1
protcol://host:port/router/?arg1&arg2
  • protcol:协议类型
  • host:主机地址
  • port:端口
  • router:路由
  • arg1&arg2:url 参数

HTTP 事务

可以使用 TELNET 去连接一个 WEB 服务器,并发起请求。

HTTP 请求

个HTTP 请求的组成是这样的:一个请求行(request line),后面跟随零个或更多个请求报头(request header),再跟随一个空的文本行来终止报头列表。一个请求行的形式是 method URI version

HTTP 支持许多不同的方法,包括 GET、POST、OPTIONS、HEAD、PUT、DELETE 和 TRACE。我们将只讨论广为应用的 GET 方法,大多数 HTTP 请求都是这种类型的。GET 方法指导服务器生成和返回 URI(Uniform Resource Identifier,统一资源标识符)标识的内容。URI 是相应的 URL 的后缀,包括文件名和可选的参数。

HTTP 响应

HTTP 响应和 HTTP 请求是相似的。一个 HTTP 响应的组成是这样的:一个响应行(response line),后面跟随着零个或更多的响应报头(response header),再跟随一个终止报头的空行,再跟随一个响应主体(response body)。一个响应行的格式是 version status-code status-message

version 字段描述的是响应所遵循的 HTTP 版本。状态码(status-code)是一个 3 位的正整数,指明对请求的处理。状态消息(status message)给出与错误代码等价的英文描述。下表列出了一些常见的状态码,以及它们相应的消息。

状态代码 状态消息 描述
200 成功 处理请求无误
301 永久移动 内容已移动到 location 头中指明的主机上
400 错误请求 服务器不能理解请求
403 禁止 服务器无权访问所请求的文件
404 未发现 服务器不能找到所请求的文件
501 未实现 服务器不支持请求的方法
505 HTTP 版本不支持 服务器不支持请求的版本

又是一个节日,睡不卓又和小伙伴一起🥹

怪好笑的😅


CSAPP 第十一章 网络编程
https://shmodifier.github.io/2023/12/25/CSAPP-第十一章-网络编程/
作者
Modifier
发布于
2023年12月25日
许可协议