Node.js 中的 QUIC 与 HTTP/3 支持介绍
- 原文链接:www.jasnell.me/posts/quic-…
- 原文作者:James M Snell
过去几年里,我一直在为 Node.js 开发原生的 QUIC 与 HTTP/3 实现。这条路很长,但如今在 Node.js 仓库里,一个可用的 node:quic 模块已经可以在 --experimental-quic 标志后面使用了。这是系列文章的第一篇,我会逐步讲解新模块的架构、概念与用法。本篇先讲基础:QUIC 是什么、Node.js 为什么要加原生支持,以及如何用简单示例上手。
该实现高度实验性,仍在积极开发中。 API 被归类为 Stability 1.0(早期开发阶段),预计还会变化。如果你试用时遇到问题或有反馈,请在 Node.js GitHub 仓库提交 issue。
该实现尚未随稳定版发布。要试用,你需要检出并构建 main 分支上的最新代码,在配置构建时加上 --experimental-quic,并用 --experimental-quic 标志运行 Node.js:
git clone https://github.com/nodejs/node
cd node
./configure --ninja --experimental-quic
make -j16
./node --experimental-quic my-app.mjs
什么是 QUIC?
QUIC 是一种通用传输协议,最初由 Google 开发,现由 IETF 标准化为 RFC 9000。它运行在 UDP 之上而非 TCP,并将 TLS 1.3 加密直接集成进传输层。这意味着每条 QUIC 连接默认都是加密的——不存在未加密模式。
下列关键特性使 QUIC 有别于 TCP+TLS(与 TCP 加 TLS 的组合相比):
- 多路复用流且无队头阻塞:单条 QUIC 连接可承载多条相互独立的数据流。若属于某条流的报文丢失,只影响该流——同连接上的其他流仍可继续推进。在 TCP 中,单个丢包会拖住整条连接。
- 更快的连接建立:QUIC 将传输握手与 TLS 握手合并为一次往返。借助 0-RTT 会话恢复,回访客户端可在握手完成前的第一个报文里就发送数据。
- 连接迁移:QUIC 连接由连接 ID 标识,而非源/目的 IP 与端口的四元组。因此连接可在网络切换(例如从 Wi-Fi 切到蜂窝)后仍保持,无需重新建立会话。
- 内置流量控制:QUIC 同时具备连接级与逐流流量控制,可在不依赖应用层变通的情况下提供细粒度背压。
HTTP/3(RFC 9114)是运行在 QUIC 之上的 HTTP 版本。它用 QUIC 流取代 HTTP/2 基于 TCP 的帧格式,并继承上述全部特性。当你使用默认 ALPN 为 'h3' 的 node:quic 模块时,会得到由 nghttp3 库驱动的 HTTP/3 会话。
为何需要原生支持?
QUIC 的若干特性使在 Node.js 中做原生实现特别有吸引力:
- UDP 处理:Node.js 已将 libuv 的
uv_udp_t接入事件循环。原生实现可把 QUIC 直接绑定到现有 UDP 基础设施,避免每个报文都经 JavaScript 桥接的开销。 - TLS 集成:QUIC 要求 TLS 1.3,且握手与传输协议深度交织。原生实现可直接使用 OpenSSL,与
node:tls、node:https已有的 TLS 基础设施共享。 - 性能:QUIC 的报文处理路径对延迟敏感。核心协议逻辑放在 C++(通过 ngtcp2 库),仅把面向应用的 API 暴露给 JavaScript,可避免每个报文都穿越 JS/C++ 边界。
node:quic 实现建立在以下四个外部依赖之上:
JavaScript API 位于管理这些依赖的 C++ 层之上,并暴露少量对象:QuicEndpoint、QuicSession、QuicStream 与 QuicError。
入门
只有以 --experimental-quic 标志启动 Node.js 时,node:quic 模块才可用:
node --experimental-quic my-app.mjs
该模块只能通过 node: 方案(node: URL scheme)访问:
import { listen, connect, QuicEndpoint } from 'node:quic';
由于 QUIC 强制 TLS 1.3,每条连接都需要 TLS 凭据。开发与测试时,可用 OpenSSL 生成自签名证书:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout key.pem -out cert.pem -days 365 -nodes \
-subj '/CN=localhost'
生成证书后,在应用中按如下方式加载密钥与证书:
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
const key = createPrivateKey(readFileSync('key.pem'));
const cert = readFileSync('cert.pem');
架构概览
在看代码之前,先理解 node:quic 模块中的四个核心对象及其关系会很有帮助:
QuicEndpoint绑定本地 UDP 端口。它既可作服务端(接受入站连接),也可作客户端(发起出站连接)。多个会话可共享同一个 endpoint。QuicSession表示本地 endpoint 与远端对等体之间的一条 QUIC 连接。每个会话有各自的 TLS 状态、流量控制窗口与拥塞控制。会话由quic.connect()(客户端)创建,或通过quic.listen()接受入站连接(服务端)创建。QuicStream是会话内的一条有序字节流。流可以是双向的(双方都可读写),也可以是单向的(一方写、另一方读)。一个会话可同时承载许多并发流。QuicError是携带数字 QUIC 错误码与类型('transport'或'application')的Error子类,便于区分协议级错误与应用级错误。
数据流大致如下:QuicEndpoint 从网络接收 UDP 报文,按连接 ID 分发给对应的 QuicSession,会话再把数据交给相关的 QuicStream。出站方向上,流把数据推入会话的发送循环,会话合并报文后经 endpoint 的 UDP 套接字发出。
一个简单的回声服务器
从一个最小示例开始:QUIC 服务器接受连接,从双向流读取数据并回显。
echo-server.mjs
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
import { listen } from 'node:quic';
import { bytes } from 'stream/iter';
const key = createPrivateKey(readFileSync('key.pem'));
const cert = readFileSync('cert.pem');
const endpoint = await listen(async (session) => {
// 每个新的入站 QUIC 会话调用一次。
session.onstream = async (stream) => {
// 读取入站流的全部内容。
const data = await bytes(stream);
// 回显并关闭写端。
const writer = stream.writer;
writer.writeSync(data);
writer.endSync();
await stream.closed;
session.close();
};
}, {
// TLS 配置。sni 映射将服务器名关联到 TLS 凭据。通配符 '*' 匹配客户端请求的任意服务器名。
sni: { '*': { keys: [key], certs: [cert] } },
// 要协商的 ALPN 协议。此处使用自定义协议名而非 'h3',以获得不带 HTTP/3 帧层的原始 QUIC 会话。
alpn: ['echo-protocol'],
});
console.log('Echo server listening on', endpoint.address);
回声服务器示例中有几点值得注意:
listen()接受一个回调,每个新入站会话调用一次。该回调在概念上类似 TCP 服务器上的'connection'事件。- TLS 凭据通过
sni选项配置,将服务器名映射到密钥/证书对。通配符'*'是匹配客户端请求的任意服务器名的兜底项。 alpn选项指定服务器支持的应用层协议。设为['h3'](默认)时会使用 HTTP/3 帧层。此处用自定义协议名以获得原始 QUIC 会话。- 远端对等体打开新流时触发
session.onstream回调。stream对象支持异步迭代读取,并有writer属性用于写入。 stream/iter中的bytes()辅助函数把异步可迭代对象的全部内容收集为单个Uint8Array,是读尽一条流的便捷方式。
一个简单的回声客户端
再看客户端:连接服务器,在双向流上发送消息并读取回显:
echo-client.mjs
import { connect } from 'node:quic';
import { bytes } from 'stream/iter';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const session = await connect('localhost:0', {
// TLS SNI 的服务器名,须与服务器期望一致。
servername: 'localhost',
// ALPN 协议,须匹配服务器提供的协议之一。
alpn: 'echo-protocol',
});
// 等待 TLS 握手完成。
const info = await session.opened;
console.log('Connected:', info.protocol, info.cipher);
// 创建双向流并一次性发送正文。
const message = 'Hello from the QUIC client!';
const stream = await session.createBidirectionalStream({
body: encoder.encode(message),
});
// 读取服务器回显。
const response = await bytes(stream);
console.log('Echo:', decoder.decode(response));
// 清理。
await stream.closed;
await session.close();
回声客户端示例中还有几点需要注意:
quic.connect()返回解析为QuicSession的 promise。默认会创建绑定到随机本地端口的新QuicEndpoint。session.openedpromise 在 TLS 握手完成后 resolve。解析值包含协商连接的详情:ALPN 协议、密码套件、服务器名、本地与远端地址,以及是否使用了 0-RTT 早期数据。createBidirectionalStream()打开新流,并可一步发送 body。body选项接受多种类型:字符串、ArrayBuffer、Uint8Array、Blob、FileHandle、异步可迭代对象、ReadableStream,甚至Promise值。这些会在后续文章中展开。session.close()执行优雅关闭:等待所有打开流完成,再向对等体发送带NO_ERROR码的CONNECTION_CLOSE帧。这与立即拆毁会话的session.destroy()相对。
用地址字符串调用 connect()
quic.connect() 接受 net.SocketAddress 对象或字符串。使用字符串时格式为 host:port:
// 连接到指定主机与端口。
const session = await connect('192.168.1.100:4433', {
servername: 'myserver.example.com',
alpn: 'my-protocol',
});
// IPv6 地址支持方括号表示法。
const session6 = await connect('[::1]:4433', {
servername: 'localhost',
alpn: 'my-protocol',
});
用 Symbol.asyncDispose 做干净关闭
QuicEndpoint 与 QuicSession 都实现了 Symbol.asyncDispose,因此可与 await using 语法配合自动清理:
dispose.mjs
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
import { listen, connect } from 'node:quic';
const key = createPrivateKey(readFileSync('key.pem'));
const cert = readFileSync('cert.pem');
{
await using endpoint = await listen(async (session) => {
session.onstream = async (stream) => {
// 处理流……
stream.writer.endSync();
};
}, {
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['my-protocol'],
});
await using session = await connect(endpoint.address, {
servername: 'localhost',
alpn: 'my-protocol',
});
await session.opened;
// 使用会话……
// 块退出时,会通过 Symbol.asyncDispose 自动调用 session.close() 与 endpoint.close()。
}
优雅关闭与立即销毁
node:quic API 提供两种拆毁会话或 endpoint 的方式:
// 优雅关闭:等待打开流结束,再发送带 NO_ERROR 的 CONNECTION_CLOSE。返回 promise。
await session.close();
// 立即销毁:立刻拆毁。若提供 error,会转发给对等体及所有挂起 promise(opened、closed、stream.closed 等)。
session.destroy(new Error('something went wrong'));
// 带显式 QUIC 错误码的销毁:
session.destroy(new Error('app error'), {
code: 42n,
type: 'application',
});
对 endpoint 而言,同样适用下列两种拆毁方式:
// 优雅:等待所有会话结束。
await endpoint.close();
// 立即:销毁所有会话,拒绝挂起操作。
endpoint.destroy(new Error('shutting down'));
QuicEndpoint 构造函数呢?
上文示例中,quic.listen() 与 quic.connect() 会隐式创建 endpoint。你也可以显式创建 endpoint 并传入:
explicit-endpoint.mjs
import { readFileSync } from 'node:fs';
import { createPrivateKey } from 'node:crypto';
import { listen, connect, QuicEndpoint } from 'node:quic';
const key = createPrivateKey(readFileSync('key.pem'));
const cert = readFileSync('cert.pem');
// 创建绑定到指定端口的 endpoint。
const endpoint = new QuicEndpoint({
address: { address: '0.0.0.0', port: 4433 },
});
// 同一 endpoint 既监听又连接。
const serverEndpoint = await listen(async (session) => {
// 处理入站会话……
}, {
endpoint,
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['my-protocol'],
});
console.log('Listening on', endpoint.address);
// { address: '0.0.0.0', port: 4433, family: 'ipv4' }
当你需要控制本地地址/端口、配置 UDP 缓冲区大小,或在多个会话间共享单个 UDP 套接字时,显式 endpoint 很有用。下一篇会详细讲 endpoint 配置。
实验性说明
我想强调:该实现高度实验性。API 面很大,会随真实使用反馈继续演进。你应预期下列情况:
- API 可能(也会)变化:方法签名、选项名、回调约定在未来版本中都可能调整。在 API 稳定之前,
--experimental-quic标志是必需的,并将继续保持必需。 - 并非所有功能都已实现:HTTP/3 服务端推送、WebTransport,以及更高级的 HTTP 语义(自动路由、内容协商等)尚不可用。
- 性能尚未优化:目前重点是正确性与 API 设计。实现成熟后会做性能调优。
如果你试用时遇到问题,请在 github.com/nodejs/node… 提交——此阶段的反馈非常宝贵。
接下来是什么?
本篇覆盖了基础。系列其余部分会深入得多:
- 第 2 部分(Endpoints、Sessions 与连接生命周期):Endpoint、Session 与 QUIC 连接生命周期 —— 连接限制、地址验证、TLS 配置、会话事件、endpoint 复用与统计。
- 第 3 部分:QUIC 流:发送与接收数据 —— 双向与单向流、body 来源、Writer API、流量控制与背压。
- 第 4 部分:Node.js 上的 QUIC 之上的 HTTP/3 —— 带伪标头的请求/响应、信息性标头、尾部标头、流优先级、GOAWAY 与 ORIGIN 帧。
- 第 5 部分:高级 QUIC:0-RTT、数据报、SNI 与可观测性 —— 会话恢复、不可靠数据报、虚拟主机、诊断通道、性能钩子与 qlog。
下一篇即该系列的第 2 部分,届时见。