从“浏览器里输入google.com然后敲回车”说起(上)

面试题-当你在浏览器中输入google.com并且按下回车之后发生了什么?我就按照我自己关心的角度来描述一番,老掉牙的问题希望能带来新的思考。

为什么我打算写这个

我的朋友lb是个很可爱的web前端开发者,很多时候我跟她沟通服务端地址变更的情况,她或多或少理不清楚前端调用服务端API接口(或者说HTTP接口)的基础逻辑,经常性的在多个环境的服务端API地址上犯迷糊,也不是很会配置前端的部署,每次一个新工程的部署我们总是要花一些力气沟通,我就想能不能写点什么帮到她。

虽然有一天我在公司的时候边联调边想到她也不是很喜欢写代码这个工作,后面几年我不再见她的时候她会去做什么工作呢,是不是回了故乡,我写这些就真的很废,对谁都没有任何作用,但是转念一想,如果能在此时此刻稍微帮到身边的人,我为什么不去做呢?何况一小些非科班的服务端开发者也搞不清楚部署和网络环境这事,指不定对他们也有一点帮助。

就通过这篇blog来整理一下客户端到服务端到底是一个什么样的流程,希望能说的清楚。

面试题-当你在浏览器中输入google.com并且按下回车之后发生了什么?

这是一个非常古老的面试题了,来自于国外,因为google不愿意遵守我国法律并没有在中国国内部署,所以是属于“被墙”的状态,去掉“墙”这个流程简化逻辑,所以不妨把这个题目换一换:

当你在浏览器中输入baidu.com.com并且按下回车之后发生了什么?

这个问题真的很有年头了,在github有国外的开发者写了对这个面试题的一些答案,最著名的就是这个alex/what-happens-when,中文也有好几位朋友去翻译了这个,比如最高赞获得认可的这个 中文版 都是超多K赞的主,已经写的很好了,又超级详细。但是我不是很关心键盘按下、键盘弹起、操作系统拿到上升沿触发这些内容,这些在我的硬件知识中也非常清晰,只是在硬件和操作系统这些基础设施的部分我不想过多描述,只是我更想写侧重于客户端发起HTTP请求之后的这些个流程和内容。

敲回车

这一部分就是我不关心的那部分。

单总线传输二进制流原理

有一个矩阵键盘,点击下了enter按键,键盘的USB口输入了一串二进制电流(和上面图类似,此次去掉差分,简化为TTL的rx的单总线模式),当PC获取掉这一串二进制数据之后,转换为了enter键的ASCII码0x0D,拿到0x0D执行对应的逻辑。

在输入完最后一个m按键(0x6D)之后(简化掉浏览器推荐用户输入地址的过程),输入了0x0D,浏览器已经拿到了指令,准备开始做点事情给用户反馈了。

浏览器准备发起HTTP请求

URL

拿到baidu.com,拼接前缀http://(此处去掉默认HTTPS,简化为HTTP协议),拼接后缀/,构成新的地址栏URL “ http://baidu.com/ ”。这样就构成了一个标准的HTTP URL。

http://baidu.com/

HTTP URL的标准格式是 http://host[:port][abs_path][:parameters][?query]#fragment

host是域名,baidu.com

port是端口,默认被省略了,http默认80,https默认433

abs_path是资源目录和路径,此处为/

:parameters是URL的参数,正常没有(我几乎就没见到过有的)

?query是传递参数,用&号隔开,此处没有

#fragment是无用的,HTTP URL中并不存在#号之后的内容,会被完全忽略

拿到了标准的HTTP URL,浏览器就知道要往哪发送请求,请求的资源目录和路径,以及要发的参数了。但是它拿到的是“baidu.com”这个域名,它要发给哪台服务器呢?

DNS

本来不应该存在域名这种东西,不过是ip地址太难记住,人们选择用域名来表示,这样也就存在一个拿域名去兑换ip地址的过程,也就是所说的域名解析。拿到baidu.com域名之后,如何去换取对应的服务器ip地址呢?现在的操作系统基本上都做成了如下步骤,先去本地hosts文件(windows下C:\Windows\System32\drivers\etc\hosts文件,macOS和CentOS下/etc/hosts)中获取,查看是否有自定义配置的ip-域名配置,如果有就按照hosts文件,没有再去网络中查询。例如windows中的hosts文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Copyright (c) 1993-2009 Microsoft Corp.
#
# This is a sample HOSTS file used by Microsoft TCP/IP for Windows.
#
# This file contains the mappings of IP addresses to host names. Each
# entry should be kept on an individual line. The IP address should
# be placed in the first column followed by the corresponding host name.
# The IP address and the host name should be separated by at least one
# space.
#
# Additionally, comments (such as these) may be inserted on individual
# lines or following the machine name denoted by a '#' symbol.
#
# For example:
#
# 102.54.94.97 rhino.acme.com # source server
# 38.25.63.10 x.acme.com # x client host

# localhost name resolution is handled within DNS itself.
# 127.0.0.1 localhost
# ::1 localhost
1.233.45.67 baidu.com

当发起baidu.com请求的时候,这台机器就自动往1.233.45.67发起连接。那么hosts文件中不存在的记录如何操作呢,自然就需要用到dns,Domain Name System,域名系统,系统会向DNS服务器(我的PC配置的223.5.5.5,阿里的dns,挺快,还不错)传输baidu.com这样的域名,DNS服务器会返回对应的ip 111.13.134.130(存在一个动态dns的东西,每个人获取到的ip地址可能不一样)。
那么是在哪里配置的这个域名和ip的关联关系呢?一般都是在买域名的服务商的管理页面可以配置dns,比如我新搞了一个haha.haizige.top二级域名(一级域名是haizige.top),在我的阿里云(万网)域名解析界面可以这样配置

配置一个二级域名的域名解析

图中的TTL时间,是DNS缓存的时间,我们新增的这个解析,点击添加的时候可以理解为向根服务器(就全球那13个根服务器,大部分都在美国,好在我国ipv6要有几台根服务器在中国国内了,希望能打破霸权,越来越好)添加,取的时候从根服务器取就很慢了,所有有很多子DNS服务器,会缓存下haha.haizige.top的ip101.23.4.5,存一个TTL时间之后过期,这样倘如十分钟之后没人访问haha.haizige.top,这个子DNS服务器就删掉这份缓存了,知道下次再有人请求,再去根服务器拿,拿到了再缓存十分钟。

至此,我们就通过浏览器输入的域名获取到了要连接的ip地址。

请求发到服务端的机器

内网和外(公)网

我当前写作的PC上的IP地址是 192.168.15.139,那么我的PC发起对ip 111.13.134.130的网络连接请求是如何发出去的呢,毕竟我们在两个并不真正互通的网络环境里,也就是天天说的内网和外网,有的也把外网说出公网,对应着内网喊作私网。

ip地址规定了下面几个是内网

ip网段 例子
10.0.0.0~10.255.255.255 10.10.30.25
172.16.0.0~172.31.255.255 172.20.54.36
192.168.0.0~192.168.255.255 192.168.199.1

除此之外ip地址还做了一些保留地址,比如127.0.0.1 (本机,不走网卡),169.254.x.x(电脑DHCP时没拿到路由器分配的内网ip地址就会是这个),224.0.0.1(组播地址)等。

Ps.刚写完这段的时候公司在搞阿里云账号的统一,云企业网,运维的某同学给内网地址配置了172.80.0.1,一时间没意识到这个公网地址,折腾了一天才找出来问题,哈哈哈哈哈,这也太不小心了。

对于一般般我的PC正常是没有公网IP的,如果想要就要花钱找持有ip的运营商租赁一个,比如在阿里云的服务器购买的默认套餐里就呆了公网ip。

那么从192.168.15.139(内外)是如何找到111.13.134.130(外网)的呢?

从我的电脑发到11.13.13.130

其实并没有寻址的这个过程,只是把要发送的东西发出去,根据ip协议就可以找到,这里要解释非常多ip协议的东西,我只能尽可能的去简化,简化成最简单的模型,大概如下

路由转发

当我的10.10.10.33需要发一个包到外网的11.13.13.130,经过很多个步骤,每个步骤都对要发送的东西进行封装和修改,大概如下:

简化的网络模型

① 应用程序拿到要发送的HTTP包,HTTP协议是个文本协议,直接转成字符数组就可以。

② 如果HTTP包太长的话,对HTTP包进行拆分成TCP协议规定的最长单元长度,然后分多次发送,这里简化流程,去掉拆包这一过程。根据TCP协议添加TCP首部,这里我简化为源端口目标端口(因为这才是这个发送路由指向的最关键的部分)。

③ 在传输层完成包装之后,再在包前方加入IP首部,这里我简化为源IP目的IP(因为这才是这个发送路由指向的最关键的部分)。

④ 在网络层完成包装之后,再在包前方加入以太网首部,在后方加入以太网尾部,这里我简化为源mac地址目的mac地址(和上面的理由一样)和尾部。

①②③中并没有什么特殊的地方,目的地址就是最终目标的地址。但是④中的目的mac地址并不是最终目标的地址,因为在发送包的那一刻并不知道最终目标的mac地址,只能填充当前网关的地址,这样网关就知道它该如何操作这个包,知道该发向哪一个下一级路由,收到这个包,把mac地址修改为新的目标mac地址。

这一过程只是能够帮助我们搞清楚到底是如何工作,不知道并不影响我们利用互联网和利用网络,就好像你不知道电子是如何围绕着原子核旋转的,但是你依然能够很开心的生活在地球上一样。

HTTP里面有什么

HTTP协议也是互联网构建的基础设施之一,庄稼生长靠太阳和金坷垃,互联网野蛮生长就靠HTTP协议这些基础设施。作为一个文本协议,肯定可以抓包看到里面有什么的,不需要特殊的解析就能肉眼看得懂的一个文本,比如我随便打开一个页面,抓一个包来看看,在我电脑上运行curl -v http://www.baidu.com 可以看到完整的包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
haizi@haizi-Code01-ubuntu:~$ curl  -v "http://www.baidu.com"
* Trying 112.80.248.75:80...
* TCP_NODELAY set
* Connected to www.baidu.com (112.80.248.75) port 80 (#0)
---------HTTP的请求------------(这是我手打的分割线)----------------------
> GET / HTTP/1.1
> Host: www.baidu.com
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
---------HTTP的响应------------(这是我手打的分割线)----------------------
< HTTP/1.1 200 OK
< Accept-Ranges: bytes
< Cache-Control: private, no-cache, no-store, proxy-revalidate, no-transform
< Connection: keep-alive
< Content-Length: 2381
< Content-Type: text/html
< Date: Sat, 11 Sep 2021 03:38:16 GMT
< Etag: "588604f8-94d"
< Last-Modified: Mon, 23 Jan 2017 13:28:24 GMT
< Pragma: no-cache
< Server: bfe/1.0.8.18
< Set-Cookie: BDORZ=27315; max-age=86400; domain=.baidu.com; path=/
<
<!DOCTYPE html>
<!--STATUS OK--><html> <head>省略n段<title>百度一下,你就知道</title></head> <body link=#0000cc>内容省略n段</body> </html>

上面这段就清晰的描述了一个HTTP包里面有什么

HTTP 请求包

上面是个非常简单的HTTP请求,用curl来发起,在浏览器中实际的状态应该是下面这种

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
GET / HTTP/1.1
Host: www.baidu.com
Connection: keep-alive
Cache-Control: max-age=0
sec-ch-ua: "Google Chrome";v="93", " Not;A Brand";v="99", "Chromium";v="93"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Linux"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&tn=baidu&wd=5291
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7,fi;q=0.6,ja;q=0.5
Cookie: BIDUPSID=8C87D94971*****8D298; PSTM=1630110406; BAIDUID=8C87D9*******1B3AFB503:FG=1; BD_UPN=123353

这个是我登录状态的打开百度首页,可以关注几个比较重点的东西,比如Cookie中会把当前域名的cookies全部带上,User-Agent中会填充浏览器的一些信息,Cache-Control会带上是否缓存的表示,Referer中会标注请求页面是从哪里跳转而来,Accept开头的相关表示了请求能够解释的编码格式、语言、压缩情况等信息,Connection会标注是否使用连接保持。

目前抓包抓到的基本上都还HTTP/1.1版本,HTTP/2.0已经在2013年就发布了,主要加入了连接复用,甚至HTTP/3.0也已经在2018年年底的时候就发布了,主要变更TCP为UDP。但是国内还没多少个网站全面HTTP/2.0,就连天天喊着自己书技术先锋的google,香港服务器的google也没有上HTTP/2.0,阿里云腾讯云这些网站都是HTTP/2.0了,感兴趣可以抓包看看。

HTTP 响应包

包体中包括了HTTP的响应内容,在浏览器中抓包HTTP响应包的包头如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
HTTP/1.1 200 OK
Bdpagetype: 2
Bdqid: 0xc881c7ec00030628
Cache-Control: private
Connection: keep-alive
Content-Encoding: gzip
Content-Type: text/html;charset=utf-8
Date: Sat, 11 Sep 2021 06:31:48 GMT
Expires: Sat, 11 Sep 2021 06:31:48 GMT
Server: BWS/1.1
Set-Cookie: BDSVRTM=129; path=/
Set-Cookie: BD_HOME=1; path=/
Set-Cookie: H_PS_PSSID=34446_34530_34144_31254_34004_34599_34584_34092_34106_34615_26350_34555; path=/; domain=.baidu.com
Strict-Transport-Security: max-age=172800
Traceid: 1631341908035053927414448048896007669288
X-Frame-Options: sameorigin
X-Ua-Compatible: IE=Edge,chrome=1
Transfer-Encoding: chunked

在开头标识了一个200,这里就是我们日常看到的那个HTTP状态码,依然可以只关心几个比较重点的东西,比如Set-Cookie包含了需要新设置的Cookie,Content-Type代表了返回个报文解析格式,这里是text/html,日常我们用的application/json也是其中一种。

其实对于HTTP协议中存的东西谁也记不住这里面具体有什么,理解原理之后,什么时候用到什么时候查一下便是。

服务端处理逻辑

终于服务端收到了数据包,把数据包按照之前数据包打包的协议进行解包,解包到最后就可以拿到HTTP的请求。

服务端解析HTTP请求

在上面发送HTTP请求的步骤中我简化了四层去把一个HTTP包进行打包,进行结果的过程和这个恰好相反,步骤大概是上面那个图的④ ③ ② ①。打包是一层层的打,解包也是一层层的解。

解包后应用层应用程序拿到的HTTP请求只需要转化为服务应用程序可以处理的对象即可根据请求的URL、PORT、METHOD、HEAD、PARAM进行对应的逻辑计算。

服务端处理并准备好结果

这里没什么好说的,无非就是我们进行增删改查,然后计算好一个处理结果,然后把结果处理一番处理成一个浏览器端可以使用的状态,然后返回。如果非要说的话可能包含了如下几个流程。比如 http://baidu.com/search?wd=guanjianzi

根据URL、PORT、METHOD找到服务端的处理逻辑。就是找到服务端对应的s函数参数为wd=guanjianzi。

检查参数合法。检查传入的参数wd是不是合法的参数,有没有加入什么奇怪的字符或者语句对服务器不太好或者不太安全。

检查cookies或者用户。检查是哪个用户进行的搜索。

执行服务端代码。按照参数为wd=guanjianzi执行s函数,拿到执行返回结果。

结果返回。把返回的数据进行处理转换成文本协议。

无论是那种cgi的方式来执行代码还是那种使用dispatch的方式来执行,大都是这么个流程,服务端就是个有一点特殊的电脑在执行代码而已。

服务端响应

在HTTP的请求头中会标识着accept,期望能够拿到的数据格式,但是服务端处理完之后可以设置Content-Type来配置响应格式。例如在web服务器中,获取一个静态资源可能accept填写的是text/xml,而返回的Content-Type返回的是text/html,也是完全ok的。现在除了静态页面的返回,大部分场景下我们还会返回文件和json(application/json)等等。

服务端二进制对象的序列化

客户端和服务端都遵守同一种协议来传输数据和对数据格式进行转换来适配HTTP这种文本协议,一个HTTP流程中服务端对要返回的数据进行序列化,转换为文本,返回给客户端,客户端进行反序列化,转化成浏览器可以使用的js对象。

经常使用的协议有json和xml这两种(其实还有很多不错的协议比如ProtoBuf)。其原理无非是按照一定的规则转换成文本格式,以json为例

1
2
3
4
5
6
7
8
9
10
11
class Test() {
String a = "Hello";
String b = "World";
}
String json = JSON.toJSONString(new Test());
此时的json字符串就为
{
"a":"Hello",
"b":"World"
}
浏览器端就可以拿到这个json进行反序列化。

客户端拿到响应

HTTP状态码

在HTTP的返回中头部的一开始就加入了HTTP的状态码,就是我们熟知的200 OK, 404 Not Found就是这其中的HTTP状态码。

分类 分类描述
1** 信息,服务器收到请求,需要请求者继续执行操作
2** 成功,操作被成功接收并处理。比如200就是成功
3** 重定向,需要进一步的操作以完成请求。比如302就是被重定向到新地址。
4** 客户端错误,请求包含语法错误或无法完成请求。404资源不存在,405POST、GET搞错了
5** 服务器错误,服务器在处理请求的过程中发生了错误。500服务端报错了

反序列化转化为js对象

就json格式而言,JavaScript Object Notation,是js可以直接使用的,但是我们平常所说的json,就是是指的json字符串,并不是js对象本身。在转换的过程中也充满了奇怪的坑,比如java端进行json序列化一个字节数据,js端对这个json进行反序列化不做正确处理就会失败,转不出来,我们可以把这种进行一些转化,比如字节数组转base64的操作就可以。

页面渲染

这里无需多言,就是把收到的数据,通过浏览器解析 HTML、CSS、JavaScript这些东西渲染成人类肉眼可见,脑袋可理解的界面。

不要走,还有下一篇

至此我大概写完了点击回车键之后的整个流程,但是仍然为了简化省去了超多细节,只好提取了部分特征。

下一篇我将从联调和部署环境的角度更加贴合实际情况的角度来关注服务端和前端页面交互时的接口问题,彻底弄清楚我们搞得那一堆奇怪的配置。