【翻译】Node.js 中的 QUIC 与 HTTP/3 支持介绍

2 阅读10分钟

Node.js 中的 QUIC 与 HTTP/3 支持介绍

过去几年里,我一直在为 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:tlsnode:https 已有的 TLS 基础设施共享。
  • 性能:QUIC 的报文处理路径对延迟敏感。核心协议逻辑放在 C++(通过 ngtcp2 库),仅把面向应用的 API 暴露给 JavaScript,可避免每个报文都穿越 JS/C++ 边界。

node:quic 实现建立在以下四个外部依赖之上:

  • ngtcp2 —— QUIC 协议状态机
  • nghttp3 —— HTTP/3 帧层
  • OpenSSL —— TLS 1.3 密码学操作
  • libuv —— 事件循环与 UDP 套接字处理

JavaScript API 位于管理这些依赖的 C++ 层之上,并暴露少量对象:QuicEndpointQuicSessionQuicStreamQuicError

入门

只有以 --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.opened promise 在 TLS 握手完成后 resolve。解析值包含协商连接的详情:ALPN 协议、密码套件、服务器名、本地与远端地址,以及是否使用了 0-RTT 早期数据。
  • createBidirectionalStream() 打开新流,并可一步发送 body。body 选项接受多种类型:字符串、ArrayBufferUint8ArrayBlobFileHandle、异步可迭代对象、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 做干净关闭

QuicEndpointQuicSession 都实现了 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 部分,届时见。