首页
/ opencode 的 Effect 架构迁移实战:移除 makeRuntime 服务门面(Facade)的完整清单与模板

opencode 的 Effect 架构迁移实战:移除 makeRuntime 服务门面(Facade)的完整清单与模板

2026-09-06 11:16:30作者:温玫谨Lighthearted

本文以 opencode 仓库中的 Facade removal checklist 为核心,讲清 opencode 基于 Effect-TS 的依赖注入体系里"服务门面"是什么、为什么必须移除,以及项目是如何按"低风险批次 + 重调用方批次"两波次完成迁移的。读完本文,你将掌握:门面模式的识别特征与危害、AppRuntime.runPromise(Effect.gen(...)) 等标准调用模板、测试改写的 effect 风格写法,以及一套可验证的"完成判定标准(Done means)"与剩余工作清单。

一、什么是"门面":makeRuntime 与双轨 API 问题

opencode 的核心服务都构建在 Effect-TS 的 Context.Service + Layer 之上,正确用法是在 Effect.gen 体内 yield* 出服务实例再调用方法。但在迁移期间,许多服务文件除了 Service 之外,还额外导出一组基于 Promise 的 async 函数:它们内部调用 makeRuntime(Service, layer) 构建一个"服务私有运行时",再用 runPromise((svc) => svc.method(...)) 把每次调用都跑一遍完整的运行时。这类导出函数就是本清单所称的"门面(facade)"。

门面带来的问题是双轨 API:同一能力既可以 yield* Npm.Service 拿到,也可以直接 await npm.install(...)。后者绕过了调用方所在 Effect 的作用域与依赖组合,测试难以注入替身,且服务文件被永久绑定到一个私有运行时上。

当前仓库中的活例证是 packages/core/src/npm.tsNpm 服务既导出 Service,又在文件尾部保留了完整的门面尾部:

const { runPromise } = makeRuntime(Service, LayerNode.compile(node))

export async function install(...args: Parameters<Interface["install"]>) {
  return runPromise((svc) => svc.install(...args))
}

export async function add(...args: Parameters<Interface["add"]>) {
  return runPromise((svc) => svc.add(...args))
}

export async function which(...args: Parameters<Interface["which"]>) {
  return runPromise((svc) => svc.which(...args))
}

这就是清单中"Priority hotspots"指出的目标:install()add()which() 等 async 门面帮助函数最终要删除,只保留 Npm.Service 这一条访问路径。

二、门面盘点与迁移进度

清单给出的当前状态是(截至该分支):

  • packages/opencode/src/ 中共有 5 处 makeRuntime(...) 调用点;
  • 其中 2 处被有意排除在迁移目标之外:src/bus/index.tssrc/effect/cross-spawn-spawner.ts
  • 剩余 2 个仍在服役的运行时门面NpmTuiConfig

迁移按两波次推进,且均已合并:

  • Wave 1(低风险批次,已合并)src/pty/index.ts(Pty)、src/skill/index.ts(Skill)、src/project/vcs.ts(Vcs)、src/tool/registry.ts(ToolRegistry)、src/auth/index.ts(Auth)。
  • Wave 2(重调用方批次,已合并)src/config/config.ts(Config)、src/provider/provider.ts(Provider)、../core/src/filesystem.ts(FileSystem)、src/lsp/index.ts(LSP)、src/mcp/index.ts(MCP)。

此外清单还记录了 session 系列(session.ts / prompt.ts / revert.ts / summary.ts)、AgentPermissionWorktreePluginSnapshot 等已完成"service-local facades removed"的文件。

清单中所有被迁移服务遵循同一共享模式,识别一个待迁移服务时可按此特征排查:

  1. 一个服务文件仍导出 makeRuntime(...) + async 门面;
  2. 一两个路由或 CLI 入口直接调用这些门面;
  3. 测试直接调用门面,需切换为 yield* svc.method(...)
  4. 待调用方全部消失后,删除 makeRuntime(...)、移除 async 门面导出、去掉 makeRuntime 的 import。

排除项的边界

清单明确了两处不参与门面移除的 makeRuntime(...),以及原因:

这一边界说明很重要:门面移除只针对"服务命名空间"的私有运行时,不代表仓库里所有 makeRuntime 调用都要消灭。从当前仓库看,packages/opencode/src/ 下仍存在的 makeRuntime 调用点还包括 installation/index.tssrc/cli/cmd/run/ 下的若干运行时启动文件,这些属于各自模块的运行时组织方式,不在本清单的完成判定范围内。

三、完成判定标准(Done means)

清单为每个服务定义了严格的五条件"完成"标准,任何一条不满足都不算迁移完成:

  1. 所有生产代码调用方停止使用 Namespace.method(...) 门面调用;
  2. 所有直接调用门面的测试停止走门面,改为从上下文 yield* 出服务;
  3. 服务文件中不再存在 makeRuntime(...)
  4. 服务文件不再导出基于运行时的门面帮助函数;
  5. grep 被迁移的门面方法名,只能找到服务实现本身或无关同名。

第 5 条是关键的可验证依据:它把"是否完成"从主观判断变成了仓库内的可执行检查。

四、调用方改造模板

门面移除后,原本 await Namespace.method(...) 的调用方需要按场景套用统一模板。以下四套模板直接继承自清单原文。

4.1 路由处理器

用一段 AppRuntime.runPromise(Effect.gen(...)) 作为处理器主体,在体内 yield* 出所需服务:

const value = await AppRuntime.runPromise(
  Effect.gen(function* () {
    const pty = yield* Pty.Service
    return yield* pty.list()
  }),
)

若两次服务调用相互独立,保持在同一个 effect 体内并使用 Effect.all(...) 并发执行。

opencode 的 AppRuntime 本体见 packages/opencode/src/effect/app-runtime.ts:它用 AppNodeBuilderV1.build 把 Npm、Auth、Config、Provider、Session、LSP、MCP 等数十个服务的 Layer 节点组合成单一 AppLayer,再以 ManagedRuntime.make(AppLayer, { memoMap }) 建出托管运行时,对外暴露 runSync / runPromise / runPromiseExit / runFork / runCallback / dispose。模板里"一段连续 body、内部自由 yield 多个服务"的写法,正是这个共享运行时设计的直接体现——所有服务在同一 Layer 图中解析,无需调用方关心依赖拼装。

4.2 普通 async CLI / 脚本入口

即使调用方本身还不是 Effect 服务,也优先用一整块连续的 AppRuntime.runPromise(Effect.gen(...)) 覆盖整个工作单元:

const skills = await AppRuntime.runPromise(
  Effect.gen(function* () {
    const auth = yield* Auth.Service
    const skill = yield* Skill.Service
    yield* auth.set(key, info)
    return yield* skill.all()
  }),
)

仅在真正的孤立单次调用或别扭的回调边界,才退回 AppRuntime.runPromise(Service.use(...))不要在同一个连续工作流里堆叠多个小 runPromise(...) 调用。清单还强调:这是合理的中间状态,不要为了移除门面而去"effect 化"整个 CLI 文件。

4.3 启动引导 / 即发即忘代码

若旧门面调用只是为了触发初始化,直接通过该文件已有的运行时调用服务:

void BootstrapRuntime.runPromise(Vcs.Service.use((svc) => svc.init()))

不要为了给 bootstrap 用而重新在服务里引入一个专用运行时。

4.4 测试

门面测试统一改写为完整 effect 风格:

it.effect("does the thing", () =>
  Effect.gen(function* () {
    const svc = yield* Pty.Service
    const info = yield* svc.create({ command: "cat", title: "a" })
    yield* svc.remove(info.id)
  }).pipe(Effect.provide(Pty.defaultLayer)),
)

三条配套规则:

  • 若仓库测试已使用 testEffect(...),优先 testEffect(Service.defaultLayer),测试体内 yield* Service.Service
  • 不要让测试走 AppRuntime,除非测试明确在验证应用运行时本身。门面移除场景下,测试通常只需提供它所需的具体服务 Layer;
  • 若测试使用 provideTmpdirInstance(...),该 fixture 需要活的 ChildProcessSpawner Layer。对于 defaultLayer 未内置该基础设施的服务,优先补充仓库标准的跨平台 spawner Layer:
const infra = CrossSpawnSpawner.defaultLayer

const it = testEffect(Layer.mergeAll(MyService.defaultLayer, infra))

缺少这一层时,测试会在运行时以 Service not found: effect/process/ChildProcessSpawner 失败。CrossSpawnSpawner 正是第二节提到的排除项之一,它作为共享基础设施 Layer 被测试复用,而不是被当作门面移除。

五、清单已回答的六个问题

这部分是迁移过程中的决策记录,对同类 Effect 项目迁移有直接参考价值:

  1. 是否要先 effect 化整个调用方? 不需要。路由文件用 AppRuntime.runPromise(Effect.gen(...)) 组合处理器;CLI 和脚本用 AppRuntime.runPromise(Service.use(...));bootstrap 用既有 bootstrap 运行时。门面移除不需要比这更大的重构。
  2. 测试是否继续从 async 测试体调用命名空间门面? 否,现在就转换。终态是 yield* svc.method(...),而不是 async 测试里的 await Namespace.method(...)
  3. 是否保留导出的 runPromise 作为便利函数? 否。本批次的目标是彻底删除服务私有运行时
  4. 路由里有 websocket 回调或嵌套 async 处理器怎么办? 保持路由形状不变,把每个门面调用替换为 AppRuntime.runPromise(Service.use(...)),或在可行时用一个 Effect.gen(...) 包裹周边 async 段。不能因为路由含回调形状代码就保留服务门面。
  5. 是否每次服务调用都套一个 runPromise 否。默认每个 handler / 命令 / 工作流只写一个连续的 AppRuntime.runPromise(Effect.gen(...)) 块,块内 yield 出所有需要的服务。多个小 runPromise 只在调用方结构强制要求时接受,如 websocket 生命周期回调、外部回调 API 或确实互不相干的孤立操作。
  6. 单个服务表达式是否要包 Effect.gen(...) 通常不。只有一个表达式时优先直写:
await Effect.runPromise(FileSystem.Service.use((svc) => svc.read({ path })))

只有工作流确实需要多个 yield 值或分支时才用 Effect.gen(...)

六、来自前两批次的经验教训

清单沉淀了六条反复出现的错误与纠正,值得在动手前通读:

  1. 测试通常应提供具体服务 Layer,而不是 AppRuntime
  2. 使用 provideTmpdirInstance(...) 且需要子进程的测试,优先补 CrossSpawnSpawner.defaultLayer
  3. 位置作用域(location-scoped)服务可能需要服务 Layer + 对应位置 fixture 两者齐全。例如 FileSystem 测试要同时提供 Location.ServiceFileSystem.locationLayer
  4. 不要为了返回结果就把单个 Service.use(...) 调用包进 Effect.gen(...),用直写形式;
  5. 为保证 CLI 可读性,当 handler 开始内联"加载配置 + 加载服务 + 批量 effect 扇出"时,抽出文件内的 preload 辅助函数;
  6. 在门面分支上 rebase 邻近合并时,优先选择已经清理过的服务/测试版本,而不是旧的内联门面时代代码。

七、剩余工作与可验证的现状

清单把剩余工作收敛为两项:

  1. 移除 Npm 的运行时门面(install() / add() / which());
  2. 移除 TuiConfig 的运行时门面(get() / waitForDependencies() 等)。

对照当前仓库,这两项与清单描述一一对应、可直接核验:

Npmpackages/core/src/npm.tsNpm.ServiceContext.Service<...>()("@opencode/Npm"))提供 add / install / which 三个方法,底层通过 @npmcli/arboristreifyEffectFlock 做安装锁、FSUtil 做目录探测;而文件尾部 L257-L269 仍保留 makeRuntime(Service, LayerNode.compile(node)) 与三个 async 门面导出——正符合"在 Npm.Service 之上仍导出运行时门面帮助函数"的未勾选状态。

TuiConfig:清单记录的路径为 src/cli/cmd/tui/config/tui.ts;从当前仓库结构看,该服务现位于 packages/opencode/src/config/tui.ts。其服务主体已完全 effect 化(TuiConfig.get / TuiConfig.pluginOrigins / TuiConfig.waitForDependencies 均为 Effect.fn,通过 Npm.Serviceyield* 安装 TUI 插件依赖),但 L262-L276 仍保留门面尾部:

export const node = LayerNode.make({ service: Service, layer, deps: [Npm.node, FSUtil.node] })

const { runPromise } = makeRuntime(Service, AppNodeBuilder.build(node))

export async function waitForDependencies() {
  await runPromise((svc) => svc.waitForDependencies())
}

export async function get() {
  return runPromise((svc) => svc.get())
}

export async function pluginOrigins() {
  return runPromise((svc) => svc.pluginOrigins())
}

按"Done means"标准,完成这两个文件即意味着:调用方(CLI / TUI 启动路径)改走 AppRuntime 或自身运行时 yield 服务,测试改为 testEffect(...) + 具体 Layer 的形式(Npm 的测试见 packages/core/test/npm.test.ts),随后删除 makeRuntime 行与三个 async 导出,并用 grep 确认被迁移方法名只剩服务实现本身。

八、完整清单快照

以下是清单中的最终状态表,便于检索引用:

服务 状态 备注
src/npm/index.tsNpm 未完成 Npm.Service 之上仍导出运行时门面帮助函数(现位于 packages/core/src/npm.ts
src/cli/cmd/tui/config/tui.tsTuiConfig 未完成 TuiConfig.Service 之上仍导出运行时门面帮助函数(现位于 packages/opencode/src/config/tui.ts
session 系列(session.ts / prompt.ts / revert.ts / summary.ts 完成 service-local facades 已移除
src/agent/agent.tsAgent 完成 service-local facades 已移除
src/permission/index.tsPermission 完成 service-local facades 已移除
src/worktree/index.tsWorktree 完成 service-local facades 已移除
src/plugin/index.tsPlugin 完成 service-local facades 已移除
src/snapshot/index.tsSnapshot 完成 service-local facades 已移除
../core/src/filesystem.tsFileSystem 完成 遗留 opencode 服务已移除
src/lsp/index.tsLSP 完成 门面已移除并合并
src/mcp/index.tsMCP 完成 门面已移除并合并
src/config/config.tsConfig 完成 门面已移除并合并
src/provider/provider.tsProvider 完成 门面已移除并合并
src/pty/index.tsPty 完成 门面已移除并合并
src/skill/index.tsSkill 完成 门面已移除并合并
src/project/vcs.tsVcs 完成 门面已移除并合并
src/tool/registry.tsToolRegistry 完成 门面已移除并合并
src/auth/index.tsAuth 完成 门面已移除并合并

九、小结

这份清单的价值不仅在于它盘点了 opencode 的 Effect 迁移进度,更在于它沉淀了一套可复用的服务门面消除方法论

  • 识别makeRuntime(...) + async 导出 = 待移除门面;
  • 改造:路由 / CLI / bootstrap / 测试四类调用方各有一套标准模板,默认"一个连续 runPromise(Effect.gen(...)) 块、块内多服务 yield";
  • 验收:五条件 Done means + grep 可验证;
  • 收敛:两波批次完成后,剩余工作从"几十个文件"收敛为 NpmTuiConfig 两个文件,且两个文件在源码中的门面尾部都清晰可见、可直接对号入座。

对正在做 Effect 化或类似 DI 架构迁移的项目,这份文档中的模板与经验教训(尤其是测试 Layer 组装与 CrossSpawnSpawner.defaultLayer 的坑)是可直接搬走的实战参考。

登录后查看全文
热门项目推荐
相关项目推荐