CSAPP:第11章 网络编程
文章目录
11.1 客户端-服务器编程模型
- 就是CS模型,常见的WEB都采用这种
11.2 网络
- 对主机而言,网络只是又一种 I/O 设备,是数据源和数据接收方
- 最流行的局域网技术是以太网(Ethernet)
- 使用一些电缆和叫做网桥(bridge)的小盒子,多个以太网段可以连接成较大的局域网,称为桥接以太网(bridged Ethernet)
- 桥接以太网能够跨越整个建筑物或者校区。在一个桥接以太网里,一些电缆连接网桥与网桥,而另外一些连接网桥和集线
器。 - 这些电缆的带宽可以是不同的。
- 网桥比集线器更充分地利用了电缆带宽(有自学习功能)。
- 桥接以太网能够跨越整个建筑物或者校区。在一个桥接以太网里,一些电缆连接网桥与网桥,而另外一些连接网桥和集线
- 在层次的更高级别中,多个不兼容的局域网可以通过叫做路由器(router)的特殊计算机连接起来,组成一个 internet(互联网络)。每台路由器对于它所连接到的每个网络都有一个适配器(端口)。
- 互联网络至关重要的特性是,它能由采用完全不同和不兼容技术的各种局域网和广域网组成。
- 可以发现,internet是由采用不同和不兼容技术的各种局域网和广域网构成的,为了消除不同网络之间的差异,需要在每台主机和路由器上运行协议软件,来控制主机和路由器如何协同工作来实现数据传输。该协议具有两个基本功能:
- **命名机制:**为主机分配唯一的具有地域性的地址——IP地址,能通过该主机地址得知对应的局域网就能在路由器中确定对应的端口,就能大量减轻了路由器记录的负担。。
- 传送机制:在不同层面上传输数据需要不同的地址,比如在局域网中需要通过MAC地址来确定目标主机,而在internet中需要通过IP地址确定路由器的端口。所以互联网协议需要在数据最外侧添加路由器的MAC地址,来使得数据能先传输到路由器,然后内侧再添加IP地址,使得路由器能确定端口。IP地址和数据构成了数据报(Datagram)。
- 以上面为例,说明如何从主机A传输数据到主机B(摘自深度人工智障)
- 1、运行在主机A中的客户端通过系统调用,从客户端的虚拟地址空间复制数据到内核缓冲区中。
- 2、主机A上的协议软件在数据前添加数据帧头
FH1
和数据包头PH
,其中FH1
中记录了路由器1的MAC地址,PH
记录了主机B的IP地址。主机A将该数据帧传输到自己的适配器上。 - 3、LAN 1根据帧头中记录的MAC地址,对其进行广播和转发。
- 4、路由器1的适配器会接收到该数据帧,并将其传送到协议软件。
- 5、协议软件从中剥离掉帧头
FH1
,获得PH
中记录的主机B的IP地址,就能将其作为路由器表的索引获得要将其转发到路由器的哪个端口。这里是将其转发到传输到路由器2的端口。 - 6、当路由器2获得该数据包时,可以从一个表中得知该IP地址对应的MAC地址,即主机B的MAC地址,就将将其再次封装成数据帧的形式,通过适配器将其传输到LAN 2中。
- 7、在LAN 2根据帧头中记录的MAC地址,对其进行广播和转发。
- 8、当主机B接收到该数据帧时,将其传送到协议软件中。
- 9、在协议软件中,判断数据帧头中记录的目的MAC地址是否与自己的MAC地址相同,发现是相同的,则会剥离包头和帧头获得数据。当服务器进行一个读取这些数据的系统调用时,就将其复制到服务器的虚拟地址空间中。
- 实际上此处省略了协议封装的细节,因为就当前的TCP/IP协议族来说至少有传输层和网络层两层的协议需要封装(套娃模式)
11.3 全球IP因特网
- TCP/IP分类:
- TCP、UDP
- IP
- 从程序员的角度,我们可以把因特网看做一个世界范围的主机集合,满足以下特性:
- 主机集合被映射为一组 32 位的 IP 地址。
- 这组 IP 地址被映射为一组称为因特网域名(Internet domain name)的标识符。
- 因特网主机上的进程能够通过连接(connection)和任何其他因特网主机上的进程通信。
11.3.1 IP地址
- 在 IP 地址结构中存放的地址总是以(大端法)网络字节顺序存放的,即使主机字节顺序(host byte order)是小端法。Unix 提供了下面这样的函数在网络和主机字节顺序间实现转换。
- 应用程序使用 inet_pton和 inet_ntop函数来实现 IP 地址和点分十进制串之间的转换。
inet_pton
函数将一个点分十进制串src
转换为二进制的网络字节顺序的IP地址,其中AF_INET
表示32位的IPv4地址,而AF_INET6
表示64位的IPv6地址。inet_ntop
函数将二进制的网络字节顺序的IP地址src
转换为对应的点分十进制表示。
11.3.2 因特网域名
- 域名集合形成了一个层次结构,每个域名编码了它在这个层次中的位置。
- com一层叫顶级域名,下方则为二级、三级域名(以此类推)
- nslookup 看域名和IP的绑定情况:
- hostname来确定本地主机的实际域名:但是实际上我只得到了主机名
11.3.3 因特网连接
- 当客户端发起一个连接请求时,客户端套接字地址中的端口是由内核自动分配的,称为临时端口(ephemeral port)。然而,服务器套接字地址中的端口通常是某个知名端口,是和这个服务相对应的。常见的默认80,或者开发时汤姆猫的8080
- CS双方可以通过套接字(socket)找到对方(一般是客户端请求服务器)
11.4 套接字接口
- 套接字接口(socket interface)是一组函数,它们和 Unix I/O 函数结合起来,用以创建网络应用。
11.4.1 套接字地址结构
- 从 Linux 内核的角度来看,一个套接字就是通信的一个端点。
- 从 Linux 程序的角度来看,套接字就是一个有相应描述符的打开文件。
- - sa_family包括以下可选值。每个值代表一种地址族(address family),在基于IP的情况中,都使用AF_INET。 - AF_INET - AF_UNIX - AF_NS - AF_IMPLINK - sin_port是端口号,16位长,**网络字节序(network byte order)**; - sin_addr是IP地址,**网络字节序(network byte order)**。sin_zero,8个字节,设置为0。 - 为何会使用两个数据结构sockaddr和sockaddr_in来表示地址 - 原因是socket设计之初本来就是准备支持多个地址协议的。不同的地址协议由自己不同的地址构造,譬如对于IPv4就是`sockaddr_in`, IPV6就是`sockaddr_in6`, 以及对于AF_UNIX就是`sockaddr_un`。 - sockaddr是对这些地址的上一层的抽象。 - sockaddr_in将地址拆分为port和IP,对编程也更友好。这样,在将所使用的的值赋值给sockaddr_in数据结构之后,通过强制类型转换,就可以转换为sockaddr。当然,从sockaddr也可以强制类型转换为sockaddr_in。
11.4.2 socket 函数
- 客户端和服务器使用 socket 函数来创建一个套接字描述符(socket descriptor)
- - 如:clientfd = Socket(AF_INET, SOCK_STREAM, 0); - AF_INET表明我们正在使用 32 位 IP 地址 - SOCK_STREAM表示这个套接字是连接一个端点 - socket 返回的 clientfd 描述符仅是部分打开的,还不能用于读写。
11.4.3 connect 函数
- 客户端通过调用 connect 函数来建立和服务器的连接。
- connect 函数试图与套接字地址为 addr 的服务器建立一个因特网连接,其中 addrlen是 sizeof(sockaddr_in)。
- connect 函数会阻塞,一直到连接成功建立或是发生错误。
- 如果成功,clientfd 描述符现在就准备好可以读写了, 并且得到的连接是由套接字对
- (x:y, addr.sin_addr:addr.sin_port)
- x:y=IP:临时端口(客户端的)
11.4.4 bind 函数
- bind 函数告诉内核将 addr 中的服务器套接字地址和套接字描述符 sockfd 联系起来。
11.4.5 listen 函数
- 客户端是发起连接请求的主动实体。服务器是等待来自客户端的连接请求的被动实体。服务器调用 listen 函数告诉内核,描述符是被服务器而不是客户端使用的。
- listen 函数将 sockfd 从一个主动套接字转化为一个监听套接字(listening socket),该套接字可以接受来自客户端的连接请求。
- backlog 参数暗示了内核在开始拒绝连接请求之前,队列中要排队的未完成的连接请求的数量。
11.4.6 accept 函数
- 服务器通过调用 accept 函数来等待来自客户端的连接请求。
- accept 函数等待来自客户端的连接请求到达侦听描述符 listenfd,然后在 addr 中填写客户端的套接字地址,并返回一个已连接描述符(connected descriptor),这个描述符可被用来利用 Unix I/O 函数与客户端通信。
- listen和accept的区别:
- 监听描述符(listen)是作为客户端连接请求的一个端点。它通常被创建一次,并存在于服务器的整个生命周期。
- 已连接描述符(accept)是客户端和服务器之间已经建立起来了的连接的一个端点。服务器每次接受连接请求时都会创建一次,它只存在于服务器为一个客户端服务的过程中。
- 调用过程:
- 1、服务器调用accept,等待连接请求到达监听描述符。
- 2、客户端调用 connect 函数,发送一个连接请求到 listenfd。
- 3、accept 函数打开了一个新的已连接描述符 connfd,在 clientfd和 connfd 之间建立连接,并且随后返回 connfd 给应用程序。客户端也从 connect 返回,在这一点以后,客户端和服务器就可以分别通过读和写 clientfd 和 connfd 来回传送数据了。
11.4.7 主机和服务的转换
-
1、getaddrinfo 函数
-
getaddrinfo 函数将主机名、主机地址、服务名和端口号的字符串表示转化成套接字地址结构。
-
可重入。
-
hits
是结构的指针: -
给定 host 和 service(套接字地址的两个组成部分),getaddrinfo 返回 result,result —个指向 addrinfo 结构的链表,其中每个结构指向一个对应于 host 和 service的套接字地址结构,如下:
- 在客户端调用了 getaddrinfo 之后,会遍历这个列表,依次尝试每个套接字地址,直到调用 socket 和 connect 成功,建立起连接。
- 服务器也会尝试遍历列表中的每个套接字地址,直到调用 socket 和 bind成功,描述符会被绑定到一个合法的套接字地址。
- 为了避免内存泄漏,应用程序必须在最后调用 freeaddrinfo, 释放该链表。
- 如果 getaddrinfo 返回非零的错误代码,应用程序可以调用 gaijtreeror,将该代码转换成消息字符串。
-
可以通过
hits
来控制result
的结果。设置hits
时,通常用memset
将整个结构清空,再有选择地设置一些字段:-
ai_family
:result
默认会包含IPv4和IPv6的套接字地址,可以通过设置ai_family
为AF_INET
或AF_INET6
来限制只包含IPv4或IPv6的套接字地址。 -
ai_socktype
:将其设置为SOCK_STREAM
可以将result
限制为每个地址最多只有一个addrinfo
结构,可以用来作为连接的一个端点。 -
ai_flags
:是一个位掩码,可以修改默认行为: -
AI_ADDRCONFIG
:只有当本地主机被配置了IPv4时,result
才会返回IPv4地址,IPv6同理。AI_CANONNAME
:如果设置了该位,则result
第一个addrinfo
结构的ai_cannonname
字段指向host
的官方名字。AI_NUMERICSERV
:强制service
参数只能是端口号。AI_PASSIVE
:使得返回的套接字地址可以被服务器用作监听套接字,此时host
应该为NULL,就会使得返回的套接字地址结构中的地址字段为通配符地址。
-
-
getaddrinfo的 host 参数可以是域名,也可以是数字地址(如点分十进制 IP 地址)。
-
service参数可以是服务名(如 http), 也可以是十进制端口号。
- 如果不想把主机名转换成地址,可以把 host 设置为 NULL。对 service 来说也是一样。但是必须指定两者中至少一个。
-
-
2、getnameinfo 函数
- getnameinfo 函数和 getaddrinfo 是相反的,将一个套接字地址结构转换成相应的主机和服务名字符串。
- 它是可重人和与协议无关的。
- 参数 flags 是一个位掩码,能够修改默认的行为。可以把各种值用 OR 组合起来得到该掩码。下面是两个有用的值:
NI_NUMERICHOST
:该函数默认返回host
中的域名,通过设置这个标志位来返回数字地址字符串。NI_NUMERICSERV
:该函数默认检查/etc/service
并返回服务名,通过设置这个标志位来返回端口号。
从上面可知,一个IP地址可能会对应多个域名,通过这个
getnameinfo
函数就能寻找IP地址对应的所有可用的域名。
11.4.8 套接字接口的辅助函数
-
这里提供两个封装函数
open_clientfd
和open_listenfd
来封装getnameinfo
和getaddrinfo
函数来进行客户端和服务端的通信。 -
1、open_clientfd 函数
-
int open_clientfd(char *hostname, char *port){ int clientfd; struct addrinfo hints, *listp, *p; /* Get a list of potential server addresses */ memset(&hints, 0, sizeof(struct addrinfo)); //初始化hints为0 hints.ai_socktype = SOCK_STREAM; /* Open a connection */ hints.ai_flags = AI_NUMERICSERV; /* ... using a numeric port arg. */ hints.ai_flags |= AI_ADDRCONFIG; /* Recommended for connections */ getaddrinfo(hostname, port, &hints, &listp); //获得一系列套接字地址 /* Walk the list for one that we can successfully connect to */ for (p = listp; p; p = p->ai_next) { //遍历所有套接字地址,找到一个可以连接的 /* Create a socket descriptor */ if ((clientfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0) continue; /* Socket failed, try the next */ /* Connect to the server */ if (connect(clientfd, p->ai_addr, p->ai_addrlen) != -1) break; /* Success */ close(clientfd); /* Connect failed, try another */ //line:netp:openclientfd:closefd } /* Clean up */ freeaddrinfo(listp); if (!p) /* All connects failed */ return -1; else /* The last connect succeeded */ return clientfd; }
-
客户端可以通过
open_clientfd
函数来建立与服务器的连接,该服务器运行在hostname
主机上,并在port
端口监听连接请求。首先会通过getaddrinfo
函数找到一系列套接字地址,并依次遍历寻找可以创建套接字描述符且连接成功的一个套接字地址,然后返回准备好的套接字描述符,客户端可以直接通过clientfd
和Unix I/O来与服务器进行通信。
-
2、open_listenfd 函数
-
调用 open_listenfd 函数,服务器创建一个监听描述符,准备好接收连接请求
-
int open_listenfd(char *port){ struct addrinfo hints, *listp, *p; int listenfd, optval=1; /* Get a list of potential server addresses */ memset(&hints, 0, sizeof(struct addrinfo)); hints.ai_socktype = SOCK_STREAM; /* Accept connections */ hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG; /* ... on any IP address */ //AI_PASSIVE保证套接字地址可被服务器用作监听套接字 hints.ai_flags |= AI_NUMERICSERV; /* ... using port number */ getaddrinfo(NULL, port, &hints, &listp); //这里的host为NULL /* Walk the list for one that we can bind to */ for (p = listp; p; p = p->ai_next) { /* Create a socket descriptor */ if ((listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0) continue; /* Socket failed, try the next */ /* Eliminates "Address already in use" error from bind */ setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, //line:netp:csapp:setsockopt (const void *)&optval , sizeof(int)); /* Bind the descriptor to the address */ if (bind(listenfd, p->ai_addr, p->ai_addrlen) == 0) break; /* Success */ close(listenfd); /* Bind failed, try the next */ } /* Clean up */ freeaddrinfo(listp); if (!p) /* No address worked */ return -1; /* Make it a listening socket ready to accept connection requests */ if (listen(listenfd, LISTENQ) < 0) { Close(listenfd); return -1; } return listenfd; }
-
首先
hints.ai_flags
包含AI_PASSIVE
使得返回的套接字地址可被服务器作为监听套接字,并在socket
函数中将host
设置为NULL,使得后面的p->ai_addr
为通配符地址,这样在bind
函数中就能使得服务器监听所有发送请求的客户端IP地址,并且这里只指定了端口号,所以就是将listenfd
用于该端口的监听。这里的setsockopt
函数使得服务器能被终止、重启和立即开始接收连接请求。最终,服务器可以直接通过listenfd
和Unix I/O来与客户端进行通信。
-
11.4.9 echo 客户端和服务器的示例
- 客户端:
- 在和服务器建立连接之后(open_clientfd)
- 客户端进人一个循环(while)
- 读去文本到buf(Rio_readlineb),发送到服务器(Rio_writen)
- 输出到stdout(标准输出)
- 当 fgets 在标准输人上遇到 EOF 时,或者因为用户在键盘上键人 Ctrl+D,或者因为在一个重定向的输人文件中用尽了所有的文本行时,循环就终止。
- 服务器端:
- 在打开监听描述符后(open_listenfd),它进人一个无限循环(while)。
- 每次循环都等待一个来自客户端的连接请求(accept),输出已连接客户端的域名和 IP 地址(getnameinfo+printf),并调用 echo 函数为这些客户端服务。
- 在 echo 程序返回后,主程序关闭已连接描述符。一旦客户端和服务器关闭了它们各自的描述符,连接也就终止了。
- echo处理逻辑:
- 该程序反复读写文本行,直到 rio_readlineb函数在第 10 行遇到 EOF。
11.5 Web服务器
- 对于 Web 客户端和服务器而言,内容是与一个 MIME ( Multipurpose Internet MailExtensions, 多用途的网际邮件扩充协议)类型相关的字节序列。
- Web 服务器以两种不同的方式向客户端提供内容:
- 静态内容(文件直接返回)、动态内容(可执行文件的输出)
- HTTP事务
- 1、HTTP请求
- 2、HTTP响应
- 服务动态内容
- 1、客户端如何将程序参数传递给服务器(get请求)
- ?分割文件和参数
- &分割参数
- 2、服务器如何将参数传递给子进程
- 它调用 fork 来创建一个子进程,并调用 execve 在子进程的上下文中执行/cgi-bin/adder程序。
- 像 adder 这样的程序,常常被称为 CGI 程序,因为它们遵守 CGI 标准的规则。
- 3、服务器如何将其他信息传递给子进程
- CGI 定义了大量的其他环境变量,一个 CGI 程序在它运行时可以设置这些环境变量。
- 4、子进程将它的输出发送到哪里
- 标准输出
- 在子进程加载并运行 CGI 程序之前,Linux dup2 函数将标准输出重定向到和客户端相关联的已连接描述符。因此,任
何 CGI 程序写到标准输出的东西都会直接到达客户端
- 1、客户端如何将程序参数传递给服务器(get请求)
11.6 综合:Tiny Web服务器
- main函数
- 36行doit是执行事务
- 37行关闭链接
- doit函数
- 从该函数中可以总结出服务器处理请求端大致步骤:
- 读request 行和headers(请求头),request中会有类型(get post之类),15-19判错
- 分析URI,并尝试找到文件(404为找不到),对于静态内容(serve_static)/动态内容(调用serve_dynamic),失败返回403
- clienterror 函数
- 相当于错误处理类(如java中的Exception这种)
- read_requesthdrs 函数(doit的20行被调用)
- 读取并忽略这些报头。
- 注意,终止请求报头的空文本行是由回车和换行符对组成的,在第 6 行中检查它。
- parse_uri 函数
- TINY 假设静态内容的主目录就是它的当前目录,而可执行文件的主目录是 ./cgi-bin。
- 任何包含字符串 cgi-bin 的 URI 都会被认为表示的是对动态内容的请求。
- 默认的文件名是./home.html
- 除此之外就是分析参数(get方法),以?为起点&为分界点
- serve_static 函数
- TINY 提供五种常见类型的静态内容:HTML 文件、无格式的文本文件,以及编码为 GIF、PNG 和 JPG 格式的图片(下方get_filetype判定)。
- 静态服务主要返回请求response头和主体(文件及参数)
- serve_dynamic 函数
- TINY 通过派生一个子进程并在子进程的上下文中运行一个 CGI 程序,来提供各种类型的动态内容。
- serve_dynamic 函数一开始就向客户端发送一个表明成功的响应行。CGI程序负责发送剩下部分。
- 通过fork创建子进程,setenv设置环境,Dup2重定向标准输出,Execve执行动态函数(见第10章Execve相关说明,该子进程就直接替换成Execve中的执行文件)
- 其间,父进程阻塞在对 wait 的调用中,等待当子进程终止的时候,回收操作系统分配给子进程的资源(第 17 行)。