Context.Reference:带默认值的服务,取代 FiberRef
有一类「服务」你并不想让它出现在类型签名里:日志级别、当前请求的租户、特性开关、追踪是否启用。它们有合理的默认值,绝大多数代码只想读,少数入口想临时改一下,改完不能泄漏到别处。
v3 用 FiberRef 解这个问题,配套的是 Effect.locally、FiberRef.make、FiberRef.get 一整套带 fiber 语义的 API。我在 effect@4.0.5 里确认过,FiberRef 模块已经不存在了。取代它的是 Context.Reference:一个带默认值的服务键。这个设计的好处是只多了一个概念:它就是 Context.Service 的特例,用同样的 provideService、Layer、Effect.service 去读写,不需要学第二套机制。
先看它的定义和读法。再讲内置的 References,最后是 feature flag 与 per-request 配置这两个落地场景,以及最重要的一条边界:什么时候不该用它。
定义与基本性质
用 Context.Reference 创建,必须给 defaultValue,它是一个函数:
import { Context } from "effect"
export const FeatureFlag = Context.Reference<boolean>("myapp/FeatureFlag", {
defaultValue: () => false
})这是官方文档里的写法。几条我实测过的性质:
- 读取它的 effect,类型里的 R 是
never。把Effect.gen里yield* Request的结果赋给Effect.Effect<...>(不带 R)能通过编译,所以它不会向上冒泡成「需要提供的依赖」。 - 没人 provide 时读到默认值。默认值函数只会求值一次并缓存:我读了两次,计数器只增加了 1。
- 缓存的是引用本身。如果
defaultValue返回一个可变对象(比如new Map()),所有没有覆盖的读取方拿到的是同一个实例,我用Map验证过两次读取===为真。所以默认值要么是不可变的,要么你明确想要全局共享。 - Reference 可以用
yield*直接读,也可以用Effect.service(ref)读。
覆盖用 Effect.provideService(effect, ref, value),作用域只在这个 effect 内部。Effect.provide(effect, Layer.succeed(ref, value)) 也可以,适合在应用装配处一次性配置;Effect.updateService(effect, ref, f) 在当前值的基础上修改。三种我都跑过。
为什么它能取代 FiberRef
FiberRef 的核心语义有两条:局部覆盖、fork 时继承。Context.Reference 用 Context 本身满足了这两条。Context 在 fiber 之间本来就是随 forkChild 传下去的,所以覆盖自然被子 fiber 继承;离计算最近的 provideService 生效,所以嵌套覆盖也有清楚的语义。
下面这个例子把几种情况放在一起,同时是 feature flag 和 per-request 配置的最小骨架:
import { Context, Effect, Fiber, Layer } from "effect"
// 特性开关:默认全部关闭(fail closed)
export const Flags = Context.Reference<ReadonlySet<string>>("app/Flags", {
defaultValue: () => new Set()
})
// 每个请求的上下文:租户与语言
export interface RequestInfo {
readonly tenant: string
readonly locale: string
}
export const Request = Context.Reference<RequestInfo>("app/Request", {
defaultValue: () => ({ tenant: "public", locale: "zh-CN" })
})
export const isEnabled = (flag: string) =>
Effect.map(Effect.service(Flags), (flags) => flags.has(flag))
// 深处的业务代码:R 是 never,读取不需要任何 provide
const checkout = Effect.gen(function*() {
const request = yield* Request
const newFlow = yield* isEnabled("new-checkout")
return `${request.tenant}/${request.locale} newFlow=${newFlow}`
})
// 请求入口:用 provideService 覆盖,只对内部生效
const handle = (request: RequestInfo) => Effect.provideService(checkout, Request, request)
const program = Effect.gen(function*() {
console.log(yield* checkout)
console.log(yield* handle({ tenant: "acme", locale: "en-US" }))
// 嵌套覆盖:离计算最近的那一层生效
const nested = Effect.provideService(
Effect.provideService(checkout, Flags, new Set(["new-checkout"])),
Flags,
new Set()
)
console.log(yield* nested)
// fork 出去的 fiber 继承覆盖
const forked = Effect.provideService(
Effect.gen(function*() {
const fiber = yield* Effect.forkChild(checkout)
return yield* Fiber.join(fiber)
}),
Flags,
new Set(["new-checkout"])
)
console.log(yield* forked)
// 也可以作为 Layer 提供,适合在应用装配处一次性配置
const FlagsLive = Layer.succeed(Flags, new Set(["new-checkout"]))
console.log(yield* Effect.provide(checkout, FlagsLive))
})
Effect.runPromise(program)输出:
public/zh-CN newFlow=false
acme/en-US newFlow=false
public/zh-CN newFlow=true
public/zh-CN newFlow=true
public/zh-CN newFlow=true逐行对应:没有 provide 时走默认值;handle 覆盖了租户,开关还是默认关;嵌套时内层的开启生效,不是外层的空集合;forkChild 出去的 fiber 看到了覆盖(forkDetach 我也试过,同样继承了覆盖值);Layer.succeed 提供的版本在装配处也一样生效。
内置的 References
effect 的 References 模块导出了一批运行时自带的 Reference,v3 里这些是各个 FiberRef。常用的几个:
MinimumLogLevel:日志级别阈值,低于它的日志被丢弃,默认"Info"。CurrentLogAnnotations:附加到每条日志上的注解,默认空对象。CurrentLogLevel:Effect.log不指定级别时用的级别。TracerEnabled:是否注册 span,默认true。TracerSpanAnnotations、TracerSpanLinks:附加到之后创建的每个 span 上。Tracer:当前使用的 tracer 服务。MaxOpsBeforeYield、PreventSchedulerYield:调度器让出 CPU 的预算,属于底层调优,一般不碰。
它们和你自定义的 Reference 没有区别,覆盖方式完全一致。下面是日志侧的行为,都是实际跑出来的:
import { Effect, References } from "effect"
const work = Effect.gen(function*() {
yield* Effect.logDebug("debug")
yield* Effect.logInfo("info")
yield* Effect.logWarning("warn")
})
const program = Effect.gen(function*() {
console.log("默认值", yield* References.MinimumLogLevel, yield* References.TracerEnabled)
console.log("-- MinimumLogLevel = Debug")
yield* Effect.provideService(work, References.MinimumLogLevel, "Debug")
console.log("-- MinimumLogLevel = Warn")
yield* Effect.provideService(work, References.MinimumLogLevel, "Warn")
console.log("-- CurrentLogAnnotations 直接覆盖")
yield* Effect.provideService(Effect.logInfo("hello"), References.CurrentLogAnnotations, { requestId: "r-1" })
console.log("-- annotateLogs 合并")
yield* Effect.annotateLogs(Effect.annotateLogs(Effect.logInfo("hello"), { a: 1 }), { b: 2 })
console.log("-- 内层覆盖整体替换")
yield* Effect.annotateLogs(
Effect.provideService(Effect.logInfo("hello"), References.CurrentLogAnnotations, { a: 1 }),
{ b: 2 }
)
})
Effect.runPromise(program)输出(时间戳略):
默认值 Info true
-- MinimumLogLevel = Debug
DEBUG debug
INFO info
WARN warn
-- MinimumLogLevel = Warn
WARN warn
-- CurrentLogAnnotations 直接覆盖
INFO hello { requestId: 'r-1' }
-- annotateLogs 合并
INFO hello { b: 2, a: 1 }
-- 内层覆盖整体替换
INFO hello { a: 1 }最后两段是重点。Effect.annotateLogs 是在当前值上合并,所以两次调用的注解都在;而直接 provideService(CurrentLogAnnotations, ...) 是整体替换。最后一段里内层的整体替换把外层 annotateLogs 想加的 b: 2 也盖掉了。结论:想「加几个注解」用 annotateLogs,只有确实要清空或整体换掉时才直接覆盖 Reference。这也是用原始 Reference 时最容易犯的错:它给你的是最底层的赋值,不是合并。
TracerEnabled 的效果我用一个计数的 tracer 验证:
import { Effect, References, Tracer } from "effect"
const program = Effect.gen(function*() {
const base = yield* References.Tracer
let created = 0
const counting = Tracer.make({
span(options) {
created++
return base.span.call(base, options)
}
})
const traced = Effect.withSpan(Effect.void, "work")
const run = (effect: Effect.Effect<void>) => Effect.provideService(effect, References.Tracer, counting)
yield* run(traced)
console.log("TracerEnabled=true 创建的 span 数", created)
yield* run(Effect.provideService(traced, References.TracerEnabled, false))
console.log("关闭后累计 span 数", created)
})
Effect.runPromise(program)输出两行都是 1:开启时创建了一个 span,在局部把 TracerEnabled 设为 false 之后,tracer 的 span 方法根本没有被调用。典型用途是高频的内部循环或健康检查端点,不想给它们产生 span 又不想改业务代码,在入口局部关掉就行。可观测性整体怎么搭,见 可观测性。
落地:feature flag 与 per-request 配置
两个场景用的是同一个机制,但我对它们的态度不太一样。
Feature flag。 上面的 Flags 例子就是推荐形态:默认值是「全部关闭」,读取方一行代码 isEnabled("new-checkout"),不关心开关从哪来。生产环境在应用装配处用 Layer.succeed(Flags, ...)(或者基于配置、远程服务构建的 Layer)提供真实集合;测试里用 provideService 针对单个用例打开某个开关,不需要 mock 任何东西,也不会泄漏到别的用例。默认值我一律选「关」,这样漏提供时是安全的降级,而不是意外上线新流程。
如果开关的值需要异步获取(读远程配置),Reference 的 defaultValue 是同步函数,放不下。这时在装配处用 Layer.effect 先把结果取出来,再 Layer.succeed 或等价地提供进 Reference,业务读取方仍然是同步读。Layer 的写法见 服务与 Layer。
Per-request 配置。 上面的 Request 例子:请求入口做一次 provideService,下面所有代码,包括内部 forkChild 出来的 fiber,都能读到当前租户和语言,而不用把参数逐层传递。并发的两个请求各自在自己的 fiber 树里覆盖,互不影响,这是它和「全局可变变量」本质的区别。
但请注意一点:Reference 适合横切的、有合理默认值的环境信息。租户和语言是边界上的例子;而「当前登录用户」就不合适,它没有合理默认值,缺了它业务代码应该编译失败,而不是悄悄以匿名运行。
边界与取舍:什么时候用 Service
Reference 把「依赖」从类型里藏起来了,这既是优点也是风险。风险的失败模型很具体:忘记 provide 的时候没有任何报错,代码静默地使用默认值。在租户隔离、权限这类场景,这是事故源头。
我的判断标准是一句话:缺失时有没有一个安全的默认值。有(日志级别、开关关闭、追踪开启),用 Context.Reference;没有(数据库连接、当前用户、必须存在的凭证),用 Context.Service,让类型系统强迫调用方提供,见 服务与 Layer。
还有两点实用的补充:
- 值的类型尽量只读、不可变,包括默认值。覆盖是整体替换,不是合并,上面
CurrentLogAnnotations的例子已经说明了这一点。需要「在当前值上叠加」的,自己封装成updateService或像annotateLogs那样的辅助函数,别让调用方直接面对原始 Reference。 - 给每个 Reference 的 key 加应用前缀(如
"myapp/Flags")。key 字符串是身份,重名会互相覆盖。
小结
FiberRef在 v4 里不存在了,替代品是Context.Reference:有默认值的服务键,读取不进 R,局部覆盖用Effect.provideService,或用 Layer 提供。- 默认值函数只求值一次并缓存,可变默认值会被所有未覆盖的读取方共享;覆盖对
forkChild和forkDetach出去的 fiber 都继承,嵌套时最近的一层生效。 References提供了MinimumLogLevel、CurrentLogAnnotations、TracerEnabled等内置项;直接覆盖是整体替换,叠加注解请用Effect.annotateLogs。- feature flag 和 per-request 配置是最合适的场景;但缺失时没有安全默认值的依赖,应当用
Context.Service,让编译器强制提供。