TLPI读书笔记第59章-Internet Domain1

在前面几个章节介绍过 socket 的一般概念和 TCP/IP 协议套件之后,现在本章可以开始介绍如何在 IPv4 ( AF_INET)和 IPv6 ( AF_INET6) domain 中使用 socket 编程了。 在第 58 章中提到过 Internet domain socket 地址由一个 IP 地址和一个端口号组成。虽然计算机使用了 IP 地址和端口号的二进制表示形式,但人们对名称的处理能力要比对数字的处理能力强得多。因此,本章将介绍使用名称标识主机计算机和端口的技术。

此外,还将介绍如何使用库函数来获取特定主机名的 IP 地址和与特定服务名对应的端口号,其中对主机名的讨论还包括了对域名系统( DNS)的描述,域名系统是一个分布式数据库,它将主机名映射到IP 地址以及将 IP 地址映射到主机名。

59.1 Internet domain socket

Internet domain 流 socket 是基于 TCP 之上的,它们提供了可靠的双向字节流通信信道。Internet domain 数据报 socket 是基于 UDP 之上的。 UDP socket 与之在 UNIX domain 中的对应实体类似,但需要注意下列差别。

1.UNIX domain 数据报 socket 是可靠的, 但 UDP socket 则是不可靠的—数据报可能会丢失、重复或到达的顺序与它们被发送的顺序不同。

2.在一个 UNIX domain 数据报 socket 上发送数据会在接收 socket 的数据队列为满时阻塞。与之不同的是,使用 UDP 时如果进入的数据报会使接收者的队列溢出,那么数据报就会静默地被丢弃。

59.2 网络字节序

IP 地址和端口号是整数值。在将这些值在网络中传递时碰到的一个问题是不同的硬件结构会以不同的顺序来存储一个多字节整数的字节。从图 59-1 中可以看出,存储整数时先存储(即在最小内存地址处)最高有效位的被称为大端,那些先存储最低有效位的被称为小端。小端架构中最值得关注的是 x86。其他群大多数架构都是大端的。一些硬件结构可以在这两种格式之间切换。在特定主机上使用的字节序被称为主机字节序。 由于端口号和 IP 地址必须在网络中的所有主机之间传递并且需要被它们所理解,因此必须要使用一个标准的字节序。这种字节序被称为网络字节序,它是大端的。 在本章后面将会介绍各种用于将主机名(如 www.kernel.org)和服务名(如 http)转换成对应的数字形式的函数。这些函数一般会返回用网络字节序表示的整数,并且可以直接将这些整数复制进一个 socket 地址结构的相关字段中。有时候可能会直接使用 IP 地址和端口号的整数常量形式, 如可能会选择将端口号硬编码进程序中,或者将端口号作为一个命令行参数传递给程序,或者在指定一个 IPv4 地址时使用诸如INADDR_ANY 和 INADDR_LOOPBACK 之类的常量。这些值在 C 中是按照主机的规则来表示的,因此它们是主机字节序的, 在将它们存储进 socket 地址结构中之前需要将这些值转换成网络字节序。 htons()、 htonl()、 ntohs()以及 ntohl()函数被定义(通常为宏)用来在主机和网络字节序之间转换整数。

#include<arpa/inet.h>
uint16_t htons(uint16_t host_uint16);
uint32_t htonl(uint32_t host_uint32);
uint16_t ntohs(uint16_t net_uint16);
uint32_t ntohl(uint32_t net_uint32);

在早期,这些函数的原型如下。这揭示出了函数名的由来—在本例中是 host to network long。 在大多数实现 socket 的早期系统中,短整数是 16 位的,长整数是 32 位的。但在现代系统中这种论断已经不再正确了(至少对 于长整数是这样的), 因此上面给出的原型实际上是为这些函数所处理的类型提供了更加精确的定义,尽管所使用的名称未发生变化。 uint16_t 和 uint32_t 数据类型是 16 位和 32 位的无符号整数。

严格地讲,只需要在主机字节序与网络字节序不同的系统上使用这四个函数,但开发人员应该总是使用这些函数,这样程序就能够在不同的硬件结构之间移植了。在主机字节序与网络字节序一样的系统上,这些函数只是简单地原样返回传递给它们的参数。

59.3 数据表示

在编写网络程序时需要清楚不同的计算机架构使用不同的规则来表示各种数据类型。本章之前已经指出过整数类型可以以大端或小端的形式存储。 此外, 还存在其他的差别, 如 C long数据类型在一些系统中可能是 32 位的,但在其他系统上可能是 64 位的。当考虑结构时,问题就更加复杂了,因为不同的实现采用了不同的规则来将一个结构中的字段对齐到主机系统的地址边界,从而使得字段之间的填充字节数量是不同的。

由于在数据表现上存在这些差异,因此在网络中的异构系统之间交换数据的应用程序必须要采用一些公共规则来编码数据。发送者必须要根据这些规则来对数据进行编码,而接收者则必须要遵循同样的规则对数据进行解码。将数据变成一个标准格式以便在网络上传输的过程被称为信号编集(marshalling)。目前,存在多种信号编集标准,如 XDR(ExterExternalData Representation,在 RFC 1014 中描述)、 ASN.1-BER( Abstract SyntaxNotation 1, http://www.asn1.org/)、 CORBA以及 XML。一般来讲,这些标准会为每一种数据类型都定义一个固定的格式(如定义了字节序和使用的位数)。除了按照所需的格式进行编码之外,每一个数据项都需要使用额外的字段来标识其类型(以及可能的话还会加上长度)。 然而,一种比信号编集更简单的方法通常会被采用:将所有传输的数据编码成文本形式,其中数据项之间使用特定的字符来分隔开,这个特定的字符通常是换行符。这种方法的一个优点是可以使用 telnet 来调试一个应用程序。要完成这项任务需要使用下面的命令。

telnet host port

接着可以输入一行传给应用程序的文本并查看应用程序发来的响应, 在 59.11 节中将会演示这项技术

如果将在一个流 socket 上传输的数据编码成使用换行符分隔的文本,那么定义一个诸如readLine()之类的函数将是比较便捷的,如程序清单 59-1 所示。

#include "readline.h"
ssize_t readLine(int fd,void *buffer,size_t n);

readLine()函数从文件描述符参数 fd 引用的文件中读取字节直到碰到换行符为止。输入字节序列将会返回在 buffer 指向的位置处,其中 buffer 指向的内存区域至少为 n 字节。返回的字符串总是以 null 结尾,因此实际上至多有(n– 1)个字节会返回。在成功时, readLine()会返回放入 buffer 的数据的字节数,结尾的 null 字节不会计算在内。

如果在遇到换行符之前读取的字节数大于或等于( n– 1),那么 readLine()函数会丢弃多余的字节(包括换行符)。如果在前面的( n– 1)字节中读取了换行符,那么在返回的字符串中就会包含这个换行符。(因此可以通过检查在返回的 buffer 中结尾 null 字节前是否是一个换行符来确定是否有字节被丢弃了。 )采用这种方法之后,将输入以行为单位进行处理的应用程序协议就不会将一个很长的行处理成多行了。当然,这可能会破坏协议,因为两端的应用程序不再同步了。另一种做法是让 readLine()只读取足够的字节数来填充提供的缓冲器,而将到下一行新行为止的剩余字节留给下一个 readLine()调用。在这种情况下, readLine()的调用者需要处理读取部分行的情况。

59.4 Internet socket 地址

Internet domain socket 地址有两种: IPv4 和 IPv6。

IPv4 socket 地址: struct sockaddr_in

一个 IPv4 socket 地址会被存储在一个 sockaddr_in 结构中,该结构在<netinet/in.h>中进行 定义,具体如下。

struct in_addr{
   in_addr_t s_addr;/*ipv4地址*/
};
struct sockaddr_in{
   sa_family_t sin_family;/*值总为 AF_INET*/
   in_port_t sin_port;    /*端口号*/
   struct in_addr sin_addr;/*ipv4地址*/
   unsigned char __pad[X];
};

在 56.4 节中曾讲过普通的 sockaddr 结构中有一个字段来标识 socket domain,该字段对应于sockaddr_in 结构中的 sin_family 字段,其值总为 AF_INET。 sin_port 和 sin_addr 字段是端口号和 IP 地址,它们都是网络字节序的。 in_port_t 和 in_addr_t 数据类型是无符号整型,其长度分别为 16 位和 32 位。

IPv6 socket 地址: struct sockaddr_in6

与 IPv4 地址一样,一个 IPv6 socket 地址包含一个 IP 地址和一个端口号,它们之间的差别在于 IPv6 地址是 128 位而不是 32 位的。 一个 IPv6 socket 地址会被存储在一个 sockaddr_in6结构中,该结构在<netinet/in.h>中进行定义,具体如下。sin_family 字段会被设置成 AF_INET6。 sin6_port 和 sin6_addr 字段分别是端口号和 IP地址。( uint8_t 数据类型被用来定义 in6_addr 结构中字节的类型,它是一个 8 位的无符号整型。 )剩余的字段 sin6_flowinfo 和 sin6_scope_id 则超出了本书的范围,在本书给出所有例子中都会将它们设置为 0。 sockaddr_in6 结构中的所有字段都是以网络字节序存储的。 IPv6 和 IPv4 一样也有通配和回环地址,但它们的用法要更加复杂一些,因为 IPv6 地址是存储在数组中的(并没有使用标量类型),下面将会使用 IPv6 通配地址( 0::0)来说明这一点。系统定义了常量 IN6ADDR_ANY_INIT 来表示这个地址,具体如下。 在 Linux 上,头文件中的一些细节与本节中的描述是不同的。特别地, in6_addr 结构包含了一个 union 定义将128 位的 IPv6 地址划分成 16 字节或八个 2 字节的整数或四个 32 字节的整数。由于存在这样的定义,因此 glibc 提供的 IN6ADDR_ANY_INIT 常量的定义实际上比正文中给出的定义多了一组嵌套的花括号。 在变量声明的初始化器中可以使用 IN6ADDR_ANY_INIT 常量,但无法在一个赋值语句的右边使用这个常量,因为 C 语法并不允许在赋值语句中使用一个结构化的常量。取而代之的做法是必须要使用一个预先定义的变量 in6addr_any, C 库会按照下面的方式对该变量进行初始化。

因此可以像下面这样使用通配地址来初始化一个 IPv6 socket 地址。IPv6 环回地址( ::1)的对应常量和变量是 IN6ADDR_LOOPBACK_INIT 和 in6addr_loopback。与 IPv4 中相应字段不同的是 IPv6 的常量和变量初始化器是网络字节序的, 但就像上面给出的代码那样,开发人员仍然必须要确保端口号是网络字节序的。 如果 IPv4 和 IPv6 共存于一台主机上,那么它们将共享同一个端口号空间。这意味着如果一个应用程序将一个 IPv6 socket 绑定到了 TCP 端口 2000 上(使用 IPv6 通配地址),那么 IPv4 TCP socket 将无法绑定到同一个端口上。 ( TCP/IP 实现确保位于其他主机上的socket 能够与这个 socket 进行通信,不管那些主机运行的是 IPv4 还是 IPv6。 )

sockaddr_storage 结构

在 IPv6 socket API 中新引入了一个通用的 sockaddr_storage 结构,这个结构的空间足以存储任意类型的 socket 地址(即可以将任意类型的 socket 地址结构强制转换并存储在这个结构中)。特别地,这个结构允许透明地存储 IPv4 或 IPv6 socket 地址,从而删除了代码中的 IP 版本依赖性。 sockaddr_storage 结构在 Linux 上的定义如下所示。

59.5 主机和服务转换函数概述

计算机以二进制形式来表示 IP 地址和端口号,但人们发现名字比数字更容易记忆。使用符号名还能有效地利用间接关系,用户和程序可以继续使用同一个名字,即使底层的数字值发生了变化也不会受到影响。 主机名和连接在网络上的一个系统(可能拥有多个 IP 地址)的符号标识符。服务名是端口号的符号表示。

主机地址和端口的表示有下列两种方法。

1.主机地址可以表示为一个二进制值或一个符号主机名或展现格式( IPv4 是点分十进制, IPv6 是十六进制字符串)。

2.端口号可以表示为一个二进制值或一个符号服务名

格式之间的转换工作可以通过各种库函数来完成。本节将对这些函数进行简要的小结。 下面几个小节将会详细描述现代 API(inet_ntop()、 inet_pton()、 getaddrinfo()、 getnameinfo()等)。 在 59.13 节中将会简要地讨论一下被废弃的 API( inet_aton()、 inet_ntoa()、 gethostbyname()、 getservbyname()等)。

在二进制和人类可读的形式之间转换 IPv4 地址

inet_aton()和 inet_ntoa()函数将一个 IPv4 地址在点分十进制表示形式和二进制表示形式之间进行转换。这里介绍这些函数的主要原因是读者在遗留代码中可能会看到这些函数。现在它们已经被废弃了。需要完成此类转换工作的现代程序应该使用接下来描述的函数。

在二进制和人类可读的形式之间转换 IPv4 和 IPv6 地址

inet_pton()和 inet_ntop()与 inet_aton()和 inet_ntoa()类似,但它们还能处理 IPv6 地址。它们将二进制 IPv4 和 IPv6 地址转换成展现格式—即以点分十进制表示或十六进制字符串表示, 或将展现格式转换成二进制 IPv4 和 IPv6 地址。

由于人类对名字的处理能力要比对数字的处理能力强,因此通常偶尔才会在程序中使用这些函数。 inet_ntop()的一个用途是产生 IP 地址的一个可打印的表示形式以便记录日志。在有些情况下,最好使用这个函数而不是将一个 IP 地址转换(“解析”)成主机名,其原因如下。

1.将一个 IP 地址解析成主机名可能需要向一台 DNS 服务器发送一个耗时较长的请求。

2.在一些场景中,可能并不存在一个 DNS( PTR)记录将 IP 地址映射到对应的主机名上。

本 节 在 介 绍 执 行 二 进 制 表 示 与 对 应 的 符 号 名 之 间 的 转 换 工 作 的 getaddrinfo() 和getnameinfo()之前(59.6 节)先介绍这些函数主要是因为它们提供的更加简单的 API,这样就能快速给出一些正常工作的使用 Internet domain socket 的例子。

主机和服务名与二进制形式之间的转换(已过时)

gethostbyname()函数返回与主机名对应的二进制 IP 地址, getservbyname()函数返回与服务名对应的端口号。对应的逆向转换是由 gethostbyaddr()和 getservbyport()来完成的。这里之所以要介绍这些函数是因为它们在既有代码中被广泛使用,但现在它们已经过时了。 ( SUSv3将这些函数标记为过时的, SUSv4 删除了它们的规范。 )新代码应该使用 getaddrinfo()和getnameinfo()函数(稍后介绍)来完成此类转换。

主机和服务名与二进制形式之间的转换(现代的)

getaddrinfo()函数是 gethostbyname()和 getservbyname()两个函数的现代继任者。 给定一个主机名和一个服务名, getaddrinfo()会返回一组包含对应的二进制IP 地址和端口号的结构。与gethostbyname()不同, getaddrinfo()会透明地处理 IPv4 和 IPv6 地址。因此使用这个函数可以编写不依赖于 IP 版本的程序。所有新代码都应该使用 getaddrinfo()来将主机名和服务名转换成二进制表示。 getnameinfo()函数执行逆向转换, 即将一个 IP 地址和端口号转换成对应的主机名和服务名。使用 getaddrinfo()和 getnameinfo()还可以在二进制 IP 地址与其展现格式之间进行转换。 在 59.10 节中讨论 getaddrinfo()和 getnameinfo()之前需要对 DNS( 59.8 节)和/etc/services文件( 59.9 节)进行描述。 DNS 允许协作服务器维护一个将二进制 IP 地址映射到主机名和将主机名映射到二进制 IP 地址的分布式数据库。 诸如 DNS 之类的系统的存在对于因特网的运转是非常关键的,因为对浩瀚的因特网主机名进行集中管理是不可能的。 /etc/services 文件将端口号映射到符号服务名。

59.6 inet_pton()和 inet_ntop()函数

inet_pton()和 inet_ntop()函数允许在 IPv4 和 IPv6 地址的二进制形式和点分十进制表示法或十六进制字符串表示法之间进行转换。

#include<arpa/inet.h>
int inet_pton(int domain,const char *src_str,void *addrptr);
const char *inet_ntop(int domain,const void *addrptr,char *dst_str,size_t len);

这些函数名中的 p 表示“展现( presentation)”, n 表示“网络( network)”。展现形式是人类可读的字符串如: 204.152.189.116( IPv4 点分十进制地址); ::1( IPv6 冒号分隔的十六进制地址); ::FFFF:204.152.189.116( IPv4 映射的 IPv6 地址)。 inet_pton()函数将 src_str 中包含的展现字符串转换成网络字节序的二进制 IP 地址。 domain 参数应该被指定为 AF_INET 或 AF_INET6。转换得到的地址会被放在 addrptr 指向的结构中,它应该根据在 domain 参数中指定的值指向一个 in_addr 或 in6_addr 结构。

inet_ntop()函数执行逆向转换。 同样, domain 应该被指定为 AF_INET 或 AF_INET6,addrptr 应该指向一个待转换的 in_addr 或 in6_addr 结构。得到的以 null 结尾的字符串会被放置在 dst_str 指向的缓冲器中。 len 参数必须被指定为这个缓冲器的大小。 inet_ntop()在成功时会返回 dst_str。如果 len 的值太小了,那么 inet_ntop()会返回 NULL 并将 errno设置成 ENOSPC。 要正确计算 dst_str 指向的缓冲器的大小可以使用在<netinet/in.h>中定义的两个常量。 这些常量标识出了 IPv4 和 IPv6 地址的展现字符串的最大长度(包括结尾的 null 字节)。 下一节将会给出使用 inet_pton()和 inet_ntop()的例子。

59.8 域名系统(DNS)

在 59.10 节中将会介绍获取与一个主机名对应的 IP 地址的 getaddrinfo()函数和执行逆向转换的 getnameinfo()函数,但在介绍这些函数之前需要解释如何使用 DNS 来维护主机名和 IP地址之间的映射关系。 在 DNS 出现以前,主机名和 IP 地址之间的映射关系是在一个手工维护的本地文件/etc/hosts 中进行定义的,该文件包含了形如下面的记录。 gethostbyname()函数(被 getaddrinfo()取代的函数)通过搜索这个文件并找出与规范主机名(即主机的官方或主要名称)或其中一个别名(可选的,以空格分隔)匹配的记录来获取一个IP 地址。 然而, /etc/hosts 模式的扩展性交叉,并且随着网络中主机数量的增长,这种方式已经变得不太可行了。

DNS 被设计用来解决这个问题。 DNS 的关键想法如下。

1.将主机名组织在一个层级名空间中。 DNS 层级中的每一个节点都有一个标签(名字),该标签最多可包含 63 个字符。层级的根是一个无名子的节点,即“匿名节点”。

2.一个节点的域名由该节点到根节点的路径中所有节点的名字连接而成,各个名字之间用点( .)分隔。如 google.com 是节点 google 的域名。

3.完全限定域名( fully qualified domain name, FQDN),如 www.kernel.org.,标识出了层级中的一台主机。区分一个完全限定域名的方法是看名字是否已点结尾,但在很多情况下这个点会被省略。

4.没有一个组织或系统会管理整个层级。相反,存在一个 DNS 服务器层级,每台服务器管理树的一个分支(一个区域)。通常,每个区域都有一个主要主名字服务器。此外,还包含一个或多个从名字服务器(有时候也被称为次要主名字服务器),它们在主要主名字服务器崩溃时提供备份。区域本身可以被划分成一个个单独管理的更小的 区域。当一台主机被添加到一个区域中或主机名到 IP 地址之间的映射关系发生变化时,管理员负责更新本地名字服务器上的名字数据中的对应名字。 (无需手动更改层级中其他名字服务器数据库)。

4.当一个程序调用 getaddrinfo()来解析(即获取 IP 地址)一个域名时, getaddrinfo()会使用一组库函数( resolver 库)来与本地的 DNS 服务器通信。如果这个服务器无法提供所需的信息,那么它就会与位于层级中的其他 DNS 服务器进行通信以便获取信息。

有时候,这个解析过程可能会花费很多时间, DNS 服务器采用了缓存技术来避免在查询常见域名时所发生的不必要的通信。 使用上面的方法使得 DNS 能够处理大规模的名空间,同时无需对名字进行集中管理。

递归和迭代的解析请求

DNS 解析请求可以分为两类:递归和迭代。在一个递归请求中,请求者要求服务器处理整个解析任务,包括在必要的时候与其他 DNS 服务器进行通信的任务。当位于本地主机上的一个应用程序调用 getaddrinfo()时,该函数会向本地 DNS 服务器发起一个递归请求。如果本地 DNS 服务器自己并没有相关信息来完成解析,那么它就会迭代地解析这个域名。

下 面 通 过 一 个 例 子 来 解 释 迭代 解 析 。 假 设 本 地 DNS 服 务 器 需 要 解 析 一 个 名 字www.otago.ac.nz。要完成这个任务,它首先与每个 DNS 服务器都知道的一小组根名字服务器中的一个进行通信。 (使用命令 dig . NS 或从网页 http://www.root-servers.org/上可以获取这组服务器列表。 )给定名字 www.otago.ac.nz,根名字服务器会告诉本 DNS 服务器到其中一台 nzDNS 服务器上查询。然后本地 DNS 服务器会在 nz 服务器上查询名字 www.otago.ac.nz,并收到一个到 ac.nz 服务器上查询的响应。之后本地 DNS 服务器会在 ac.nz 服务器上查询名字www.otago.ac.nz 并被告知查询 otago.ac.nz 服务器。 最后本地 DNS 服务器会在 otago.ac.nz 服务器上查询 www.otago.ac.nz 并获取所需的 IP 地址。

如果向 gethostbyname()传递了一个不完整的域名,那么解析器在解析之前会尝试补全。域名补全的规则是在/etc/resolv.conf 中定义的。在默认情况下,解析器至少会使用本机的域名来补全。 例如, 如果登录机器 oghma.otago.ac.nz 并输入了命令 ssh octavo,得到的 DNS 查询将会以 octavo.otago.ac.nz 作为其名字。

顶级域

紧跟在匿名根节点下面的节点被称为顶级域( TLD)。 (在这些之下的节点是二级域,以此类推。 ) TLD 可以分为两类:通用的和国家的。 在历史上存在七个通用的 TLD,其中大多数都可以被看成是国际的。在图 59-2 中给出了其中 4 个原始通用的 TLD。另外三个是 int、 mil 和 gov,其中后两个是保留给美国使用的。近来,一组新的通用 TLD 被添加进来了(如 info、 name 以及 museum)。 每个国家都有一个对应的国家(或地理) TLD(在 ISO 3166-1 中进行了标准化),它是一个由 2个字符组成的名字。在图 59-2 中给出了其中一些: de(德国, Deutschland)、 eu(欧洲联盟的超国家地理 TLD)、 nz(新西兰)以及 us(美利坚合众国)。一些国家将它们的 TLD 划分成一组二级域名,其划分方式与通用域类似。如新西兰用 ac.nz(学术机构)、 co.nz(商业)以及 govt.nz(政府)。

59.9 /etc/services 文件

正如在 58.6.1 节中指出的那样,众所周知的端口号是由 IANA 集中注册的,其中每个端口都有一个对应的服务名。由于服务号是集中管理并且不会像 IP 地址那样频繁变化,因此没有必要采用 DNS 服务器来管理它们。 相反, 端口号和服务名会记录在文件/etc/services 中。 getaddrinfo()和 getnameinfo()函数会使用这个文件中的信息在服务名和端口号之间进行转换。

协议通常是 tcp 或 udp。可选的(以空格分隔)别名指定了服务的其他名字。此外,每一行中都可能会包含以#字符打头的注释。

正如之前指出的那样,一个给定的端口号引用 UDP 和 TCP 的的唯一实体,但 IANA 的策略是将两个端口都分配给服务,即使服务只使用了其中一种协议。如 telnet、 ssh、 HTTP 以及SMTP,它们都只使用 TCP,但对应的 UDP 端口也被分配给了这些服务。相应地, NTP 只使用 UDP,但 TCP 端口 123 也被分配给了这个服务。在一些情况中,一个服务既会使用 TCP也会使用 UDP, DNS 和 encho 就是这样的服务。最后,还有一些极少出现的情况会将数值相同的 UDP 和 TCP 端口分配给不同的服务,如 rsh 使用 TCP 端口 514,而 syslog daemon(37.5 节) 则是使用了 UDP 端口 514。这是因为这些端口在采用现行的 IANA 策略之前就分配出去了。

原文地址:https://www.cnblogs.com/wangbin2188/p/14888497.html