赞助商LobeHubLobeHub了解更多
ddshfind
GitHub

第 8 课:反应式余效应:依赖齐了自动启动

一句话版:反应式余效应就是「一张会自动变化的依赖表」——组件只需要声明自己要什么,系统盯着这张表,依赖一旦齐了就自动把组件启动(Reload),依赖一旦缺了就自动把组件卸载(Unload),先后顺序和时机全都不用你操心。


1. 余效应上下文:一张「键 → 有类型值」的依赖表

上一课我们认识了可回退效应——它解决「干到一半能撤销」。但组件之间怎么互相依赖,还没解决。这一课就来补上这一块。

传统的答案是 IoC 容器(控制反转容器):一张裸的键值映射,往里注册 "database" → 数据库对象,别的组件按名字取。Cordis 论文把它升级成一张带类型的依赖表,叫做余效应上下文

Σ ≔ (𝑘 : 𝐾) ⇀ 𝒱𝑘

读法:对每一种「依赖键」k(来自键集 K),表里可以存一个类型为 𝒱𝑘 的值。注意那个符号是偏函数(⇀ 表示部分):表里可以没有某个键。用大白话拆开看:

成分是什么例子
k依赖的名字"database""translator"
依赖本身数据库连接、翻译器对象
类型族 𝒱「键 → 该键的值类型」对照表"database" 的值必须是数据库连接类型

有了 𝒱,每个键都静态绑定了自己值的类型——这是它比裸键值表强的地方:依赖访问有静态类型安全,取错类型在编译期就报错,而不是运行到一半才炸。

表上有四个记号(论文定义 12):

  • σ(k):查表——键 k 在表里时的取值;
  • σ[k ↦ v]:放进去——往表里加入键 k、值为 v(前提:k 之前不在表里);
  • σ ∖ k:拿掉——把键 k 从表里移除(前提:k 本来就在表里);
  • k ∈ dom(σ)k 在不在表里。

注意两个前提条件:「不能重复放」「不能拿不存在的」。这正对应可回退效应里的幂等性要求——同一件事不能做两遍,撤销才说得清。

两个核心操作(论文定义 13):

  • get(k):从表里取键 k 的值(前提:k 在表里,否则运行时失败);
  • set(k, v):往表里放键 k、值为 v(前提:k 不在表里),返回「新表 + 撤销函数」,撤销函数负责把 k 从表里拿回去。

这里藏着一个全课最重要的洞察:set 本身就是一个余效应上下文上的效应函数。所以上一课那套效应机制直接就能用——系统会自动跟踪依赖注册,需要时自动撤销注册。余效应操作是效应,而效应是可回退的:一个负责「按需装」,一个负责「能拆掉」,两者严丝合缝地咬合在一起。

和 IoC 容器对比一下:

IoC 容器余效应上下文 Σ差别在哪
裸键值映射依赖偏函数带类型族 𝒱,静态类型安全
手动注册、手动注入set / getset 是效应函数,可自动撤销
缺了依赖就报错先等依赖齐再激活不乐观访问(下一节讲)

2. 规格与满足性:全部齐了才算数

有了这张表,组件怎么表达「我准备好了」?答案是:声明一份依赖规格 d——一个键的集合,表示「我要用这些键」。

系统判断组件能不能启动,靠一个满足谓词

σ ⊨ d  当且仅当  d 里每一个键都在 dom(σ) 里

用公式写就是 σ ⊨ d ≔ ∀ k ∈ d. k ∈ dom(σ)。用大白话说:只要缺一个,就不满足——这是「与」的关系,不是「或」,全部齐了才算数

举个贯穿全课的例子:一个翻译插件要干活,需要两样东西——"database"(查词库)和 "translator"(翻译引擎)。它声明的规格就是:

d = { "database", "translator" }

表的变化和插件状态:

表里有什么满足吗翻译插件
只有 "database"不满足(缺 translator)不启动
"database""translator" 都在满足启动干活
"translator" 还在,"database" 被拿走不满足(缺 database)停用

为什么这么严格?论文说得直白:组件不应该乐观地访问依赖——访问一个不存在的依赖会在运行时失败。正确姿势是「等全部依赖就位再激活」,而不是「边访问边炸」。

还有两个技术点,保证这套判断真的可行:

  • 可判定dom(σ) 是有限集,所以「满不满足」总能算出来,不会卡住;
  • 每次变化都被看见:所有对表的修改都经由效应函数完成(这些函数的逆会恢复此前的定义域),所以每一个效应边界上的满足性变化都能被检测到——这就是「反应性」的代数基础:系统保证每一次余效应变化都会被观察到。

3. 通知与分类:把每次变化分成激活、停用、中性

现在系统手里有一张表 σ,每个组件手里有一份规格 d。接下来规则很简单:表一变,就对比「变化前 σ」和「变化后 σ′」,按 d 的满足性有没有改变来分类(论文定义 15):

分类条件含义系统动作
激活型之前不满足、之后满足依赖刚刚齐了触发组件效应执行(完整跟踪)→ Reload
停用型之前满足、之后不满足依赖刚刚缺了恢复已累积的效应 → Unload
中性其他情况(两边都满足,或两边都不满足)满足性没变不启不停(值变了就重载)

用伪代码写,就是论文里那个三叉判断:

if      之前不满足 且 之后满足  →  激活型
else if 之前满足 且 之后不满足  →  停用型
else                            →  中性

三个重点:

① 激活触发 Reload,停用触发 Unload。 激活型迁移说明依赖刚刚集齐,系统执行组件的效应并完整跟踪,组件开始运行;停用型迁移说明依赖刚刚缺失,系统恢复此前累积的所有效应——组件被干干净净卸载,不留任何副作用。而中性迁移呢?比如「值变了但依赖仍齐」——满足性没变,不启不停,但系统会触发一次重载(reload)让组件拿到新值。

② 依赖顺序自动产生,不用手动声明。 设组件 A 通过 set("database", ...) 提供键,组件 B 声明 "database" ∈ d_B。那么:

  • B 的满足性要求 "database" ∈ dom(σ),而 "database" 要等 A 完全激活set 真正生效后才在表里——所以 B 在 A 之后激活(依赖方在其依赖项之后激活);
  • 反过来,卸载 A 会把 "database" 从表里拿走、瞬间破坏 B 的满足性——所以系统保证 A 开始恢复之前,B 已完全停用(依赖项在其依赖方之后卸载)。

这个先后顺序不需要任何人去声明,它是通知机制的自然结果。论文的原话是:正确的依赖顺序成为一种结构性保证——你没法写错,因为顺序是系统推导出来的。

③ 组件自己什么都不用管。 启动、等待、停用全部由系统盯着满足性自动完成,组件唯一要做的事就是声明「我要什么」。下面这张图就是这一整条流转:

等待依赖不满足(缺东西)不启动,不报错依赖齐了 → 激活激活(运行)执行组件效应,自动跟踪依赖满足(都齐了)依赖没了 → 停用停用撤销全部副作用系统盯着变化,自动激活 / 停用——组件自己不用管值变了但依赖仍齐 → 自动重启(重载)

依赖满足性变化 → 激活 / 停用 / 中性 三种迁移分类

图里的流转是:等待(缺东西)→ 依赖齐了 → 激活(运行)→ 依赖没了 → 停用(撤销全部副作用);底部那句「值变了但依赖仍齐 → 自动重启(重载)」对应的正是中性迁移里的重载行为。

4. 隔离与拦截:同一张表的两种高级玩法

基础上下文 Σ 是一张扁平的表,所有组件共享。真实系统常常不够用,论文给了两个扩展方向——隔离拦截,它们解决的是完全不同的问题。

4.1 隔离(Isolation):同一个键,在不同上下文解析成不同值

先看场景:多租户系统里,租户 A 和租户 B 都想用 "database" 这个键,但必须连各自的数据库。一张扁平的表做不到。

解法是把表拆成两层

Σiso = 隔离域表 ρ(键 → 域标识符 r) × 依赖表 σ(域标识符 r → 有类型值)

访问键 k 时,先查 ρ(k) 拿到域标识符 r,再查 σ(r) 拿到实际值。多了一个操作 isolate(k, r):把键 k 绑定到域 r。这样一来:

  • 同一个键 k,在不同隔离域里解析出完全不同的值
  • 隔离可以在运行时动态调整——比传统依赖注入更细粒度,可以为特定组件单独定制;
  • 所有操作仍然是效应函数(𝔈Σiso),照样可回退。

论文把这叫做「运行时特设多态系统」。它广泛适用于多租户系统、测试环境(每个测试用例一个隔离域,互不污染)和组件沙箱

4.2 拦截(Interception):附加横切元数据,不动依赖值

再看另一个场景:你不想换依赖值,只想在访问时附加一点额外信息——比如给每次数据库访问带上「当前用户是谁」,或者加个日志标签、权限标记。

解法是给表加上元数据层

Σinter = 上下文元数据 ι(上下文携带) × 提供者函数表 σ(键 → 「元数据 → 值」的函数)

访问键 k 时,系统把组件声明的元数据 d(k)上下文携带的元数据 ι(k) 合并(每个键有自己的合并语义,比如标量字段取右值、集合字段取并集),再把提供者函数作用在合并结果上。注意合并是右偏的:上下文元数据有优先权,可以覆盖组件声明。这样一来,外层上下文就能约束组件怎么使用余效应,而无需修改组件本身

隔离 vs 拦截,一句话分清

隔离 Σiso拦截 Σinter
解决什么问题同一个键在不同上下文解析为不同的值访问时附加额外的行为 / 元数据
实现手段键 → 域 → 值,双层映射元数据合并 + 提供者函数
打个比方每个租户用自己的数据库连接每次访问自动带上「当前用户」标签
依赖值变了吗变了(换了一个值)没变(还是那个值,只是附加信息)

关键点回顾

  1. 余效应上下文 Σ 是一张「键 → 有类型值」的依赖表set 放、get 取、可撤销;带类型族 𝒱 保证静态安全,比 IoC 容器的裸键值表强。
  2. 满足谓词 σ ⊨ d:d 里每个键都要在表里——缺一个就不满足,全部齐了才算数。
  3. 每次表变化都按满足性是否改变分类:激活型 → Reload,停用型 → Unload,中性 → 不启不停(值变了就重载)。
  4. 依赖顺序是结构性保证,不用手动声明:依赖方在依赖项激活之后激活,依赖项在依赖方停用之后才卸载。
  5. 隔离 = 同一个键在不同上下文解析成不同值(多租户 / 测试);拦截 = 附加横切元数据,不动依赖值

🚀 下一课我们把镜头拉近,看看单个组件的一生:第 9 课「组件生命周期:幂等、迭代、纪元、异步」。

自测题 · 反应式余效应

完成作答后点击「提交答案」,可以查看对错与解析。

1. 翻译插件声明规格 d = {"database", "translator"},当前依赖表 σ 里只有 "database"。关于 σ ⊨ d,下列说法正确的是?
2. 某组件的规格 d 一直满足,这次表变化后仍然满足,只是某个依赖的值变了。这次迁移应被分类为?
3. 组件 A 通过 set("database", ...) 提供依赖,组件 B 声明 d_B = {"database"}。关于 A 和 B 的启停顺序,正确的是?
4. 多租户系统里,租户 A 和租户 B 都要用 "database" 这个键,但必须连各自的数据库。这应该用什么机制解决?