计算机网络基础

379 阅读39分钟

前言

本文旨在让计算机开发者能够快速了解计算机网络的基础知识,并且能够手写 HTTP 协议,了解其底层原理。

比如现在大家都用 Webpack,但是很少有人知道为什么要用 Webpack,看了本文你就会知道原因。另外,在 Hybrid App、Weex、React Native 或者 Electron 这些和底层打交道技术,底层通信往往需要一套协议去规范消息格式。而且,底层通信的消息长度是有限制的,所以如果要把大量的信息传给底层,需要进行分包处理。如果学习了 HTTP 协议的原理,那就会明白 HTTP 是怎么在 TCP 中做分包的,这种思路也可以用在底层通信中。所以,学习计算机网络,不仅是学习新知识,更是触类旁通,能够将其中的思想活学活用到日常开发中。

为了能够更清楚的解析每一步的代码思路,本文的代码写的有些冗余,请谅解。

OSI 模型

国际标准化组织给全球的计算机网络设置了一个标准化模型,叫做开放式系统互联通信参考模型(Open System Interconnection Reference Model,缩写为 OSI),计算机根据这个模型就可以与全球的互联网通信了。该模型把计算机网络分成了 7 个层次,7 层最高,1 层最低,高层是低层的应用实现,低层是高层的基础。

随着计算机技术的发展,有一些复杂的协议甚至跨越了多个层,而且层级越高,各个协议之间的界限也越来越模糊,甚至在最高的应用层,也有基于应用层二次封装的更高的协议,那它应该属于哪一层呢?因此,我们了解 OSI 模型,只需要了解现代计算机网络的组成部分,并不需要过多的了解各个层级的具体差别。下面就来简单的介绍一下这几个层级:

物理层

物理层就是实现计算机网络通信的各种基础硬件,比如网线、网卡、网口、中继器、集线器等。注意,路由器其实包含集线器功能和路由软件功能,后者不属于物理层,所以路由器一般不算做物理层。

数据链路层

数据链路层就是在物理层之上的基础软件,负责网络通信,包含寻址、错误侦测、改错等保证正常通信的基础功能。像以太网、WiFi、2/3/4/5G 这些都属于数据链路层。

网络层

有了 1-2 层,计算机就可以向外发送数据了,但是这就和寄快递一样,现在仅仅有了寄出能力,具体寄给谁,怎么收别人的快递还没有一个方案。因此还需要一个统筹整个网络的协议,定义整个网络的收发规则,这就是网络层。用的最多的网络层就是 IP 协议,它对计算机进行编号,在同一个局域网内每个计算机都有一个独立无二的 IP 地址,这样发出的网络数据就可以传输给具体的某台计算机。在第六代 IP 协议(IPv6)中,IP 地址的容量大大增加,甚至可以做到世界上每一粒沙子都有一个 IP 地址,这为万物互联提供了基础。

传输层

在保证了数据包的收发以后,就要对数据的传输做更加精细的控制了,比如怎么保证数据包的先后顺序、怎么保证数据包不丢失、丢失数据包以后怎么重新传输等,这就是传输层。传输层有两种协议:TCPUDP。TCP 是先在两台计算机中打开一条通道,然后在这个通道上面传输数据,加上强大的重传机制,可以保证高质量的传输。而 UDP 不建立连接,直接发送数据,虽然没有建立连接的延迟,但是数据可能丢失、乱序。

会话层

1-4 层其实已经可以完成数据交换了,而对于 TCP 这样基于“连接”的协议还有一定优化的空间。会话层就是用来维护两台计算机之间数据传输连接的。比如在一个 TCP 连接中,计算机 A 发送了一些数据给计算机 B,发送完毕以后,B 计算机不会立刻关闭连接,而是等待一段时间,在这段时间内 A 计算机再次发送数据时就不会再重新建立连接了。还有 SSL/TLS 协议,它在两台计算机建立链接时会做证书认证。因此,会话层提高了传输速度,同时保障了传输的安全性。

表现层

在保障了数据传输的过程后,数据本身也有一定的优化空间。表现层负责对数据进行压缩、加密,提高了传输的效率和安全性。如 GZIP 协议会对数据进行压缩,SSL/TLS 协议会对数据进行加密。

应用层

应用层就是根据 1-6 层提供的功能,把它们组合成更强大的新功能。比如最常用的 HTTP 协议,它就是基于 TCP 协议,定义传输数据的格式,从而可以在浏览器和 Web 服务器中传输数据。如果加上第 5-6 层的 TLS,就可以实现 HTTPS。

学习建议

物理层不是软件,数据链路层、网络层由网络硬件内置固件提供,而传输层一般都是操作系统和网卡驱动共同实现。因此,对于一般开发者来说,个人认为传输层就是最底层了,我们只需要了解传输层协议的原理,但是不需要去手写它。对于更高的 5-7 层,开发者完全有能力自己手写,从而能最深刻的了解它们。

网络基础概念

IP 地址

IP 是 Internet Protocol 的缩写,属于 OSI 模型中的网络层协议,而 IP 地址是 IP 协议的一部分,代表计算机在网络上的地址。

当计算机的一张网卡接入网络时,这张网卡就必须要有一个 IP 地址,这个 IP 地址在网络上是唯一的,不能与其他的网卡冲突。就算是同一台计算机的不同网卡(如有线网卡和无线网卡),在接入同一个网络时,它们的 IP 也不一样。IP 地址可以手动设置,但为了保证每一个网卡的 IP 地址都不相同,在现代计算机网络中,通常采用服务器自动分配的方式,这个分配 IP 地址的协议叫做DHCP(动态主机设置协议,Dynamic Host Configuration Protocol)

随着 IP 版本的变化,IP 地址的格式也有所不同。目前广泛采用的是第四代 IP(IPv4)地址,它的格式是“点分十进制”,也就是 a.b.c.d,其中 a,b,c,d 都是 0~255 之间的十进制整数,如 192.168.1.1。由此可见,IPv4 地址的数量就是 256^4=4294967296,也就是 4 亿多个,数量相比于如今的网络设备来说十分稀缺。

而第六代 IP(IPv6)地址是一个 128bit 的地址,一般用 16 进制表示,每 4 个 16 进制字符为一组,中间用冒号:隔开,如 ABCD:EF01:2345:6789:ABCD:EF01:2345:6789。IPv6 地址极大的扩充了 IP 地址的数量,可以做到“世界上每一粒沙子都有自己的 IP 地址”。

路由、网关与 NAT

在 IPv4 的时代,IP 地址早就不够用了,为了解决这个问题,人们从生活中找到了灵感——每家每户派一个代表出来联网,他会把全家的数据对外收发。我们把这个代表叫做路由(Route),由于历史原因,很多文献也把路由称为网关(Gateway),但其实这两者不能完全等价,网关是处理网络数据的一种设备,可以说,路由是一种可以转发内网和外网数据的网关,而新的网关还有防火墙等其他功能。

一台路由器的核心功能叫做NAT(Network Address Translation,网络地址转换),路由器首先会在互联网上取得一个 IP 地址,这个路由器上连接的所有设备都会通过这个 IP 地址来访问互联网。路由器内部又会新开一个叫作局域网的网络环境,局域网的 IP 地址和外部完全隔离。比如有两台路由器,它们在互联网上的 IP 分别是 1.1.1.1 和 1.1.1.2,而它们的内部 IP 地址都是 192.168.1.1,虽然它们的内部 IP 地址都一样,但是由于内部的局域网和外部的互联网完全隔离,所以此时互联网上只有 1.1.1.1 和 1.1.1.2 这两台路由器,它们的 IP 是不重复的,所以不会产生冲突。当连接路由器的设备需要访问互联网时,会先给路由器发送请求,路由器又会把这个请求传递到互联网,等待互联网返回信息时,再传回原设备。因此,路由器上的所有设备都共用一个互联网 IP,极大地解决了 IP 地址不足的问题。

NAT 技术不仅解决了网络地址不足的问题,它还会保护局域网上的所有设备免受互联网攻击。由于每次对外通信都是路由器代劳的,所以就算黑客想攻击网络设备,他也只能攻击路由器本身而无法攻击局域网上的设备,就像你去邮局寄东西,中途包裹被人截获,这个人也只能知道这个包裹是这个邮局寄出的,而不知道寄件人的地址,他顶多只能把邮局炸了,影响不到寄件人自身。

此外,随着 IPv4 地址越来越不够用,在 NAT 技术非常普及的今天,局域网的规模也不仅是一台路由器这么大了:一个区域、一个城市、甚至一个国家都是一个局域网。所以,就算你不用路由器直接连接电信网络,电信会给你所谓的“公网 IP”,也仅仅只是在你所在的城市这个大局域网下的 IP。如果真的需要一个全球性的 IP 地址,可以去电信高价购买,或者选择阿里云这样的云服务商。所以,这也是为什么用一台设备调试另一台设备的时候,需要保持两台设备在同一个网络下。如果两台设备不在同一个网络下,就算都直连“互联网”,也可能因为一台是电信一台是联通而分别连接的是电信、联通的局域网而非真正的互联网。

域名与 DNS

域名(Domain Name) 是由一串用点分隔的名字组成的字符串,如www.baidu.com。域名是IP的别名,由于IP地址非常难记,所以人们就给它一个别名方便记忆。

一个域名对应着一个 IP,由于计算机只认 IP 地址,所以在访问一个域名以后,计算机会先去DNS(域名系统,Domain Name System) 查找该域名对应的 IP 地址,再去访问这个 IP。

IP 和域名的关系,一般来说,一个 IP 可以有多个域名,而一个域名只对应一个 IP。不过更严格的说法是,在一个 DNS 中,一个 IP 可以有多个域名,而一个域名同时只对应一个 IP

至于为什么一个 IP 可以有多个域名,很简单,就像一个人可以有多个名字一样。随着时代的发展,人们还发明了一种新玩法——虚拟主机。也就是说,同一个 IP 地址,同一台服务器,你访问的域名不同,服务器给你提供的服务也不同,就像访问了不同的服务器一样。比如一台服务器同时绑定了 a.com 和 a.cn 这两个域名,它们都对应的同一个 IP 地址,但是服务器会根据域名的不同,访问前者会给你提供国际版网站,而访问后者就会给你提供中国版网站。这样又进一步的提高了 IP 地址的利用率。然而,域名的申请和维护也是要钱的,为了节省成本,人们又开发了二级域名的玩法:比如我还是注册了 a.com 这个域名,这时候我可以配置一个二级域名 cn.a.com,这个二级域名附属于一级域名,不需要额外注册,然后将这个二级域名当做虚拟主机访问就可以了。

而一个域名对应的 IP 可能会变化,这是由于现在的网站都是多台服务器同时运行的,它们分布在全球各地,美国的 DNS 解析该域名,就会返回美国的 IP 地址而不是中国的。或者当其中一台服务器挂掉时,DNS 返回的 IP 就应该是另一台正常的服务器 IP。还有一些小地方的电信运营商提供 DNS,第一次返回的是广告服务器的 IP 地址,二次访问才会返回真正的 IP 地址,我们把这种行为叫DNS 污染。因此,域名对应的 IP 地址随着环境会有许多变化,但是同一条件下一个域名只会有一个 IP 地址。

提供 DNS 的服务器叫做DNS 服务器(Domain Name Server),一般由电信部门提供,而由于 DNS 污染问题日益严重,各大云服务公司也推出了自己的 DNS 服务器,一些大公司也会在公司内部搭建私有 DNS 服务器,用来支持一些内部域名。由于只有访问到 DNS 才能解析域名,所以 DNS 服务器必须以 IP 地址的形式提供,否则就陷入悖论了。

传输层协议

TCP

TCP 全称传输控制协议(TCP,Transmission Control Protocol),它是一个基于连接的协议,也就是说,在它发送数据之前,先要和目标计算机建立一个连接。

TCP 的传输是可靠的,它拥有数据包丢失重传的功能,并且可以保证数据包的先后顺序。HTTP 协议也是基于 TCP 协议的应用层协议。

使用 TCP 协议时,首先开启一个 TCP 服务器,然后再开启一个 TCP 客户端,客户端连接服务器时,就建立了一个连接。当一方通过 send 方法发送数据时,另一方的 data 事件就会被触发,从而获取数据。不过由于单个数据包的大小是有限制的(最大 64K,取决于 MTU 和 Payload Length,参考TCP、UDP 数据包大小的限制),所以接收端会收到很多零碎的小数据,需要我们手动把这些数据拼接起来。拼接的时候直接顺序拼接就行,因为数据包的顺序问题 TCP 已经帮我们保证了。

TCP 协议的结构、特性,在TCP 详解这篇文章已经讲得非常到位了,这里不再重复,只把几个重要的点划出来。

三次握手和四次挥手

三次握手指的是 TCP 建立连接的时候需要有三次数据交流:

  • 客户端 - - > 服务器

    客户端对服务器发送一个请求:“你有一堆货物到了,我可以来你家派送吗?”

  • 客户端 < - - 服务器

    服务器收到客户端发过来的请求,对客户端说:“可以,你来吧。”

  • 客户端 - - > 服务器

    客户端收到服务器的确认后,再次对服务器说:“那你把门打开,我准备出发了”。

服务端收到消息后,打开了门,客户端就源源不断的把货物送给服务端。

Q&A: 为什么不在服务端确认以后直接发数据,而是又要给服务器发一条消息以后才开始传输

其实,多做一步的目的就是让客户端来控制服务器开门的时机,从而可以规避一些无效连接。比如,在第一次握手的时候,网络出现阻塞,导致客户端给服务器发送的请求没有被服务器确认,客户端会以为本次数据包丢失,就会又发一个请求给服务端。等网络通畅后,就会有两次请求发到服务端。如果只有两次握手,不去找客户端确认,那服务端此时就应该去建立连接,就开了两扇门给客户端。而如果是三次握手,服务端又会分别发送两条确认信息给客户端。客户端收到两条消息后,发现有一条是重复的,就会忽略其中一条,只会给服务器发送一个开门的消息,这样服务器就只会开一扇门了。

那么,四次挥手又是什么样的呢?

四次挥手是指关闭连接的时候,客户端和服务器直接进行的四次数据交流:

  • 客户端 - - > 服务器

    客户端对服务器发送一个结束请求:“我货发完了,准备溜了。”

  • 客户端 < - - 服务器

    服务器收到客户端发过来的请求,对客户端说:“收到,我最后再给你说几句话。”

  • 客户端 < - - 服务器

    服务器说完了他想说的话,对客户端说:“准备好了,你可以走了。”

  • 客户端 - - > 服务器

    客户端收到服务器的告别信息后,对服务器说:“再见。”,服务器关上了门,本次连接结束。

  • 客户端等待 2MSL 后关闭连接

    按理来说,服务器收到客户端的告别信息后,就会关门,结束这次连接。但是客户端说的这句“再见”可能由于网络原因没有正确送达服务器,导致服务器迟迟没有关门。所以客户端此时不会立马走人,他会在服务器门口等待 2MSL(Maximun Segment Life,报文最大寿命,即数据在网络中的最大生存时间),第一个 MSL 过后,如果服务器还没有关门,那说明客户端告别的信息已经丢失或者超时死亡,客户端就又会重新对服务器告别,并且继续等待 1MSL。如果网络通畅,刚刚重发的告别信息服务端已经收到,服务端关上了门,客户端心满意足的离开;而如果网络不通,发送告别信息后过了 1MSL 后服务端依然没有关门,这个时候客户端已经等不及了,直接开溜。而服务器会由于检测不到客户端的存在而主动关门。

    关于 TCP 是如何检测客户端和服务器是否还在线上的原理,可以参考这篇文章:心跳机制 tcp keepalive 的讨论、应用及“断网”、“断电“检测的 C 代码实现(Windows 环境下)

确认应答机制(ACK 机制)

确认应答机制也是 TCP 的一个重要特性,它可以保证数据包的顺序以及不丢失。

简单来说,发送方给接收方 10000 个字节的数据,由于 TCP 有数据包大小限制,所以只能一段一段的发。一开始,发送方给接收方发送 1000 字节的数据,TCP 内部会给每一个字节标上序号,然后接收方就会收到这 1000 字节的数据,通过分析,其中最大的编号是 1000,那么接收方就会告诉发送方:“你的 1000 字节数据我收到了,你下一个数据应该是 1001。”发送方下次再发的时候,就会从 1001 开始编号,编到 2000,然后把编号 1001-2000 的数据发送给接收方,直到发送完所有数据。

如果中途有数据包丢失,比如发送方这个时候发送编号 3001-4000 的数据,接收方说:“收到,但是你下一个发送的应该是 2001。”发送方收到这条消息后,就知道 2001-3000 这段的数据丢了,于是就会重新发送 2001-3000 的数据。接收方收到重发的数据后,就会把 2001-3000 这段数据插在 2000 和 3001 中间,保证排序,然后再给发送方说:“收到,你下一个应该从 4001 开始发。”这样就完成了一次数据重传,同时保证了顺序。

滑动窗口

滑动窗口机制就是 TCP 中的流水线机制,发送方会向接收方发送多条数据,而不会等待接收方的回答。滑动窗口的大小就是指同时发送的数据包的个数。

比如滑动窗口大小是 4000 字节,每个数据包大小是 1000 字节,那么发送方就会发送 1-1000、1001-2000、2001-3000、3001-4000 这 4 条数据,等接收方返回:“收到,下一条是 4001”的时候,说明此时 1-1000 这条数据接收方已经收到了,发送方就会继续发送 4001-5000 这条数据。

如果中途有数据包丢失,那么也没有大碍,将丢失的那条数据再重新进入窗口重新传输一次就行了。

对于开发者来说,滑动窗口的思想是非常重要的。它可以抽象成这样一个问题:

现在有 100 个任务,有 4 个人干活,需要写一个算法,保证每个人都有活干,一个人做完了一件事就立马安排新的事,直到把所有的任务都做完。

这个问题还可以进一步升级成:现在有 100 个人要上楼,他们的体重都不一样,有 4 部电梯,每一部电梯的承载量也是不一样的。需要设计一个算法,使得这 100 个人尽可能快的全部上楼。

对于多线程来说,这个算法就是线程池算法;对于通信来说,这个就是消息队列算法。它的应用非常广泛,对于刚刚兴起的大前端来说,线程池和消息队列都没有作为内置的功能,想要成为高级前端工程师,这方面的能力是必备的。

对于其他的 TCP 特性,本文就不再重复阐述了,可以看上面提到的文章。

UDP

UDP 全称用户数据报协议(UDP,User Datagram Protocol),它是一个无连接的协议,它非常简单,就是发数据,也没有返回。既然不需要等待返回,也就不需要什么滑动窗口、确认应答了,也就没有顺序了。

UDP 协议适合那种讲究速度的、小型的、重复的、可以丢包的数据,比如网络游戏,网游会源源不断的发数据,就算中途丢了那么几个也没关系(不过如果是重要的数据,如技能释放还是会走 TCP),还有视频、语音聊天,它的数据本来就是有时效性的,数据丢了重发也没有意义,这些场景就非常适合 UDP。

除了有时效性的数据,UDP 还非常适合广播。由于 UDP 是不需要连接的,同时给大量的客户端发送数据而不需要建立大量的连接,极大地减轻了网络负载。

HTTP

HTTP(HyperText Transfer Protocol,超文本传输协议)是一个应用层协议,1.x/2.0 版本是基于 TCP 的,3.0 是基于 UDP+QUIC 的。由于 3.0 标准还没有成型,所以下文重点将基于 TCP 的版本。

传输模型

HTTP 是一个“请求-响应”模型的协议,完成一次 HTTP 请求分为以下几步:

  • 客户端向服务器建立连接
  • 服务器开启连接
  • 客户端发送请求,如果请求有 body,那么必须要加上 Content-Length 属性来告诉服务器 body 大小;如果没有,就不需要这个属性
  • 服务端拿到请求地址、请求头以后就可以开始进行部分逻辑了,比如访问的网址不对,服务端就会返回 404。如果一切正常,就开始解析 body。
  • 服务器解析请求头中 Content-Length 的值。如果 Content-Length 存在,则再去接收 Content-Length 长度的数据作为请求体。如果 body 长度大于 Content-Length 的值(没有 Content-Length 属性而在接收完请求头后又接到了数据,或者接收到了长度大于 Content-Length 的 body),服务器就会返回 400 bad request 的错误
  • 服务器接收完所有的数据,就开始进行处理,然后返回不同的处理结果给客户端
  • 传输完毕后,服务器主动关闭连接,客户端也被动的关闭连接

可以看到,HTTP 是一个基于一次性 TCP 连接的协议,由于每一次请求都需要建立一次 TCP 连接,所以它适合传输较大、较重要、不在意延迟的数据,不适合那种低延迟、很多次的数据。后者可以采用 WebSocket 建立一个 TCP 连接或者直接使用 UDP。

报文格式

HTTP 传输的报文是基于一定的格式的,请求报文和响应报文的格式还不一样。HTTP 报文以 CRLF(也就是\r\n)作为换行符,如果需要用 JS 模板字符串构造报文,除非 JS 文件本身是 CRLF 作为换行符,否则不能直接用回车作为换行符,应该手动写\r\n。同时也不建议使用模板字符串来构造报文,因为会多出来很多空格,影响解析。

请求报文

下面来看一段请求报文:

POST /login HTTP/1.1
Host: developer.mozilla.org
Accept-Language: zh-cn

数据主体body

这个报文分为三个部分:

第一行是方法名[空格]访问路径[空格]HTTP的版本

第二行开始,就是 HTTP 请求头的各个属性,格式:属性名:[空格]属性值,每一行一个属性

等所有的请求属性都写完了,就空一行,代表属性已经写完了,然后再到新的一行去写请求主体,也就是 body。

响应报文

同样先来看一段响应报文:

HTTP/1.1 200 OK
Date: Sat, 09 Oct 2010 14:28:02 GMT
Server: Apache
Last-Modified: Tue, 01 Dec 2009 20:18:22 GMT
ETag: "51142bc1-7449-479b075b2891b"
Accept-Ranges: bytes
Content-Length: 13
Content-Type: text/html

<html></html>

第一行是HTTP版本[空格]响应状态码[空格]响应结果字符串,注意,浏览器一般只会看状态码,响应结果字符串其实是可以自定义的,甚至你可以返回一个 200 的状态码,然后让它的响应结果变成“失败”。

第二行开始就是和请求报文一样,每一行写一个响应属性。

等所有的响应属性都写完了,和请求报文一样,空一行,然后再到新的一行写响应主体。

可以看到,HTTP 就只是用来传输数据的,至于缓存、gzip 压缩等特性,并不是 HTTP 本身的功能,而是 HTTP 服务器和客户端根据 HTTP 标准制定的配套软件。

HTTP/1.1 在网络方面改进

可以看到,在 HTTP 协议中,每一次请求都需要新开一个 TCP 连接,中间的握手挥手非常麻烦。如果可以复用同一个 TCP 连接,在请求完成以后不关闭,下一次请求接着用,那就可以有效的减少建立连接的消耗。于是,HTTP/1.1 诞生了。

长连接

HTTP/1.1 默认支持复用 TCP 连接,如果是支持长连接的 HTTP/1.0 版本,可以在请求头中加入 Connection: keep-alive 来启用长连接的特性,代表这一次请求可以复用之前建立的 TCP 连接,传输完成后也不会被关闭。

不过,之前的请求结束是以服务器主动关闭连接为标志,如果启用了长连接,那么如何判断数据传输完毕了呢?

结合 POST 发送数据的思路,在响应头里也有 Content-Length 属性,它的值是一个数字,代表请求/响应主体的字节数,它代表的是传输时主体的实际大小,也就是说,如果把主体用 gzip 方式压缩,那么 Content-Length 应该是压缩后的大小。当客户端接收完 Content-Length 长度的数据后,就结束这一次 HTTP 请求,同时保留 TCP 连接。

除了 Content-Length,Transfer-Encoding: chunk 属性也可以用于长连接,后者用于不确定主体大小的情况。

设置 Transfer-Encoding: chunk 属性后,客户端将根据报文的内容来确定数据是否接收完成。如果连续收到两个 CRLF 标志(\r\n\r\n),则代表数据传输完成,客户端结束请求。

部分请求与断点续传

除了长连接,HTTP/1.1 还支持获取响应数据中指定范围的内容。

在请求数据时,可以在请求头设置 Range: bytes=0-1023 属性来获取 0-1023 字节范围的数据,这提供了多线程同时下载一个文件的多个部分的功能。甚至还可以通过 Range: bytes=0-50, 100-150 来支持多个范围。

除此之外,还可以根据时间进行筛选。If-Range 属性可以获取指定时间以后的数据,比如 If-Range: Wed, 21 Oct 2015 07:28:00 GMT,它就会返回 2015-10-21 07:28:00 以后的资源内容。

虚拟主机

在现代的 Web 云结构中,往往存在一个 IP 地址对应多个主机名(子域名),这些主机只有名字,没有实体,是主机通过软件模拟的,叫做虚拟主机。虚拟主机 IP 相同,主机名不同,HTTP/1.1 提供了 Host 请求头,可以指定要访问的主机。如果访问虚拟主机时没有带主机名,则会返回 400 Bad Request。

HTTP/1.1 其他的缓存、RESTful 等功能都不属于网络传输方面的内容,本文不做说明,有需要的可以自行参考 MDN。

HTTP/2.0

HTTP/2.0 是在 HTTP/1.1 过了十几年以后推出的重大变更,它完全兼容 HTTP/1.1,并且带来了极大的性能优化。目前 HTTP/2.0 已经普及,在生产实际中我们应尽量使用它。

多路复用

HTTP/2.0 最重要的特性就是多路复用。有人会问,HTTP/1.1 不是已经实现了连接复用吗?其实虽然 HTTP/1.1 减少了连接的建立和释放,但是它是一个串行的传输方式,也就是只有等这一次请求完成以后再去请求下一次。如果有一次请求非常慢,它会阻塞后面所有请求。

现代浏览器在 HTTP/1.1 请求中引入了滑动窗口的概念,开启了 5 个线程去做请求,从而有效缓解了阻塞问题,但是每个线程中请求依然要排队执行,还是可能会阻塞。因此,在 HTTP/1.x 的时代,我们需要采取精灵图、Webpack 等工具,对网络资源进行打包,从而减少请求数。

而在 HTTP/2.0 中,它会同时发起多个请求而不会等待它们返回。发起请求之前,它会把每个请求都分成一个个二进制的小块,我们把它叫做,每个帧有一个 stream id 属性,代表它属于哪一个请求,这样多个请求在同一个 TCP 连接上就不会搞混了。

关于 HTTP/2.0 和 1.1 速度的对比,可以参考这个网站:HTTP/2 is the future of the Web,可以非常明显了感受到新版本带来的性能提升。打开开发人员工具,也可以看到每一个小图片的加载并不会等待上一个小图片加载完成。

因此,在 HTTP/2.0 的时代,我们还需要 Webpack 吗?

Server Push

很多请求都是可以预测的。比如客户端访问了一个 HTML 文件后,它肯定马上就会接着访问这个页面的 CSS 和 JS。如果服务端接收到客户端主请求,能够“预测”主请求的依赖资源,在响应主请求的同时,主动推送依赖资源至客户端。客户端解析主请求响应后,就将依赖资源写入缓存,下次就可以直接从本地缓存获取依赖资源。在 HTTP/2.0 中,这个功能叫做 Server Push,它为减少请求数进一步做了优化。

二进制格式

HTTP/2 的报文格式由文本改成了二进制,二进制在网络传输的时候稳定性更高,同时也不易被修改。

头部压缩

在 HTTP 传输中,每次都要传递头部,而大部分的请求中,头部都是大同小异的。在 HTTP/2.0 中,每次传递的头部并不是全量,而是相对于上一次的变化量,这样可以大大减少头部的重复传输。

HTTP3.0

在 HTTP/2.0 中,采取了大量的性能优化方案,大大提高了 HTTP 的速度。但连接复用和多路复用产生了一个非常严重的问题。

由于存在连接复用,所以单个页面的所有请求都存在一个 TCP 连接上,后面为了解决阻塞,浏览器还会开启多个线程去请求。再后来由于出现了多路复用,阻塞问题彻底解决,那么浏览器又会回到一个线程做请求的逻辑。

在网络通畅的时候,TCP 的高效复用会非常迅速,而如果存在丢包,为了保证数据包顺序,整个 TCP 连接的数据都需要重传,这样复用的请求越多,重传的数据包也越多,从而导致严重的性能问题。

Google 在推行 HTTP/2.0 的时候就发现了这个问题,而面向连接的 TCP 协议已经无法解决这个问题,于是 Google 自己搞了一个基于 UDP 协议的 QUIC 协议,这个协议复刻了 TCP 的握手、应答机制,而不需要在一个连接上排队,从而集两大协议的精华与一身,成为下一代传输层协议。HTTP3.0 也是基于此协议,又被称为 HTTP over QUIC。

Node.js 15 已经实验性的支持 QUIC 协议,使用const { createQuicSocket } = require('net');就可以创建 QUIC 服务器和客户端,和 TCP 和 UDP 的使用方法是一样的。

对于前端开发者来说,浏览器和 Node.js 对于 QUIC、HTTP3.0 的支持都还处于实验性阶段,仅作前瞻性学习,不要在生产环境使用。

传输层协议实践

虽然在前端开发中,我们基本上不会直接使用传输层协议,但是为了更好的了解 HTTP 的原理,我们依然有必要了解更加底层的实现。接下来,我们根据 Node.js 来打造 TCP、UDP 的服务器与客户端。

下列代码均基于 Node.js 15.0.2。

注意:网络传输中,所有的数据都需要为 Buffer 形式,就算是字符串,尤其是含有非英文字符的字符串,也需要用 Buffer.from 包裹,否则可能出现乱码。

TCP

// tcp-server.mjs
import { createServer } from 'net';

const server = createServer((socket) => {
  // Server connection事件
  console.log('客户端已连接');

  socket.on('error', (err) => {
    console.error(err.message);
  });
  socket.on('end', () => {
    console.log('客户端发出告别信息');
  });
  socket.on('close', () => {
    console.log('客户端关闭了连接');
  });
  // 只有绑定了data监听器,tcp才会正常运行,否则会被暂停。这是为了防止data还没被监听时,数据就传过来了,导致数据丢失。
  socket.on('data', (data) => {
    console.log(`客户端:${data.toString()}`);
  });
  // socket.resume(); // 如果没绑定data事件,需要手动调用resume以继续连接
  socket.write(Buffer.from('收到啦'));
  // socket.end(); // 服务端主动关闭连接
})
  .on('error', (err) => {
    // 处理错误
    console.error(err.message);
  })
  .on('close', () => {
    console.log('服务器关闭');
  });

// 获取任意未使用的端口。
server.listen(18080, () => {
  console.log('打开服务器', server.address());
});
// tcp-client.mjs
import { createConnection } from 'net';

const client = createConnection({ port: 18080 }, () => {
  // Client connection事件
  console.log('已连接到服务器');
  for (let i = 0; i < 10; i++) {
    client.write(Buffer.from(`正在发送快递${i + 1}/10`));
  }
  client.end(Buffer.from('快递发完了,再见'));
})
  .on('error', (err) => {
    console.error(err.message);
  })
  .on('data', (data) => {
    console.log(`服务端:${data.toString()}`);
  })
  .on('end', () => {
    console.log('向服务器告别');
  })
  .on('close', () => {
    console.log('已从服务器断开');
  });

执行代码,可以看到以下输出:

服务端:

打开服务器 { address: '::', family: 'IPv6', port: 18080 }
客户端已连接
客户端:正在发送快递1/10正在发送快递2/10正在发送快递3/10
客户端:正在发送快递4/10正在发送快递5/10正在发送快递6/10正在发送快递7/10正在发送快递8/10正在发送快递9/10正在发送快递10/10快递发完了,再见
客户端发出告别信息
客户端关闭了连接

客户端:

已连接到服务器
服务端:收到啦
向服务器告别
已从服务器断开

以上有几个细节需要注意:

  1. 客户端调用 11 次 write 方法和 1 次 end 方法,而服务端只收到了两次客户端传过来的消息。这是由于传输的过程中,操作系统、网卡等软硬件将较小的数据包合并,从而大大减小了处理开销。这和 HTTP 中一次请求对应一次返回的一一对应模型不一致,开发者需要手动将传递过来的数据分割。
  2. 如果服务器没有绑定 data 事件,TCP 连接会暂停,直到绑定 data 事件或者手动调用 resume 方法。这是由于 TCP 连接的建立会先于事件绑定,如果在事件绑定完成之前就有数据传过来,由于没有事件接收,这些数据就丢失了。

UDP

// udp-server.mjs
import { createSocket } from 'dgram';
const server = createSocket('udp4');

server.on('error', (err) => {
  console.log(`服务器异常:\n${err.stack}`);
  server.close();
});

server.on('message', (msg, rinfo) => {
  console.log(`服务器接收到来自 ${rinfo.address}:${rinfo.port}${msg}`);
});

server.on('listening', () => {
  const address = server.address();
  console.log(`服务器监听 ${address.address}:${address.port}`);
});

server.bind(18081);
// udp-client.mjs
import { createSocket } from 'dgram';

const client = createSocket('udp4');
client.send(Buffer.from('Hello'), 18081, '127.0.0.1', (err) => {
  client.close();
});

执行代码,可以看到以下结果:

服务端:

服务器监听 0.0.0.0:18081
服务器接收到来自 127.0.0.1:58034 的 Hello

相比 TCP,UDP 的逻辑要简单的多,因为它不需要建立连接,发送一条数据以后就结束了使命,也不会接收服务器传来的消息。

上面提到 TCP 的两个注意事项,第一条是连接暂停的问题,UDP 中没有连接,这个问题也就不存在了。接下来看第二个问题:如果在 UDP 中也传递多条数据,会不会发生数据包合并呢?

于是我们修改客户端代码:

// udp-client.mjs
import { createSocket } from 'dgram';

const client = createSocket('udp4');
client.send(Buffer.from('Hello'), 18081, '127.0.0.1', (err) => {
  client.send(Buffer.from('Hello'), 18081, '127.0.0.1', (err) => {
    client.send(Buffer.from('Hello'), 18081, '127.0.0.1', (err) => {
      client.send(Buffer.from('Hello'), 18081, '127.0.0.1', (err) => {
        client.close();
      });
    });
  });
});

由于陷入了回调地狱,我们改成 Async/Await 方式:

// udp-client.mjs
import { createSocket } from 'dgram';

const client = createSocket('udp4');
const send = (msg, port, host) =>
  new Promise((resolve, reject) =>
    client.send(msg, port, host, (err) => {
      if (err) {
        return reject(err);
      }
      return resolve();
    })
  );
for (let i = 0; i < 10; i++) {
  await send(Buffer.from(`正在发送快递${i + 1}/10`), 18081, 'localhost');
}
client.close();

执行客户端代码,可以看到服务端有以下输出:

服务器监听 0.0.0.0:18081
服务器接收到来自 127.0.0.1:56304 的 正在发送快递1/10
服务器接收到来自 127.0.0.1:56304 的 正在发送快递2/10
服务器接收到来自 127.0.0.1:56304 的 正在发送快递3/10
服务器接收到来自 127.0.0.1:56304 的 正在发送快递4/10
服务器接收到来自 127.0.0.1:56304 的 正在发送快递5/10
服务器接收到来自 127.0.0.1:56304 的 正在发送快递6/10
服务器接收到来自 127.0.0.1:56304 的 正在发送快递7/10
服务器接收到来自 127.0.0.1:56304 的 正在发送快递8/10
服务器接收到来自 127.0.0.1:56304 的 正在发送快递9/10
服务器接收到来自 127.0.0.1:56304 的 正在发送快递10/10

这里没有发生数据包合并,因为在 UDP 中,每一次数据传输都是独立的,操作系统不能判断这些数据包是不是同一个来源,自然就不能合并了。

Nodejs 中的 HTTP 模块

Nodejs 中内置了 HTTP 模块,我们可以这样使用:

// http-server.mjs
import { createServer } from 'http';

const server = createServer((req, res) => {
  console.log('请求方法:' + req.method);
  console.log('请求地址:' + req.url);
  console.log('请求HTTP版本:' + req.httpVersion);
  // 遍历请求头
  for (const headerName in req.headers) {
    if (req.headers.hasOwnProperty(headerName)) {
      const headerValue = req.headers[headerName];
      console.log(`请求头:${headerName}: ${headerValue}`);
    }
  }
  // 获取contentLength
  const contentLength = req.headers['content-length'];
  let totalData = Buffer.from('');
  // 如果有contentLength,那说明包含data,需要监听,否则就直接响应客户端
  if (contentLength) {
    req.on('data', (data) => {
      totalData = Buffer.concat([totalData, data]);
      if (totalData.length >= contentLength) {
        console.log(`请求数据:${totalData.toString()}`);
        res.setHeader('Content-Type', 'text/html');
        res.setHeader('X-Foo', 'bar');
        res.writeHead(200, { 'Content-Type': 'text/plain' });
        res.end(Buffer.from('ok'));
      }
    });
  } else {
    res.setHeader('Content-Type', 'text/html');
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end(Buffer.from('ok'));
  }
});

server.listen(8080);

上面的示例解析了整个请求报文,接下来我们根据报文格式中提到的请求报文和响应报文来构建 HTTP 服务器和客户端:

请求报文:

POST /login HTTP/1.1
Host: developer.mozilla.org
Accept-Language: zh-cn

数据主体body

我们可以用 http.request 来创建一个 HTTP 客户端:

// http-client.mjs
import { request } from 'http';

const url = 'http://127.0.0.1:8080/login';
const msg = Buffer.from('数据主体body');
const headers = {
  host: 'developer.mozilla.org',
  'accept-language': 'zh-cn',
  'content-length': msg.length,
};
const req = request(
  url,
  {
    method: 'POST',
    headers,
  },
  (res) => {
    let totalData = Buffer.from('');
    res.on('data', (data) => {
      totalData = Buffer.concat([totalData, data]);
    });
    res.on('end', () => {
      console.log(`服务器返回数据:${totalData.toString()}`);
    });
  }
);

req.end(msg);

响应报文:

HTTP/1.1 200 OK
Date: Sat, 09 Oct 2010 14:28:02 GMT
Server: Apache
Last-Modified: Tue, 01 Dec 2009 20:18:22 GMT
ETag: "51142bc1-7449-479b075b2891b"
Accept-Ranges: bytes
Content-Length: 13
Content-Type: text/html

<html></html>

可以接着通过 createServer 创建服务器:

// http-server.mjs
import { createServer } from 'http';

const server = createServer((req, res) => {
  console.log('请求方法:' + req.method);
  console.log('请求地址:' + req.url);
  console.log('请求HTTP版本:' + req.httpVersion);
  // 遍历请求头
  for (const headerName in req.headers) {
    if (req.headers.hasOwnProperty(headerName)) {
      const headerValue = req.headers[headerName];
      console.log(`请求头:${headerName}: ${headerValue}`);
    }
  }
  // 获取contentLength
  const contentLength = req.headers['content-length'];
  let totalData = Buffer.from('');
  // 如果有contentLength,那说明包含data,需要监听,否则就直接响应客户端
  const resData = Buffer.from('<html></html>');
  const resFunc = () => {
    res.statusCode = 200;
    res.statusMessage = 'OK';
    res.setHeader('Date', 'Sat, 09 Oct 2010 14:28:02 GMT');
    res.setHeader('Server', 'Apache');
    res.setHeader('Last-Modified', 'Tue, 01 Dec 2009 20:18:22 GMT');
    res.setHeader('ETag', '"51142bc1-7449-479b075b2891b"');
    res.setHeader('Accept-Ranges', 'bytes');
    res.setHeader('Content-Length', resData.length);
    res.setHeader('Content-Type', 'text/html');
    res.end(resData);
  };
  if (contentLength) {
    req.on('data', (data) => {
      totalData = Buffer.concat([totalData, data]);
      if (totalData.length >= contentLength) {
        console.log(`请求数据:${totalData.toString()}`);
        resFunc();
      }
    });
  } else {
    resFunc();
  }
});

server.listen(8080);

执行代码,可以看到以下结果:

服务端:

请求方法:POST
请求地址:/login
请求HTTP版本:1.1
请求头:host: developer.mozilla.org
请求头:accept-language: zh-cn
请求头:content-length: 16
请求头:connection: close
请求数据:数据主体body

客户端:

服务器返回数据:<html></html>

手写 HTTP 1.x 协议

接下来就是本文最重要的部分:用 TCP 手写 HTTP 1.x 服务器。之所以只手写 HTTP 1.x,是因为它体量小,但是又能充分体现 TCP 的原理,非常适合计算机网络知识的学习。

从 TCP 角度看 HTTP

首先,我们来复习一下一次 HTTP/1.0 请求会经过哪些步骤

  • 客户端向服务器建立连接
  • 服务器开启连接
  • 客户端发送请求,如果请求有 body,那么必须要加上 Content-Length 属性来告诉服务器 body 大小;如果没有,就不需要这个属性
  • 服务端拿到请求地址、请求头以后就可以开始进行部分逻辑了,比如访问的网址不对,服务端就会返回 404。如果一切正常,就开始解析 body。
  • 服务器解析请求头中 Content-Length 的值。如果 Content-Length 存在,则再去接收 Content-Length 长度的数据作为请求体。如果没有 Content-Length 属性而在接收完请求头后又接到了数据,服务器就会返回 400 bad request 的错误
  • 服务器接收完所有的数据,就开始进行处理,然后返回不同的处理结果给客户端
  • 传输完毕后,服务器主动关闭连接,客户端也被动的关闭连接

从 TCP 的角度,逐个翻译上面的每一个步骤,那就是:

  • 客户端向服务器请求连接
  • 服务端接受请求,建立一个连接
  • 客户端发送一段 HTTP 报文给服务器,这个报文里有请求方法、请求地址、协议版本、请求头,有可能还有请求体。如果有请求体,那在请求头里还需要加一个 Content-Length 属性来告诉服务器请求体大小
  • 服务器不断的接收客户端传来的信息,如果接收到连续两个\r\n 换行,说明有一行是空的,这是请求头和请求体的分隔标志,代表请求头已经传完了。此时服务器会解析请求头部分,看看有没有 Content-Length 这个属性。如果有,就再接收 Content-Length 长度的数据,然后就结束客户端请求阶段。如果没有 Content-Length,但是两个\r\n 后依然有数据,说明有请求体但是没有给长度,这是不符合规范的,服务器此时就会结束后续处理,返回一个 HTTP/1.1 400 bad request 的响应报文
  • 如果服务端接收到的数据是正确的,就开始将拿到的数据做进一步处理,如返回对应网址的数据,针对请求头的属性做处理等,最终拿到应该返回给客户端的响应报文。服务端把这次请求的状态、需要给客户端的响应头以及响应数据放在响应报文里,然后传给客户端
  • 传输完毕后,服务器主动关闭连接,客户端也被动的关闭连接,此时客户端拿到的数据就是完整的响应报文,从而可以进一步处理

如果加上 HTTP/1.1 呢?HTTP/1.1 在网络方面的改进就是加入了长连接。具体的表现是,服务器发送的响应报文中指定了响应体的长度,从而不需要服务端关闭连接,客户端就可以正确拿到响应体。

那稍微修改上面的步骤,一个 HTTP/1.1 的请求就是这样的:

  • 客户端向服务器请求连接
  • 服务端接受请求,建立一个连接
  • 客户端发送一段 HTTP 报文给服务器,这个报文里有请求方法、请求地址、协议版本、请求头,有可能还有请求体。如果有请求体,那在请求头里还需要加一个 Content-Length 属性来告诉服务器请求体大小
  • 服务器不断的接收客户端传来的信息,如果接收到连续两个\r\n 换行,说明有一行是空的,这是请求头和请求体的分隔标志,代表请求头已经传完了。此时服务器会解析请求头部分,看看有没有 Content-Length 这个属性。如果有,就再接收 Content-Length 长度的数据,然后就结束客户端请求阶段。如果没有 Content-Length,但是两个\r\n 后依然有数据,说明有请求体但是没有给长度,这是不符合规范的,服务器此时就会结束后续处理,返回一个 HTTP/1.1 400 bad request 的响应报文
  • 如果服务端接收到的数据是正确的,就开始将拿到的数据做进一步处理,如返回对应网址的数据,针对请求头的属性做处理等,最终拿到应该返回给客户端的响应报文。服务端把这次请求的状态、需要给客户端的响应头以及响应数据放在响应报文里,然后传给客户端。如果有响应体,则响应头中必须包含 Content-Length 属性,代表响应体的长度
  • 客户端接收服务器传来的响应报文,如果检测到两个\r\n,说明响应头已经传输完毕。客户端根据响应头中的 Content-Length 拿到了完整的响应体,并进一步做处理。

HTTP/1.0 的实现

首先我们先编写一个非常简单的 TCP 服务器和客户端,然后再加以改造:

// my-http-server.mjs
import { createServer } from 'net';

const server = createServer((socket) => {
  console.log('客户端已连接');

  socket.on('error', (err) => {
    console.error(err.message);
  });
  socket.on('end', () => {
    console.log('客户端发出告别信息');
  });
  socket.on('close', () => {
    console.log('客户端关闭了连接');
  });
  socket.on('data', (data) => {
    console.log(`客户端:${data.toString()}`);
  });
})
  .on('error', (err) => {
    console.error(err.message);
  })
  .on('close', () => {
    console.log('服务器关闭');
  });

server.listen(8080, () => {
  console.log('打开服务器', server.address());
});
// my-http-client.mjs
import { createConnection } from 'net';

const client = createConnection({ port: 8080 }, () => {
  console.log('已连接到服务器');
  client.end();
})
  .on('error', (err) => {
    console.error(err.message);
  })
  .on('data', (data) => {
    console.log(`服务端:${data.toString()}`);
  })
  .on('end', () => {
    console.log('向服务器告别');
  })
  .on('close', () => {
    console.log('已从服务器断开');
  });

报文生成器

HTTP 报文有严格的格式,手动编写不仅复杂,更容易出错,因此我们需要一个报文生成器。并且请求报文和响应报文的第一行的格式还有微小的不同,所以我们需要为它们分别创建生成器。

先复习一下请求报文的格式

第一行是方法名[空格]访问路径[空格]HTTP的版本

第二行开始,就是 HTTP 请求头的各个属性,格式:属性名:[空格]属性值,每一行一个属性

等所有的请求属性都写完了,就空一行,代表属性已经写完了,然后再到新的一行去写请求主体,也就是 body。

这里我们需要用到的参数有:方法名、访问路径、HTTP 的版本、请求头、请求体,用代码表示为:

// util.mjs
export const requestSegmentGenerator = (
  method,
  path,
  httpVersion,
  headers,
  body
) => {};

接下来就是拼接这些参数了。首先是第一行,直接用模板字符串拼接即可。不要忘了这里需要使用 Buffer.from 来包裹:

// util.mjs
export const requestSegmentGenerator = (
  method,
  path,
  httpVersion,
  headers,
  body
) => {
  let segment = Buffer.from(`${method} ${path} ${httpVersion}\r\n`);
};

由于 Buffer 的合并操作比较繁琐,所以我们写一个合并 Buffer 的工具函数:

// util.mjs
const bufferConcat = (...buffers) => {
  if (buffers.length >= 2) {
    let base = Buffer.from('');
    for (const buffer of buffers) {
      if (buffer instanceof Buffer) {
        base = Buffer.concat([base, buffer]);
      } else {
        // 如果不是Buffer,就先转换成Buffer再合并
        base = Buffer.concat([base, Buffer.from(buffer)]);
      }
    }
    return base;
  }
  if (buffers.length === 1) {
    if (buffer[0] instanceof Buffer) {
      return buffer[0];
    } else {
      // 如果不是Buffer,就转换成Buffer
      return Buffer.from(buffers[0]);
    }
  }
  // 参数为空则返回空Buffer
  return Buffer.from('');
};

然后是请求头,其实和响应头的格式是一样的,我们写一个头部生成器,将头部数据变成报文形式。

由于头部数据是 key: value 格式,所以此处我们采用对象来表示请求头。记住,头部写完以后需要单独空一行代表头部的结束:

// util.mjs
const headersGenerator = (headers) => {
  let base = '';
  for (const key in headers) {
    if (headers.hasOwnProperty(key)) {
      const value = headers[key];
      base = bufferConcat(base, `${key}: ${value}\r\n`);
    }
  }
  base = bufferConcat(base, `\r\n`);
  return base;
};

然后直接将头部对象传入合并:

// util.mjs
export const requestSegmentGenerator = (
  method,
  path,
  httpVersion,
  headers,
  body
) => {
  let segment = Buffer.from(`${method} ${path} ${httpVersion}\r\n`);
  segment = bufferConcat(segment, headersGenerator(headers));
};

最后就是 body 了,直接合并即可。不过还需要注意一点,如果有 body,则在 headers 里面需要添加 Content-Length 属性,而这个属性的值,有一个概念特别需要注意:

如果 body 是字符串,body.length 代表是这个字符串的文字个数,比如“你好 a”这个字符串,有两个汉字和一个英文字母,body.length 为 3。

而 Content-Length 代表的是字符个数,对于汉字来说,1 个汉字=2 个字符,所以其实“你好 a”这个字符串的 Content-Length 应该为 4。所以,如果 body 是个字符串,我们应该把它转换成 Buffer,这样它的长度就会取字符的长度了:

// util.mjs
export const requestSegmentGenerator = (
  method,
  path,
  httpVersion,
  headers,
  body
) => {
  let segment = Buffer.from(`${method} ${path} ${httpVersion}\r\n`);
  if (body) {
    if (typeof body === 'string') {
      body = Buffer.from(body);
    }
    headers['Content-Length'] = body.length;
  }
  segment = bufferConcat(segment, headersGenerator(headers));
  segment = bufferConcat(segment, body);
  return segment;
};

同理,响应报文的生成器的逻辑也是类似的,只不过我们需要判断一下 HTTP 的版本,如果是 HTTP/1.1 并且有 body 的话则需要在 headers 里面需要添加 Content-Length 属性:

// util.mjs
export const responseSegmentGenerator = (
  httpVersion,
  statusCode,
  statusText,
  headers,
  body
) => {
  let segment = Buffer.from(`${httpVersion} ${statusCode} ${statusText}\r\n`);
  if (httpVersion === 'HTTP/1.1' && body) {
    if (typeof body === 'string') {
      body = Buffer.from(body);
    }
    headers['Content-Length'] = body.length;
  }
  segment = bufferConcat(segment, headersGenerator(headers));
  segment = bufferConcat(segment, body);
  return segment;
};

完整报文解析器

假设服务器或客户端拿到一段完整的报文后,应该怎么解析其中的 HTTP 版本、头部属性等字段呢?所以我们还需要一个报文解析器。

首先,请求报文和响应报文的格式只有第一行不同,所以我们拿到一段报文后,先取出它的第一行来针对请求或响应报文分别解析,其他的部分可以放入通用的逻辑中。

而报文的第一行会有三个属性,这三个属性又是通过空格来分隔的。

所以,我们可以先来解析第一行,解析完成以后再把未解析的部分重新还原成字符串:

// util.mjs
export const requestSegmentResolver = (segment) => {
  const lines = segment.split('\r\n');
  const [method, path, httpVersion] = lines.shift().split(' ');
  segment = lines.join('\r\n');
};

export const responseSegmentResolver = (segment) => {
  const lines = segment.split('\r\n');
  const [httpVersion, statusCode, statusText] = lines.shift().split(' ');
  segment = lines.join('\r\n');
};

现在变量 segment 中已经没有第一行的内容了,后续可以走通用的逻辑,于是我们新创建一个 commonSegmentResolver 函数用来解析格式相同的部分。在这个函数中,我们可以接着将 headers 和 body 分隔开。根据规则,headers 结束的标志是一个额外的换行,也就是连续两个\r\n,因此我们以\r\n\r\n 为分隔符,将 header 和 body 分隔开:

// util.mjs
const commonSegmentResolver = (segment) => {
  const [headers, body] = segment.split('\r\n\r\n');
};

此时 headers 就是一个有多行的字符串,每一行就是一个 header 属性,属性名和属性值用“:[空格]”隔离,因此我们可以继续分割 headers,然后将每个属性放入 headersObj 这个对象里,完成 headers 的解析。最后把 headersObj 和 body 返回:

// util.mjs
const commonSegmentResolver = (segment) => {
  const [headers, body] = segment.split('\r\n\r\n');
  const headersObj = {};
  for (const header of headers.split('\r\n')) {
    const [name, value] = header.split(': ');
    headersObj[name] = value;
  }
  return [headersObj, body];
};

将 headers 解析器拆分成 headersResolver,即:

// util.mjs
const headersResolver = (headers) => {
  const headersObj = {};
  for (const header of headers) {
    const [name, value] = header.split(': ');
    headersObj[name] = value;
  }
  return headersObj;
};
const commonSegmentResolver = (segment) => {
  const [headers, body] = segment.split('\r\n\r\n');
  const headersObj = headersResolver(headers.split('\r\n'));
  return [headersObj, body];
};

然后把 commonSegmentResolver 放入 requestSegmentResolver 和 responseSegmentResolver 即可:

// util.mjs
const headersResolver = (headers) => {
  const headersObj = {};
  for (const header of headers) {
    const [name, value] = header.split(': ');
    headersObj[name] = value;
  }
  return headersObj;
};
const commonSegmentResolver = (segment) => {
  const [headers, body] = segment.split('\r\n\r\n');
  const headersObj = headersResolver(headers.split('\r\n'));
  return [headersObj, body];
};
export const requestSegmentResolver = (segment) => {
  const lines = segment.split('\r\n');
  const [method, path, httpVersion] = lines.shift().split(' ');
  segment = lines.join('\r\n');
  const [headersObj, body] = commonSegmentResolver(segment);
  return [method, path, httpVersion, headersObj, body];
};

export const responseSegmentResolver = (segment) => {
  const lines = segment.split('\r\n');
  const [httpVersion, statusCode, statusText] = lines.shift().split(' ');
  segment = lines.join('\r\n');
  const [headersObj, body] = commonSegmentResolver(segment);
  return [httpVersion, statusCode, statusText, headersObj, body];
};

注意,此时的报文是一个完整的报文。实际情况下,服务器会在报文没有接收完毕之前就开始解析,下面我们再来实现流式报文的解析逻辑。

流式报文解析器

流式报文解析器的逻辑大概是:

  1. 判断接收到的报文有没有第一行,如果有就解析它,然后等待后续报文
  2. 如果检测到\r\n\r\n,说明 headers 已经传完,开始解析 headers 到 headerObj 中。如果是请求报文或者是 HTTP/1.1 中的响应报文,接收方会在 headerObj 中查找 Content-Length 属性,并在接收到 Content-Length 的数据时停止接收,开始解析请求 body;如果是 HTTP/1.0 中的响应报文,接收方就会等待服务器断开连接

首先,解析需要分成三个阶段:解析首行、解析 headers、解析 body。所以我们应该在定义一个 stage 字段来代表当前的解析进度,并且设置几个变量存放解析结果。下面以请求报文解析器来举例:

// util.mjs
{
  // 请求报文解析器
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let method, path, httpVersion; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  const streamRequestSegmentResolver = (segment) => {};
}

首先要判断第一行是否已经收到,即判断是否有\r\n,如果有,则将第一行取出并解析后放入变量中:

// util.mjs
{
  // 请求报文解析器
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let method, path, httpVersion; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  const streamRequestSegmentResolver = (segment) => {
    segment = restSegment + segment;
    if (stage === 0 && segment.includes('\r\n')) {
      // 进入首行解析逻辑
      const lines = segment.split('\r\n');
      [method, path, httpVersion] = lines.shift().split(' '); // 解析首行数据
      segment = lines.join('\r\n'); // 未解析完的报文重新组合成字符串
      stage = 1; // 进入headers解析阶段
    }
  };
}

接下来就要开始解析 headers 了,在完整报文解析器中的 commonSegmentResolver 函数已经实现了这个逻辑,不过那是针对于完整报文的,所以我们还需要判断 headers 是否接收完毕:

// util.mjs
{
  // 请求报文解析器
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let method, path, httpVersion; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  const streamRequestSegmentResolver = (segment) => {
    if (stage === 0 && segment.includes('\r\n')) {
      // 进入首行解析逻辑
      const lines = segment.split('\r\n');
      [method, path, httpVersion] = lines.shift().split(' '); // 解析首行数据
      segment = lines.join('\r\n'); // 未解析完的报文重新组合成字符串
      stage = 1; // 进入headers解析阶段
    }
    // 首先解析完成,并且headers接收完成,则进入headers解析逻辑
    if (stage === 1 && segment.includes('\r\n\r\n')) {
      [headersObj, body] = commonSegmentResolver(segment);
      segment = body; // 此时只剩下body未解析,将body存回segment继续走下面的逻辑
      stage = 2;
    }
  };
}

headers 解析完毕以后,就可以根据 Content-Length 属性来判断 body 是否存在、接收完成。在请求报文中,如果没有 Content-Length 但却有 body,或者 body 长度大于 Content-Length,则需要报错。注意,此处 Content-Length 为字符串类型,我们需要转换成数字:

// util.mjs
{
  // 请求报文解析器
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let method, path, httpVersion; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  const streamRequestSegmentResolver = (segment) => {
    if (stage === 0 && segment.includes('\r\n')) {
      // 进入首行解析逻辑
      const lines = segment.split('\r\n');
      [method, path, httpVersion] = lines.shift().split(' '); // 解析首行数据
      segment = lines.join('\r\n'); // 未解析完的报文重新组合成字符串
      stage = 1; // 进入headers解析阶段
    }
    // 首先解析完成,并且headers接收完成,则进入headers解析逻辑
    if (stage === 1 && segment.includes('\r\n\r\n')) {
      [headersObj, body] = commonSegmentResolver(segment);
      segment = body; // 此时只剩下body未解析,将body存回segment继续走下面的逻辑
      stage = 2;
    }
    if (stage === 2) {
      const contentLength = +headersObj['Content-Length'] || 0;
      const bodyLength = segment.length;
      // 如果body长度和Content-Length一致,则解析完成
      if (contentLength === bodyLength) {
        body = segment;
        stage = 3;
      }
      // 如果body长度小于Content-Length,则说明body还没接收完,继续等待
      else if (contentLength > bodyLength) {
      }
      // 如果body长度比Content-Length大,则说明Content-Length错误,这时应该抛出400 bad request
      else if (contentLength < bodyLength) {
        throw {
          statusCode: 400,
          statusText: 'bad request',
        };
      }
    }
  };
}

最后,由于是流式报文,我们接收到的参数 segment 只是一个报文片段,未处理完的报文我们会存放在 restSegment 变量中,因此,每一轮处理的内容应该是“上一次未处理完的报文+本次接收到的新报文”,即 restSegment + segment。每一轮处理完了,我们应该返回当前的处理进度和处理结果:

// util.mjs
{
  // 请求报文解析器
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let method, path, httpVersion; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  const streamRequestSegmentResolver = (segment) => {
    segment = restSegment + segment;
    if (stage === 0 && segment.includes('\r\n')) {
      // 进入首行解析逻辑
      const lines = segment.split('\r\n');
      [method, path, httpVersion] = lines.shift().split(' '); // 解析首行数据
      segment = lines.join('\r\n'); // 未解析完的报文重新组合成字符串
      stage = 1; // 进入headers解析阶段
    }
    // 首先解析完成,并且headers接收完成,则进入headers解析逻辑
    if (stage === 1 && segment.includes('\r\n\r\n')) {
      [headersObj, body] = commonSegmentResolver(segment);
      segment = body; // 此时只剩下body未解析,将body存回segment继续走下面的逻辑
      stage = 2;
    }
    if (stage === 2) {
      const contentLength = +headersObj['Content-Length'] || 0;
      const bodyLength = segment.length;
      // 如果body长度和Content-Length一致,则解析完成
      if (contentLength === bodyLength) {
        body = segment;
        stage = 3;
      }
      // 如果body长度小于Content-Length,则说明body还没接收完,继续等待
      else if (contentLength > bodyLength) {
      }
      // 如果body长度比Content-Length大,则说明Content-Length错误,这时应该抛出400 bad request
      else if (contentLength < bodyLength) {
        throw {
          statusCode: 400,
          statusText: 'bad request',
        };
      }
    }
    // 未解析完的报文存放在restSegment中等待下一轮处理
    restSegment = segment;
    // 返回当前处理进度和结果
    return [stage, method, path, httpVersion, headersObj, body, restSegment];
  };
}

请求流式报文解析器就写完了,关于流式响应报文解析器,只需要改造两点:

  1. 首行数据从[method, path, httpVersion] 变成 [httpVersion, statusCode, statusText]
  2. 如果是 HTTP/1.0,则 Content-Length 应设置成无限大
// util.mjs
{
  // 响应报文解析器
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let httpVersion, statusCode, statusText; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  const streamResponseSegmentResolver = (segment) => {
    segment = restSegment + segment;
    if (stage === 0 && segment.includes('\r\n')) {
      // 进入首行解析逻辑
      const lines = segment.split('\r\n');
      [httpVersion, statusCode, statusText] = lines.shift().split(' '); // 解析首行数据
      segment = lines.join('\r\n'); // 未解析完的报文重新组合成字符串
      stage = 1; // 进入headers解析阶段
    }
    // 首先解析完成,并且headers接收完成,则进入headers解析逻辑
    if (stage === 1 && segment.includes('\r\n\r\n')) {
      [headersObj, body] = commonSegmentResolver(segment);
      segment = body; // 此时只剩下body未解析,将body存回segment继续走下面的逻辑
      stage = 2;
    }
    if (stage === 2) {
      const contentLength =
        httpVersion === 'HTTP/1.1'
          ? +headersObj['Content-Length'] || 0
          : Infinity;
      const bodyLength = segment.length;
      // 如果body长度和Content-Length一致,则解析完成
      if (contentLength === bodyLength) {
        body = segment;
        stage = 3;
      }
      // 如果body长度小于Content-Length,则说明body还没接收完,继续等待
      else if (contentLength > bodyLength) {
      }
      // 如果body长度比Content-Length大,则说明Content-Length错误,这时应该抛出400 bad request
      else if (contentLength < bodyLength) {
        throw {
          statusCode: 400,
          statusText: 'bad request',
        };
      }
    }
    // 未解析完的报文存放在restSegment中等待下一轮处理
    restSegment = segment;
    // 返回当前处理进度和结果
    return [
      stage,
      httpVersion,
      statusCode,
      statusText,
      headersObj,
      body,
      restSegment,
    ];
  };
}

最后,为了把这个代码块导出为模块,我们要把这个块放到函数中形成闭包:

// util.mjs
export const createStreamRequestSegmentResolver = () => {
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let method, path, httpVersion; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  const streamRequestSegmentResolver = (segment) => {
    segment = restSegment + segment;
    if (stage === 0 && segment.includes('\r\n')) {
      // 进入首行解析逻辑
      const lines = segment.split('\r\n');
      [method, path, httpVersion] = lines.shift().split(' '); // 解析首行数据
      segment = lines.join('\r\n'); // 未解析完的报文重新组合成字符串
      stage = 1; // 进入headers解析阶段
    }
    // 首先解析完成,并且headers接收完成,则进入headers解析逻辑
    if (stage === 1 && segment.includes('\r\n\r\n')) {
      [headersObj, body] = commonSegmentResolver(segment);
      segment = body; // 此时只剩下body未解析,将body存回segment继续走下面的逻辑
      stage = 2;
    }
    if (stage === 2) {
      const contentLength = +headersObj['Content-Length'] || 0;
      const bodyLength = segment.length;
      // 如果body长度和Content-Length一致,则解析完成
      if (contentLength === bodyLength) {
        body = segment;
        stage = 3;
      }
      // 如果body长度小于Content-Length,则说明body还没接收完,继续等待
      else if (contentLength > bodyLength) {
      }
      // 如果body长度比Content-Length大,则说明Content-Length错误,这时应该抛出400 bad request
      else if (contentLength < bodyLength) {
        throw {
          statusCode: 400,
          statusText: 'bad request',
        };
      }
    }
    // 未解析完的报文存放在restSegment中等待下一轮处理
    restSegment = segment;
    // 返回当前处理进度和结果
    return [stage, method, path, httpVersion, headersObj, body, restSegment];
  };
  return streamRequestSegmentResolver;
};

export const createStreamResponseSegmentResolver = () => {
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let httpVersion, statusCode, statusText; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  const streamResponseSegmentResolver = (segment) => {
    segment = restSegment + segment;
    if (stage === 0 && segment.includes('\r\n')) {
      // 进入首行解析逻辑
      const lines = segment.split('\r\n');
      [httpVersion, statusCode, statusText] = lines.shift().split(' '); // 解析首行数据
      segment = lines.join('\r\n'); // 未解析完的报文重新组合成字符串
      stage = 1; // 进入headers解析阶段
    }
    // 首先解析完成,并且headers接收完成,则进入headers解析逻辑
    if (stage === 1 && segment.includes('\r\n\r\n')) {
      [headersObj, body] = commonSegmentResolver(segment);
      segment = body; // 此时只剩下body未解析,将body存回segment继续走下面的逻辑
      stage = 2;
    }
    if (stage === 2) {
      const contentLength =
        httpVersion === 'HTTP/1.1'
          ? +headersObj['Content-Length'] || 0
          : Infinity;
      const bodyLength = segment.length;
      // 如果body长度和Content-Length一致,则解析完成
      if (contentLength === bodyLength) {
        body = segment;
        stage = 3;
      }
      // 如果body长度小于Content-Length,则说明body还没接收完,继续等待
      else if (contentLength > bodyLength) {
      }
      // 如果body长度比Content-Length大,则说明Content-Length错误,这时应该抛出400 bad request
      else if (contentLength < bodyLength) {
        throw {
          statusCode: 400,
          statusText: 'bad request',
        };
      }
    }
    // 未解析完的报文存放在restSegment中等待下一轮处理
    restSegment = segment;
    // 返回当前处理进度和结果
    return [
      stage,
      httpVersion,
      statusCode,
      statusText,
      headersObj,
      body,
      restSegment,
    ];
  };
  return streamResponseSegmentResolver;
};

然而,我们的报文解析器不是一次性的,解析完一个报文后肯定还要解析新的报文。所以我们还需要写一个函数来清除解析器的状态:

// util.mjs
export const createStreamRequestSegmentResolver = () => {
  // 请求报文解析器
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let method, path, httpVersion; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  const streamRequestSegmentResolver = (segment) => {
    segment = restSegment + segment;
    if (stage === 0 && segment.includes('\r\n')) {
      // 进入首行解析逻辑
      const lines = segment.split('\r\n');
      [method, path, httpVersion] = lines.shift().split(' '); // 解析首行数据
      segment = lines.join('\r\n'); // 未解析完的报文重新组合成字符串
      stage = 1; // 进入headers解析阶段
    }
    // 首先解析完成,并且headers接收完成,则进入headers解析逻辑
    if (stage === 1 && segment.includes('\r\n\r\n')) {
      [headersObj, body] = commonSegmentResolver(segment);
      segment = body; // 此时只剩下body未解析,将body存回segment继续走下面的逻辑
      stage = 2;
    }
    if (stage === 2) {
      const contentLength = +headersObj['Content-Length'] || 0;
      const bodyLength = segment.length;
      // 如果body长度和Content-Length一致,则解析完成
      if (contentLength === bodyLength) {
        body = segment;
        stage = 3;
      }
      // 如果body长度小于Content-Length,则说明body还没接收完,继续等待
      else if (contentLength > bodyLength) {
      }
      // 如果body长度比Content-Length大,则说明Content-Length错误,这时应该抛出400 bad request
      else if (contentLength < bodyLength) {
        throw {
          statusCode: 400,
          statusText: 'bad request',
        };
      }
    }
    // 未解析完的报文存放在restSegment中等待下一轮处理
    restSegment = segment;
    // 返回当前处理进度和结果
    return [stage, method, path, httpVersion, headersObj, body, restSegment];
  };
  // 清除解析器状态
  const clearResolver = () => {
    stage = 0;
    restSegment = '';
  };
  return [streamRequestSegmentResolver, clearResolver];
};

export const createStreamResponseSegmentResolver = () => {
  // 响应报文解析器
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let httpVersion, statusCode, statusText; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  const streamResponseSegmentResolver = (segment) => {
    segment = restSegment + segment;
    if (stage === 0 && segment.includes('\r\n')) {
      // 进入首行解析逻辑
      const lines = segment.split('\r\n');
      [httpVersion, statusCode, statusText] = lines.shift().split(' '); // 解析首行数据
      segment = lines.join('\r\n'); // 未解析完的报文重新组合成字符串
      stage = 1; // 进入headers解析阶段
    }
    // 首先解析完成,并且headers接收完成,则进入headers解析逻辑
    if (stage === 1 && segment.includes('\r\n\r\n')) {
      [headersObj, body] = commonSegmentResolver(segment);
      segment = body; // 此时只剩下body未解析,将body存回segment继续走下面的逻辑
      stage = 2;
    }
    if (stage === 2) {
      const contentLength =
        httpVersion === 'HTTP/1.1'
          ? +headersObj['Content-Length'] || 0
          : Infinity;
      const bodyLength = segment.length;
      // 如果body长度和Content-Length一致,则解析完成
      if (contentLength === bodyLength) {
        body = segment;
        stage = 3;
      }
      // 如果body长度小于Content-Length,则说明body还没接收完,继续等待
      else if (contentLength > bodyLength) {
      }
      // 如果body长度比Content-Length大,则说明Content-Length错误,这时应该抛出400 bad request
      else if (contentLength < bodyLength) {
        throw {
          statusCode: 400,
          statusText: 'bad request',
        };
      }
    }
    // 未解析完的报文存放在restSegment中等待下一轮处理
    restSegment = segment;
    // 返回当前处理进度和结果
    return [
      stage,
      httpVersion,
      statusCode,
      statusText,
      headersObj,
      body,
      restSegment,
    ];
  };
  const clearResolver = () => {
    stage = 0;
    restSegment = '';
  };
  return [streamResponseSegmentResolver, clearResolver];
};

以上就是流式报文解析器的逻辑,当报文数据源源不断的接收时,循环调用 streamSegmentResolver,直到 stage === 3 或者 HTTP/1.0 中服务器主动关闭连接,报文就解析完了。解析完以后,手动调用 clear 来清除解析器的状态。

客户端发送报文

有了报文生成器,接下来就可以发送报文了。现在应该让客户端发送一段请求报文给服务器,同时服务器能把报文显示在屏幕上。

我们直接调用 client.write 来发送一个路由为/的 HTTP/1.0 GET 请求:

// my-http-client.mjs
import { createConnection } from 'net';
import { requestSegmentGenerator } from './util.mjs';

const client = createConnection({ port: 8080 }, () => {
  console.log('已连接到服务器');
  client.write(
    requestSegmentGenerator('GET', '/', 'HTTP/1.0', {}, 'hello').toString()
  );
})
  .on('error', (err) => {
    console.error(err.message);
  })
  .on('data', (data) => {
    console.log(`服务端:${data.toString()}`);
  })
  .on('end', () => {
    console.log('向服务器告别');
  })
  .on('close', () => {
    console.log('已从服务器断开');
  });

运行代码,可以看到服务器端有以下输出:

打开服务器 { address: '::', family: 'IPv6', port: 8080 }
客户端已连接
客户端:GET / HTTP/1.0
Content-Length: 5

hello

这和我们预期的报文格式是一致的。

服务器解析报文

有了流式报文解析器以后,我们只需要每次接收到数据后放入解析器,等待 stage === 3 就可以了:

// my-http-server.mjs
import { createServer } from 'net';
import { createStreamRequestSegmentResolver } from './util.mjs';

const server = createServer((socket) => {
  console.log('客户端已连接');
  const [
    streamRequestSegmentResolver,
    clearResolver,
  ] = createStreamRequestSegmentResolver();
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let method, path, httpVersion; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  socket.on('error', (err) => {
    console.error(err.message);
  });
  socket.on('end', () => {
    console.log('客户端发出告别信息');
  });
  socket.on('close', () => {
    console.log('客户端关闭了连接');
  });
  socket.on('data', (data) => {
    console.log(`正在解析:${data.toString()}`);
    [
      stage,
      method,
      path,
      httpVersion,
      headersObj,
      body,
      restSegment,
    ] = streamRequestSegmentResolver(data);
    if (stage === 3) {
      console.log('数据传输完成');
      clearResolver();
    }
  });
})
  .on('error', (err) => {
    console.error(err.message);
  })
  .on('close', () => {
    console.log('服务器关闭');
  });

server.listen(8080, () => {
  console.log('打开服务器', server.address());
});

运行代码,可以看到服务端有以下结果:

打开服务器 { address: '::', family: 'IPv6', port: 8080 }
客户端已连接
正在解析:GET / HTTP/1.0
Content-Length: 5

hello
stage 3
数据传输完成

这也和我们预期的是一致的、

服务端响应报文

当服务端数据接收完毕以后,就可以处理信息并且返回给客户端结果了。由于有响应报文生成器,我们可以很快的将想要发送的数据转换成报文。我们这里让服务端返回“收到啦,Content-Length 为${Content-Length}”这样一个报文:

// my-http-server.mjs
import { createServer } from 'net';
import { createStreamRequestSegmentResolver } from './util.mjs';

const server = createServer((socket) => {
  console.log('客户端已连接');
  const [
    streamRequestSegmentResolver,
    clearResolver,
  ] = createStreamRequestSegmentResolver();
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let method, path, httpVersion; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  socket.on('error', (err) => {
    console.error(err.message);
  });
  socket.on('end', () => {
    console.log('客户端发出告别信息');
  });
  socket.on('close', () => {
    console.log('客户端关闭了连接');
  });
  socket.on('data', (data) => {
    console.log(`正在解析:${data.toString()}`);
    [
      stage,
      method,
      path,
      httpVersion,
      headersObj,
      body,
      restSegment,
    ] = streamRequestSegmentResolver(data);
    if (stage === 3) {
      console.log('数据传输完成');
      socket.end(
        responseSegmentGenerator(
          'HTTP/1.0',
          200,
          'OK',
          {},
          `收到啦,Content-Length为${headersObj['Content-Length'] || 0}`
        )
      );
      clearResolver();
    }
  });
})
  .on('error', (err) => {
    console.error(err.message);
  })
  .on('close', () => {
    console.log('服务器关闭');
  });

server.listen(8080, () => {
  console.log('打开服务器', server.address());
});

测试代码,可以看到以下输出结果:

服务端:

打开服务器 { address: '::', family: 'IPv6', port: 8080 }
客户端已连接
正在解析:GET / HTTP/1.0
Content-Length: 5

hello
数据传输完成
客户端发出告别信息
客户端关闭了连接

客户端:

已连接到服务器
服务端:HTTP/1.0 200 OK

收到啦,Content-Length为5
向服务器告别
已从服务器断开

结果是符合预期的。

既然服务端和客户端都能正常工作,那浏览器应该也能正常工作。于是我们打开http://127.0.0.1:8080进行测试: 浏览器返回乱码

可以看到响应结果乱码了,这是因为浏览器默认的编码是 ISO-8859-1,也叫作 Latin1,顾名思义就是只支持拉丁语系,中文是不支持的。所以,我们在响应头中要加上一个 Content-Type 属性来指定它的编码。由于这个头是通用的,所以我们写在 responseSegmentGenerator 函数里:

// util.mjs
export const responseSegmentGenerator = (
  httpVersion,
  statusCode,
  statusText,
  headers,
  body
) => {
  let segment = Buffer.from(`${httpVersion} ${statusCode} ${statusText}\r\n`);
  if (httpVersion === 'HTTP/1.1' && body) {
    headers['Content-Length'] = body.length;
  }
  // 指定UTF-8编码
  if (!headers['Content-Type']) {
    headers['Content-Type'] = 'text/html; charset=utf-8';
  }
  segment = bufferConcat(segment, headersGenerator(headers));
  segment = bufferConcat(segment, body);
  return segment;
};

重启服务器,再次打开浏览器,可以看到以下结果: 浏览器正常返回

浏览器工作正常了,一个简单的 HTTP/1.0 服务器就写完了。

错误处理

之前我们说过,请求报文中如果 Content-Length 和 body 长度不一致,会返回 400 错误。这个错误我们是在 streamResponseSegmentResolver 函数中抛出的,因此,我们应该将该函数以及后续逻辑用 try-catch 块包裹,如果检测到错误就返回给客户端:

// my-http-server.mjs
import { createServer } from 'net';
import {
  createStreamRequestSegmentResolver,
  responseSegmentGenerator,
} from './util.mjs';

const server = createServer((socket) => {
  console.log('客户端已连接');
  const [
    streamRequestSegmentResolver,
    clearResolver,
  ] = createStreamRequestSegmentResolver();
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let method, path, httpVersion; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  socket.on('error', (err) => {
    console.error(err.message);
  });
  socket.on('end', () => {
    console.log('客户端发出告别信息');
  });
  socket.on('close', () => {
    console.log('客户端关闭了连接');
  });
  socket.on('data', (data) => {
    console.log(`正在解析:${data.toString()}`);
    try {
      [
        stage,
        method,
        path,
        httpVersion,
        headersObj,
        body,
        restSegment,
      ] = streamRequestSegmentResolver(data);
      if (stage === 3) {
        console.log('数据传输完成');
        socket.end(
          responseSegmentGenerator(
            'HTTP/1.0',
            200,
            'OK',
            {},
            `收到啦,Content-Length为${headersObj['Content-Length'] || 0}`
          )
        );
      }
    } catch (err) {
      console.error(err);
      socket.end(
        responseSegmentGenerator(
          'HTTP/1.0',
          err.statusCode,
          err.statusText,
          {},
          ''
        )
      );
      clearResolver();
    }
  });
})
  .on('error', (err) => {
    console.error(err.message);
  })
  .on('close', () => {
    console.log('服务器关闭');
  });

server.listen(8080, () => {
  console.log('打开服务器', server.address());
});

然后在requestSegmentGenerator函数中我们将Content-Length的值改成body.length - 1,运行代码:

服务端:

打开服务器 { address: '::', family: 'IPv6', port: 8080 }
客户端已连接
正在解析:GET / HTTP/1.0
Content-Length: 4

hello
{ statusCode: 400, statusText: 'bad request' }
客户端发出告别信息
客户端关闭了连接

客户端:

已连接到服务器
服务端:HTTP/1.0 400 bad request
Content-Type: text/html; charset=utf-8


向服务器告别
已从服务器断开

可以看到,错误已经正常抛出并返回给客户端了。

长连接支持

关于长连接的支持,需要改造三个地方:

  1. 服务端返回的响应报文中,如果有响应体,则响应头中必须包含 Content-Length 属性,代表响应体的长度
  2. 服务端发送响应数据后不要关闭连接
  3. 客户端需要判断响应报文是否接收完成

服务端改造

针对第一点,responseSegmentGenerator 已经帮我们改造好了。而针对第二点,我们需要在服务端stage === 3的时候判断 HTTP 的版本,然后用socket.write代替socket.end

// my-http-server.mjs
import { createServer } from 'net';
import {
  createStreamRequestSegmentResolver,
  responseSegmentGenerator,
} from './util.mjs';

const server = createServer((socket) => {
  console.log('客户端已连接');
  const [
    streamRequestSegmentResolver,
    clearResolver,
  ] = createStreamRequestSegmentResolver();
  let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
  let method, path, httpVersion; // 首行数据
  let headersObj = {}; // headers对象
  let body; // body内容
  let restSegment = ''; // 存放还没解析完的报文
  socket.on('error', (err) => {
    console.error(err.message);
  });
  socket.on('end', () => {
    console.log('客户端发出告别信息');
  });
  socket.on('close', () => {
    console.log('客户端关闭了连接');
  });
  socket.on('data', (data) => {
    console.log(`正在解析:${data.toString()}`);
    try {
      [
        stage,
        method,
        path,
        httpVersion,
        headersObj,
        body,
        restSegment,
      ] = streamRequestSegmentResolver(data);
      if (stage === 3) {
        console.log('数据传输完成');
        if (httpVersion === 'HTTP/1.1') {
          socket.write(
            responseSegmentGenerator(
              'HTTP/1.1',
              200,
              'OK',
              {},
              `收到啦,Content-Length为${headersObj['Content-Length'] || 0}`
            )
          );
        } else {
          socket.end(
            responseSegmentGenerator(
              'HTTP/1.0',
              200,
              'OK',
              {},
              `收到啦,Content-Length为${headersObj['Content-Length'] || 0}`
            )
          );
        }
      }
    } catch (err) {
      console.error(err);
      socket.end(
        responseSegmentGenerator(
          'HTTP/1.0',
          err.statusCode,
          err.statusText,
          {},
          ''
        )
      );
      clearResolver();
    }
  });
})
  .on('error', (err) => {
    console.error(err.message);
  })
  .on('close', () => {
    console.log('服务器关闭');
  });

server.listen(8080, () => {
  console.log('打开服务器', server.address());
});

修改客户端的 HTTP 版本,执行代码,可以看到以下输出:

HTTP/1.0:

已连接到服务器
服务端:HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8

收到啦,Content-Length为5
向服务器告别
已从服务器断开

HTTP/1.1:

已连接到服务器
服务端:HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8

收到啦,Content-Length为5

可以看到,HTTP/1.1 的客户端在接收完数据后并没有断开连接,服务端改造成功了。

客户端改造

和服务端类似,客户端也要创建一个 streamResponseSegmentResolver 来解析响应报文,而 streamResponseSegmentResolver 已经实现了判断 HTTP 版本的逻辑:

// my-http-client.mjs
import { createConnection } from 'net';
import {
  requestSegmentGenerator,
  createStreamResponseSegmentResolver,
} from './util.mjs';

const [
  streamResponseSegmentResolver,
  clearResolver,
] = createStreamResponseSegmentResolver();

let stage = 0; // 0: 还未解析首行,1: 首行已解析,还未解析headers,2: headers已解析,还未解析body,3: 解析完成
let httpVersion, statusCode, statusText; // 首行数据
let headersObj = {}; // headers对象
let body; // body内容
let restSegment = ''; // 存放还没解析完的报文

const client = createConnection({ port: 8080 }, () => {
  console.log('已连接到服务器');
  client.write(
    requestSegmentGenerator('GET', '/', 'HTTP/1.1', {}, 'hello').toString()
  );
})
  .on('error', (err) => {
    console.error(err.message);
  })
  .on('data', (data) => {
    console.log(`服务端:${data.toString()}`);
    [
      stage,
      httpVersion,
      statusCode,
      statusText,
      headersObj,
      body,
      restSegment,
    ] = streamResponseSegmentResolver(data);
    if (stage === 3) {
      console.log(`数据传输完成:${body.toString()}`);
      clearResolver();
    }
  })
  .on('end', () => {
    console.log('向服务器告别');
  })
  .on('close', () => {
    console.log('已从服务器断开');
  });

执行代码可以看到客户端有以下输出:

已连接到服务器
服务端:HTTP/1.1 200 OK
Content-Length: 20
Content-Type: text/html; charset=utf-8

收到啦,Content-Length为5
数据传输完成:收到啦,Content-Length为5

服务端在没有关闭连接的情况下,客户端就已经判断数据接收完成,客户端改造成功了。

用浏览器测试,也是正常的: 浏览器正常返回_HTTP/1.1

至此,手写 HTTP/1.1 服务器+客户端就完成了。至于平时用到的其他特性,如缓存、Gzip 压缩、Range 属性等,都是拿到数据以后再做的程序逻辑,和计算机网络无关,故本文不做讲解。

总结

虽然在日常工作中,直接和网络底层打交道的情况不多,但是这并不代表它不重要。学习网络原理知识,会让前端开发者对浏览器的原理、协议的封装、打包工具的意义、性能优化等都有所了解,在面向大型应用和云的时代,学会计算机网络知识是十分必要的。