从浏览器中输入网址到网页显示的探索之旅

前言

了解网络的全貌,对于经常和网络技术打交道的人来说,还是很有必要的。最近看了《网络是怎么连接的》,记录一下读书笔记。
现在就让我们开启从浏览器中输入网址到网页显示的探索之旅吧!

1 浏览器生成消息

1.1 对URI进行解析

用户在浏览器中输入网址后,浏览器的第一步工作就是对URI进行解析。
比如通过对http://www.wxample.com/dir1/file1.html 进行拆分,解析,就能知道要访问的是 www.example.com 这个web服务器上路径是/dir1/file1.html的文件了。

1.2 生成HTTP请求消息

确定了web服务器和文件名后,浏览器就根据这些信息来生成HTTP请求消息了。
http协议定义了客户端和服务器之间交互的消息内容和步骤。根据http协议的规定,请求消息和响应消息是这样的:
http消息

1.3 向DNS服务器查询Web服务器的IP地址

生成HTTP消息之后,浏览器还需要根据域名查询IP地址,才能委托操作系统发送消息。
我们的计算机的操作系统的socket库(socket库是用于调用网络功能的程序组件集合)中,存在DNS解析器。浏览器程序中调用解析器后,会委托给操作系统的协议栈执行发送消息的操作,然后通过网卡将消息发送给DNS服务器。然后DNS服务器会返回响应消息,其中就包含查询到的IP地址。解析器会取出IP地址,并将其写入浏览器指定的内存地址中。如下图:
DNS解析
那DNS服务器是怎么查询ip地址的呢?
DNS服务器中的所有信息都是按照域名以分层次的结构来保存的,最顶层为根域。比如,查询www.example.com对应的ip地址,客户端会先访问最近的一台DNS服务器,该服务器会在保存的记录中查找与查询的域名匹配的记录,如果有,就返回给客户端。如果没有,则从根域开始向下查找。根域DNS服务器中没有www.example.com这个域名,根据域名结构知道是属于com域的,则返回com域的DNS服务器的ip地址,以此类推,最后返回www.example.com的ip地址。

1.4 委托协议栈发送消息

知道了ip地址后,浏览器就可以委托操作系统的协议栈向目标ip发送消息了。浏览器将按照指定的顺序来调用Socket库中的程序组件。

  1. 创建套接字 <描述符> = socket(<使用IPv4>, ...);
  2. 将管道连接到服务器端的套接字上 connect(<描述符>, <服务器的IP地址和端口号>, ...);
  3. 收发数据 write(<描述符>, <发送数据>, ...); <接收数据长度> = read(<描述符>, ...);
  4. 断开管道并删除套接字 close(<描述符>);

那协议栈具体是怎么工作的呢?我们来继续探索。

2 协议栈通过TCP协议发送消息

消息收发操作

浏览器、邮件等一般应用程序收发数据时用TCP
DNS查询等收发较短的控制数据时用UDP

2.1 创建套接字

套接字的实体就是通信控制信息。在协议栈的内部有一块用于存放控制信息的内存空间,例如通信对象的IP地址、端口号、通信操作的进行状态等。这个内存空间就是套接字的实体。可以用netstat命令显示套接字内容,如下图
nestat
创建套接字的过程就是:

  1. 协议栈收到应用程序创建套接字的申请
  2. 分配用于存放套接字的内存空间
  3. 在内存空间中写入初始状态的控制信息
  4. 把表示这个套接字的描述符返回给应用程序

而远方的服务器在系统启动时就创建了套接字,等待客户端连接。

2.2 连接

因为套接字中只有初始状态的控制信息,浏览器调用socket库的connect时需要把服务器的IP地址和端口号等控制信息告知协议栈,客户端再将我们客户端的IP地址和端口号等信息(TCP头部)告知服务器。这个交换控制信息的过程就建立了连接。这个过程也就是TCP的“三次握手”。
三次握手

这个过程具体是这样的:

  1. 客户端创建TCP头部。
    客户端创建包含控制信息的头部(TCP头部),其中就有客户端和服务器的端口号。有了端口号,客户端的套接字就知道应该连接服务器的哪个套接字。并把头部中的控制位字段的 SYN比特设置为1,以表示连接。

    以下为TCP头部主要字段,字段具体的设置将会在下面章节提及。

字段名称 含义
发送方端口号 发送网络包的程序的端口号
接收方端口号 网络包的接收方程序的端口号
序号 发送数据的顺序编号,发送方告诉接收方 这是所有发送数据的第几个字节
ACK号 接收数据的顺序编号,接受方告诉发送方 已经收到了所有发送数据的第几个字节
控制位 该字段的每个比特分别为:URG、ACK(接收有效,通常表示已收到数据)、PSH、RST(异常中断)、SYN(连接)、FIN(断开连接)
  1. 客户端将信息传递给IP模块并委托它发送,到达服务器后服务器返回响应。
    通过网络到达服务器后,服务器的IP模块会把数据传递给TCP模块,TCP模块会根据头部的信息(端口号)找到对应的套接字,套接字会写入相关信息。然后,服务器的TCP模块返回响应,与步骤1的客户端一样在TCP头部设置端口号和 SYN比特,此外,还 设置ACK号并将ACK控制位设为1,表示已经接收到网络包。
  2. 客户端收到响应的网络包。
    网络包通过IP模块到达TCP模块,并通过TCP头部信息(控制位SYN比特是否为1)确认连接服务器是否成功。如果成功,则在套接字中写入服务器的IP地址、端口号等,并将状态(state)改为连接完毕(established)。然后客户端 设置ACK号并将ACK比特设为1,返回给服务器,表示已经收到网络包。服务器收到这个网络包后,连接操作就完成了。

2.3 收发数据

当控制流程从connect返回到应用程序的时候,接下来就是调用socket库的write将要发送的数据交给协议栈,协议栈收到数据后执行发送操作。

  1. 发送的时机。
    协议栈并不是一收到数据就发送出去的,而是会将数据存放在内部的发送缓冲区,直到一定量才会发送。这个数值根据两个因素决定:每个网络包能容纳的数据长度;应用程序发送数据的频率。这个值在不同操作系统上的设置是不同的。如果长度优先,则效率高,而延迟长;如果时间优先,则效率降低,延迟减少。此外,应用程序还可以指定选项,比如浏览器一般会使用不等待填满缓冲区直接发送的选项。
  2. 对较大的数据进行拆分
    当HTTP请求消息很长(如提交表单数据的POST请求),超过MSS长度时,发送缓冲区中的数据会被以MSS长度为单位拆分,添加TCP头部,放进单独的网络包中。

    MTU:一个网络包的最大长度,以太网中一般为1500字节
    MSS:数据包的最大长度,理论上为MTU除去头部长度

  3. 使用ACK号确认是否收到网络包
    确认的原理是这样的:
    在建立连接的第一次握手时,在将SYN设为1的同时,设置序号字段的值(序号的初始值)。
    TCP在拆分数据时,会计算这块数据相当于所有数据中的第几个字节(从初始值开始计算),并把这个数写到TCP头部的序号字段中。接收方收到网络包时,会计算数据长度(数据长度 = 网络包长度 - 头部长度),并把目前收到的数据总长度写入TCP头部的ACK号字段中返回给发送方。
    这样,接收方就能这样检查:上次接收到第n字节,这次如果收到序号为n+1的包,则没有遗漏。发送方也能确认接收方目前接收到n个数据,这次应该发送序号为n+1的包了。
    另外,发送过的包都会保存在发送缓冲区中,如果对方没有返回对应的ACK号,就会重新发送这些包。
  4. 接收HTTP响应消息
    浏览器在发送请求消息后,会调用read来委托协议栈获取响应消息。像上面说过的接收方,协议栈在接收到所有数据后会检查数据块是否丢失,如果没有就返回ACK号,并将数据块按顺序连接起来,最后交给应用程序。

2.4 断开

收发数据完毕后,服务器和客户端都可以先发起断开,这里以从服务器断开管道并删除套接字为例子说明。断开的过程也就是TCP的“四次挥手”。
四次挥手

  1. 服务器的应用程序调用socket库的close程序,协议栈会生成包含断开信息的TCP头部,即将控制位中的FIN比特设为1,委托IP模块向客户端发送数据。同时服务器的套接字中也会记录断开操作(改变state等)。
  2. 客户端收到FIN为1的TCP头部,客户端的协议栈会将套接字的state设为断开,然后返回一个ACK号,告诉服务器已经收到了。之后就等待应用程序来获取数据。
  3. 应用程序如上面“接收HTTP响应消息”所述调用read来读取数据,如果协议栈已经收到所有数据了就能马上读取了,否则则继续等待协议栈。
  4. 最后,应用程序调用close来结束操作。这时协议栈会跟步骤1中的服务器一样,生成FIN比特为1的包,通过IP模块发送。服务器收到之后返回ACK号。到这里就结束了。

和服务器的通信结束后,客户端等待一段时间就可以删除该套接字了。

3 IP与以太网的包收发操作

喝杯茶继续。
接下来我们来探索IP与以太网是怎么进行包收发操作的。
我们在上面的章节中经常提及协议栈的IP模块,IP模块到底做了什么工作呢?IP模块负责给包添加两个头部:

  1. IP头部:IP用的头部,包含IP地址
  2. MAC头部:以太网用的头部,包括MAC地址

3.1 生成IP头部

主要字段如下

字段名称 含义
标志 表示是否允许分片,以及当前包是否为分片包
协议号 表示协议的类型,如TCP:06,UDP:11
发送方IP地址 网络包发送方的IP地址
接收方IP地址 网络包接收方的IP地址

其中,接收方IP地址就是TCP模块告知的,而发送方IP地址则需要通过判断发送所使用的网卡,并填写该网卡的IP地址。那如何确定使用哪一个网卡发送呢?IP模块会根据路由表来确定把包交给哪块网卡。在我们可以通过route print显示路由表。

路由表
首先,会对套接字中的目的地ip与Network Destination进行比较,找到匹配的那一行;然后查看该行,Gateway即要转发到的下一个路由器(最近的网络转发设备)的ip地址,Interface就是使用的网卡的ip地址。这样,我们就知道该用哪个网卡发送包,即IP头部的发送方ip地址字段了。

3.2 生成MAC头部

以太网在判断网络包目的地时和TCP/IP的方式不同,需要知道MAC地址才能在以太网中将包发往目的地。因此还需要加上MAC头部。
MAC头部主要字段

字段名称 含义
接收方MAC地址 网络包接收方的MAC地址
发送方MAC地址 网络包发送方的MAC地址
以太类型 使用的协议类型,如0800:IP协议、0806ARP协议等

刚刚说到通过查路由表可以知道要转发到的下一个路由器的ip地址和使用的网卡ip地址,我们根据这两个ip地址查询得到的MAC地址就是接收方的MAC地址和发送方的MAC地址。这里我们需要使用ARP。
具体是这样的,在以太网中,我们可以通过广播的方法把包发给同一个子网中的所有设备。ARP就是利用广播向所有设备提问:“这个IP地址xxx.xxx.xxx.xxx是谁的?请把你的MAC地址告诉我。”然后就会有设备回答:“这个IP地址是我的,我的MAC地址是XX-XX-XX-XX-XX-XX。”这样,我们就能得到对应的MAC地址了。

3.3 网卡把包转换成电/光信号发送出去

网卡接收到网络包之后,就会把IP模块生成的网络包转换成电或光信号,这样就在网线上传输了。这里就不详细说了。

3.4 路由器的包转发操作

包到达路由器后,路由器会根据路由表再一次判断要转发到的下一个路由器的ip地址,改写MAC头部中的接收方MAC地址,然后转发到下一个路由器。这个过程不断重复,最终网络包就会被送到目的地了。
在转发前,当包的长度大于输出端口的MTU(一个包能传输的最大数据长度)时,且IP头部的标志字段显示可以分片,路由器会使用分片功能拆分大网络包,并更新IP头部。如果包过大且不允许分包,路由器会丢弃这个包,并通过ICMP消息通知对方。

4 请求到达服务器,响应返回浏览器

在到达服务器前,可能网络包还会经过防火墙,又或者是经过缓存服务器时直接从缓存服务器中读出数据,这里就不详细说了。

4.1 服务器的接收操作

  1. 到达服务器后,服务器的网卡将接收到的信号转换成数字信息,校验信号是否失真,检查包的MAC头部的接收方MAC地址。检查完成后,网卡会通过中断将网络包到达的事件通知到CPU。然后网卡驱动会根据MAC头部的协议类型把包交给相应的协议栈。
  2. 协议栈IP模块会先检查IP头部,检查包是否分片。如果是分片的包,则把包暂存在内存,等所有分片的包全部到达后再还原成原始包。然后检查IP头部的协议号字段,把包交给相应的模块。
  3. TCP模块会先检查包的TCP头部,然后根据发送方IP、发送方端口号、接收方IP及接收方端口号找到对应的套接字。如果能找到相应的套接字则返回ACK号(第二次握手),最后建立连接(完成三次握手)。建立连接之后收到的包,TCP模块会检查TCP头部的序号,如果正常则把包放进缓冲区,最后还原成原始包。

    我们在3.1有提到,服务器在启动时就创建套接字等待连接。每次有新的客户端发起连接(第一次握手)时,服务端开始接收连接操作。协议栈会给等待连接的套接字复制一个副本,然后将控制信息写入这个新的套接字中。这个套接字与等待连接的套接字的端口号是一样的,所以还需要其他信息来做区别。

  4. 在TCP完成所有数据的接收操作后,控制流程会转移到服务器程序,对收到的数据进行处理。
  5. 当数据收发完成后,便进行断开操作。

4.2 浏览器接受响应消息

服务器发送的响应消息到达客户端后,经过网卡、协议栈,最后到达浏览器。
接下来,浏览器会根据http头部的Content-Type字段、文件扩展名等判断数据类型,然后将数据显示出来就可以了。不同类型等数据显示操作的过程不一样,这里就不探讨了。
浏览器显示网页内容成功!用户访问完成!

参考

  • 《网络是怎么连接的》