跳转至

Effect 服务

服务端功能是 Effect 服务。传输层会调用它们:位于 ws.ts 中的 WebSocket RPC 处理器、HTTP 路由、MCP 工具、定时任务,以及 CLI。本页列出编写这些服务的规则。Effect Service Conventions 评审检查也强制同样的规则;请保持两者同步。

功能归属

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

传输处理器只做三件事:解码请求、调用一个服务方法,并把该服务的类型化错误映射为传输层的错误。不做其他任何事。文件系统、Git、进程或持久化操作、文件夹命名、多步分发、重试与回滚,都属于服务层。

原因在于可触达性。用户通过 WebSocket 触发能力,代理通过 MCP 工具触达它,定时任务和 CLI 也会运行它。写在某个处理器中的逻辑,在其他调用方中就不存在,测试它还需要 socket。做纯工作的普通函数(slug、SVG、消息)可以放在服务旁边;能力本身才是方法。

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

处理器不添加自己的 span 或请求指标。Group 中间件对每一次调用进行授权(RpcAuthorization.ts),服务端的 group 还会对它进行埋点(RpcInstrumentation.ts)。带有每次调用上下文(如线程 id)的处理器,使用 Effect.annotateCurrentSpan 添加它。

服务模块的结构

每个服务一个模块,按以下顺序组织:导入、错误与 schema、内联接口的 Context.Service 标签、make,然后是 layer。WorkspacePaths.ts 和 T3ProjectFileLoader.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"]。
  • 依赖 来自环境(yield* FileSystem.FileSystem),绝不作为 make 的参数,这样 make 和 layer 的类型就能表明它们需要什么。绝不要将依赖藏在模块全局变量、对单例的闭包,或者调用运行时支持或命令式 API 的 Layer.succeed 中。测试可以直接传入服务实例。配置、不可变值,以及有意为之的回调作为参数是可以的;它们不是服务。
  • make 在模块拥有构造逻辑时存在,且保持私有,除非另一个模块导入它。Knip 会在存在未使用导出时让 CI 失败。不要为了强行使用 Layer.effect 而写 make = Effect.succeed(...);请使用匹配的构造函数,如 Layer.succeed 或 Layer.sync。
  • 命名。 以实现命名的模块直接使用 make 和 layer(NodePtyAdapter.ts)。同时容纳实现的端口模块则会为它们命名,例如 makeCloudflaredRelayClient 和 layerCloudflared。
  • 移动。 移动服务时要删除旧文件,并更新所有调用方,包括编排、MCP、测试和集成测试脚手架。不要使用重导出垫片。
  • 测试 通过服务来检验行为,仅对外部依赖使用测试层。不要 mock 被测逻辑。

Effect 函数与追踪

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

服务操作和 I/O 默认使用命名 span。对于逐块/逐事件的工作、紧密循环,或已被同一操作的 span 覆盖的辅助函数,应刻意使用 Effect.fnUntraced。请在函数旁或一组相关函数旁说明原因。循环本身不能成为豁免理由:其外层操作仍然可以有一个有用的 span,而不必追踪每一次迭代。将已有的 span 名称和选项移入 Effect.fn,而不是为同一操作添加第二个 span;对于依赖函数参数的属性,请标注当前 span。当外层 tracer 覆盖必须覆盖 span 创建时,请在未追踪函数内保留显式的 withSpan。

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

运行时边界

ManagedRuntime.make、runPromise 和 runPromiseExit 属于应用层和框架边界:React、原生回调、CLI、HTTP 适配器。绝不要放在领域服务、仓储、持久化代码或服务构造函数中。命名适配器可以将服务桥接到 Promise API,但没有任何 Effect 服务依赖它。

在应用拥有的层中一次性组合共享资源,并将其上下文提供给集成运行时。不要为每个功能创建托管或 Atom 运行时来分发。当获取可能失败且调用方需要回退时,保持失败的类型化:操作上的错误或显式的可选服务层。不要用命令式运行时绕开该层。

错误

  • 属性。 失败是带有结构化属性的 Schema.TaggedError 类:操作或阶段、资源路径或实体 ID、规范化类别或状态。消息是固定的或从这些属性构建的,绝不要来自 cause、cause.message 或字符串化的缺陷。不要有复制 cause.message 的 detail 字段。
  • 原因。 包装失败的错误将直接的底层错误保留为 cause;当每次构造都包装一个时,使其为必填。没有底层内容的验证和领域错误则没有。
  • 安全值。 属性和日志注解保持有界:不包含原始负载、命令参数或输出、签名 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 后缀或其上方的注释说明了原因?

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 t3/t3code