版本号就是日期,Cloudflare 开源的 workerd 把 API 兼容做成了时间机器

发布于 · 2,564 字 · 约 6 分钟#Github 解读#DevOps原文链接
版本号就是日期,Cloudflare 开源的 workerd 把 API 兼容做成了时间机器 封面图
  • Cloudflare workerd 版本号采用日期格式,实现 API 兼容性
  • workerd 设计原则:nanoservices 和 capability bindings
  • workerd 强制 IO 操作绑定到请求生命周期,提高安全性
  • 兼容性通过编译期配置实现,无需运行时判断
  • workerd 使用 Rust 优化 C++ 代码,提升性能和安全性

2026 年 9 月 13 日上午九点刚过,workerd 发了一个新版本,v1.20260913.1。

版本号的后半段,就是当天的日期。

这不是巧合。这个仓库 2022 年 9 月开源,四年下来发了 586 个 release,版本号全是这个格式,最近五天每个自然日一版,周末不停。README 里有一句原话,版本号就是一个日期,对应这个版本支持的最大兼容日期。你写 Worker 的时候声明一个 compatibility date,运行时就按那一天的 API 行为来跑,哪怕四年后的今天装了最新版,你也可以把日期拨回 2022 年,它会老老实实模拟当年的老行为。

仓库在这里,workerd,Cloudflare Workers 背后的 JavaScript/Wasm 运行时,8724 颗星,Apache 2.0 协议,和线上生产环境同一套代码。

今天想拆的就是「版本号是日期」背后的那套工程。看完你会发现这不是发布策略的花活,是整个运行时设计的地基。

先说清楚它是什么

其实吧,Node.js 给你的是一个通用平台,workerd 给你的是一个请求执行器。你的代码只是 handler,进程模型、事件循环、隔离、生命周期统统不归你管。README 列了三种用法,自托管为 Workers 设计的应用、本地开发调试、把它当可编程的 HTTP 代理用。

它的设计原则里有两条值得单独说。一条叫 nanoservices,把应用拆成很多小组件,但组件间调用不走网络,跑在同一个线程里,开销是一次本地函数调用。另一条叫 capability bindings,配置里用命名的能力把服务和外部资源接起来,代码里没有全局命名空间可用,README 的说法是这样天然免疫 SSRF 攻击,因为你根本没有一个可以乱连全网的对象。

你想想看,下面这两行就是 workerd 官方 hello world 示例的全部,一个完整服务。

addEventListener('fetch', event => {
  event.respondWith(handle(event.request));
});

全局作用域里,连 fetch 都是非法的

在 workerd 里写 await fetch(...) 写在模块顶层,会直接抛错。错误信息很有个性,翻译过来是,全局作用域内禁止异步 IO、定时器和随机数生成,请到 handler 里去做。

这件事不是运行时随手拦的,架构上就焊死了。核心在 src/workerd/io/io-context.c++ 的第 28 行,一个 thread_local 指针。

static thread_local IoContext* threadLocalRequest = nullptr;

每个请求进来,运行时创建一个 IoContext 挂到线程局部变量上,所有 C++ 侧 API 想做 IO,都得先通过 IoContext::current() 拿到它,拿不到就抛上面那个错。为什么把口子收得这么死,理由其实很朴素。一个 isolate 的顶层代码只在启动时执行一次,之后可能要服务几百万个请求,如果允许顶层发 fetch,这次 fetch 到底属于哪个请求,语义上就没法交代。把 IO 绑死在 IoContext 的生命周期里,靠的是类型系统和线程模型,不是代码评审时的自觉。

isolate 的复用模型也有讲究。src/workerd/io/worker.h 第 701 行的注释写得很直白,一个 Worker 可以在多个线程之间弹跳着处理请求,但同一时刻只能在一个线程上执行,所以每个线程执行前必须拿 Worker::Lock,而且这个锁必须在栈上分配。一个脚本对应一个长寿命的 isolate,每个请求只是新开一个 IoContext,这就是 Workers 冷启动能压到毫秒级的底层原因。

兼容性是编译出来的,不是运行时判断的

compatibility date 机制,很多平台都有类似的影子,讲道理,workerd 这套的严格程度超出一般 feature flag 的做法,它把兼容性做成了编译期配置。

真相源是一个 Cap'n Proto schema,src/workerd/io/compatibility-date.capnp。里面定义了一个 CompatibilityFlags 结构体,每个功能对应一个字段,字段上挂注解声明自己的语义,比如某个字段标着 compatEnableDate 加一个日期字符串,意思是这个日期之后该功能默认开启。还有 enable 和 disable 两类 flag,让你可以不挪日期、单独开关某个行为。

部署的时候,compileCompatibilityFlags() 函数把日期加上显式 flag 列表,编译成一份持久化的位集,跟着 worker 一起存储。API 注册的地方直接按 flag 分支,src/workerd/api/http.h 里 Fetcher 类型注册方法的位置有段警告注释,新方法必须挂兼容 flag 才能暴露,否则会和 JS RPC 的通配属性撞名。连 TS 类型都是从同一份 schema 生成的,一份定义,运行时判断、持久化格式、类型声明三处共用。有意思的是 schema 注释里还留着历史痕迹,这套东西曾经叫 FeatureFlags,很多代码还这么叫它,作者懒得改名了。

配合版本号即日期的发布节奏,效果就是你永远不需要「升级适配」。新版本装上去,你的旧 worker 行为一根毫毛都不会动,想用新行为,自己挪日期。最近一周这个仓库每个自然日都发了版,周末不停。

C++ 和 V8 之间的胶水叫 jsg

90 个 .c++ 实现文件铺在 src/workerd/api/ 下面,fetch、streams、crypto、KV、R2、Durable Objects,每个能力一对头文件和实现文件。把 C++ 类型暴露成 JS 对象靠的是 src/workerd/jsg/ 自研的绑定层,核心是几个宏,JSG_RESOURCE_TYPE 声明一个可暴露给 JS 的资源类型,JSG_METHOD 注册方法。

拿 fetch 举例。http.h 里的声明长这样。

JSG_RESOURCE_TYPE(Fetcher, CompatibilityFlags::Reader flags) {
  JSG_METHOD(fetch);
  JSG_METHOD(connect);
}

C++ 侧的实现函数签名返回 jsg::Promise<jsg::Ref<Response>>,框架自动把它转成 JS 的 Promise 和 Response 对象,写 C++ 的人不用碰 V8 的句柄栈。C++ 对象和 V8 wrapper 之间用双引用计数维持身份,基类 Wrappable 的头文件里有一大段注释解释为什么 wrapper 不能被 GC 回收,细节到 V8 internal field 的存储位置。

事件循环压根没有自己造。workerd 跑在 kj 上面,这是 Kenton Varda 的 C++ 异步框架,从 capnproto 仓库的 v2 分支引入。Kenton 也是 workerd 的主要作者,977 次提交排在贡献榜第二。main 函数最后一行是 kj::runMainAndExit,整个进程就是一个 kj 事件循环。连服务器配置文件都是 capnp 格式,hello world 的配置十几行,声明一个 service、一个监听端口,worker 脚本直接内嵌进去。

测试体系同样值得一读,416 个 .wd-test 文件,测试脚本是标准 Worker 格式,自带一个 test() handler,由运行时自己驱动断言。

200 个 Rust 文件正在往 C++ 心脏里长

说个可能反直觉的事实,这个 C++ 项目的 src/rust/ 目录下有 200 个 Rust 文件,全走 Bazel 编译。

最狠的一手是 fork 了 cxx-rs 放进树里改造,让 Rust 的 async fn 跨过 FFI 之后直接产出 kj::Promise,错误自动变成 kj 异常,而不是在 tokio 和 kj 两套异步世界之间做翻译。Rust 对象也已经进了 V8 的 wrapper 体系,src/workerd/jsg/wrappable.h 第 168 行并排定义了两个 tag,C++ 对象用 0xeb04,Rust 对象用 0xeb05。

这些 Rust 代码不是外围实验。node 兼容层里的 dns 和 url 模块是 Rust 写的,TS 类型剥离用 SWC 做的 transpiler 是 Rust,V8 字节码缓存生成器是 Rust,连 Python Workers 的 import 解析器都是 Rust。issue 区还在继续加码,#7010 在重做 kj-rs 的异步桥接机制,#6360 计划用 Rust 重写 node 的 Buffer。

我一直觉得这是整个仓库最值得长期观察的一条线。Cloudflare 没有喊重写口号,就是在 C++ 心脏上一块一块接 Rust 的器官,而且为了异步模型不打架,付出的是长期维护一个 cxx fork 的代价。这个决心,比任何架构宣言都硬。

拿它之前,先看看水有多深

泼几盆冷水,都有实据。

最要紧的一条,README 里有段大写字母的警告,workerd 不是加固过的沙箱。它能保证 Worker 只碰配置允许的资源,但防不住运行时自身实现层面的漏洞,跑不可信代码必须再套一层虚拟机之类的真隔离。自托管场景把用户代码直接丢给它跑,是会出事的。

从 Node 迁移的人还容易摔在 nodejs_compat 上。issue #854 报的是 nodejs_compat 下动态 require node:events 报错,29 条评论,从 2023 年开到现在没关。想平滑迁移的人,这里是最深的坑。

构建门槛也劝退了大多数想动手的人。Bazel 加 V8 15.3.76.11,patches 目录下给 V8 打了 41 个补丁,还得和 ICU 版本严格对齐。普通 C++ 项目改一行自己编译的路子,在这里基本走不通。725 个 open issue,贡献榜前排清一色 Cloudflare 员工,jasnell 2289 次、kentonv 977 次、fhanau 821 次,外部贡献者要过的第一道墙就是这套构建系统。

可以带走的模式,日期即 ABI

坦白讲,单看任何一条技巧,workerd 都只是工程做得扎实。但「版本号即日期」这件事,值得起一个名字,我管它叫日期即 ABI。

语义化版本告诉你「这次升级不兼容」,把决定权扔给你。日期即 ABI 反过来,任何破坏性变更都挂在一个日期上,日期之后默认新行为,之前永远老行为,用户随时回拨。升级运行时不再是一个需要评估的风险事件,版本号本身就是最大支持日期的声明。你在自己的长期演进系统里也能用这套,内部 SDK、SaaS API、数据库方言,任何「老用户不能伤、新行为要上线」的两难,都可以把它变成一次带日期的默认值切换。

行动建议分三种人。写 Cloudflare Workers 的人,读它能弄懂行为边界的来历,compatibility date 不再是玄学。想学 V8 嵌入的人,src/workerd/jsg/ 是现成教材,比官方 sample 工业化得多。想自托管 Workers 应用的人,samples 目录从 helloworld 到 durable-objects-chat 都有,配一个 capnp 文件就能跑起来,跑之前,记得上面那盆沙箱的冷水。

评论互动

© 2026 王若风的技术博客 · Powered by Astro