0%

Network 服务端响应返回浏览器

介绍了服务器从接收数据包并由 MAC、IP、TCP 模块逐步解析合并,创建 Socket 并与客户端通信的大致流程。

下图为第六章内容概览

服务器概览

CS的数据收发阶段异同

客户端和服务端区别体现在如何调用 Socket 库上。首先,客户端的数据收发需要经过下面 4 个阶段。

  1. 创建套接字(创建套接字阶段)
  2. 用管道连接服务器端的套接字(连接阶段)
  3. 收发数据(收发阶段)
  4. 断开管道并删除套接字(断开阶段)

相对地,服务器是将阶段(2)改成了等待连接,具体如下。

  1. 创建套接字(创建套接字阶段)
  2. (2-1)将套接字设置为等待连接状态(等待连接阶段)
    (2-2)接受连接(接受连接阶段)
  3. 收发数据(收发阶段)
  4. 断开管道并删除套接字(断开阶段)

套接字副本

Socket 建立连接后,先为 Socket 复制出新的副本,并让客户端的 Socket 连接到这个副本上。

在复制出一个新的套接字之后,原来那个处于等待连接状态的套接字会怎么样呢?其实它还会以等待连接的状态继续存在,当再次调用 accept,客户端连接包到达时,它又可以再次执行接受连接操作。接受新的连接之后,和刚才一样,协议栈会为这个等待连接的套接字复制一个新的副本,然后让客户端连接到这个新的副本套接字上。像这样每次为新的连接创建新的套接字就是这一步操作的一个关键点。如果不创建新副本,而是直接让客户端连接到等待连接的套接字上,那么就没有套接字在等待连接了,这时如果有其他客户端发起连接就会遇到问题。为了避免出现这样的情况,协议栈采用了这种创建套接字的新副本,并让客户端连接到这个新副本上的方法。此外,创建新套接字时端口号也是一个关键点。端口号是用来识别套接字的,因此我们以前说不同的套接字应该对应不同的端口号,但如果这样做,这里就会出现问题。因为在接受连接的时候,新创建的套接字副本就必须和原来的等待连接的套接字具有不同的端口号才行。这样一来,比如客户端本来想要连接 80 端口上的套接字,结果从另一个端口号返回了包,这样一来客户端就无法判断这个包到底是要连接的那个对象返回的,还是其他程序返回的。因此,新创建的套接字副本必须和原来的等待连接的套接字具有相同的端口号。

但是这样一来又会引发另一个问题。端口号是用来识别套接字的,如果一个端口号对应多个套接字,就无法通过端口号来定位到某一个套接字了。当客户端的包到达时,如果协议栈只看 TCP 头部中的接收方端口号是无法判断这个包到底应该交给哪个套接字的。

这个问题可以用下面的方法来解决,即要确定某个套接字时,使用服务器端和客户端 Socket 对应的 IP 和端口号。

服务器上可能存在多个端口号相同的套接字,但客户端的套接字都是对应不同端口号的,因此我们可以通过客户端的端口号来确定服务器上的某个套接字。不过,使用不同端口号的规则仅限一台客户端的内部,当有
多个客户端进行连接时,它们之间的端口号是可以重复的。因此,我们还必须加上客户端的 IP 地址才能进行判断。

说句题外话,既然通过客户端 IP 地址、客户端端口号、服务器 IP 地址、服务器端口号这 4 种信息可以确定某个套接字,那么要指代某个套接字时用这 4 种信息就好了,为什么还要使用描述符呢?这个问题很好,不
过我们无法用上面 4 种信息来代替描述符。原因是,在套接字刚刚创建好,还没有建立连接的状态下,这 4 种信息是不全的。此外,为了指代一个套接字,使用一种信息(描述符)比使用 4 种信息要简单。出于上面两个原因,应用程序和协议栈之间是使用描述符来指代套接字的。

使用描述符来指代套接字的原因如下。

  1. 等待连接的套接字中没有客户端IP 地址和端口号
  2. 使用描述符这一种信息比较简单

服务器的接收操作

MAC模块处理

网卡的 MAC 模块将网络包从信号还原为数字信息,校验 FCS 并存入缓冲区。网卡驱动会根据 MAC 头部判断协议类型,并将包交给相应的协议栈。

还原后的数字信息被保存在网卡内部的缓冲区中。在这个过程中,服务器的 CPU 并不是一直在监控网络包的到达,而是在执行其他的任务,因此 CPU 并不知道此时网络包已经到达了。但接下来的接收操作需要 CPU 来参与,因此网卡需要通过中断将网络包到达的事件通知给 CPU。

接下来,CPU 就会暂停当前的工作,并切换到网卡的任务。然后,网卡驱动会开始运行,从网卡缓冲区中将接收到的包读取出来,根据 MAC 头部的以太类型字段判断协议的种类,并调用负责处理该协议的软件。这
里,以太类型的值应该是表示 IP 协议,因此会调用 TCP/IP 协议栈,并将包转交给它。

实际的工作过程因操作系统的不同而不同,大多数情况下,网卡驱动并不会直接调用协议栈,而是先切换回操作系统,然后再由操作系统去调用协议栈,由协议栈继续执行接收操作。

IP模块处理

当网络包转交到协议栈时,IP 模块会首先开始工作,检查 IP 头部。IP 模块首先会检查 IP 头部的格式是否符合规范,然后检查接收方 IP 地址,查看包是不是发给自己的

服务器操作系统中内置了可实现路由器功能的软件,只要启用这一功能,服务器就可以像路由器一样工作。

服务器也可以启用类似防火墙的包过滤功能,这时,在包转发的过程中还会对包进行检查,并丢弃不符合规则的包。

当服务器启用类似路由器的包转发功能时,对于不是发给自己的包,会像路由器一样根据路由表对包进行转发。确认包是发给自己的之后,接下来需要检查包有没有被分片。检查 IP 头部的内容就可以知道是否分片,如果是分片的包,则将包暂时存放在内存中,等所有分片全部到达之后将分片组装起来还原成原始包;如果没有分片,则直接保留接收时的样子,不需要进行重组。到这里,我们就完成了包的接收。

接下来需要检查 IP 头部的协议号字段,并将包转交给相应的模块。例如,如果协议号为 06(十六进制),则将包转交给 TCP 模块;如果是 11(十六进制),则转交给 UDP 模块。这里我们假设这个包被交给 TCP 模块处理,然后继续往下看。

TCP模块如何处理连接包

TCP 头部中的控制位 SYN 为 1 时,发起连接的包(下图 ①)。这时,TCP 模块会执行接受连接的操作,不过在此之前,需要先检查包的接收方端口号,并确认在该端口上有没有与接收方端口号相同且正在处于等待连接状态的套接字。如果指定端口号没有等待连接的套接字,则向客户端返回一个表示接收方端口不存在等待连接的套接字的 ICMP 错误通知消息。

如果存在等待连接的套接字,则为这个套接字复制一个新的副本,并将发送方 IP 地址、端口号、序号初始值、窗口大小等必要的参数写入这个套接字中,同时分配用于发送缓冲区和接收缓冲区的内存空间。然后生成代表接收确认的 ACK 号,用于从服务器向客户端发送数据的序号初始值,表示接收缓冲区剩余容量的窗口大小,并用这些信息生成 TCP 头部,委托 IP 模块发送 b给客户端。

这个包到达客户端之后,客户端会返回表示接收确认的 ACK 号,当这个 ACK 号返回服务器后,连接操作就完成了。这时,服务器端的程序应该进入调用 accept 的暂停状态,当将新套接字的描述符转交给服务器程序之后,服务器程序就会恢复运行。

TCP模块如何处理数据包

当数据包到达时 TCP 模块是如何处理的(上图 ②)。首先,TCP 模块会检查收到的包对应哪一个套接字。在服务器端,可能有多个已连接的套接字对应同一个端口号,因此仅根据接收方端口号无法找到特定的套接字。这时我们需要根据 IP 头部中的发送方 IP 地址和接收方 IP 地址,以及 TCP 头部中的接收方端口号和发送方端口号找到匹配的套接字。找到 4 种信息全部匹配的套接字之后,TCP 模块会对比该套接字中保存的数据收发状态和收到的包的 TCP 头部中的信息是否匹配,以确定数据收发操作是否正常。具体来说,就是根据套接字中保存的上一个序号和数据长度计算下一个序号,并检查与收到的包的 TCP 头部中的序号是否一致。如果两者一致,就说明包正常到达了服务器,没有丢失。这时,TCP 模块会从包中提出数据,并存放到接收缓冲区中,与上次收到的数据块连接起来。这样一来,数据就被还原成分包之前的状态了。需要注意的是,拼合数据块的操作在每次收到数据包时都会进行,而不是等所有数据全部接受完毕之后再统一拼合的。当收到的数据进入接收缓冲区后,TCP 模块就会生成确认应答的 TCP 头部,并根据接收包的序号和数据长度计算出 ACK 号,然后委托 IP 模块发送给客户端。

在返回 ACK 号之前,会先等待一段时间,看看能不能和后续的应答包合并。

收到的数据块进入接收缓冲区,意味着数据包接收的操作告一段落了。接下来,应用程序会调用 Socket 库的 read(上图 ③)来获取收到的数据,这时数据会被转交给应用程序。如果应用程序不来获取数据,则数据会被一直保存在缓冲区中,但一般来说,应用程序会在数据到达之前调用 read 等待数据到达,在这种情况下,TCP 模块在完成接收操作的同时,就会执行将数据转交给应用程序的操作。

然后,TCP 模块处理数据包流程就结束了,控制流程会转移到服务器程序,对收到的数据进行处理,也就是检查 HTTP 请求消息的内容,并根据请求的内容向浏览器返回相应的数据。

TCP模块的断开操作

当数据收发完成后,便开始执行断开操作。在 TCP 协议的规则中,断开操作可以由客户端或服务器任何一方发起,具体的顺序是由应用层协议决定的。Web 中,这一顺序随 HTTP 协议版本不同而不同,在 HTTP1.0 中,是服务器先发起断开操作。

这时,服务器程序会调用 Socket 库的 close,TCP 模块会生成一个控制位 FIN 为 1 的 TCP 头部,并委托 IP 模块发送给客户端。当客户端收到这个包之后,会返回一个 ACK 号。接下来客户端调用 close,生成一个
FIN 为 1 的 TCP 头部发给服务器,服务器再返回 ACK 号,这时断开操作就完成了。HTTP1.1 中,是客户端先发起断开操作,这种情况下只要将客户端和服务器的操作颠倒一下就可以了。

无论哪种情况,当断开操作完成后,套接字会在经过一段时间后被删除。

Web服务器程序解释请求消息并作出响应

将请求的URI转换为实际的文件名

下图展示了服务器程序的工作过程,这个过程不仅限于 Web 服务器,对于各种服务器程序都是共通的,收发数据的过程也是大同小异的。各种服务器程序的不同点在于图中(b)客户端通信部分的第一行调用 read
后面的如下部分。

[ 处理请求消息内容 ]

下图中只写了一行,但实际上这里应该是一组处理各种工作的程序A,或者说这里才是服务器程序的核心部分。接下来让我们来对这一部分进行探索。

Web 服务器中,下图的 read 获取的数据内容就是 HTTP 请求消息。服务器程序会根据收到的请求消息中的内容进行相应的处理,并生成响应消息,再通过 write 返回给客户端。请求消息包括一个称为“方法”的命令,以及表示数据源的 URI(文件路径名),服务器程序会根据这些内容向客户端返回数据,但对于不同的方法和 URI,服务器内部的工作过程会有所不同。下面我们从简单的开始依次进行介绍。

最简单的一种情况如上图中的例子所示,请求方法为 GET,URI 为一个 HTML 文件名。这种情况只要从文件中读出 HTML 文档,然后将其作为响应消息返回就可以了。不过,按照 URI 从磁盘上读取文件并没有这
么简单。如果完全按照 URI 中的路径和文件名读取,那就意味着磁盘上所有的文件都可以访问,Web 服务器的磁盘内容就全部暴露了,这很危险。

对于 UNIX 系操作系统的服务器来说,URI 的路径名和磁盘文件的路径名格式是相同的,对于 Windows 也只要将“/”替换成“\”就可以了,因此我们可以将 URI 当作是磁盘文件的路径名。

因此,这里需要一些特殊的机制。Web 服务器公开的目录其实并不是磁盘上的实际目录,而是如下图这样的虚拟目录,而 URI 中写的就是在这个虚拟目录结构下的路径名。因此,当读取文件时,需要先查询虚拟目录与实际目录的对应关系,并将 URI 转换成实际的文件名后,才能读取文件并返回数据。

有些 Web 服务器程序还具有文件名改写功能,只要设置好改写的规则,当 URI 中的路径符合改写规则时,就可以将 URI 中的文件名改写成其他的文件名进行访问。当出于某些原因 Web 服务器的目录和文件名发生变化,但又希望用户通过原来的网址进行访问的时候,这个功能非常有用。

运行CGI程序

如果 URI 指定的文件内容为 HTML 文档或图片,那么只要直接将文件内容作为响应消息返回客户端就可以了。但 URI 指定的文件内容不仅限于 HTML 文档,也有可能是一个程序。在这个情况下,服务器不会直接返回文件内容,而是会运行这个程序,然后将程序输出的数据返回给客户端。Web 服务器可以启动的程序有几种类型,每种类型的具体工作方式有所区别,下面我们来看看 CGI 程序是如何工作的。

当需要 Web 服务器运行程序时,浏览器发送的 HTTP 请求消息内容会和访问 HTML 文档时不太一样,我们先从这里开始讲。Web 服务器运行程序时,一般来说浏览器会将需要程序处理的数据放在 HTTP 请求消息中发送给服务器。这些数据有很多种类,例如购物网站订单表中的品名、数量、发货地址等,搜索引擎中输入的关键字也是一个常见的例子。

总之,浏览器需要在发送给 Web 服务器的请求消息中加入一些数据。我们在第 1 章曾经介绍过有两种加入数据的方法。一种是在 HTML 文档的表单中加上 method=”GET”,通过 HTTP 的 GET 方法,将输入的数据作为参数添加在 URI 后面发送给服务器。另一种方法是在 HTML 文档的表单中加上 method=”POST”,将数据放在 HTTP 请求消息的消息体中发送给服务器。

收到请求消息之后,Web 服务器会进行下面的工作。首先,Web 服务器会检查 URI 指定的文件名,看一看这个文件是不是一个程序。这里的判断方法是在 Web 服务器中事先设置好的,一般是通过文件的扩展名来进行判断,例如将.cgi、.php 等扩展名的文件设置为程序,当遇到这些文件时,Web 服务器就会将它们作为程序来对待。也可以设置一个存放程序的目录,将这个目录下的所有文件都作为程序来对待。此外,还可以根据文件的属性来进行判断。

如果判断要访问的文件为程序文件,Web 服务器会委托操作系统运行这个程序,然后从请求消息中取出数据并交给运行的程序。如果方法为 GET,则将 URI 后面的参数传递给程序;如果方法为 POST,则将消息体
中的数据传递给程序(如下图)。

接下来,运行的程序收到数据后会进行一系列处理,并将输出的数据返回给 Web 服务器。程序可以返回各种内容,无论如何,为了将数据处理的结果返回给客户端,首先需要将它返回给 Web 服务器。这些输出的数据一般来说会嵌入到 HTML 文档中,因此 Web 服务器可以直接将其作为响应消息返回给客户端。输出数据的内容是由运行的程序生成的,Web 服务器并不过问,也不会去改变程序输出的内容。

Web服务器的访问控制

正如我们前面讲的,Web 服务器的基本工作方式就是根据请求消息的内容判断数据源,并从中获取数据返回给客户端,不过在执行这些操作之前,Web 服务器还可以检查事先设置的一些规则,并根据规则允许或禁止
访问。这种根据规则判断是否允许访问的功能称为访问控制,一些会员制的信息服务需要限制用户权限的时候会使用这一功能,公司里也可以利用访问控制只允许某些特定部门访问。

Web 服务器的访问控制规则主要有以下 3 种。

  1. 客户端 IP 地址
  2. 客户端域名
  3. 用户名和密码

以上规则可针对作为数据源的文件和目录进行设置,当收到客户端的请求消息时,服务器会根据 URI 判断数据源,并检查数据源对应的访问控制规则,只有允许访问时才读取文件或运行程序。下面我们来看一下设定
访问控制规则时,服务器是如何工作的。

首先是根据客户端 IP 地址设置的规则,这个情况很简单,在调用 accept 接受连接时,就已经知道客户端的 IP 地址了,只要检查其是否允许访问就可以了。

当根据客户端域名设置规则时,需要先根据客户端 IP 地址查询客户端域名,这需要使用 DNS 服务器。一般我们使用 DNS 服务器都是根据域名查询 IP 地址,其实根据 IP 地址反查域名也可以使用 DNS 服务器。具体来说,这个过程是这样的。收到客户端的请求消息后,Web 服务器(①)会委托协议栈告知包的发送方 IP 地址,然后用这个 IP 地址生成查询消息并发送给最近的 DNS 服务器(②)。接下来,DNS 服务器找出负责管辖该 IP 地址的 DNS 服务器,并将查询转发给它(③),查询到相应的域名之后返回结果(④),然后 Web 服务器端的 DNS 服务器再将结果转发给Web 服务器(⑤)。这样一来,我们就可以根据发送方 IP 地址查询到域名。接下来,为了保险起见,还需要用这个域名查询一下 IP 地址,看看结果与发送方 IP 地址是否一致(⑥)。这是因为有一种在 DNS 服务器上注册假域名的攻击方式,因此我们需要进行双重检查,如果两者一致则检查相应的访问控制规则,判断是否允许访问。从上图中可以看出,这种方式需要和 DNS 服务器进行多次查询,整个过程比较耗时,因此 Web 服务器的响应速度也会变慢。

如果用户名和密码已设置好,那么情况如下图。通常的请求消息中不包含用户名和密码,因此无法验证用户名和密码(①)。因此,Web 服务器会向用户发送一条响应消息,告诉用户需要在请求消息中放入用户名和密码(②)。浏览器收到这条响应消息后,会弹出一个输入用户名和密码的窗口,用户输入用户名和密码后(③),浏览器将这些信息放入请求消息中重新发送给服务器(④)。然后,Web 服务器查看接收到的用户名和密码与事先设置好的用户名和密码是否一致,以此判断是否允许访问,如果允许访问,则返回数据(⑤)。

返回响应消息

当服务器完成对请求消息的各种处理之后,就可以返回响应消息了。这里的工作过程和客户端向服务器发送请求消息时的过程相同。

首先,Web 服务器调用 Socket 库的 write,将响应消息交给协议栈。这时,需要告诉协议栈这个响应消息应该发给谁,但我们并不需要直接告知客户端的 IP 地址等信息,而是只需要给出表示通信使用的套接字的描述
符就可以了。套接字中保存了所有的通信状态,其中也包括通信对象的信息,因此只要有描述符就万事大吉了。

接下来,协议栈会将数据拆分成多个网络包,然后加上头部发送出去。这些包中包含接收方客户端的地址,它们将经过交换机和路由器的转发,通过互联网最终到达客户端。

浏览器接收响应消息并显示内容

通过响应的数据类型判断其中的内容

Web 服务器发送的响应消息会被分成多个包发送给客户端,然后客户端需要接收数据。首先,网卡将信号还原成数字信息,协议栈将拆分的网络包组装起来并取出响应消息,然后将消息转交给浏览器。这个过程和服务器的接收操作相同。接下来,我们来看一看浏览器是如何显示内容的。

要显示内容,首先需要判断响应消息中的数据属于哪种类型。Web 可以处理的数据包括文字、图像、声音、视频等多种类型,每种数据的显示方法都不同,因此必须先要知道返回了什么类型的数据,否则无法正确显示。

这时,我们需要一些信息才能判断数据类型,原则上可以根据响应消息开头的 Content-Type 头部字段的值来进行判断。这个值一般是下面这样的字符串。

Content-Type: text/html

其中“/”左边的部分称为“主类型”,表示数据的大分类;右边的“子类型”表示具体的数据类型。在上面的例子中,主类型是 text,子类型是 html。主类型和子类型的含义都是事先确定好的,下表列出了其中主要的一些类型。上面例子中的数据类型表示遵循 HTML 规格的 HTML 文档。

此外,当数据类型为文本时,还需要判断编码方式,这时需要用 charset 附加表示文本编码方式的信息,内容如下。

Content-Type: text/html; charset=utf-8

这里的 utf-8 表示编码方式为 Unicode,如果是 euc-jp 就表示 EUC 编码,iso-2022-jp 表示 JIS 编码,shift_jis 表示 JIS 编码。中文常用的编码包括 gb2312、gbk、gb18030、big5 等。

除了通过 Content-Type 判断数据类型,还需要检查 Content-Encoding 头部字段。如果消息中存放的内容是通过压缩或编码技术对原始数据进行转换得到的,那么 Content-Encoding 的值就表示具体的转换方式,通过这个字段的值,我们可以知道如何将消息中经过转换的数据还原成原始数据。

Content-Type 字段使用的表示数据类型的方法是在 MIME 规格中定义的,这个规格不仅用于 Web,也是邮件等领域中普遍使用的一种方式。不过这种方式也只不过是一种原则性的规范,要通过 Content-Type 准确判断数据类型,就需要保证 Web 服务器正确设置 Content-Type 的值,但现实中并非总是如此。如果 Web 服务器管理员不当心,就可能会因为设置错误导致 Content-Type 的值不正确。因此,根据原则检查 Content-Type 并不能确保总是能够准确判断数据类型。

MIME:Multipurpose Internet Mail Extensions,多用途因特网邮件扩充。原本是为在电子邮件中附加图片和附件等非文本信息而制定的一种规格,后来在 Web 的领域也得到了广泛使用。

因此,有时候我们需要结合其他一些信息来综合判断数据类型,例如请求文件的扩展名、数据内容的格式等。比如,我们可以检查文件的扩展名,如果为 .html 或 .htm 则看作是 HTML 文件,或者也可以检查数据的内容,如果是以<html>开头的则看作是 HTML 文档。不仅是 HTML 这样的文本文件,图片也是一样。图片是经过压缩的二进制数据,但其开头也有表示内容格式的信息,我们可以根据这些信息来判断数据的类型。不过,这部分的逻辑并没有一个统一的规格,因此不同的浏览器以及不同的版本都会有所差异。

浏览器显示网页内容!访问完成!

判断完数据类型,我们离终点就只有一步之遥了。接下来只要根据数据类型调用用于显示内容的程序,将数据显示出来就可以了。对于 HTML 文档、纯文本、图片这些基本数据类型,浏览器自身具有显示这些内容的
功能,因此由浏览器自身负责显示。

不同类型的数据显示操作的过程也不一样,我们以 HTML 文档为例来介绍。HTML 文档通过标签表示文档的布局和字体等样式信息,浏览器需要解释这些标签的含义,按照指定的样式显示文档的内容。实际的显示操
作是由操作系统来完成的,浏览器负责对操作系统发出指令,例如在屏幕上的什么位置显示什么文字、使用什么样的字体等。

网页中还可以嵌入图片等数据,HTML 文档和图片等数据是分别存在在不同的文件中的,HTML 文档中只有表示图片引用的标签。在读取文档数据时,一旦遇到相应的标签,浏览器就会向服务器请求其中的图片文件。

这个请求过程和请求 HTML 文档的过程是一样的,就是在 HTTP 请求消息的 URI 中写上图片文件的文件名即可。将这个请求消息发送给 Web 服务器之后,Web 服务器就会返回图片数据了。接下来,浏览器会将图片嵌入到标签所在的位置。JPEG 和 GIF 格式的图片是经过压缩的,浏览器需要将其解压后委托操作系统进行显示。当然,为了避免图片和文字重叠,在显示文字的时候需要为图片留出相应的位置。

像 HTML 文档和图片等浏览器可自行显示的数据,就会按照上述方式委托浏览器在屏幕上显示出来。不过,Web 服务器可能还会返回其他一些类型的数据,如文字处理、幻灯片等应用程序的数据。这些数据无法由浏
览器自行显示,这时浏览器会调用相应的程序。这些程序可以是浏览器的插件,也可以是独立的程序,无论如何,不同类型的数据对应不同的程序,这一对应关系是在浏览器中设置好的,只要按照这一对应关系调用相应的程序,并将数据传递给它就可以了。然后,被调用的程序会负责显示相应的内容。

到这里,浏览器的显示操作就完成了,可以等待用户的下一个动作了。当用户点击网页中的链接,或者在网址栏中输入新的网址时,访问 Web 服务器的操作就又开始了。