在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 至關重要。
【相關推薦:《nodejs 教學》】
在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 也引入了許多其他重要功能:
為Node.js 核心實作QUIC 的工作從2019 年3月開始,並由NearForm 和Protocol Labs 共同贊助。我們利用出色的 ngtcp2 函式庫來提供大量的低層實作。因為 QUIC 是許多 TCP 特性的重新實現,所以對 Node.js 意義重大,並且與 Node.js 中目前的 TCP 和 HTTP 相比能夠支援更多特性。同時對用戶隱藏了大量的複雜性。
在實作新的 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 伺服器是一個 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 用戶端和伺服器之間幾乎沒有區別:
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 董事会任职。
原文地址:https://www.nearform.com/blog/a-quic-update-for-node-js/
作者:James Snell
译文地址:https://segmentfault.com/a/1190000039308474
翻译:疯狂的技术宅
更多编程相关知识,请访问:编程视频!!
以上是詳解Node.js中的QUIC協議的詳細內容。更多資訊請關注PHP中文網其他相關文章!