作者:James Snell翻译:疯狂的技术宅
在2019年3月,受到 NearForm 和 Protocol Labs 的支持,我开始为 Node.js 实现 QUIC 协议 支持。这个基于 UDP 的新传输协议旨在最终替代所有使用 TCP 的 HTTP 通信。
熟悉 UDP 的人可能会产生质疑。众所周知 UDP 是不可靠的,数据包经常会有丢失、乱序、重复等情况。 UDP 不保证高级协议(例如 HTTP)严格要求的 TCP 所支持的可靠性和顺序。那就是 QUIC 进来的地方。
QUIC 协议在 UDP 之上定义了一层,该层为 UDP 引入了错误处理、可靠性、流控制和内置安全性(通过 TLS 1.3)。实际上它在 UDP 之上重新实现了大多数 TCP 的特效,但是有一个关键的区别:与 TCP 不同,仍然可以不按顺序传输数据包。了解这一点对于理解 QUIC 为什么优于 TCP 至关重要。
QUIC 消除了队首阻塞的根源
在 HTTP 1 中,客户端和服务器之间所交换的所有消息都是连续的、不间断的数据块形式。虽然可以通过单个 TCP 连接发送多个请求或响应,但是在发送下一条完整消息之前,必须先等上一条消息完整的传输完毕。这意味着,如果要发送一个 10 兆字节的文件,然后发送一个 2 兆字节的文件,则前者必须完全传输完毕,然后才能启动后者。这就是所谓的队首阻塞,是造成大量延迟和不良使用网络带宽的根源。
HTTP 2 尝试通过引入多路复用来解决此问题。 HTTP 2 不是将请求和响应作为连续的流传输,而是将请求和响应分成了被称为帧的离散块,这些块可以与其他帧交织。一个 TCP 连接理论上可以处理无限数量的并发请求和响应流。尽管从理论上讲这是可行的,但是 HTTP 2 的设计没有考虑 TCP 层出现队首阻塞的可能性。
TCP 本身是严格排序的协议。数据包被序列化并按照固定顺序通过网络发送。如果数据包未能到达其目的地,则会阻止整个数据包流,直到可以重新传输丢失的数据包为止。有效的顺序是:发送数据包1,等待确认,发送数据包2,等待确认,发送数据包3……。使用 HTTP 1,在任何给定时间只能传输一个 HTTP 消息,如果单个 TCP 数据包丢失,那么重传只会影响单个 HTTP 请求/响应流。但是使用 HTTP 2,则会在丢失单个 TCP 数据包的情况下阻止无限数量的并发 HTTP 请求/响应流的传输。在通过高延迟、低可靠性网络进行 HTTP 2 通信时,与 HTTP 1 相比,整体性能和网络吞吐量会急剧下降。
在 HTTP 1 中,该请求会被阻塞,因为一次只能发送一条完整的消息。
在 HTTP 2 中,当单个 TCP 数据包丢失或损坏时,该请求将被阻塞。
在QUIC中,数据包彼此独立,能够以任何顺序发送(或重新发送)。
幸运的是有了 QUIC 情况就不同了。当数据流被打包到离散的 UDP 数据包中传输时,任何单个数据包都能够以任意顺序发送(或重新发送),而不会影响到其他已发送的数据包。换句话说,线路阻塞问题在很大程度上得到解决。
QUIC 引入了灵活性、安全性和低延迟
QUIC 还引入了许多其他重要功能:
- QUIC 连接的运行独立于网络拓扑结构。在建立了 QUIC 连接后,源 IP 地址和目标 IP 地址和端口都可以更改,而无需重新建立连接。这对于经常进行网络切换(例如 LTE 到 WiFi)的移动设备特别有用。
- 默认 QUIC 连接是安全的并加密的。 TLS 1.3 支持直接包含在协议中,并且所有 QUIC 通信都经过加密。
- QUIC 为 UDP 添加了关键的流控制和错误处理,并包括重要的安全机制以防止一系列拒绝服务攻击。
- QUIC 添加了对零行程 HTTP 请求的支持,这与基于 TCP 的 TLS 之上的 HTTP 不同,后者要求客户端和服务器之间进行多次数据交换来建立 TLS 会话,然后才能传输 HTTP 请求数据,QUIC 允许 HTTP 请求头作为 TLS 握手的一部分发送,从而大大减少了新连接的初始延迟。
为 Node.js 内核实现 QUIC
为 Node.js 内核实现 QUIC 的工作从 2019 年 3 月开始,并由 NearForm 和 Protocol Labs 共同赞助。我们利用出色的 ngtcp2 库来提供大量的低层实现。因为 QUIC 是许多 TCP 特性的重新实现,所以对 Node.js 意义重大,并且与 Node.js 中当前的 TCP 和 HTTP 相比能够支持更多特性。同时对用户隐藏了大量的复杂性。
“quic” 模块
在实现新的 QUIC 支持的同时,我们用了新的顶级内置 quic
模块来公开 API。当该功能在 Node.js 核心中落地时,是否仍将使用这个顶级模块,将在以后确定。不过当在开发中使用实验性支持时,你可以通过 require('quic')
使用这个 API。
const { createSocket } = require('quic')
quic
模块公开了一个导出:createSocket
函数。这个函数用来创建 QuicSocket
对象实例,该对象可用于 QUIC 服务器和客户端。
QUIC 的所有工作都在一个单独的 GitHub 存储库 中进行,该库 fork 于 Node.js master 分支并与之并行开发。如果你想使用新模块,或者贡献自己的代码,可以从那里获取源代码,请参阅 Node.js 构建说明。不过它现在仍然是一项尚在进行中的工作,你一定会遇到 bug 的。
创建QUIC服务器
QUIC 服务器是一个 QuicSocket
实例,被配置为等待远程客户端启动新的 QUIC 连接。这是通过绑定到本地 UDP 端口并等待从对等方接收初始 QUIC 数据包来完成的。在收到 QUIC 数据包后,QuicSocket
将会检查是否存在能够用于处理该数据包的服务器 QuicSession
对象,如果不存在将会创建一个新的对象。一旦服务器的 QuicSession
对象可用,则该数据包将被处理,并调用用户提供的回调。这里有一点很重要,处理 QUIC 协议的所有细节都由 Node.js 在其内部处理。
const { createSocket } = require('quic')
const { readFileSync } = require('fs')
const key = readFileSync('./key.pem')
const cert = readFileSync('./cert.pem')
const ca = readFileSync('./ca.pem')
const requestCert = true
const alpn = 'echo'
const server = createSocket({
// 绑定到本地 UDP 5678 端口
endpoint: { port: 5678 },
// 为新的 QuicServer Session 实例创建默认配置
server: {
key,
cert,
ca,
requestCert
alpn
}
})
server.listen()
server.on('ready', () => {
console.log(`QUIC server is listening on ${server.address.port}`)
})
server.on('session', (session) => {
session.on('stream', (stream) => {
// Echo server!
stream.pipe(stream)
})
const stream = session.openStream()
stream.end('hello from the server')
})
如前所述,QUIC 协议内置并要求支持 TLS 1.3。这意味着每个 QUIC 连接必须有与其关联的 TLS 密钥和证书。与传统的基于 TCP 的 TLS 连接相比,QUIC 的独特之处在于 QUIC 中的 TLS 上下文与 QuicSession
相关联,而不是 QuicSocket
。如果你熟悉 Node.js 中 TLSSocket
的用法,那么你一定注意到这里的区别。
QuicSocket
(和 QuicSession
)的另一个关键区别是,与 Node.js 公开的现有 net.Socket
和 tls.TLSSocket
对象不同,QuicSocket
和 QuicSession
都不是 Readable
或 Writable
的流。即不能用一个对象直接向连接的对等方发送数据或从其接收数据,所以必须使用 QuicStream
对象。
在上面的例子中创建了一个 QuicSocket
并将其绑定到本地 UDP 的 5678 端口。然后告诉这个 QuicSocket
侦听要启动的新 QUIC 连接。一旦 QuicSocket
开始侦听,将会发出 ready
事件。
当启动新的 QUIC 连接并创建了对应服务器的 QuicSession
对象后,将会发出 session
事件。创建的 QuicSession
对象可用于侦听新的客户端服务器端所启动的 QuicStream
实例。
QUIC 协议的更重要特征之一是客户端可以在不打开初始流的情况下启动与服务器的新连接,并且服务器可以在不等待来自客户端的初始流的情况下先启动其自己的流。这个功能提供了许多非常有趣的玩法,而这在当前 Node.js 内核中的 HTTP 1 和 HTTP 2 是不可能提供的。
创建QUIC客户端
QUIC 客户端和服务器之间几乎没有什么区别:
const { createSocket } = require('quic')
const fs = require('fs')
const key = readFileSync('./key.pem')
const cert = readFileSync('./cert.pem')
const ca = readFileSync('./ca.pem')
const requestCert = true
const alpn = 'echo'
const servername = 'localhost'
const socket = createSocket({
endpoint: { port: 8765 },
client: {
key,
cert,
ca,
requestCert
alpn,
servername
}
})
const req = socket.connect({
address: 'localhost',
port: 5678,
})
req.on('stream', (stream) => {
stream.on('data', (chunk) => { /.../ })
stream.on('end', () => { /.../ })
})
req.on('secure', () => {
const stream = req.openStream()
const file = fs.createReadStream(__filename)
file.pipe(stream)
stream.on('data', (chunk) => { /.../ })
stream.on('end', () => { /.../ })
stream.on('close', () => {
// Graceful shutdown
socket.close()
})
stream.on('error', (err) => { /.../ })
})
对于服务器和客户端,createSocket()
函数用于创建绑定到本地 UDP 端口的 QuicSocket
实例。对于 QUIC 客户端来说,仅在使用客户端身份验证时才需要提供 TLS 密钥和证书。
在 QuicSocket
上调用 connect()
方法将新创建一个客户端 QuicSession
对象,并与对应地址和端口的服务器创建新的 QUIC 连接。启动连接后进行 TLS 1.3 握手。握手完成后,客户端 QuicSession
对象会发出 secure
事件,表明现在可以使用了。
与服务器端类似,一旦创建了客户端 QuicSession
对象,就可以用 stream
事件监听服务器启动的新 QuicStream
实例,并可以调用 openStream()
方法来启动新的流。
单向流和双向流
所有的 QuicStream
实例都是双工流对象,这意味着它们都实现了 Readable
和 Writable
流 Node.js API。但是,在 QUIC 中,每个流都可以是双向的,也可以是单向的。
双向流在两个方向上都是可读写的,而不管该流是由客户端还是由服务器启动的。单向流只能在一个方向上读写。客户端发起的单向流只能由客户端写入,并且只能由服务器读取;客户端上不会发出任何数据事件。服务器发起的单向流只能由服务器写入,并且只能由客户端读取;服务器上不会发出任何数据事件。
// 创建双向流
const stream = req.openStream()
// 创建单向流
const stream = req.openStream({ halfOpen: true })
每当远程对等方启动流时,无论是服务器还是客户端的 QuicSession
对象都会发出提供 QuicStream
对象的 stream
事件。可以用来检查这个对象确定其来源(客户端或服务器)及其方向(单向或双向)
session.on('stream', (stream) => {
if (stream.clientInitiated)
console.log('client initiated stream')
if (stream.serverInitiated)
console.log('server initiated stream')
if (stream.bidirectional)
console.log('bidirectional stream')
if (stream.unidirectional)
console.log(‘’unidirectional stream')
})
由本地发起的单向 QuicStream
的 Readable
端在创建 QuicStream
对象时总会立即关闭,所以永远不会发出数据事件。同样,远程发起的单向 QuicStream
的 Writable
端将在创建后立即关闭,因此对 write()
的调用也会始终失败。
就是这样
从上面的例子可以清楚地看出,从用户的角度来看,创建和使用 QUIC 是相对简单的。尽管协议本身很复杂,但这种复杂性几乎不会上升到面向用户的 API。实现中包含一些高级功能和配置选项,这些功能和配置项在上面的例子中没有说明,在通常情况下,它们在很大程度上是可选的。
在示例中没有对 HTTP 3 的支持进行说明。在基本 QUIC 协议实现的基础上实现 HTTP 3 语义的工作正在进行中,并将在以后的文章中介绍。
QUIC 协议的实现还远远没有完成。在撰写本文时,IETF 工作组仍在迭代 QUIC 规范,我们在 Node.js 中用于实现大多数 QUIC 的第三方依赖也在不断发展,并且我们的实现还远未完成,缺少测试、基准、文档和案例。但是作为 Node.js v14 中的一项实验性新功能,这项工作正在逐步着手进行。希望 QUIC 和 HTTP 3 支持在 Node.js v15 中能够得到完全支持。我们希望你的帮助!如果你有兴趣参与,请联系 https://www.nearform.com/cont... !
鸣谢
在结束本文时,我要感谢 NearForm 和 Protocol Labs 在财政上提供的赞助,使我能够全身心投入于对 QUIC 的实现。两家公司都对 QUIC 和 HTTP 3 将如何发展对等和传统 Web 应用开发特别感兴趣。一旦实现接近完成,我将会再写一文章来阐述 QUIC 协议的一些奇妙的用例,以及使用 QUIC 与 HTTP 1、HTTP 2、WebSockets 以及其他方法相比的优势。
James Snell( @jasnell)是 NearForm Research 的负责人,该团队致力于研究和开发 Node.js 在性能和安全性方面的主要新功能,以及物联网和机器学习的进步。 James 在软件行业拥有 20 多年的经验,并且是 Node.js 社区中的知名人物。他曾是多个 W3C 语义 web 和 IETF 互联网标准的作者、合著者、撰稿人和编辑。他是 Node.js 项目的核心贡献者,是 Node.js 技术指导委员会(TSC)的成员,并曾作为 TSC 代表在 Node.js Foundation 董事会任职。
本文首发微信公众号:前端先锋
欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章
欢迎继续阅读本专栏其它高赞文章:
- 深入理解Shadow DOM v1
- 一步步教你用 WebVR 实现虚拟现实游戏
- 13个帮你提高开发效率的现代CSS框架
- 快速上手BootstrapVue
- JavaScript引擎是如何工作的?从调用栈到Promise你需要知道的一切
- WebSocket实战:在 Node 和 React 之间进行实时通信
- 关于 Git 的 20 个面试题
- 深入解析 Node.js 的 console.log
- Node.js 究竟是什么?
- 30分钟用Node.js构建一个API服务器
- Javascript的对象拷贝
- 程序员30岁前月薪达不到30K,该何去何从
- 14个最好的 JavaScript 数据可视化库
- 8 个给前端的顶级 VS Code 扩展插件
- Node.js 多线程完全指南
- 把HTML转成PDF的4个方案及实现