《探索浏览器底层并实现简易浏览器 -- 第一章:请求和响应》

前言

在之前准备面试的八股文的时候,肯定不少会被问到例如"浏览器的渲染原理"、"从输入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内核):

  1. socket():OS内核创建socket对象,分配文件描述符
  2. connect():OS内核尝试建立连接,包括TCP三次握手
  3. send():OS内核将数据写入发送缓冲区,交给网卡驱动向请求的地址发出
  4. recv():OS内核从接收缓冲区中取出响应数据,返回给浏览器
  5. 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等功能,如果你想去参考一下或者上述的代码执行有问题,也可以去拉我的代码下来看。

总结

在本章节,我们主要学到了:

  1. 浏览器的运行原理是使用操作系统提供的接口,委托操作系统调用网络相关的底层功能及硬件,与请求目标进行交互。
  2. 请求头、响应头的基本格式。
  3. 如何使用python简单实现浏览器的请求和处理响应的功能。

接下来的章节会继续讲述浏览器如何渲染响应内容,以及后续的解析dom、cssom树等,希望大家学得开心~

相关推荐
魔云连洲10 天前
浏览器强缓存还未过期,但服务器资源已经变了怎么办?
前端·缓存·浏览器
打小就很皮...1 个月前
浏览器存储 Cookie,Local Storage和Session Storage
前端·缓存·浏览器
小妖6661 个月前
chrome 浏览器怎么不自动提示是否翻译网站
浏览器
大名人儿1 个月前
【浏览器网络请求全过程】
浏览器·网络请求·详解·全过程
windliang1 个月前
Cursor 写一个网页标题重命名的浏览器插件
前端·浏览器
前端付豪1 个月前
1、为什么浏览器要有渲染流程? ——带你一口气吃透 Critical Rendering Path
前端·后端·浏览器
啵啵学习1 个月前
浏览器插件,提示:此扩展程序未遵循 Chrome 扩展程序的最佳实践,因此已无法再使用
前端·chrome·浏览器·插件·破解
前端南玖1 个月前
通过performance面板验证浏览器资源加载与渲染机制
前端·面试·浏览器
mx9511 个月前
真实业务场景:在React中使用Web Worker实现HTML导出PDF的性能优化实践
性能优化·浏览器