前言
在之前准备面试的八股文的时候,肯定不少会被问到例如"浏览器的渲染原理"、"从输入URL到页面呈现都发生了什么"这类的问题。
浏览器的渲染步骤,从解析dom、cssom树,到获取layout树,获取绘制指令等一系列步骤最后到光栅化和画;以及单线程JS为了实现异步的事件循环设计,这些都是我们背烂了的东西。
但是入职之后,很多时候这些知识并不能满足我们的开发尤其是调试(修bug)。因此我一直很想知道浏览器的底层机制到底是怎么样的,不光是面向前端,而是面向操作系统。
国内很少有针对浏览器的书籍,更多的也是面向面试、或偏向网络基础网络安全、或是最底层的OS (涉及太多知识,不能在一个合适的周期内学完)。在经过一段时间的寻找之后,我找到了一本来自犹他大学教授写的《Web Browser Engineering》(browser.engineering/http.html )。
这本书从一个相比于前端开发更广的角度,讲解了浏览器的一系列机制,并用Python实现一个简易的浏览器。 刚好硕士学习的是人工智能,对Python也比较熟练,这里会一边讲解对这本书的观后感结合自己对浏览器的理解,一边也一起实现这个简易的浏览器。
完整的代码我会放在github,代码会随着这个专题一起更新并完善对应的readme。 github.com/codeAlwaysP...
Tips
实践部分也会涉及到一部分的浏览器机制,但因为更细节,所以没有放在原理讲。如果希望对浏览器机制更熟悉的小伙伴,建议完整地看完~
原理
URL的定义
"A web browser displays information identified by a URL." 浏览器会展示一份以URL为身份的信息。我们现在浏览的每一个网站都是由一个URL组成的,一个URL的主要组成部分有(这里暂时忽略端口、参数等):
css
https://(协议)example.org(主机)/index.html(路径)
协议 + 主机 + 路径
这个URL最终会被我们熟知的DNS解析成一个IP地址,比如www.baidu.com 会被解析为 119.63.197.151,如果你在浏览器中键入这个ip地址,也能访问到百度。可以用www.nslookup.io/ 这个网站转换其他的URL。
建立请求
那么浏览器是怎么访问URL的?这里最重要的一点是,浏览器本身不能直接操作网络硬件,而是通过调用OS(操作系统)提供的网络接口发起请求。由OS负责解析 DNS、选择网络接口(如以太网或 WiFi),封装数据包并通过网络协议栈发送出去。数据在传输过程中会经过路由器、交换机等中间设备,最终抵达目标服务器,建立连接。
浏览器调用OS提供的接口顺序,大致可以分为以下几个环节(注意这里的调用方都是浏览器,而调用后的执行方都是OS内核):
- socket():OS内核创建socket对象,分配文件描述符
- connect():OS内核尝试建立连接,包括TCP三次握手
- send():OS内核将数据写入发送缓冲区,交给网卡驱动向请求的地址发出
- recv():OS内核从接收缓冲区中取出响应数据,返回给浏览器
- close():OS内核释放资源,可能涉及TCP四次挥手
socket
这里重点介绍一下socket函数,作为浏览器告诉OS:"我要发起一个请求。" 的第一步,socket函数是这场对话最重要的传话者。我们可以看下这个函数在C++中的写法:
c++
SOCKET WSAAPI socket(
[in] int af,
[in] int type,
[in] int protocol
);
socket函数提供了三个参数:
- af:又叫address family,中文是地址族或地址系列规范,最经典的代表是IPV4或IPV6。
- type:套接字的类型规范,最常见的有SOCK_STREAM(常用于TCP)和SOCK_DGRAM(常用于UDP)。
- protocol:要使用的协议类型,常见的是IPPROTO_TCP和IPPROTO_UDP。
socket的具体文档可以参考learn.microsoft.com/zh-cn/windo... 。
调用socket函数后,OS此时就准备好开始这次请求,这时浏览器就可以开始执行后续的connect、send等函数,OS会根据这些函数的调用网卡驱动执行对应的操作。在整个过程中,浏览器并没有任何可以调用电脑硬件的资格和能力,而是通过OS提供的接口,请求OS进行相关的行为并获取资源,进行后续的渲染。
像浏览器这样的没有硬件操作权限、只是在OS提供的资源的基础上进行服务的软件,被称为用户态;而像OS这样可以调用硬件的系统层,被称为内核态(Kernel)。用我自己的话总结,浏览器上网的本质是用户态通过接口和内核态的交互。
实现
接下来,我们使用python来实现一个简易的浏览器,这里可能会忽略一些细节的处理部分,主要是带大家一起实现浏览器的一些基本功能,了解整个浏览器运行的机制。没有学过python的小伙伴们可以大概看一下python的语法,在python类中,self和js中对象的this指向本质上一样,self指向了当前所在的类。
URL
我们建立一个名为URL的类,负责url的解析、请求的发送以及响应的接收。这里我们可以使用python自带的socket库,它一样也是OS提供的socket接口。另外考虑到可能会建立https连接,我们也引入一个ssl库。
初始化、url分解
在类的初始化阶段,我们可以先将传入的url进行拆解,拆分出协议、主机、路径、端口这四部分。这里我们要利用一个知识点:Http的默认端口是80,Https默认的端口是443。
python
import socket
import ssl
class URL:
def __init__(self, url):
# 将传入的url拆分为协议和剩余部分
self.scheme, url = url.split("://", 1)
# 检查协议是否在白名单内
assert self.scheme in ['http', 'https']
# 为url补全路径部分
if "/" not in url:
url = url + "/"
# 将剩余url拆分为主机和路径部分
self.host, url = url.split("/", 1)
self.path = "/" + url
# 根据协议选择默认端口
if self.scheme == 'http':
self.port = 80
elif self.scheme == 'https':
self.port = 443
# 如果主机包含端口号, 则拆分出来
if ":" in self.host:
self.host, port = self.host.split(":", 1)
self.port = int(port)
建立socket实例、准备请求
经过初始化阶段后,我们已经初步可以解析出我们建立连接所需要的信息了,当然这里还没有一些特殊情况的处理,后续会进一步完善。有了信息,我们就需要定义一个request方法来建立连接了。
我们使用socket.socket初始化一个套接字实例,这里的参数是一个比较经典的TCP请求的组合,如果想要探索更多组合的小伙伴可以去看下文档,对响应的处理也需要变化。
初始化完毕后,我们可以调用connect函数向指定的主机+端口发起连接。连接被创建后,我们就可以写我们的请求头了。这里我们只考虑GET的情况,所以先不写请求体了。
请求头的标准格式可以参考developer.mozilla.org/zh-CN/docs/... ,这里给到一个示例:
plain
GET /home.html HTTP/1.1
Host: developer.mozilla.org
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:50.0) Gecko/20100101 Firefox/50.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://developer.mozilla.org/testpage.html
Connection: keep-alive
Upgrade-Insecure-Requests: 1
If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT
If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a"
Cache-Control: max-age=0
这里的每个字段的意思就不细说,需要强调的重点反而是,请求头的每一行都需要用 \r\n 进行换行,此外在请求头的最后一行输入结束后,需要再进行一次换行,代表请求头的信息到此为止。
利用这个标准,我们可以制作我们的简易请求头。
python
class URL:
...
def request(self):
# 浏览器通信基于底层OS的socket
s = socket.socket(
family=socket.AF_INET,
type=socket.SOCK_STREAM,
proto=socket.IPPROTO_TCP
)
# 如果协议是https, 则创建SSL安全套接字
if self.scheme == 'https':
ctx = ssl.create_default_context()
s = ctx.wrap_socket(s, server_hostname=self.host)
# 连接到服务器, 端口取决于默认端口或url中的指定端口
s.connect((self.host, self.port))
# 使用宏替换符号拼接请求和主机
# \r\n代表换行且光标回到行首,\n代表换行但光标垂直向下, 使用\r\n非常重要
request = "GET {} HTTP/1.0\r\n".format(self.path)
request += "Host: {}\r\n".format(self.host)
# User-Agent需要真实一点,这代表浏览器的身份标识,不真实的User-Agent在Https的请求下可能会被拒绝或重定向至http
request += "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\n"
# 最后一次空行告诉浏览器请求已经发送完毕
request += "\r\n"
发送请求、获取响应
只要电脑的网络保持正常,我们就可以继续发出请求并且拿到响应。和浏览器的顺序一样,我们在python中执行send方法,这里需要注意的是,由于我们自定义的request是text的形式,而最终我们的请求应该以字节的方式发送,因此我们还需要对我们的request进行一次编码,这里用最经典的utf-8进行编码即可。
收到响应后,我们可以按照响应的标准格式解析信息。响应的标准格式可以参考 developer.mozilla.org/zh-CN/docs/... 。
响应头的第一行信息固定由三部分组成,协议版本、响应码、响应状态;接下来就是我们熟知的一些字段如Set-Cookie、Transfer-Encoding等。
python
class URL:
def request(self):
...
# 发送请求
s.send(request.encode("utf8"))
response = s.makefile("r", encoding="utf8", newline="\r\n")
# 获取响应头的第一行信息
statusline = response.readline()
version, status, explanation = statusline.split(" ", 2)
# 获取响应头的剩余信息
response_headers = {}
while True:
line = response.readline()
if line == "\r\n": break
header, value = line.split(":", 1)
response_headers[header.casefold()] = value.strip()
content = response.read()
s.close()
return content
简单测试
现在,我们已经基本实现了浏览器请求和获得响应的功能,结合书中给的范例,我们可以简单测试。第一章我们可以先忽略返回的html文本中带有的tag,比如div、h、p这些,单纯输出标签内部的文本内容。
python
class URL
...
def show(body):
in_tag = False
for c in body:
if c == "<":
in_tag = True
elif c == ">":
in_tag = False
elif not in_tag:
print(c, end="")
def load(url:URL):
body = url.request()
show(body)
if __name__ == "__main__":
import sys
load(URL(sys.argv[1]))
假设你的文件叫url.py,在命令行中执行,我们就可以看到终端输出的网页的返回文本了。
cmd
python3 url.py https://www.baidu.com
练习
在这本书中,作者也留下了几个练习,感兴趣的小伙伴可以去看下这本书,试着自己实现一下。在我上面提供的github链接里,已经实现了比如支持HTTP1.1,缓存长连接、缓存返回的文本、支持gzip和chunked等功能,如果你想去参考一下或者上述的代码执行有问题,也可以去拉我的代码下来看。
总结
在本章节,我们主要学到了:
- 浏览器的运行原理是使用操作系统提供的接口,委托操作系统调用网络相关的底层功能及硬件,与请求目标进行交互。
- 请求头、响应头的基本格式。
- 如何使用python简单实现浏览器的请求和处理响应的功能。
接下来的章节会继续讲述浏览器如何渲染响应内容,以及后续的解析dom、cssom树等,希望大家学得开心~