摘要
本文主要讲述了一个 http request 请求从发出到收到 response 的整个生命周期,希望可以通过对整个流程的一个描述来梳理清楚五层网络协议的定义以及各层之间是如何协作的。
使用Golang发起一个HTTP请求
对于后端来说通过 http 请求来进行远程调用是再寻常不过的事了,以 Golang 的 resty
包为例,我们通过下面这个语句来发起一个请求并获得所请求的服务器的 response,简单起见这里我们使用 GET 方法进行请求:
client := resty.New()
headers := map[string]string{
"Connection": "Keep-Alive",
}
resp1, _ := client.R().
EnableTrace().
SetHeaders(headers).
Get("https://httpbin.org/get")
fmt.Println("Request Trace Info:")
ti := resp1.Request.TraceInfo()
fmt.Println(" DNSLookup :", ti.DNSLookup)
fmt.Println(" TCPConnTime :", ti.TCPConnTime)
fmt.Println(" TLSHandshake :", ti.TLSHandshake)
fmt.Println(" IsConnReused :", ti.IsConnReused)
fmt.Println(" RemoteAddr :", ti.RemoteAddr.String())
我们在应用层发起请求,应用层是用户的,所以 HTTP 报文的内容都是一些人类可阅读的 ASCII 码点,但计算机只懂得二进制,光纤中认识光信号,所以这个 HTTP 报文还需要经过一一些处理才能穿越那些物理链路发送到我们的目的服务器上。首先来讲讲 HTTP 报文格式
在我们这个例子里我们的请求方法是 GET,GET 和 POST 是最常见的 HTTP 方法,除此以外还包括 DELETE、HEAD、OPTIONS、PUT、TRACE。我们没有传头部字段,也就是 HEADER , HTTP 的头部可以分为两种,一种是通用头部如 Cache-Control、 Connection、Date、Pragma、Transfer-Encoding、Upgrade、Via 等,可以通过它传递一些信息,对通用头部的扩展要求通讯双方都支持此扩展,如果存在不支持的通用头部,一般将会作为实体头部处理。实体头域包含关于实体的原信息,实体头包括 Allow、Content-Base、Content-Encoding、Content-Language、Content-Length、Content-Location、Content-MD5、Content-Range、Content-Type、Etag、Expires、Last-Modified、extension-header。extension-header 允许客户端定义新的实体头,但是这些域可能无法为接受方识别。
在这个请求里我们也没有消息体,URL为 https://httpbin.org/get, 是一个很简单的 GET 请求。
http 响应报文结构和请求差不多,区别在于状态行,状态码(Status-Code)主要用于机器理解,短语(Reason-Phrase)Status-Code 提供一个简单的文本描述,主要帮助用户理解:
- 1xx : 信息响应类,表示接收到请求并且继续处理
- 2xx : 处理成功响应类,表示动作被成功接收、理解和接受
- 3xx : 重定向响应类,为了完成指定的动作,必须接受进一步处理
- 4xx : 客户端错误,客户请求包含语法错误或者是不能正确执行
- 5xx : 服务端错误,服务器不能正确执行一个正确的请求
几个常见的状态码和短语:
- 200 OK最好的情况,即处理成功
- 404 Not Found不希望看到的响应之一,即找不到所请求的资源
- 500 Internal Server Error不希望看到的响应之二,服务端发生了错误
说完了 HTTP 报文,接下来我们来实践一下,看看上面那段代码发起一个 HTTP 请求,它的运行结果如下:
可以看到我们这个请求是成功了的,对方服务器返回了 200, 短语是 OK,意味着目标服务器成功处理了我们的请求。
输出的Request Trace Info
信息可以帮助我们理解整个请求的过程,我们一行一行地看:
DNSLookup
HTTP 报文里包含了目的服务器的地址,也就是我们上面输入的 URL,一个 URL 由协议头(HTTP、HTTPS、SFTP 等)+ 域名 + 资源路径组成,在我们这个例子里协议头为https(HTTPS = HTTP + SSL(TLS),它和 HTTP 的区别在于加了一道身份验证所以更安全),域名是 httpbin.org ,资源路径是 /get,也就是我们以 HTTPS 协议所约定的方式去获取 httpbin.org 所映射的服务器上的 /get 路径下的资源。
域名由字符串组成,机器是无法读懂的,所以我们需要一个服务去将它解析成机器能读懂的地址,也就是 IP,而这个服务就是 DNS
(Domain Name System)域名系统,它是用于实现域名和IP地址相互映射的一个分布式数据库,这里输出的 DNSLookup
的值就是本次请求里花费在 DNS
解析上的时间。
域名解析的过程大致如下:
完整的DNS解析过程有以下几个步骤:
(1)查看浏览器缓存(我们这里是直接通过后端来发起请求,所以没有这一步)
当用户通过浏览器访问某域名时,浏览器首先会在自己的缓存中查找是否有该域名对应的 IP
地址(若曾经访问过该域名且没有清空缓存便存在)。
(2)查看系统缓存
当浏览器缓存中无域名对应 IP
则会自动检查用户计算机系统 Hosts
文件 DNS
缓存是否有该域名对应 IP。
(3)查看路由器缓存
当浏览器及系统缓存中均无域名对应 IP
则进入路由器缓存中检查,以上三步均为客服端的 DNS
缓存。
(4)查看ISP DNS 缓存
当在用户客服端查找不到域名对应 IP
地址,则将进入 ISP DNS
缓存中进行查询。比如你用的是电信的网络,则会进入电信的 DNS
缓存服务器中进行查找。
(5)询问根域名服务器
当以上均未完成,则进入根服务器进行查询。全球仅有 13 台根域名服务器,1 个主根域名服务器,其余 12 为辅根域名服务器。根域名收到请求后会查看区域文件记录,若无则将其管辖范围内顶级域名(如.com、.cn等)服务器 IP
告诉本地 DNS
服务器。
(6)询问顶级域名服务器
顶级域名服务器收到请求后查看区域文件记录,若无记录则将其管辖范围内权威域名服务器的 IP
地址告诉本地 DNS
服务器。
(7)询问权威域名(主域名)服务器
权威域名服务器接受到请求后查询自己的缓存,如果没有则进入下一级域名服务器进行查找,并重复该步骤直至找到正确记录。
(8)保存结果至缓存
本地域名服务器把返回的结果保存到缓存,以备下一次使用,同时将该结果反馈给客户端,客户端通过这个 IP
地址即可访问目标Web服务器。至此,DNS
递归查询的整个过程结束。
通过域名解析服务我们获得了目标服务器的 IP
,它会在网络层被用到。
TCPConnTime
建立 TCP
(Transmission Control Protocol) 连接所花的时间。TCP 属于传输层协议,除了 TCP
外还有 UDP
也是常用的传输层协议。本文的传输层协议选择了 TCP, 我们在应用层准备好了 HTTP
报文,然后选择一个 TCP server
去传输这个 HTTP
报文给目标服务器,TCP server
通过什么方式进行与目标服务器的沟通对于应用层来说是透明的(即不可见),应用层只需要等待 TCP server
返回的目标服务器的应答结果就好了。
TCP 是面向连接的协议,而 UDP 是无连接的,使用 TCP 进行通信的双方在互相发送消息之前要先建立一个连接,这个连接建立的过程被称为三次握手。我们先来看看 TCP 报文格式:
端口(port),主要分为物理端口和逻辑端口。我们一般说的都是逻辑端口,用于区分不同的服务。因为网络中一台主机只有一个 IP
,但是一个主机可以提供多个服务,端口号就用于区分一个主机上的不同服务。一个IP地址的端口通过16bit进行编号,最多可以有65536个端口,标识是从0~65535。
端口号分为系统端口(System Ports)0~ 1023、用户端口(User Ports)1024~ 49151和动态端口号(Dynamic Ports)49152~65535。我们自己的服务一般都绑定在注册端口上。
系统端口(0~ 1023):也叫做公认端口(Well Known Ports),它们紧密绑定(binding)于一些服务。通常这些端口的通讯明确表明了某种服务的协议。任何TCP/IP实现所提供的服务都用0-1023之间的端口号。我们的私用端口号不应该使用这个区间内的端口,除非你向IANA注册了。例如:80端口实际上总是 http 通讯、443对应着 https(在本文中我们使用的就是 https 协议,在最后一行输出的 RemoteAddr 可以看到目标服务器的端口号就是443)、21对应着 ftp,25对应着 smtp,110对应着 pop3 等。
互联网号码分配局(英语:Internet Assigned Numbers Authority,缩写 IANA),是一家互联网地址指派机构,管理国际互联网中使用的 IP 地址、域名和许多其它参数的机构。 IP 地址、自治系统成员以及许多顶级和二级域名分配的日常职责由国际互联网注册中心(IR)和地区注册中心承担。IANA 是由 ICANN 管理的。
用户端口(1024~ 49151):也叫做注册端口(Registered Ports),从1024到49151。它们松散地绑定于一些服务。也就是说有许多服务绑定于这些端口,这些端口同样用于许多其它目的。例如:许多系统处理动态端口从1024左右开始。
动态端口(49152~65535):也叫做私有或动态端口(Private or Ephemeral Ports),从49152到65535。理论上,不应为服务分配这些端口。实际上,机器通常从1024起分配动态端口。只要运行的程序向系统提出访问网络的申请,那么系统就可以从这些端口号中分配一个供该程序使用。比如 49152 端口就是分配给第一个向系统发出申请的程序。在关闭程序进程后,就会释放所占用的端口号。
所以当 client
准备发出网络请求的时候,client
所在的进程首先要向系统申请一个端口号作为源端口号,系统会随机从49152~65535中分配一个可用的端口号给这进程,这样当目标服务器处理完请求要给我们返回数据的时候才能通过这个源端口号找到发出请求的这个端口所对应的服务并把 response 交给这个服务。可以说 HTTP
报文是面向服务的,它是服务与服务之间的交流,传输层是面向进程的,两个端口号标识了两个进程,一个进程里可能会有许多个服务(路由,或者说 API)。
序列号
在一个 TCP
连接中传送的字节流中的每一个字节都按顺序编号,这个编号就类似于数组的下标,数组里每个元素都有自己的下标。例如,一报文段的序号是 101,共有 100 字节的数据。这就表明:本报文段的数据的第一个字节的序号是 101,最后一个字节的序号是 200。显然,下一个报文段的数据序号应当从 201 开始,即下一个报文段的序号字段值应为 201。
确认号
期望收到对方下一个报文段的第一个数据字节的序号。若确认号为 N
,则表明:到序号 N-1
为止的所有数据都已正确收到。
标志位字段
比较常见的标志位 SYN
、ACK
、FIN
会在 TCP
连接建立与释放的时候使用到,也就是我们常说的三次握手四次挥手。先讲讲三次握手建立连接:
三次握手的过程如图所示,连接的建立一般都是由客户端主动发起的,客户端发送一个报文给服务器,告诉它我想要和你建立一个连接进行数据交换,进行数据交换之前有些事情需要先同步(synchronize,也就是 SYN
标志位,SYN
=1 表示这是一个用于同步信息的报文),双方得约定好初始序列号(Init Sequense Number,ISN
)、窗口大小等信息,连接建立好了之后交换数据的时候才好判断数据的起始与结束,通过 seq 字段来告诉对方本报文的序列号。
第一次握手,SYN
= 1,客户端告诉服务器自己的初始序列号是 x (seq = x),服务器收到了这个报文可以确定客户端发送正常,自己接收正常。
第二次握手,服务器端发出报文 SYN
= 1, ACK
= 1(仅当 ACK
=1 时,确认号字段才有效。TCP规定,在连接建立后所有报文的传输都必须把ACK置1),表示这是一个应答报文,并且告诉了客户端自己的初始序列号是 y (seq
= y),以及确认自己收到了客户端的前 x 个字节的信息,接下来希望收到 x + 1 的信息(ack
= x + 1),但是客户端刚刚的报文明明没有携带信息,为什么说收到了前 x 个字节的信息呢,因为 TCP 规定,SYN报文段(SYN
= 1 的报文段)不能携带数据,但需要消耗掉一个序号。
第二次握手成功之后客户端确认了自己发送正常,接收正常,服务器发送正常,接收正常。服务器端确认了客户端发送正常,自己接收正常,所以还需要第三次握手来让服务器端确认自己发送正常以及客户端接收正常。
第三次握手,双方已经同步完序列号信息了,所以第三次握手不用 SYN
标志位了,客户端应答(ACK
= 1)服务器第二次握手时发来的报文,表示自己收到了服务器端的前 y 个字节的信息(ACK
= y + 1),告诉服务器端自己这个报文的起始序号是 x + 1(seq
= x + 1),当服务器收到这个报文后服务器就可以确认自己发送正常以及客户端接收正常了,连接就建立完成了可以进行信息传输了。
如果第三次握手的报文因为各种各样的原因丢了,服务器端没有收到,那么服务器就会进行首次重传,若等待一段时间仍未收到客户确认包,就进行第二次重传。如果重传次数超过系统规定的最大重传次数,则系统将该连接信息从半连接队列中删除。每次重传等待的时间不一定相同,一般会是指数增长。
说完了三次握手建立连接再来说说四次挥手断开连接:
四次挥手的过程如图所示。
第一次挥手,客户端发出一个 FIN
报文,消耗一个序列号,告诉服务器端自己没有要发送的数据了,连接可以断开了。
第二次挥手,服务器端服务器端发送一个 ACK
报文表示收到了客户端要断开连接的报文(ACK
= 1, ack
= u + 1),序号 u 之前的数据都以及收到了。此时连接进入半关闭状态,但是服务器端可能还有数据没有发完,所以可以继续发送数据,直到服务器端发完了要发的数据,发送第三次挥手的报文。
第三次挥手,服务器端发送 FIN
报文,消耗一个序列号(seq
= w),告诉客户端自己的数据也发完了,因为前面是服务器端单向发数据给客户端,所以 ack
还是为 u + 1。
第四次挥手,客户端收到了服务器端要断开连接的报文,回复一个 ACK
报文,让服务器端知道自己收到了它的 FIN
报文,服务器端收到报文后立马断开了连接。
首部长度
也叫做数据偏移,它指出 TCP
报文段的数据起始处距离 TCP
报文段的起始处有多远,也就是 TCP
报文段的首部长度。
窗口大小
报文能不能正常被传输、接收,不止取决于通信的双方,还取决于外部环境,也就是网络环境,因为网络链路里不止当前的通信双方在传输数据,而是由很多台在发送数据的主机在共同使用。所以数据要正常被传输,有两个问题要解决,一个是发送方与接收方速率匹配,接收方能及时处理发送方发送过来的数据,使得发送双方速率匹配的策略我们称为流量控制;另一个就是需要有一个良好的网络环境,努力使整体网络环境的通畅的策略我们称为拥塞控制。流量控制与拥塞控制都是通过设置窗口大小来完成的,当然这两个概念都是针对 TCP
协议来说的,它是一个无私的协议,UDP
可不管网络是否拥塞,它会一直发向网络中发送数据。
在介绍确认号这个首部字段的时候我们提到了 TCP
采用的是累积确认的方式,下面我们来具体讲解是怎么个累积确认法。引入累积确认主要是为了提高通信效率,如果没有累积确认的话,接收方收到一个报文之后回复一个 ACK
报文,发送方接到这个 ACK
报文才能发送下一个报文,一包一确认的方式并不是很高明,往返时间越长,通信的效率就越低。而使用累积确认,发送方就可以连续发送一批报文,而不需要等待接收方回复了再发送下一个包。
虽然发送方可以一次性发一批报文,但是这个批大小肯定不能是无限大的,得有个规则来约束它,这个大小就是窗口大小。窗口大小以字节为单位,比如当前窗口大小为1024个字节,那么在不需要等待接收方确认的情况下发送发可以连续发送报文直到已发送的报文的长度加起来等于1024个字节。
流量控制:流量控制的过程就是通过不断的调整窗口大小来给让接收方能来得及处理接收到的数据,所以窗口大小是由接收方决定的。在三次握手建立 TCP
连接的时候就已经同步好了窗口大小的信息,并且如果在后续的数据传输中因为种种原因需要调整窗口大小也是允许的
拥塞控制:引入拥塞控制这个概念后窗口大小就受到流量控制和拥塞控制这两个策略共同影响了,通过拥塞控制算法算出拥塞窗口cwnd
的大小,实际窗口大小 = min(cwnd
,rwnd
)rwnd
就是流量控制中的接收窗口。
拥塞窗口cwnd
变化的规则:
- 只要网络中没有出现拥塞,
cwnd
就会增大; - 一但网络中出现了拥塞,
cwnd
就减少;
只要「发送方」没有在规定时间内接收到 ACK
应答报文,也就是发生了超时重传,就会认为网络出现了用拥塞。
拥塞控制主要是四个算法:
- 慢启动
- 拥塞避免
- 拥塞发生
- 快速恢复
关于窗口大小的更多细节可以看看这篇博客
校验和
数据段
segment data,这里放着 HTTP
请求报文,像这样:
(图片来自这篇博客)
RemoteAddr
这个远端地址包括了目标服务器的 IP
和端口号, 端口号在上文我们已经说过了,它会被写在 TCP
报文里,而 IP 则会被写在网络层的 IP
数据报里。应用层通过 DNS
服务获得了 IP
,然后通过函数调用传参的方式来把这个 IP
传给实现了传输层协议的 server,当然传输层的的报文里并不需要用到 IP
,但是域名是属于应用层的东西,如果没有在应用层完成解析获得 IP,后面的层因为没有域名信息就无法获得 IP
无法正常工作了,所以 IP 从应用层通过参数传递的方式传给传输层,传输层再通过参数传递的方式传给真正需要用到它的网络层,让网络层写进它自己的报文里。Golang 的 net
包的 net.LookupHost()
函数可以完成域名解析获得 IP
,对 DNS
解析的过程有兴趣的朋友可以看看这篇博客。
我们先来看看 IP
数据报的结构:
就像 TCP
报文的数据段应用层报文一样,IP
数据报的数据部分指的就是传输层报文,在本文里就是 TCP
报文,如下图所示:
IsConnReused
连接是否复用,说到这个话题又回到了应用层的 HTTP
协议,前面我们说到 HTTP
报文有各种各样的 HEADER
字段,其中有一个叫做Connection
的通用 header,它的的取值为Keep-Alive
或close
。当在 header 里加入Connection: Keep-Alive
意味着开启长连接,
我们把上面例子里的代码进行一些小小修改,变成这样:
client := resty.New()
headers := map[string]string{
"Connection": "Keep-Alive",
}
resp1, _ := client.R().
EnableTrace().
SetHeaders(headers).
Get("https://httpbin.org/get")
// Explore trace info
fmt.Println("Request Trace Info:")
ti := resp1.Request.TraceInfo()
fmt.Println(" DNSLookup :", ti.DNSLookup)
fmt.Println(" TCPConnTime :", ti.TCPConnTime)
fmt.Println(" TLSHandshake :", ti.TLSHandshake)
fmt.Println(" IsConnReused :", ti.IsConnReused)
fmt.Println(" RemoteAddr :", ti.RemoteAddr.String())’
resp2, _ := client.R().
EnableTrace().
SetHeaders(headers).
Get("https://httpbin.org/get")
fmt.Println()
fmt.Println("******** Request with Keep-Alive *******")
fmt.Println()
cname, _ := net.LookupCNAME("httpbin.org")
host, _ := net.LookupHost("httpbin.org")
addr, _ := net.LookupAddr("httpbin.org")
ip, _ := net.LookupIP("httpbin.org")
fmt.Println(cname)
fmt.Println(host)
fmt.Println(addr)
fmt.Println(ip)
// Explore trace info
fmt.Println("Request Trace Info:")
ti2 := resp2.Request.TraceInfo()
fmt.Println(" DNSLookup :", ti2.DNSLookup)
fmt.Println(" TCPConnTime :", ti2.TCPConnTime)
fmt.Println(" TLSHandshake :", ti2.TLSHandshake)
fmt.Println(" IsConnReused :", ti2.IsConnReused)
fmt.Println(" RemoteAddr :", ti2.RemoteAddr.String())
这次我们主要想看看 Keep-Alive 的实际效果,所以一些没啥用的信息就不输出了,这段代码的运行效果如下:
可以看到在第二次请求的时候 DNSLookup、TCPConnTime、TLSHandshake 这三个与连接建立有关的时间花费都变成了0啦!IsConnReused 也由 false 变成了 true,说明这次的连接是复用的第一次请求的时候建立的连接,所以不需要再重新建立连接,那些与连接建立的时间花费自然也变成零了(从图上可以看出来节省了大约2.5s 的时间)。
关于长连接有兴趣的朋友可以看看这篇博客
总结
实践很重要,上学的时候学计网总觉得是一知半解的,书上说分层协作各层解耦,层与层之间是透明的,也就是互相看不见,