实战中学习浏览器工作原理 — 之 HTTP 请求与解析
我是三钻,一个在《技术银河》中等你们一起来终生漂泊学习。
点赞是力量,关注是认可,评论是关爱!下期再见 👋!
前沿
浏览器工作原理是一块非常重要的内容,我们经常看到的 重绘
、重排
或者一些讲解CSS属性的时候,都会用到一些浏览器工作原理的知识来讲解。理论化学习浏览器工作原理,效果不是很大,而且很枯燥,所以这里我们从零开始用 JavaScript
来实现一个浏览器。
通过自己实现一遍简单的浏览器,我们会对浏览器的基本原理有更为深刻的理解。
浏览器基础渲染流程
首先浏览器是由5个步骤完成的整体渲染
我们从URL访问一个网页,经过浏览器的解析和渲染后成为了Bitmap
最后通过我们的显卡驱动设配出去画面,让我们看到完成的页面
这是一个浏览器的渲染流程
这里我们只实现一个简单的基础流程,但是真正的浏览器还包含了很多功能,比如历史等等
我们主要需要完成的是从 URL 请求到 Bitmap 页面展示的整个流程就可以了。
浏览器流程:
URL
部分,经过HTTP
请求,然后解析返回内容,然后提取HTML
内容得到
HTML
后,我们可以通过文本分析(parse),然后把HTML的文本编程一个DOM
树这个时候的
DOM
树是光秃秃的,下一步我们进行 CSS 计算(CSS computing),最终把 CSS 挂载在这个 DOM 树上经过计算后,我们就拥有一个有样式的 DOM 树,这个时候我们就可以布局(或者排版)了
通过布局计算,每一个 DOM 都会得到一个计算后的盒(当然实际浏览器中是每个CSS都会生成一个盒,但是为了简化这个,我们这里只做到每个 DOM 只生成一个盒即可)
最后我们就可以开始渲染(Render),把这个 DOM 树该有背景图的有背景图,该有背景色的有背景色,最后把这些样式画到一张图片上。然后我们可以通过操作系统和硬件驱动提供的API接口,展示出来给用户看了。
有限状态机去处理字符串
因为这个处理字符串是整个的浏览器里面贯穿使用的技巧,如果不会用这个状态机,后面实现和读浏览器实现的代码会非常吃力。所以这里我们先讲讲什么是有限状态机。
每一个状态都是一个机器
每个机器都是互相解耦,强有力的抽象机制
在每一个机器里,我们可以做计算、存储、输出等
所有的这些机器接受的输入是一致的
状态机的每一个机器本身没有状态,如果我们用函数来表达的话,它应该是纯函数(无副作用)
无副作用指的是:不应该再受外部的输入控制,输入是可以的
每一个机器知道下一个状态
每一个机器都有确定的下一个状态(Moore)
每一个机器根据输入决定下一个状态(Mealy)
JavaScript 中如何实现
Mealy 状态机:
以上代码我们看到,每一个函数是一个状态
然后函数的参数是输入
input
这个函数的返回值就是下一个状态,也就意味着下一个返回值一定得是一个状态函数
状态机理想的实现方式:一系列返回状态函数的一批状态函数
调用状态函数的时候,往往会用一个循环来获取输入,然后通过
state = state(input)
,来让状态机接收输入来完成状态切换Mealy
型状态机,返回值一定是根据input
返回下一个状态Moore
型状态机,返回值是与input
没有任何关系,都是固定的状态返回
不使用状态机处理字符串
我们首先了解一下,在不使用状态机的情况下来实现一些字符串的处理方式:
第一问题:在一个字符串中,找到字符“a”
第二个问题:不准使用正则表达式,纯粹用 JavaScript 的逻辑实现:在一个字符串中,找到字符“ab”
「直接寻找 a
和 b
,都找到时返回」
第三个问题:不准使用正则表达式,纯粹用 JavaScript 的逻辑实现:在一个字符串中,找到字符“abcdef”
方法一:「使用暂存空间,移动指针来检测」
方法二:「使用 substring
和匹配字符的长度来截取字符,看是否等于答案」
方法三:「逐个查找,直到找到最终结果」
使用状态机处理字符
这里我们使用状态机的方式来实现:在一个字符串中,找到字符“abcdef”
首先每一个状态都是状态函数
我们应该有一个开始状态和结束状态函数,分别问题
start
和end
状态函数名字都代表当前状态的情况
matchedA
就是已经匹配中a
字符了,以此类推每一个状态中的逻辑就是匹配下一个字符
如果匹配成功返回下一个状态函数
如果匹配失败返回开始状态
start
因为字符中最后一个是
f
字符,所以matchedE
成功后,可以直接返回 结束状态end
end
这个结束状态,也被称为陷阱方法 (Trap),因为状态转变结束了,所以让状态一直停留在这里,知道循环结束
问题升级:用状态机实现字符串“abcabx”的解析
这个问题与上面的区别在于"ab"有重复
所以我们分析的逻辑应该是:
第一次 “b” 后面是 "c",而第二次 “b” 后面就应该是 “x”
如果第二次的后面不是 “x” 的话就回到上一个判断状态函数
HTTP 协议解析基础知识
ISO-OSI 七层网络模型
HTTP
组成:
应用
表示
会话
对应 node 的代码里,我们有熟悉的
require('http')
TCP
组成:
传输
因为网页是需要可靠传输,所以我们只关心TCP
Internet
组成:
网络
有时候讲上网有两层意思
网页所在的应用层的协议(外网)—— 负责数据传输的是 Internet
公司内网,叫 Intranet
4G/5G/Wi-Fi
组成:
数据链路
物理层
为了完成对数据准确的传输
传输都是点对点的传输
必须有直接的连接才能进行传输
TCP与IP的基础知识
流
TCP层中传输数据的概念是 “流”
流是一种没有明显的分割单位
它只保证前后的顺序是正确的
端口
TCP 协议是被计算机里面的软件所使用的
每一个软件都会去从网卡去拿数据
端口决定哪一个数据分配给哪一个软件
对应 node.js 的话就是应用
require('net')
包
TCP的传输概念就是一个一个的数据包
每一个数据包可大可小
这个取决于你整个的网络中间设备的传输能力
IP地址
IP根据地址去找到数据包应该从哪里到哪里
在 Internet 上的连接关系非常复杂,中间就会有一些大型的路由节点
当我们访问一个 IP 地址时,就会连接上我们的小区地址上,然后到电信的主干
如果是访问外国的话,就会再上到国际的主干地址上
这个IP地址是唯一的标识,连入 Internet 上的每一个设备
所以 IP 包,就是通过 IP 地址找到自己需要被传输到哪里
libnet/libpcap
IP 协议需要调用到 C++ 的这两个库
libnet 负责构造 IP 包并且发送
labpcap 负责从网卡抓取所有流经网卡的IP包
如果我们去用交换机而不是路由器去组网,我们用底层的 labpcap 包就能抓到很多本来不属于发给我们的IP包
HTTP
组成
Request 请求
Response 返回
相对于 TCP 这种全双工通道,就是可以发也可以收,没有优先关系
而 HTTP 特别的是必须得先由客户端发起一个 request
然后服务端回来一个 response
所以每一个 request 必定有一个对应的 response
如果 request 或者 response 多了都说明协议出错
HTTP请求 —— 服务端环境准备
在我们编写自己的浏览器之前,我们首先建立一个node.js服务端。
首先我们编写一个 node.js
的服务端:
了解 HTTP Request 协议
在编写我们的客户端代码之前,我们需要先了解一下 HTTP 协议。
我们先来看看 HTTP 协议的 request 部分:
POST / HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
field1=aaa&code=x%3D1
HTTP 协议是一个文本型的协议,文本型的协议一般来说与二进制的协议是相对的,也意味着这个协议里面所有内容都是字符串,每一个字节都是字符串的一部分。
HTTP 协议的第一行叫做
request line
,包含了三个部分:Method:例如 POST,GET 等
Path:默认就是 “/”
HTTP和HTTP版本:HTTP/1.1
然后接下来就是
Headers
Header的行数不固定
每一行都是以一个冒号分割了
key: value
格式Headers是以空行进行结束
最后的一部分就是
body
部分:这个部分的内容是以
Content-Type
来决定的Content-Type 规定了什么格式,那么 body 就用什么格式来写
接下来我们就可以开始编写代码了!
实现 HTTP 请求
设计一个HTTP请求的类
content type 是一个必要的字段,要有默认值
body 是 KV 格式
不同的 content-type 影响 body 的格式
Request 类
请求方法
Request 类中的 send 函数编写
Send 函数是一个 Promise 的形式
所以在 send 的过程中会逐步收到 response
最后把 response 构造好之后再让 Promise 得到 resolve
因为过程是逐步收到信息的,我们需要设计一个 ResponseParse
这样 Parse 可以通过逐步地去接收 response 的信息来构造 response 对象不同的部分
设计 ResponseParser
Receive 函数接收字符串
然后用状态机对逐个字符串进行处理
所以我们需要循环每个字符串,然后加入
recieveChar
函数来对每个字符进行处理
了解 HTTP Response 协议
在接下来的部分,我们需要在代码中解析 HTTP Response 中的内容,所以我先来了解一下 HTTP Response 中的内容。
HTTP/1.1 200 OK
Content-Type: text/html
Date: Mon, 23 Dec 2019 06:46:19 GMT
Connection: keep-alive
Transfer-Encoding: chunked
26
<html><body> Hello World <body></html>
0
首先第一行的
status line
与 request line 相反第一部分是 HTTP协议的版本:HTTP/1.1
第二部分是 HTTP 状态码:200 (在实现我们的浏览器,为了更加简单一点,我们可以把200以外的状态为出错)
第三部分是 HTTP 状态文本:OK
随后的部分就是 header 部分
HTML的 request 和 response 都是包含 header 的
它的格式跟 request 是完全一致的
最后是一个空行,用来分割 headers 和 body 内容的部分的
最后这里的就是 body 部分了
这里 body 的格式也是根据 Content-Type 来决定的
这里有一种比较典型的格式叫做
chunked body
(是 Node 默认返回的一种格式)Chunked body 是由一个十六进制的数字单独占一行
后面跟着内容部分
最后跟着一个十六进制的0,0之后就是整个 body 的结尾了
这个也是用来分割 body 的内容
实现发送请求
这里我们开始实战,通过实现 send 函数中的逻辑来真正发送请求到服务端。
设计支持已有的 connection 或者自己新增 connection
收到数据传给 parser
根据 parser 的状态 resolve Promise
通过以上思路,我们来实现代码:
实现 RequestParser 类
现在我们来具体实现 RequestParser类的代码。
Response 必须分段构造,所以我们要用一个 Response Parser 来 “装配”
ResponseParser 分段处理 Response Text,我们用状态机来分析文本结构
实现 Body 内容解析器
最后我们来实现 Body 内容的解析逻辑。
Response 的 body 可能根据 Content-Type 有不同的结构,因此我们会采用子 Parser 的结构来解决问题
以 ChunkedBodyParser 为例,我们同样用状态机来处理 body 的格式