• Undici v7发布,为什么说这将是一个必须升级的版本!
  • 发布于 2个月前
  • 185 热度
    0 评论
  • 李明发
  • 19 粉丝 39 篇博客
  •   
最初发布于 2018 年的现代 Node.js HTTP 客户端库 —— Undici,在 2024 年取得了显著的增长。下载量从 2023 年的超过 1.89 亿次飙升至 2024 年(1 月至 11 月)的惊人的 4.37 亿次以上,这突显了它在 Node.js 生态系统中的关键作用。今天,Node.js Undici 工作组欣然宣布 Undici v7 的发布。

此版本引入了对 fetch() 规范的更严格遵循、WebSocketStream、一个开创性的缓存实现,以及可定制的拦截器,显著增强了 HTTP 工作流。团队还进行了多项优化和改进,以与即将发布的 Node.js 版本保持一致。在本文中,我们将探讨这个版本为何是一个必须升级的版本。

更严格的 fetch() 规范遵循
在最初将 fetch() 引入 Node.js 时,我们需要为 NPM 注册表上各种 polyfill 的用户提供迁移路径。因此,我们添加了对第三方 Blob、FormData 和 AbortController 的支持。这些支持经过了测试但没有详细记录。然而,这些 polyfill 不符合规范,导致了相当多的维护问题。在 Undici v7 中,我们决定完全移除 fetch() 实现中的这些支持。因此,仅提供的类将能够与其配合使用。
例如,以下代码将不再起作用:
import { fetch } from 'undici'
import FormData from 'form-data'
const body = new FormData()
body.append('file', new Blob(['hello world'], { type: 'text/plain' }), 'hello.txt')
await fetch('http://localhost:3000/upload', {
  method: 'POST',
  body
})
相反,请使用以下方法:
import { fetch, FormData } from 'undici'
// 堆代码 duidaima.com
const body = new FormData()
body.append('file', new Blob(['hello world'], { type: 'text/plain' }), 'hello.txt')
await fetch('http://localhost:3000/upload', {
  method: 'POST',
  body
})
同样的逻辑适用于 Request、Response、Headers 等。这将在 Node.js v24 及更高版本中生效。

WebSocketStream
在 Node.js v22 中发布了标准兼容的 WebSocket 实现后,@KhafraDev 在 PR #3560 中添加了 WebSocketStream。
示例代码如下:
import { WebSocketStream } from "undici";
const ws = new WebSocketStream('ws://localhost:3000/')
const { readable, writable } = await ws.opened
const writer = writable.getWriter();
writer.write('hello world')
for await (const value of readable) {
  const parsed = new TextDecoder().decode(value)
  console.log('received', parsed)
  if (value === 'end') {
    writer.close()
  }
}
请注意,此实现是实验性的。

组合拦截器
从 Undici v6.20 开始,我们为 Undici 添加了一个新的 API,可以完全自定义请求的生命周期,从而实现最大的灵活性:compose()。这允许您将多个拦截器链接在一起,从而非常方便地自定义行为。
以下是使用拦截器的示例:
import {
  Agent,
  interceptors,
  setGlobalDispatcher,
  getGlobalDispatcher
} from "undici";

setGlobalDispatcher(getGlobalDispatcher().compose(
  interceptors.redirect({ maxRedirections: 3, throwOnMaxRedirects: true }),
  interceptors.retry({
    maxRetries: 3,
    minTimeout: 1000,
    maxTimeout: 10000,
    timeoutFactor: 2,
    retryAfter: true,
  })
))
// 可以直接与原生 fetch 一起使用,无需修改。
await fetch(...)
以下是预构建的拦截器:
redirect:自动跟随 HTTP 304 和 Location 头。
retry:自动重试失败的请求。
dump:转储所有请求的主体。
dns:为源缓存 DNS 查询。
cache:实现符合 RFC-9111 的缓存(参见下一节)。
您可以在这里找到所有拦截器的文档。

缓存
Undici 实现了 RFC-9111 中描述的缓存机制,并作为共享缓存使用。缓存拦截器会挂接到客户端进行的每个请求中。它本质上是介于 request() 或 fetch() 调用和将请求发送到服务之间的中间件。对于每个请求,拦截器会访问其缓存存储,检查是否存在可以使用的缓存响应。如果有响应,拦截器会直接返回该响应。

如果没有响应,拦截器会将请求进一步传递给拦截器链的下游,最终将请求发送到服务器。拦截器会为响应附加一个处理程序,并确定是否可以缓存它。缓存响应的最低条件是存在 cache-control 头并且具有 public 指令。
import { Agent, setGlobalDispatchers, interceptors, request } from 'undici'
setGlobalDispatcher(getGlobalDispatcher().compose(
  interceptors.cache(/* optional object for configuring */))
)
await request(‘http://localhost:3000’)
// Native fetch() support
await fetch(‘http://localhost:3000’)
我们还增加了一个 SQLite 缓存存储,如果 Node.js 的实验性 SQLite API 可用。它会将响应存储在 SQLite 数据库中,该数据库可以是内存型或基于文件的,这意味着它可以用于与其他 Node 进程共享缓存响应。
import { request, setGlobalDispatcher, getGlobalDispatcher, interceptors, cacheStores } from 'undici'
// you will need to run this file with --experimental-sqlite setGlobalDispatcher(getGlobalDispatcher().compose(
  interceptors.cache({
    store: new cacheStores.SqliteCacheStore({
      location: './cache.db'
    })
  })
))
const res = await request('http://localhost:3000/')
console.log(res.statusCode)
console.log(await res.body.text())
在组合时,请勿混用不同版本的 Undici
Node.js 核心自带的 Undici 版本较旧,分别为前一版本线的 v6 和 v5。如果您希望使用 compose() 功能,必须安装一个新的 undici.Agent,否则新版 Undici v7 的拦截器将会崩溃。
'use strict'

// Do not initialize fetch() before requiring/importing undici
// Uncommenting the following line will break your code
// fetch('https://example.com').catch(console.error)

const undici = require('undici')

undici.setGlobalDispatcher(undici.getGlobalDispatcher().compose((dispatch) => {
  return (opts, handler) => {
    if (!handler.onRequestStart) {
      throw new Error('Handler must implement onRequestStart')
    }
    return dispatch(opts, handler)
  }
}))

undici.request('https://example.com').then((res) => {
  console.log(res.statusCode)
  return res.body.dump()
}).catch(console.error)
(示例使用 CommonJS,因为无法用 ESM 复制此功能。)

长期支持
鉴于 Undici 在 Node.js 中被嵌入用于 fetch(),其支持周期与 Node.js 的发布计划紧密相关。目前,Node.js v18 自带 Undici v5,这意味着 v5 将在 2025 年 4 月结束长期支持 (EOL)。需要注意的是,Undici v6 并未引入明显的破坏性更改,因此 Node.js v18 理论上可以升级到 v6。

Node.js v20 和 v22 自带 Undici v6,因此计划支持 Undici v6 至 2027 年 4 月 30 日。对于 Undici v7,我们计划将其嵌入到 Node.js v24 中。未来我们可能会发布新版本的 Undici,这些版本可能与 Node.js 中的暴露接口不完全兼容,因此难以预测 Undici v7 的支持期限。

总结:
Undici v5 将于 2025 年 4 月 30 日结束支持。
Undici v6 将于 2027 年 4 月 30 日结束支持。

Undici v7 暂无计划的支持期限。


其他相关更改
默认启用阻塞(除非为 HEAD 请求)
众所周知,Undici 支持 HTTP 管线化,允许在收到第一个请求的输出前发送多个请求。我们正在更改默认行为,默认启用阻塞选项:这样,Undici 会在接收到响应的头部后再发送下一个请求。

升级到 llhttp v9
新版本的 HTTP 解析器切换为始终严格的解析逻辑。如果您仅连接到符合 HTTP 规范的服务器,这一更改不会对您产生影响。如果这是一个重要问题,我们建议深入研究并参与贡献。

移除 throwOnError 选项
undici.request() 以前有一个 throwOnError 选项,用于对 4xx-5xx 范围内的状态码自动抛出异常。然而,这一功能现在由拦截器替代,因此不再需要这个选项。要实现相同的功能,现在可以使用拦截器:

import { createServer } from 'node:http'
import { once } from 'node:events'
import { interceptors, getGlobalDispatcher, request } from 'undici'

const server = createServer((req, res) => {
  console.log('request', req.url)
  res.statusCode = 404
  res.end('hello world')
})

server.listen(3000)
await once(server, 'listening')

const dispatcher = getGlobalDispatcher().compose(
  interceptors.responseError()
))
如果您尝试以下代码,将会触发异常:
await request('http://localhost:3000/', { dispatcher }).
基准测试
以下基准测试是在专用硬件上,使用 Node.js v22.11.0 运行,采用 50 个 TCP 连接和 HTTP/1.1 的管线化因子为 10 的环境中测得的:

注意:请根据您的具体使用场景进行基准测试,因为结果可能因使用场景的差异而有所不同。

招募贡献者
Undici 是一个社区驱动的项目!
我们要特别感谢以下成员,他们在过去几个月里付出了巨大努力,推动了这一版本的发布:
@ronag
@Uzlopak
@metcoder95
@Ethan-Arrowood
@szmarczak
@mertcanaltin
@KhafraDev
@tsctx
@mcollina (作者本人)
我们也需要您的加入!项目中有许多“初学者友好”的问题,您可以从这些问题开始贡献。

来自 Platformatic 的一则说明
开源生态的繁荣依赖于无数维护者的不懈努力。为了确保这一生态的可持续性,我们每个人都应积极支持维护者的工作。因此,我们已签署“开源承诺”(Open Source Pledge),并为这一令人激动的 Undici 版本提供了支持。
致所有贡献者:感谢您无私付出的努力,即使这些工作常常得不到应有的认可。
致所有公司:如果您有兴趣支持维护者的工作,请了解如何参与“开源承诺”,并加入这一行列。

用户评论