服务:接口与实现分离,Context.Service
前八章我们处理的是「一段计算做什么、可能怎么失败、数据长什么样」。从这一章开始换一个问题:这段计算依赖什么。发邮件、读数据库、拿当前时间、调用支付网关,这些都不是计算本身,而是计算所处的环境。环境怎么表达,决定了代码好不好测、好不好换、好不好读。
要解决的问题
看一段典型的 TypeScript:
import { mailer } from "./infra/mailer"
import { db } from "./infra/db"
export async function signup(email: string) {
await db.users.insert({ email })
await mailer.send(email, "welcome")
}signup 的签名是 (email: string) => Promise<void>,它说了「我要一个邮箱」,却没说「我要一个数据库和一个邮件客户端」。依赖藏在 import 语句里,是模块级单例。这种写法的代价在三个场景里会同时出现:
- 测试时只能 mock 整个模块,替换的粒度是文件而不是能力。
- 想在某个调用链里临时换一个实现(比如演练模式下不真发邮件),要么改全局,要么到处加开关参数。
- 读签名读不出依赖,读者必须翻完函数体才知道它碰了哪些外部系统。
朴素做法为什么不够
常见的第一个改进是手动传参:signup(db, mailer, email)。依赖显式了,但调用链一深,中间函数就要替下游转发自己根本不用的参数。第二个改进是引入 DI 容器加装饰器,参数不用传了,但依赖关系变成运行时反射,缺了一个 provider 要等到启动甚至首次调用才报错。
两者其实在解决同一件事:让依赖可见,同时不让它污染每一层的参数列表。手动传参牺牲了后者,容器牺牲了前者的静态检查。
Effect 的做法:依赖是类型的一部分
回忆第 2 章:Effect<A, E, R> 的第三个参数 R,就是「运行这个 Effect 需要从环境里拿到什么」。服务(service)就是 R 的取值单位:一个服务代表一种能力,用接口描述,实现留到别处提供。
定义一个服务只需要写接口:
import { Context, Data, Effect } from "effect"
class EmailError extends Data.TaggedError("EmailError")<{
readonly reason: string
}> {}
class Mailer extends Context.Service<Mailer, {
readonly send: (to: string, body: string) => Effect.Effect<void, EmailError>
}>()("app/Mailer") {}Context.Service<Self, Shape>()("key") 给了你三样东西:类型 Mailer(代表接口)、一个运行时 key(字符串 "app/Mailer")、以及一个可以被 yield* 的值。接口里方法返回的是 Effect,所以失败也在类型里;接口只描述「能做什么」,不涉及「怎么做」。
业务代码取用服务的方式,就是 yield* 它:
const signup = (email: string) =>
Effect.gen(function* () {
const mailer = yield* Mailer
yield* mailer.send(email, "welcome")
return email
})
// Effect<string, EmailError, Mailer>我没有给 signup 写返回类型,R 是推导出来的:yield* Mailer 这一行既取到了实例,又把 Mailer 记进了 R。多个服务在组合时自动取并集,中间函数什么都不用转发。如果你忘了提供实现就想运行,把它交给 Effect.runPromise 会得到编译错误,并且错误信息会直接点名缺的是 Mailer。这是 DI 容器做不到的:缺依赖变成了编译期问题。
心智模型:函数签名里的「插座」
一个类比:R 像一个电器背面的电源规格标签。它不负责发电,只声明「我需要 220V 插座」。服务就是插座的规格,Layer(下一章)是真正接到墙上的电源。同一台电器,插到家里的电源是真实运行,插到测试台的稳压器就是测试,电器本身不改一个字。
「提供」的动作是 Effect.provide。提供之后,对应的服务从 R 里消失:
import { Layer } from "effect"
const MailerLive = Layer.succeed(Mailer, {
send: (to, body) => Effect.log(`send ${to}: ${body}`)
})
const runnable = signup("a@b.c").pipe(Effect.provide(MailerLive))
// Effect<string, EmailError, never>这里的 Layer.succeed 是最简单的一种提供方式,Layer 本身是下一章的主题,现在只需要知道「它是一份实现的配方」。换一份实现,只需要换这一个值:
const MailerFake = Layer.succeed(Mailer, { send: () => Effect.void })signup 的代码完全不动。这就是接口与实现分离在 Effect 里的具体形态:接口属于业务代码的词汇表,实现属于程序入口(或测试)的装配决定。
什么算一个好服务
服务的设计比语法更重要,下面是几条取舍。
第一,接口按能力划分,而不是按技术划分。Mailer.send、Clock.currentTimeMillis、PaymentGateway.charge 是能力;HttpClient、PostgresClient 是技术。业务代码最好依赖前者,后者留给实现去依赖。这样把数据库从 Postgres 换成别的,业务代码一行不改。
第二,接口里的方法不应该再带 R。如果 Mailer.send 返回的是 Effect<void, EmailError, HttpClient>,那么每个使用 Mailer 的地方都得同时处理 HttpClient,实现细节泄漏到了接口。方法的 R 应当是 never,实现需要的东西,在构造这个服务的时候就取好(第 10 章)。
第三,接口里的错误只暴露调用方能处理的。把底层的 SqlError 原样写进接口,等于把存储实现焊死在业务层。第 6 章讲过:错误要在边界上翻译成领域语言,服务接口就是那条边界。
第四,key 字符串是运行时身份。两个服务用了同一个 key,它们会占同一个槽位。惯例是写成 包名/服务名,比如 "app/Mailer",避免撞车。
第五,时间、随机数这类环境量也可以是服务。Effect 自带的 Clock、Random 就是这样:你不直接调 Date.now(),而是通过服务取时间,测试时才能换成可控的时钟。这一点第 12 章会用到。
常见误区
- 把服务当成「全局单例的新写法」。服务不是用来存全局可变状态的。有状态的服务,状态应当在构造时创建、封装在实现里,而不是在模块顶层放一个
let。 - 每个函数都做成服务。
formatPrice这样的纯函数不需要服务。判断标准:这个东西有没有「换一个实现」的合理场景(测试替身、不同环境、不同供应商)。没有,就是普通函数。 - 服务接口过大。一个
AppContext塞进二十个方法,等于又退回了全局对象。拆成小而专一的服务,R里的联合类型才有信息量。 - 在
yield*之后到处传服务实例。取一次、闭包里用,不要把实例当参数一路往下传,否则又回到了手动传参。 - 以为
R是运行时注入。R只存在于类型层面,它保证的是「运行前必须提供」;真正的提供发生在Effect.provide那一刻。
小结
- 依赖是类型的一部分:
R记录需要什么,Effect.provide把它消掉。 Context.Service定义能力的接口,接口不含实现、方法不带R、错误只含调用方能处理的。- 业务代码
yield*服务,换实现只需要换提供的那个值,业务代码不动。
服务只是「接口」。真正的实现从哪来、怎么用别的服务构造、资源什么时候建立和释放,是下一章 Layer 的内容:Layer:构建依赖图、生命周期与组合。