Skip to content

Chapter 11.B Network Programming

11.1 客户端-服务器编程模型

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

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

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

一台主机可以同时运行许多不同的客户端和服务器, 而且一个客户端和服务器的事务可以在同一台或是不同的主机上. 无论客户端和服务器是怎样映射到主机上的, 客户端-服务器模型都是相同的.

11.2 网络

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

对主机而言, 网络只是又一种 I/O 设备, 是数据源和数据接收方. 一个插到 I/O 总线扩展槽的适配器提供了到网络的物理接口. 从网络上接收到的数据从适配器经过 I/O 和内存总线复制到内存, 通常是通过 DMA 传送. 类似地, 数据也能从内存复制到网络.

物理上而言, 网络是一个按照地理远近组成的层次系统. 最低层是 LAN(Local Area Network, 局域网), 在一个建筑或者校园范围内. 目前最流行的局域网技术是以太网 (Ethernet).

以太网段包括一些电缆和一个叫做集线器的小盒子, 以太网段通常跨越一些小的区域, 每根电缆都有相同的最大位带宽. 一端连接到主机的适配器, 另一端连接到集线器的一个端口上. 集线器不加分辨低将从一个端口上收到的每个位复制到其他所有的端口上. 因此每台主机都能看到每个位.

每个以太网的适配器都有一个全球唯一的 48 位地址 (MAC地址), 存储在适配器的非易失性存储器上. 一台主机可以发送一段位(称为帧)到这个网段内的其他任何主机. 每个帧包括一些固定数量的头部(header)位, 用来标识此帧的源和目的地址以及此帧的长度. 此后紧随的就是数据为的有效载荷 (payload). 每个主机适配器都能看到这个帧, 但是只有目的主机实际读取它.

集线器的问题

  • 集线器是物理层设备,它收到一个帧后,会无脑地将其广播给所有连接的端口。
  • 此时,网络中的所有主机确实都能看到这个帧,但由于帧的目的 MAC 地址不是自己,所以除了目的主机外,其他主机都会丢弃它。但如果有人恶意使用网络嗅探器,就可以截获并读取这些数据。

使用一些电缆和叫做网桥的小盒子, 多个以太网段可以连接成较大的局域网, 称为桥接以太网 (bridged Ethernet), 桥接以太网能够跨越整个建筑物或者校区. 在桥接以太网里, 一些电缆连接网桥与网桥, 而另外一些连接网桥和集线器.

网桥比集线器更充分地利用了电缆带宽。利用一种聪明的分配算法,它们随着时间自动学习哪个主机可以通过哪个端口可达,然后只在有必要时,有选择地将帧从一个端口复制到另一个端口。例如,如果主机A发送一个帧到同网段上的主机B,当该帧到达网桥X的输入端口时,X就将丢弃此帧,因而节省了其他网段上的带宽。然而,如果主机A发送一个帧到一个不同网段上的主机C,那么网桥X只会把此帧复制到和网桥Y相连的端口上,网桥Y会只把此帧复制到与主机C的网段连接的端口。

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

互联网络至关重要的特性是能够采用完全不同和不兼容技术的各种局域网和广域网组成. 每台主机和其他每台主机都是物理相连的. 如何能够让某台源主机跨过所有不兼容的网络发送数据位到另一台目的主机呢?

解决方法就是 Protocol. 协议控制主机和路由器如何协同工作来实现数据传输, 这种协议必须提供两种基本能力:

  • 命名机制: 不同的局域网技术有不同和不兼容的方式来为主机分配地址. 互联网络协议通过定义一种一致的主机地址格式消除了这些差异. 每台主机会被分配至少一个这种互联网络地址(通常指 IP地址), 这个地址唯一地标识了这台主机.
  • 传送机制. 互联网络协议通过定义一种把数据位捆扎成不连续的包的方式传送数据. 包头包括包的大小和目的主机的地址, 有效载荷包括从源主机发出的数据位.

11.3 全球 IP 因特网


每台因特网主机都运行实现 TCP/IP 协议的软件. 因特网的客户端和服务器混合使用套接字接口函数和 Unix I/O 函数来进行通信. 通常将套接字函数实现为系统调用, 这些系统调用会陷入内核, 并调用各种内核模式的 TCP/IP 函数.

协议 层级 传输目标 传输特性 作用
IP 网络层 (L3) 主机到主机 不可靠、无连接 提供基本的路由和寻址
UDP 传输层 (L4) 进程到进程 不可靠、无连接 为应用进程提供最小开销的传输服务
TCP 传输层 (L4) 进程到进程 可靠面向连接 确保数据的完整性和顺序,提供应用所需的可靠服务

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

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

11.3.1 IP 地址

32位无符号整数
TCP/IP 为任意整数数据项定义了统一的网络字节顺序(大端法), 例如 IP 地址, 它放在包头中跨过网络被携带. 在 IP 地址结构中存放的地址总是以大端法网络字节顺序存放的, 即使主机字节顺序是小端法. Unix 提供了下面的函数实现顺序转换.

IP 地址通常是以一种称为点分十进制表示法来表示的, 每个字节由它的十进制表示, 并且用句点和其他字节间分开. 例如 128.2.194.242 就是地址 0x8002c2f2 的点分十进制表示. 在 Linux 上可以使用 HOSTNAME 命令确定主机的 IP 地址.

应用程序通过 inet_pton 和 inet_ntop 函数来实现 IP 地址和点分十进制串之间的转换.

主机可能有很多接口, 每个接口都可能对应一个不同的 IP 地址. 多个主机也可以共享一个公网 IP 地址.

子网

子网是一些设备, 它们接口可以物理上地连接而不用通过路由器. 在同一个子网内,设备可以直接通过它们的 MAC 地址进行数据帧的转发,而不需要 L3(网络层)的路由功能.

子网是 IP 地址结构化寻址的体现。一个 IP 地址被逻辑上划分成了两部分:

  • 子网部分 (subnet part):

    • 由 IP 地址的高位比特(common high order bits) 构成。

    • 同一个子网内的所有设备,它们的 IP 地址的子网部分必须是相同的

    • 这部分地址由 子网掩码 (Subnet Mask) 来界定。

  • 主机部分 (host part):

    • 由 IP 地址剩余的低位比特 (remaining low order bits) 构成。

    • 这部分用于唯一标识该子网内的特定主机

例如, 223.1.3.0/24 说明高 24 位是子网部分, 低 8 位是主机部分. 子网掩码就是 255.255.255.0.

全 0 和全 1 的主机部分

在 IPv4 网络中,IP 地址由网络部分主机部分组成。当你把主机部分的所有比特位(Binary Bits)全部设为 0 或全部设为 1 时,它们具有特殊的含义,不能分配给具体的电脑、手机等设备使用。全 0 代表网络本身, 全 1 代表广播.

IP 地址结构

为什么要有私有 IP?

🌍 全球公有 IP 地址资源的限制 (IPv4)

这是最主要的原因:

  • 地址耗尽: IPv4 地址只有约 43 亿个。如果全球每一个联网设备(包括您的手机、电脑、智能家居设备等)都要求一个唯一的公有 IP 地址,这个资源池早就耗尽了。
  • 解决方案: 通过划分私有 IP 地址范围(\(10.x.x.x\)\(172.16.x.x\)\(192.168.x.x\))和使用 NAT (网络地址转换) 技术,一个家庭或企业内部的数千台设备可以共享一个或几个公有 IP 地址来访问互联网。这极大地缓解了 IPv4 地址的压力。

🔒 安全性的提升
私有 IP 地址和 NAT 机制天然地为内部网络提供了一层安全保护:

  • 不可路由性: 私有 IP 地址在互联网上是不可路由的。互联网上的路由器被配置为丢弃所有目标地址是私有 IP 地址的数据包。
  • 隐藏内部结构: 外部攻击者无法直接通过您的公有 IP 地址获知您的内部网络结构、设备数量和具体的私有 IP 地址。他们无法直接寻址您的内部设备。
  • NAT 屏障: 只有内部设备主动发起连接时,NAT 才会为其创建映射。外部设备无法主动发起对内部私有 IP 地址的连接,除非路由器或防火墙被配置了端口转发规则。

⚙️ 灵活性和管理效率

  • 本地地址分配: 使用私有 IP 地址,网络管理员(或您的家用路由器)可以自由地、本地地分配 IP 地址,无需向任何互联网权威机构注册或报告。
  • 网络迁移: 如果您的公司更换了 ISP(互联网服务提供商),您的公有 IP 地址可能会改变。但是,您的内部网络使用的私有 IP 地址可以保持不变,大大简化了网络迁移和维护工作。

11.3.2 因特网域名

由于 IP 地址很难记住, 所以因特网定义了一组更加人性化的域名 (domain name), 以及一种将域名映射到 IP 地址的机制. 域名是一串用句点分割的单词, 例如 whaleshark.ics.cs.cmu.edu

域名集合形成了一个层次结构, 每个域名编码了它在这个层次中的位置. 树的节点反向到根的路径形成了域名. 子树称为子域.

因特网定义了域名集合和 IP 地址集合之间的映射, 这个映射是通过分布世界范围内的数据库 (称为 DNS(Domain Name System), 域名系统)来维护的. DNS 数据库由上百万的主机条目结构组成, 每条定义了一组域名和一组 IP 地址之间的映射.

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

回送地址(回环地址)

回环地址属于特殊的 A 类地址范围(从 \(127.0.0.0\)\(127.255.255.255\)),主要用于以下目的:

  1. 指向本机: 127.0.0.1 始终指向当前正在使用它的设备本身。它也被称为 localhost
  2. 本地测试: 它用于在本地测试网络应用程序和服务。当一个程序(如 Web 浏览器)连接到 127.0.0.1 时,数据包不会离开设备,而是直接通过内部的网络协议栈被发送回自己。
  3. 网络诊断: 如果对 127.0.0.1 的 ping 测试失败,通常表示本机系统的 TCP/IP 协议栈在严重故障。

11.3.3 因特网连接

因特网客户端和服务器通过在连接上发送和接受字节刘来通信. 从连接一对进程的意义上而言, 连接是点对点的. 从数据可以同时双向流动的角度来说, 它是全双工的. 并且从由源进程发出的字节流最终被目的进程以它发出的顺序收到它的角度来说, 它也是 可靠的.

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

当客户端发起连接请求时, 客户端套接字地址中的端口是由内核自动分配的, 称为临时端口. 然而, 服务器套接字地址中的端口通常是某个知名端口, 是和这个服务相对应的. 例如, Web 服务器通常使用端口 80, 电子邮件服务器通常使用端口 25. 每个具有知名端口的服务都有一个对应的知名的服务名. 例如 Web 服务的知名名字是 http, email 是 smtp. 文件 /etc/services 包含一张这台机器提供的知名名字和知名端口之间的映射.

一个连接是由两端的套接字地址唯一决定的, 这对套接字地址叫做套接字对, 用以下元组表示:

\[ (\text{cliaddr:cliport, servaddr:servport}) \]

11.4 套接字接口

套接字接口是一组函数, 它们和 Unix I/O 函数结合起来, 用以创建网络应用.

11.4.1 套接字地址结构

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

套接字地址存放在如图所示的类型为 sockaddr_in 的 16 字节结构中.

sin_family 字段用于指定此套接字地址将使用的协议族地址族 (Address Family)

  • 值: 它是一个整数值,但通常使用宏来表示特定的协议族。

  • 最常见的值:

    • AF_INET (Address Family Internet):用于 IPv4 协议。这是互联网通信最常用的值。

    • AF_INET6 (Address Family Internet 6):用于 IPv6 协议

    • AF_UNIXAF_LOCAL:用于同一操作系统内不同进程之间的本地通信(Unix 域套接字)。

sin_port 成员是一个 16 位的端口号

sin_addr 成员是一个 32 位的 IP 地址. IP 地址和端口号总是以网络字节顺序(大端法)存放的.

11.4.2 socket 函数

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

11.4.3 connect 函数

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

connect 函数试图与套接字地址为 addr 的服务器建立一个因特网连接, 其中 addrlensizeof(sockaddr_in). connect 函数会阻塞, 一致到连接成功建立或是发生错误. 如果成功, clientfd 描述现在就准备好可以读写了. 对于 socket, 最好的方法是用 getaddrinfo 来为 connect 提供参数.

11.4.4 bind 函数

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

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

这样就可以在指定的端口进行连接了, 而不是像客户端一样由内核随机决定.

11.4.5 listen 函数

客户端是发起连接请求的主动实体, 服务器是等待来自客户端的连接请求的被动实体. 默认情况, 内核会认为 socket 函数创建的描述符对应于主动套接字. 服务器调用 listen 函数告诉内核, 描述符是被服务器而不是客户端使用的.

listen 函数将 sockfd 从一个主动套接字转化为一个监听套接字, 该套接字可以接受来自客户端的连接请求. backlog 参数暗示了内核在开始拒绝连接请求之前, 队列中要排队的未完成的连接请求的数量. 其确切含义要求对 TCP/IP 协议的理解, 通常会将它设置为一个较大值, 如 1024.

11.4.6 accept 函数

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

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

区别点 监听描述符 (Listening) 已连接描述符 (Connected)
目的 接受新的 TCP 连接请求。 传输应用数据。
生命周期 服务器启动到关闭。 TCP 连接建立到连接终止。
创建者 listen() 函数设置。 accept() 函数返回
连接状态 LISTEN (被动等待)。 ESTABLISHED (双向可通信)。
数量关系 每个端口 1 个 每个客户端连接 1 个

11.4.7 主机和服务的转换

Linux 提供了一些强大的函数实现二进制套接字地址结构和主机名, 主机地址, 服务名和端口号的字符串表示之间的相互转化. 当和套接字接口一起使用时, 这些函数能使我们编写独立于任何特定版本的 IP 协议的网络程序.

1. getaddrinfo 函数

getaddrinfo 函数将主机名, 主机地址, 服务名和端口号的字符串表示转化成套接字地址结构. 它是已弃用的 gethostbynamegetservbyname 函数的替代品, 它和以前的函数不同, 它是可重入的, 适用于任何协议.

给定 host 和 service (套接字地址的两个组成部分), getaddrinfo 返回 result , result 是一个指向 addrinfo 结构的链表, 其中每个结构指向一个对应于 host 和 service 的套接字地址结构. (注意一个主机名可能对应很多个 IP 地址)

客户端调用了 getaddrinfo 后, 会遍历这个列表, 依次尝试每个套接字地址, 直到调用 socketconnect 成功, 建立起连接. 类似地, 服务器会遍历这个列表中的每个套接字地址, 直到调用 socketbind 成功. 描述符会被绑定到一个合法的套接字地址. 为了避免内存泄漏, 应用程序必须在最后调用 freeaddrinfo 释放该链表. 如果 getaddrinfo 返回非零的错误代码, gai_strerror 可以将该代码转换成消息字符串.


2. getnameinfo 函数

getnameinfo 函数和 getaddrinfo 是相反的, 将一个套接字地址结构转换成相应的主机和服务名字符串. 它是已弃用的 gethostbyaddrgetservbyaddr 函数的替代品, 它和以前的函数不同, 它是可重入的, 适用于任何协议.

11.4.8 套接字接口的辅助函数

用高级的辅助函数包装一下相关函数会方便很多.

1. open_clientfd 函数

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


2. open_listenfd 函数

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

11.4.9 echo 客户端和服务器的示例

见教材

11.5 Web 服务器

11.5.1 Web 基础

11.5.2 Web 内容

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

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

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

  • 确定一个 URL 指向的是静态内容还是动态内容没有标准的规则. 一种经典的做法是确定一组目录, 所有的可执行文件都存放在这些目录中.
  • 后缀中最开始的 '/' 表示的是被请求内容类型的主目录.
  • 最小的 URL 后缀是 '/' 字符, 所有服务器将其扩展为某个默认的主页, 例如 /index.html. 这解释了为什么简单地在浏览器键入一个域名就可以取出一个网站的主页. 浏览器在 URL 后添加缺失的 '/', 并传递给服务器, 服务器又把 '/' 扩展到某个默认的文件名.

11.5.3 HTTP 事务

我们可以使用 Linux 的 TELNET 程序来和因特网上的任何 Web 服务器执行事务.

每次输入一个文本行, 并键入回车键, TELNET 会读取该行, 在后面加上回车和换行符号(\r\n) 并且将这一行发送到服务器. HTTP 标准要求每个文本行都由一对回车和换行符来结束. 为了发起事务, 我们输入一个 HTTP 请求, 服务器返回 HTTP 响应, 然后关闭连接.

1. HTTP 请求

一个 HTTP 请求由一个请求行 (Line5) 后面跟随零个或更多个请求报头 (Line6), 再跟随一个空的文本行来终止报头列表 (Line7), 一个请求行的形式是

Text Only
1
method URI version

HTTP 支持许多不同的方法, 包括 GET, POST, OPTIONS 等等. 我们只讨论广为应用的 GET 方法.

GET 方法指导服务器生成和返回 URI 标识的内容. URI 是相应 URL 的后缀, 包括文件名和可选参数.

请求报头提供了额外的信息.

2. HTTP 响应

HTTP 响应由一个响应行 (Line8) 后面跟随着零个或更多个响应报头, 再跟随一个空行, 然后是响应主体. 响应行的格式是

Text Only
1
version status-code status-message

状态码是一个 3 位的正整数, 指明对请求的处理.

11.5.4 服务动态内容

CGI (Common Gateway Interface, 通用网关接口) 的实际标准出现解决了服务动态内容的问题.