Effect 程序应该在哪里 run?
在一个 service 方法里,为了把结果交给只接受 Promise 的库,就地调一次 Effect.runPromise,看起来再自然不过。结果是外层请求被取消了,里面这一段还在继续跑。
答案是:run 只放在边缘,数量尽可能趋近于一。 边缘之内全是组合,没有执行;边缘本身是宿主调用你的那一个入口,具体在哪取决于谁拥有主循环。
先交代一个前提:Effect 是一个值,是对计算的描述,不运行就什么都不会发生;运行它的函数是 Effect.runPromise、Effect.runFork、Effect.runSync 这一族,统称 run*(见 Effect 是一个值)。而宿主,比如 CLI 的 main、Express 或 Hono 的路由回调、Cloudflare Worker 的 fetch,只会调用函数、等一个 Promise,它不懂 Effect。下面依次回答:随手 run 到底坏在哪,边缘在哪,怎么接。
为什么不能哪里要结果就哪里 run?
因为每一次就地 run,都会静默地断掉一样东西。
- 中断断开。fiber 之间有父子关系,父被中断,子跟着被中断(见 Fiber 与中断)。就地
runPromise会启动一棵全新的 fiber 树,和外层没有任何关系。外层被取消了,里面这段还在跑。 - 依赖断开。依赖通过 Layer 提供,沿着 Effect 的组合结构传下去(见 Layer)。你在中间另起一次运行,那里的环境是空的,得重新 provide 一遍,很容易提供出第二份不一样的实现。
- 类型信息断开。
runPromise返回的是 Promise,失败变成了 rejection,错误通道E里的精确类型在这里被擦掉。
还有一类更隐蔽的朴素做法:为了「省事」,在每个请求里重新构建一次 Layer。依赖图能工作,但数据库连接池之类的资源被反复创建、反复销毁,成本和泄漏风险都被放大。
边缘到底在哪里?
可以这样想:Effect 程序像一份菜谱,run* 是开火。厨房里应该只有一个灶台开关,所有菜谱都交给它;如果每个厨师都自带打火机,你就再也说不清火是谁点的、什么时候该熄。
边缘的位置取决于宿主的形状,有三种。
- 你拥有主循环:脚本、CLI、独立的服务进程。用平台包提供的入口(Node 下是
NodeRuntime.runMain),它负责接管信号、中断主 fiber、等 finalizer 跑完再退出。这是最理想的情况,一次运行贯穿整个进程。 - 别人拥有主循环:你只是 Express 里的一堆回调。没有一个「整个进程」的入口给你包,只有许多次零散的调用。你需要的是一个长期存在、已经装配好依赖的运行环境,每次回调都从中取用,这就是
ManagedRuntime,细节见 Effect 的 ManagedRuntime 是什么?。 - 运行时本身没有进程概念:比如 Cloudflare Workers,环境绑定随每个请求而来,边缘就是每次
fetch,依赖在请求内部装配。这种情况单独写在 Effect 怎么接入 Cloudflare Workers?。
接入 Web 框架时适配层怎么写?
写法是同一个形状:一个 runtime,加一个很薄的适配函数,把框架的请求翻译成 Effect 程序,再把 Effect 的结果翻译回框架的响应。
import { Effect } from "effect"
declare const runtime: {
runPromise: <A, E>(e: Effect.Effect<A, E, any>, o?: { signal?: AbortSignal }) => Promise<A>
}
declare const getUser: (id: string) => Effect.Effect<{ name: string }, never, never>
// 适配层:框架的 Request -> Effect -> Response
export async function GET(request: Request): Promise<Response> {
const id = new URL(request.url).searchParams.get("id") ?? ""
const program = getUser(id).pipe(
Effect.map((user) => Response.json(user))
)
return runtime.runPromise(program, { signal: request.signal })
}这里有三个设计意图。
第一,适配层之外没有任何 run*。业务代码是返回 Effect 的普通函数,可以单独测试。
第二,失败应该在适配层之内就被翻译成 HTTP 响应(见 错误设计 里「在边界翻译」的做法),而不是让 rejection 冒到框架里,被框架当成 500。原因就是前面说的:Promise 的 rejection 里已经没有 E 的类型了。
第三,把宿主提供的取消信号 request.signal 作为 signal 传给 runPromise:客户端断开时,对应的 fiber 被中断,沿途的 finalizer 会执行(见 资源与 Scope)。这是中断机制在边缘的延伸。
还有哪些写法等于把边界画错了?
- 在业务代码里「顺手」run 一下。凡是 service 方法里出现
runPromise,几乎都是边界画错了。把它改成返回 Effect,让调用方去 run。 - 在模块顶层运行 Effect,比如为了「预热」连接。导入期应该只有定义,运行放在入口。这样模块可以被安全地导入、被测试、被打包器分析。
- 手写
process.on("SIGINT", () => process.exit())。这会跳过所有 finalizer。信号处理交给runMain,它会走中断、再走清理。 - 每个请求构建一次
ManagedRuntime。运行时是长期对象,存在的意义就是复用;每请求构建,等于绕了一圈回到「每请求重建 Layer」。
这几条的共同点是同一条不变量:run 只在边缘,边缘之内全是描述。想看怎么在测试里守住这条边界,可以接着读 Effect 运行时边界与测试。
本文整理自本站 Effect 教程中关于运行时边界的一章,文中 API 与行为描述以该章为准,没有标注对应的 effect 版本号。代码示例沿用该章原样,本次改写没有重新运行,也没有针对不同框架逐一验证适配层写法。