T3 Code 非官方中文翻译不是官方产品,也不是 T3 Tools 官方文档。源提交 d2c25950b4d9英文原文GitHub 源文件
Skip to content

Effect 服务 ​

服务器功能是 Effect 服务。传输层调用它们:ws.ts 里的 WebSocket RPC 处理程序、HTTP 路由、MCP 工具、计划任务,以及 CLI。本页给出编写它们的规则。Effect 服务约定 这条审查检查执行同一套规则;两边要保持同步。

功能放在哪里 ​

一项服务器能力,是它所属领域文件夹里某个服务上的方法(project/、workspace/、git/、provider/ 等)。扩展已经拥有该领域的服务;只有没有任何服务拥有它时,才新增一个。

传输处理程序只做三件事:解码请求、调用一个服务方法、把服务的类型化错误映射成传输层的错误。除此之外什么都不做。文件系统、Git、进程或持久化方面的工作,以及文件夹命名、多步分发、重试和回滚,都属于服务。

原因是调用范围。用户从 WebSocket 触发一项能力,智能体通过 MCP 工具到达它,计划任务和 CLI 也会运行它。写进某一个处理程序的逻辑,其他入口就没有,而且测试它还得有一个套接字。纯计算(一个 slug、一张 SVG、一条消息)用普通函数放在服务旁边即可;能力本身就是那个方法。

ts
// ws.ts: a thin handler
[WS_METHODS.projectsCreateNew]: (input) =>
  projectFolders
    .createNamedProject(input)
    .pipe(Effect.mapError((cause) => new ProjectCreateNewError({ cause }))),

处理程序不自己加 span,也不自己加请求指标。分组中间件为每一次调用授权(RpcAuthorization.ts),服务器的分组也会对它做插桩(RpcInstrumentation.ts)。带有单次调用上下文(例如对话线程(thread) id)的处理程序,用 Effect.annotateCurrentSpan 把该上下文加进去。

服务模块的形态 ​

每个服务一个模块,顺序是:导入、错误与 schema、内联了接口的 Context.Service 标签、make,然后是 layer。WorkspacePaths.ts 和 T3ProjectFileLoader.ts 是合适的参照。

ts
export class FooWriteError extends Schema.TaggedError<FooWriteError>()("FooWriteError", {
  path: Schema.String,
  cause: Schema.Defect(),
}) {
  override get message(): string {
    return "Failed to write the foo file.";
  }
}

export class Foo extends Context.Service<
  Foo,
  { readonly write: (input: { readonly path: string }) => Effect.Effect<void, FooWriteError> }
>()("t3/area/Foo") {}

const make = Effect.gen(function* () {
  const fileSystem = yield* FileSystem.FileSystem;
  // ...
  return Foo.of({ write });
});

export const layer = Layer.effect(Foo, make);
  • 导入。 从子路径以命名空间导入 Effect 模块:import * as Effect from "effect/Effect",绝不要 import { Effect } from "effect"。消费方也这样使用服务模块:import * as Foo from "./Foo.ts",然后是 yield* Foo.Foo 和 Foo.layer。绝不要 import { layer as fooLayer }。像 @t3tools/contracts 这样的包,以及只用到纯辅助函数、错误、schema、配置值或类型的模块,用具名导入即可。barrel 把整个服务模块暴露为 export * as TokenStore from "./tokenStore.ts",而不是重命名后的 make 和 layer 导出。
  • 接口。 不要单独的 FooShape;类型命名为 Foo["Service"]。
  • 依赖来自环境(environment)(yield* FileSystem.FileSystem),绝不要作为 make 的参数,这样 make 和 layer 的类型就能表明它们需要什么。绝不要把一个依赖藏进模块级全局变量、包住单例的闭包,或一个会调用有运行时支持的 API 或命令式 API 的 Layer.succeed。测试可以直接传入服务实例。配置、不可变值,以及有意传入的回调,可以作为参数;它们不是服务。
  • make 在本模块负责构造时存在,并且保持私有,除非另一个模块要导入它。未使用的导出会让 Knip 使 CI 失败。不要写 make = Effect.succeed(...) 去强行得到 Layer.effect;用合适的构造方式,例如 Layer.succeed 或 Layer.sync。
  • 命名。 按实现来命名的模块,使用不加修饰的 make 和 layer(NodePtyAdapter.ts)。同时放着实现的端口模块会给这些实现命名,例如 makeCloudflaredRelayClient 和 layerCloudflared。
  • 移动。 移动一个服务时,删掉旧文件,并更新每一个消费方,包括编排、MCP、测试和集成测试工具。不要留再导出垫片。
  • 测试通过服务来检验行为,测试层只用于外部依赖。不要模拟被测的那段逻辑。

Effect 函数与追踪 ​

可复用函数使用 Effect.fn("Service.operation"),不要用返回 Effect.gen 的箭头函数或函数声明。具名形式会创建 span;Effect.fn(function* ...) 只捕获栈帧。Effect.gen 留给内联程序和层的构造。共享外层操作 span 的内联处理程序,使用 Effect.fnUntraced(function* ...)。

服务操作和 I/O 默认使用具名 span。按块或按事件(event)的工作、紧循环,以及已经被同一操作的 span 覆盖的辅助函数,才有意使用 Effect.fnUntraced。把原因写在函数旁边,或写在一组相关函数旁边。循环本身并不构成豁免:外层操作仍然可以有一个有用的 span,而不必追踪每一次迭代。已有的 span 名称和选项移进 Effect.fn,不要为同一操作再加第二个 span;依赖函数参数的属性,标注在当前 span 上。当外层追踪器的覆盖必须包住 span 的创建时,在未被追踪的函数内部保留显式的 withSpan。

effect 组合子放在生成器函数体之后。需要函数输入的变换,在 effect 之后接收这份输入。生成器需要显式返回类型时使用 Effect.fn.Return<A, E, R>,并保留泛型参数、惰性、作用域和中断行为。

运行时边界 ​

ManagedRuntime.make、runPromise 和 runPromiseExit 属于应用和框架的边界:React、原生回调、CLI、HTTP 适配器(adapter)。绝不要出现在领域服务、仓储、持久化代码或服务构造函数里。具名适配器可以把服务桥接到 Promise API,但任何 Effect 服务都不依赖这个适配器。

共享资源在应用拥有的层里组合一次,再把它的上下文提供给集成运行时。不要为每个功能单独建一个托管运行时或 Atom 运行时再分发出去。获取可能失败、调用方又需要回退时,让失败保持类型化:操作上的一个错误,或一层显式的可选服务。不要用命令式运行时绕开这一层。

错误 ​

  • 属性。 失败是带结构化属性的 Schema.TaggedError 类:操作或阶段、资源路径或实体 id、规范化后的类别或状态。消息是固定文本,或由这些属性拼成,绝不要来自 cause、cause.message,或把缺陷字符串化之后的结果。不要有 detail 字段去复制 cause.message。
  • 原因。 包装了一次失败的错误,把直接的底层错误留在 cause;如果每一种构造都会包装一个,就把它设成必填。下面没有任何东西的校验错误和领域错误,则没有这个字段。
  • 安全的值。 属性和日志标注保持有界:不要原始载荷、命令(command)参数或输出、签名 URL、凭据、查询字符串,或任意的缺陷文本。精确值只存在于 cause。对外给出类别、长度、计数,或 URL 的协议和主机。DeviceBootError.diagnostics 是有意留下的例外:已授权的设备用户和智能体需要设备中心的启动命令和失败文本,才能诊断主机设置。它上限为 2,000 个字符,并写进错误消息;其他服务错误仍遵守上面的规则。
  • 转换。 在失败发生的地方构造错误,只在传输层把它映射成传输错误。转换边界放行已经处在目标通道里的领域错误,只包装未知的或更底层的失败。在上下文已经清楚的地方分别映射每一次失败;不要把整条流水线包进一个泛化错误。
  • 判别符。 不要把同一个区分编码两次:既做成一个特定标签,又做成单值的 operation、reason、kind 或 phase 字面量。这一区分要驱动控制流或面向用户的消息时,拆成不同的类;只帮助诊断的字段,就保持为字段。能到达 HTTP、RPC、持久化状态或 UI 的消息就是行为,重构时要留住它。
  • 映射器。 不要写一个只做 (...args) => new SomeError({ ...args }) 的辅助函数。映射器只在它会做规范化、放行领域错误或补上上下文时才保留。属于目标错误的映射器,是那个类上的静态工厂。
  • 谓词直接导出为 export const isFoo = Schema.is(Foo),而不是再包一层去调用私有 Schema.is 的函数。
  • 捕获。 用 Effect.catchTags({ ... }) 捕获已知标签,哪怕只有一个标签;不要用 catchTag,也不要用带 schema 谓词的 catchIf。Effect.catch 用来处理整个通道;catchIf 用来做结构性检查,例如平台错误码。

推送之前 ​

  • 你改过的处理程序,有没有做了解码、调用和映射错误之外的事?
  • 智能体(MCP)或计划任务能不能用这项能力?如果不能,这是有意的吗?
  • 新增服务之前,有没有先扩展这个领域里已有的服务?
  • 跑过 knip 了吗?没有导入方的新导出会让它失败。
  • 你加上的每一条用来关掉 lint、类型检查器或 LSP 诊断的指令,有没有在 -- reason 后缀里,或在它上方的注释里,写明原因?

非官方译文 · 源提交 d2c25950b4d9 · 不代表 T3 Tools