技术5/30/2026·9 min

机制与策略分离:编译器把 Remote Config DSL 变成跨端契约

原来的 remote config 是两份 YAML,没有任何构建期语义:产品可以给幽灵 key 配 override、给值越类型/越域、把实验权重凑成不等于 100。把机制与策略拆成 .def.rc / .ctl.rc 两种方言后,这些都在运行前被编译器拒掉。

问题:YAML 没有契约

仓库里的 remote config 最初是两份职责分裂的 YAML —— module 侧声明 key,product 侧填值,两者在构建期没有任何东西把它们连起来。于是 product YAML 可以:

  • 引用一个 module 从未声明的 key(幽灵 key,静默地什么都不做);
  • 给一个值,类型只靠推断、从不校验;
  • 把 rollout 百分比写到 [0, 100] 之外;
  • 声明一个变体权重加起来不等于 100 的实验。

这些问题要么根本不报、要么到运行期才暴露 —— 通常表现为某个 config 对一部分 install 静默失效。这违背了仓库在别处坚持的一条原则:编译期能拦住的,就不该丢给运行时去假设输入合法。

替代方案是一个有两种方言的 DSL:.def.rc机制(一个 knob 被控制成什么),.ctl.rc策略(产品实际把它控制成什么)。这条分界线就是做语言、而不是再加一层 YAML lint 的全部理由:方言分开之后,编译器可以充当二者之间的裁判。

机制:声明边界

.def.rc catalog 为每个 knob 声明类型、域,以及最关键的策略面(policy surface):

config isFeatureEnabled: bool {
    default     = false
    description = "Feature toggle"
    policy      = rollout
}

config maxRetries: int {
    default     = 3
    range       = 0...10
}

policy 是一个格子,顺序为 static < overridable < rollout < experimentable,高档位允许其下的所有操作:

// ast.rs
pub enum Policy { Static, Overridable, Rollout, Experimentable }

impl Policy {
    pub fn allows_override(self) -> bool   { !matches!(self, Policy::Static) }
    pub fn allows_rule(self) -> bool       { matches!(self, Policy::Rollout | Policy::Experimentable) }
    pub fn allows_experiment(self) -> bool { matches!(self, Policy::Experimentable) }
}

static 的 knob 不可碰;overridable 接受产品改默认值,但不接受定向;rollout(kill-switch 用的档位)接受 override 加定向/灰度规则;experimentable 再加 A/B 实验。机制作者定上限,产品够不到上限之上。

类型与域同样由机制决定:int/doublerangestringregexenum 是闭合成员集,外加一个 json 逃生舱。visibility = internal 标记产品完全不能控制的 knob(由更上层注入)。

策略:在契约内行使控制

.ctl.rcuse <catalog> 开头,然后行使控制:

product "doodlemotion"
use video_generation

override isFeatureEnabled = false
override maxRetries = 3

rule isFeatureEnabled {
    when app_version >= "2.0.0"
    rollout 50% by install_id salt "vidgen-rollout-1"
    value = true
}

use 定义了一个闭世界:可见的配置只有被点名 catalog 里的那些(它们就是 Bazel deps)。产品写下的每一条都要逐 key 对照这个世界裁决。

编译器即裁判 —— 三阶段,约束按可见范围分层

设计原则是:把每条约束放进能看见它的最小作用域。这映射成三个编译阶段、三个 CLI 子命令、三个 Bazel 宏。

P1 —— catalog(每 module)。 check_catalog 校验 .def.rc 自洽:重复 key、requireddefault 互斥、default 落在域内、enum 成员非空且唯一、range lo <= hi、regex 只配字符串且能编译。随后把 catalog 降级成 descriptor —— 一份机器可读的 serde JSON「已发布接口」,供 P2 加载。值校验(这个字面量满不满足类型与域?)集中在唯一的共享核 coerce_value,P1(catalog default)和 P2(产品值)共用,使这条规则只有一个真相源:

// value.rs —— int 路径
Value::Num(n) if n.fract() == 0.0 => {
    check_range(*n, domain)?;
    // f64 -> i64 会静默饱和;超 i64 的字面量在此硬失败,不让错值
    // 落进 snapshot、descriptor 或 emit 出的 Swift 编译期默认。
    if *n < i64::MIN as f64 || *n >= 9_223_372_036_854_775_808.0 {
        return Err(ValueError::Range { value: *n, lo: i64::MIN as f64, hi: i64::MAX as f64 });
    }
    Ok(J::Number((*n as i64).into()))
}

P2 —— product(每 product)。 link(control, descriptors)use 的 catalog 建符号表,对每个 override / rule / experiment 逐条裁决。错误类型就是被执行化的契约:

  • UnknownConfig —— 最初的动机:产品引用了没有 catalog 定义的 key。
  • TypeMismatch / DomainRange / DomainEnum / DomainRegex —— 值越出声明的类型或域。
  • PolicyNotAllowed —— 在 overridable knob 上写定向规则,或在 rollout knob 上挂实验。
  • InternalKnobOverridden —— 产品去碰 internal knob。
  • MissingRequiredDecision —— required knob 没有产品 override。
  • DuplicateSalt / ConflictingDriver —— salt 被复用,或同一 config 同时被 rule 和 experiment 驱动。
  • VariantWeightSum —— 实验变体权重不等于 100(容差 1e-6:33.33/33.33/33.34 这类三路小数拆分会累积浮点舍入,1 ULP 的容差会误拒)。
  • UnknownDimension —— 在白名单 app_version / os / os_version / region / env 之外的维度上定向。
  • UnsupportedStickyBy —— 用 install_id 以外的键分桶,而运行期求值器只支持 install_id;没有这道检查,DSL 就能写一个运行期会静默忽略的 bucket key。

最后两条是同一个思路:编译器拒绝产出运行期会静默丢弃的东西。维度白名单严格对齐求值器接受的集合,sticky 键白名单对齐它实际用来分桶的集合。

P3 —— fleet(一个 gate)。 有两类不变量是任何单个产品编译都看不见的,于是单列成一遍扫全部 catalog descriptor 加 P2 产出的 per-product manifest:

  • CrossProductSalt —— 同一个 salt 被两个产品使用,破坏它们之间的分桶独立性。
  • DuplicateKey —— 同一个 namespace.key 在两个 catalog 里定义。

P3 还把配置控制图和代码依赖图对齐。一个 Bazel aspect 遍历 app target 的传递依赖,收集打了 remote_config_catalog=<name> 标签的 catalog,产出一份 linkage 文件 —— 在分析期完成,不编译 Swift。check_linkage 据此强制:若 app 链接了带 required knob 的 catalog,产品必须决策它(RequiredNotCovered,致命);并在产品控制了一个它的 app 根本没链接的 catalog 时告警(orphan control)。

按构造就是 drop-in

DSL 只替换 authoring 与 codegen。它为 parameters 形状产出的 snapshot 与旧的 serde_yaml 路径逐字节兼容 —— 相同字段名、2 空格 pretty、末尾换行、params 按 (namespace, key) 排序 —— 因此下游(嵌入的 aggregate、Go registry、求值器)零改动。示例 BUILD.bazel 挂了一个 diff gate,证明 DSL snapshot 与 YAML snapshot 逐字节相同,外加一个 fleet_check gate,于是这份等价关系是每次构建都验证、而非口头断言。

实现是单个 Rust crate remoteconfig_dsl,镜像 metric codegen 的结构(lexer -> parser -> ir -> emit,内联 #[cfg(test)],一个 rust_test 覆盖 lib):lexer/parser/ast/value/ir/link/fleet/emit 合计约 4000 行,122 个测试函数,实测文件级覆盖率 ≥ 95%。

可迁移的部分

可复用的不是这门 DSL,而是约束放在哪里。把一个配置系统拆成能被控制什么(机制)和实际控制成什么(策略),各给一份独立输入,编译器就能拿一方裁决另一方。然后把每条不变量都收进能看见它的最小一遍:per-module 事实进 P1,per-product 决策进 P2,fleet 级唯一性进 P3。回报是:幽灵 key、越域的值、越权的灰度、跨产品 salt 碰撞,都变成有名字的构建错误 —— 而不是一份对某些 install 静默做错事的 config。