Effect 的 RPC 契约怎么放进共享文件,客户端怎么调用?
给 RPC 契约漏实现一个 handler,我以为会在运行时才报「找不到 handler」,结果编译就停在了启动入口。
答案是:把契约写成只依赖 effect 的共享文件,服务端用 toLayer 实现,客户端用 RpcClient.make 调用。 契约里每个 Rpc 声明 payload、success、error,服务端和客户端读同一份,漏实现的 handler 是编译错误。
effect/rpc 的核心设计和 HttpApi 一样:契约是 Schema 声明。我对它只有一条使用规则,就是契约文件不依赖任何实现,这样它可以放进一个共享包,浏览器、Worker、另一个服务都能 import,而不会把数据库驱动带过去。下面依次看契约、服务端、客户端和测试。如果想先看契约的基本写法,另有 Effect 的 RPC 契约怎么写?。
契约怎么写?
// file: contract.ts
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/rpc"
export class TodoNotFound extends Schema.TaggedError<TodoNotFound>()("TodoNotFound", { id: Schema.Int }) {}
export class Todo extends Schema.Class<Todo>("Todo")({
id: Schema.Int,
title: Schema.String,
done: Schema.Boolean
}) {}
export class TodoRpcs extends RpcGroup.make(
Rpc.make("GetTodo", {
payload: { id: Schema.Int },
success: Todo,
error: TodoNotFound
}),
Rpc.make("CreateTodo", {
payload: { title: Schema.NonEmptyString },
success: Todo
}),
Rpc.make("WatchTodos", {
success: Todo,
stream: true
})
) {}每个 Rpc 有 payload、success、error 三个槽。没写 error 的,错误通道就是 Never,也就是该调用在业务上不会失败;stream: true 把 success 包成 Stream。
服务端怎么实现,漏了会怎样?
服务端实现用 toLayer,得到的是一个提供 handler 的 Layer:
// file: rpc-server.ts
import { Effect, Layer, Ref, Stream } from "effect"
import { HttpRouter } from "effect/http"
import { RpcSerialization, RpcServer } from "effect/rpc"
import { Todo, TodoNotFound, TodoRpcs } from "./contract.js"
export const TodoHandlers = TodoRpcs.toLayer(
Effect.gen(function*() {
const store = yield* Ref.make<ReadonlyArray<Todo>>([new Todo({ id: 1, title: "write", done: false })])
return TodoRpcs.of({
GetTodo: ({ id }) =>
Ref.get(store).pipe(
Effect.flatMap((all) => {
const t = all.find((x) => x.id === id)
return t ? Effect.succeed(t) : Effect.fail(new TodoNotFound({ id }))
})
),
CreateTodo: ({ title }) =>
Ref.modify(store, (all) => {
const t = new Todo({ id: all.length + 1, title, done: false })
return [t, [...all, t]]
}),
WatchTodos: () => Stream.fromIterableEffect(Ref.get(store))
})
})
)
export const RpcRoutes = RpcServer.layerHttp({ group: TodoRpcs, path: "/rpc", protocol: "http" }).pipe(
Layer.provide(TodoHandlers),
Layer.provide(RpcSerialization.layerNdjson)
)
export const Serve = HttpRouter.serve(RpcRoutes)TodoRpcs.of 让 handler 的形状被契约约束,写错返回类型会编译失败。
更有意思的是漏实现:我把 Layer.provide(TodoHandlers) 拿掉,Layer 的环境里留下了 Handler<"GetTodo">、Handler<"CreateTodo">、Handler<"WatchTodos"> 这三个未满足的依赖,编译就停在启动入口。给契约加一个新 Rpc,所有服务端实现都必须跟着补,否则编译不过。这是 Layer 依赖追踪在契约上的直接收益。
客户端怎么调用,错误通道里有什么?
传输由一个 Protocol Layer 决定:
// file: rpc-client.ts
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer, Stream } from "effect"
import { FetchHttpClient } from "effect/http"
import { RpcClient, RpcSerialization } from "effect/rpc"
import { createServer } from "node:http"
import { TodoNotFound, TodoRpcs } from "./contract.js"
import { Serve } from "./rpc-server.js"
const ClientProtocol = RpcClient.layerProtocolHttp({ url: "http://localhost:3457/rpc" }).pipe(
Layer.provide(RpcSerialization.layerNdjson),
Layer.provide(FetchHttpClient.layer)
)
const useClient = Effect.gen(function*() {
const client = yield* RpcClient.make(TodoRpcs)
console.log(yield* client.GetTodo({ id: 1 }))
const err = yield* client.GetTodo({ id: 42 }).pipe(
Effect.catchTags({
TodoNotFound: (e) => Effect.succeed(e),
RpcClientError: (e) => Effect.die(e)
})
)
console.log(err instanceof TodoNotFound)
console.log(yield* Stream.runCollect(client.WatchTodos()))
})
const Server = Serve.pipe(Layer.provide(NodeHttpServer.layer(createServer, { port: 3457 })))
await Effect.scoped(
Layer.build(Server).pipe(Effect.andThen(useClient.pipe(Effect.provide(ClientProtocol))))
).pipe(Effect.runPromise)我用真实的 Node 服务端和 FetchHttpClient 跑过。几个观察:
GetTodo({ id: 42 })的错误通道是TodoNotFound | RpcClientError。前者是契约里声明的业务错误,后者是传输层的失败,两者在同一个通道里但_tag不同,所以catchTags能分别处理。运行结果里TodoNotFound是真正的类实例,字段完整,instanceof成立。- 我把客户端指向一个没有服务的端口,得到的是
RpcClientError,其reason._tag是HttpError。网络不通在类型里是显式的,你必须决定怎么处理。 WatchTodos()在客户端是一个Stream,用Stream.runCollect能拿到全部元素。取消、背压、重试和普通 Stream 一致。
传输是可替换的:ClientProtocol 可以换成 Socket 或 Worker 版本(RpcClient.layerProtocolSocket、layerProtocolWorker,服务端对应 RpcServer.layerProtocolWebsocket 等),契约和业务代码都不变。
非法的 payload 会变成什么?
这是我没预料到的行为:用空字符串调用 CreateTodo(违反 NonEmptyString),结果不是错误通道里的值,而是 defect(Cause 里是 Die,带 Schema 校验失败的信息)。
我的理解是,payload 不合法属于调用方的编程错误,契约里没有给它留位置。如果你需要把「用户输入不合法」当成可恢复的业务错误,就应该在契约里显式声明一个 error,并在 handler 里自己校验,而不是依赖 payload 的 Schema 去拦截。
测试时怎么绕开网络?
RpcTest.makeClient 把客户端直接接到服务端 handler 上,不经过任何序列化和网络:
// file: rpc-test.ts
import { Effect } from "effect"
import { RpcTest } from "effect/rpc"
import { TodoRpcs } from "./contract.js"
import { TodoHandlers } from "./rpc-server.js"
await Effect.scoped(
Effect.gen(function*() {
const client = yield* RpcTest.makeClient(TodoRpcs)
console.log(yield* client.GetTodo({ id: 1 }))
})
).pipe(Effect.provide(TodoHandlers), Effect.runPromise)它要求环境里有 handler 的 Layer,这与生产环境一致,所以测的是真正的业务 handler,只是少了一层传输。我的建议是:业务逻辑用这种内存方式测,序列化与传输用一两个端到端用例覆盖。更一般的测试写法见 Effect 代码怎么测试,才不用 mock 整个模块?。
版本:相关包用的是 4.0.5。HTTP 传输下的调用、TodoNotFound的实例性质、连不上端口时的RpcClientError、WatchTodos的Stream.runCollect、空字符串 payload 变成 defect、漏掉 handler 时的编译错误,我都跑过或编译过。Socket、Worker 等其他传输,我只确认它们在包里存在,没有跑过。RpcTest.makeClient的示例我没有单独运行。