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