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

前言

在之前准备面试的八股文的时候,肯定不少会被问到例如"浏览器的渲染原理"、"从输入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树等,希望大家学得开心~

相关推荐
闲坐含香咀翠1 天前
记一次交互优化:从根源上解决Axios请求竞态问题
前端·http·浏览器
前端小巷子1 天前
浏览器的同源策略与跨域问题
前端·面试·浏览器
bo521001 天前
浏览器缓存优先级
前端·面试·浏览器
mortimer11 天前
Chrome 开发者工具终极指南:从入门到精通
前端·chrome·浏览器
专注VB编程开发20年11 天前
java/.net跨平台UI浏览器SDK,浏览器控件开发包分析
linux·ui·跨平台·浏览器·cef·miniblink
断竿散人11 天前
🌈CSS渐进增强实战指南:构建跨世代浏览器的稳健体验
前端·css·浏览器
前端小巷子14 天前
跨标签页通信(五):IndexedDB
前端·面试·浏览器
前端小巷子15 天前
跨标签页通信(四):SharedWorker
前端·面试·浏览器
Senar16 天前
听《富婆KTV》让我学到个新的API
前端·javascript·浏览器
前端小巷子16 天前
跨标签页通信(三):Web Storage
前端·面试·浏览器