Workflow SDK 将 TypeScript 转化为持久化执行,而无需新的基础设施。
在不可靠、无状态的基础设施之上编排长时间运行、有状态的逻辑,这个想法并不新鲜。我们早已拥有消息队列、作业运行器、微服务编排和完整的工 作流引擎。但我们从未拥有过一个写起来感觉良好的版本。
我花了大约六个月的时间,主要在周末,开发一个 Temporal 的分支,试图将其变成一个无服务器的解决方案,并拥有更接近 Vercel 原生体验的开发者体验。最终我意识到,要交付那种体验,我也需要拥有执行环境。于是我放弃了这个分支,加入了 Vercel,并开始与 Nathan Rajlich 一起从头开发一个新的框架。
复制标题链接代码已经是 DAG
工作流就是一个 DAG,即有向无环图。在 Temporal(以及之前的 Cadence)之前,几乎每个工作流框架都要求你手动绘制那个 DAG。Apache Airflow 是典型的例子。你将管道描述为一个显式的任务和依赖关系图,而你的实际逻辑则被埋没在节点内部。"
这对我来说总是感觉本末倒置。我们已经有一种工具来表达“做这个,然后做那个,这些并行,在这里分支”。它叫做编程语言。抽象语法树就是一个 DAG。软件本身就是一个 DAG。
我抽象地知道这一点,但直到我看到 Temporal,我才真正明白,在那里你编写看起来像普通顺序代码的东西,而引擎在底层使其持久化。这一直是梦想。
复制标题链接运行 Temporal 教会了我什么
一旦你的基础设施存在,Temporal 就很棒。我是从零开始的,设置它意味着:
搭建 Temporal Cloud(或自托管服务器:前端、历史、匹配和 Worker 服务,加上 Cassandra/Postgres/MySQL 后端和分片)。
运行你自己的 worker 集群:Temporal 从不执行你的代码。你的 worker 轮询服务器并运行你的工作流和活动。在实践中,那就是你拥有的一个 Kubernetes 集群。
将所有东西连接起来:任务队列、活动注册、客户端配置。
自己管理 worker:扩展、正常运行时间、重启,以及 worker 进程的构建和部署管道。
设置加密:worker 通过互联网与控制平面通信,因此你需要配置双向 TLS 和数据转换器,以便在负载离开你的环境之前对其进行加密。
一旦所有这些都就位,工作流的梦想基本上就实现了。但我想要一些易于启动、并且随着我的项目成长为生产环境仍能扩展的东西。
复制标题链接对进行中的工作流进行版本控制
除了自己拥有 worker 的构建和部署过程之外,还有一个更困难的问题。如果我在运行仍在进行中时更改了工作流代码,我有点卡住了。不再有任何 worker 运行旧版本的、正确的代码。重放会用旧的事件历史命中我的新代码,并因非确定性错误而中断。
官方的答案是补丁 API(patched()
/ GetVersion
)。你根据变更 ID 对代码进行分支,并随着旧运行超出保留期,让它经历多阶段的先弃用后移除的生命周期。这能行,但意味着演进工作流代码时必须非常小心。经过足够多的变更后,工作流代码会腐化成一片版本标志的灌木丛。
复制标题链接我无法解释 Temporal 信号
每当我尝试向朋友展示运行中的 Temporal 演示时,信号 / 查询 / 更新都会变成最令人困惑的部分。它们是三个独立的原语,用于向运行中的工作流输入和输出数据,每个都有自己的规则。查询不能阻塞,信号是即发即忘并被缓冲,而且你必须知道何时该用哪一个。概念确实能理解,但并不很直观。对我来说,这是一种 DX 异味。如果我无法不用白板就解释清楚人在回路原语,那它就太复杂了。这就是为什么 Workflow SDK 用单一原语——hook——取代了这三者。
复制标题链接Workflow SDK 今天的样子
使用 Workflow SDK,你编写一个普通代码文件。"use workflow"
标记一个编排器;"use step"
标记一个具有副作用的工作单元。在两者之间,只是 await
、try/catch
、Promise.all
、循环和条件语句。
export async function processOrderWorkflow(orderId: string) { "use workflow"; const order = await fetchOrder(orderId); await chargePayment(order); return { orderId, status: "completed" };}
async function chargePayment(order: Order) { "use step"; // full Node.js access in here const charge = await stripe.charges.create({ /* ... */ }); return { chargeId: charge.id };}
一个订单工作流调用一个持久步骤。步骤中未捕获的错误会自动重试。
从这一个文件中可以得出几点:
没有 DAG 文件:编译器读取指令,并将代码拆分为工作流和客户端/步骤包。图就是你的控制流。重试:未捕获的错误默认会重试。抛出 new
FatalError(...)
以停止。抛出 new RetryableError(..., { retryAfter })
以自定义退避。设置 fn.maxRetries = n
以调整。临时 Webhook:const webhook = createWebhook()
在运行中的工作流内用一行代码创建一个真实的、可调用的 URL。运行会停在那里,直到有人访问它,无需路由或处理器。钩子:Webhook 是更通用原语之上的语法糖。用 createHook<T>()
创建一个钩子,等待它,别人发送的数据就是返回的内容。这一个概念取代了信号、查询和更新
下面是一个端到端的临时 Webhook。一个审批流程可以暂停几秒或几周,无需额外部署任何东西。
export async function approveExpense(expense: Expense) { "use workflow"; // A real, callable URL, created inside the run const webhook = createWebhook(); // Sending the email is a durable step await emailManager(expense.managerEmail, webhook.url); // Parks here until someone POSTs to webhook.url const request = await webhook; const { approved } = await request.json(); return { expenseId: expense.id, approved };}
一个审批工作流会停在 Webhook 上,直到有人响应,可能是几秒,也可能是几周。
复制标题链接一个库,而不是一个平台
传统引擎要求你把基础设施带到框架中。Workflow SDK 把框架带到你已有的基础设施上。
复制标题链接DBOS 的想法是对的
当我在研究如何真正构建这个时,我研究了所有能找到的工作流引擎。最突出的是 DBOS,一个几乎完全在客户端运行的开源持久执行库。它的服务器唯一需要的就是 Postgres。
Workflow SDK 采用了这种形态。没有需要自托管或付费的定制编排器,也没有需要运维的新的有状态系统。它唯一需要的基础设施就是你已经在运行的基础设施:你的应用、一个数据库、一个队列。所有繁重的工作都在库中完成。
复制标题链接后端是可替换的
Workflow SDK 把这个想法推进到不止一个数据库。整个东西是一个规范,一种编写工作流、步骤和钩子的方式,任何基础设施都可以支撑。你可以混合搭配底层来适配你的技术栈:
Redis / Kafka 等用于你的流
Postgres / Cassandra / 文件系统 / Turso / Durable Objects 等用于持久性
Vercel Queues/ SQS / Cloudflare Queues 等用于你的队列
运行时只与一个接口通信,即 World,它涵盖存储、队列、认证和流式传输。替换任何一层都不会触及你的工作流代码。
事实上,我们维护了一个受 DBOS 启发的第一方 Postgres world,使用 Postgres 实现持久性、队列和流式传输。并且在每个 world 中,都没有 worker 集群,也没有控制平面。框架集成暴露两个普通 HTTP 端点,像你应用的其他部分一样部署。
复制标题链接甚至 Vercel 后端也“只是”一个 CRUD API
Vercel Workflow Server 是支撑 Vercel 上工作流运行的后端,它是无状态的,完全不进行计算或编排。它就像 Postgres 世界一样,只是扩展了 Vercel 的认证和多租户能力。一个 CRUD API,仅此而已。它甚至作为一个普通的 Vercel 部署运行。所有繁重的工作、所有实际的工作流逻辑,都存在于开源、Apache 许可的客户端库中,可以自由扩展。
所以“托管”产品并不是一个你被锁定的黑盒。它只是另一个 World,一个你可以阅读、分叉和扩展的规范的实现。数据格式也不是专有的。你可以替换技术栈中的任何部分。
复制标题链接某些模式依赖平台
我应该坦率地说。Workflow SDK 的一些设计选择受到 Vercel 执行代码方式的影响。最明显的例子是版本控制,正是这个代码演进问题让传统引擎如此痛苦。
我们的答案是,将每次运行固定到启动它的特定部署上。运行会针对它开始时的那份确切代码副本继续执行,因此更改工作流永远不会破坏正在进行的运行。这是一个干净的解决方案,但之所以容易构建,只是因为 Vercel 已经长期保留不可变部署。官方的 Postgres world 尚未以这种方式跟踪和路由版本。令人鼓舞的是,社区 world 已经按预期实现了该规范。Platformatic 在 Kubernetes 上实现的版本安全持久工作流就是一个很好的例子。
我认为这正是正确的权衡。固定版本将版本控制的复杂性从编写工作流的开发者身上转移到了基础设施上。这个负担落在了 world 作者、基础设施提供商以及我们作为框架作者的身上。如果使用框架的每个人都遇到同样的难题,并且必须以同样痛苦的方式自己解决,那么框架就辜负了他们。一个好的框架会将这种复杂性向上游转移。
复制标题链接步骤应该是免费的
性能是我们接下来要解决的下一个大问题。调用一个步骤并不便宜。每个步骤都涉及网络通信和通过队列的一次往返,以正确地持久提交其结果。为了正确性,这是正确的做法。但这与分布式计算应该感觉像函数调用的宣传相悖。
当你编写普通代码时,你不会考虑函数调用的开销。它快得几乎免费。但如果每个步骤都带有沉重的开销,抽象就会泄漏。你开始配给步骤,考虑粒度,拒绝将一个小函数变成检查点,因为“不值得一个步骤”。
所以目标很明确:步骤应该是免费的。
在理想版本中,你可以从现有代码库中取出任何函数,添加“use step”,一切就会正常工作,没有序列化约束,也没有性能损失。
这个梦想驱动着我们。
如果任何开发者都能将一个普通函数变成具有独立微服务的性能、网络、可观测性和可用性的东西,而只需两个词:“use step”,那会怎样?那将改变我们编写代码的方式。
Workflow v5(撰写本文时处于测试阶段)已经带来了高达 5 倍的性能提升,且未改变面向用户的 API,而 v6 将进一步推进,同时让第三方世界也变得同样快速。我们会在 GitHub 讨论中逐步分享细节,你今天就可以通过 npm install workflow@beta 开始测试
。
我着手构建一个持久化框架,它感觉就像编写普通代码,运行在你已有的基础设施上,并且足够快,让你无需犹豫就能使用步骤。它今天已成为现实,我们正在公开构建其余部分。
复制标题链接来和我们一起构建 Workflow SDK
如果在现实约束下设计持久化系统听起来很有趣,Vercel 的 Workflow 团队正在招聘。
查看开放职位。
