Files
OpenFlare/docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md
T

24 KiB
Raw Blame History

Cordis 配置扩展点设计 (Config Extension Point)

  • 文档状态: 已敲定 (Approved)
  • 版本: v1.0.0 (2026-08-29)
  • 适用范围: backend/core/(微内核)、backend/plugins/(自包含插件)、backend/cmd/(组合根)、backend/pkg/(无状态基础库)

0. 背景与动机

backend/pkg/config 同时承担了三件事:viper 装载 config.yaml、环境变量覆盖、以及以全局单例 config.Config 暴露全量配置模型。它与架构文档对 backend/pkg/ 的定位("Stateless utilities and algorithm libraries")冲突,并且带来两个结构性问题:

  1. 配置所有权倒挂:任何包都能读到全量配置,因此 cmd 直接替 cache 插件判断 Redis 是否启用、risk_control 直接判断 clickhouse.enabled。配置的"读者"与"所有者"没有关系约束。
  2. 隐式全局状态:init() 内完成文件搜索、解析与 log.Fatalf,并以 isTest() 猜测执行上下文来禁用数据库/Redis/ClickHouse;测试通过改写全局单例驱动生产代码路径。

本设计把"配置的读取框架"下沉为内核扩展点,把"读哪些字段"的所有权交给各插件自己声明,并一次性迁移全部 27 个消费文件(109 处引用),彻底删除全局单例。

AGENTS.md 与 new-setting skill 中早已写明插件应通过 ctx.Config().Bind(...) 绑定静态配置,但该 API 在代码中从未存在——本设计同时修正这一文档漂移。


1. 决策记录

# 决策 理由与取舍
D1 预声明阶段 + 配置门禁 内核在 Apply 之前收集声明并求值门禁,使组合根不再跨插件读配置选实现。代价是给 Fiber 增加"被门禁跳过"语义。
D2 混合读取形态:结构体 Bind + 泛型 Get redis/database 等 14+ 字段结构体整体消费,逐 key 声明不可读;app.session_secret 等单字段不值得为它绑一个结构体。
D3 共享声明 + 内核冲突校验 配置是进程级只读事实,不存在数据表那种写竞争,因此允许读者各自声明同一 key;由内核强制"重复声明必须一致"兜底。放弃严格单所有权(需为若干配置值另造契约接口,且 driver_http 需 session store 连接参数是真实底层依赖)。
D4 一次性全量迁移 不留双轨,架构一次到位;接受较大的 diff。
D5 显式测试缝 删除 isTest() 魔法,测试通过 core.WithConfigValues(...) 注入。放弃"测试环境自动禁用中间件"的安全网,换取语义透明与可并行。
D6 内核持抽象,viper 归 infra 适配器 微内核防线规定 core/ 严禁 import 具体运行时依赖。core 只依赖 ConfigSource 接口,viper/yaml 装载放 plugins/infra/config。放弃"全放 core/config"(污染内核纯净性)与"完全插件化 + contracts.ConfigService"(门禁求值在 core,而 config 插件 Apply 尚未运行,存在鸡生蛋时序问题)。
D7 顺带解耦 pkg/idgen 其 init() 读全局配置,导致 pkg 反向依赖配置单例。

2. 分层与物理结构

backend/core/extpoints/config.go     # 配置引擎(仅 stdlib + reflect):
                                     #   ConfigSource 接口、声明注册、解析、冲突校验、脱敏 dump
backend/core/config.go               # 泛型读取入口与 App 装配选项(Go 方法不支持类型参数)
backend/core/fiber.go                # 新增 FiberSkipped 状态与门禁求值
backend/core/types.go                # 新增 ConfigExtension / ConfigBinding / ConfigView 别名
backend/plugins/infra/config/        # viper + yaml 适配器,实现 core.ConfigSource(非 core.Plugin)
backend/cmd/                         # 组合根:host 声明集 + app.Prepare()
backend/pkg/idgen/                   # 移除 config 依赖,改为显式 Init(nodeID)
删除 backend/pkg/config/              # 全局单例 config.Config 一并消失

职责边界:

单元 做什么 不做什么
core/extpoints 配置引擎 维护 key 注册表、按优先级解析、类型转换、冲突校验、脱敏输出 不知道任何具体 key 的名字,不读文件,不 import viper
plugins/infra/config 定位 config.yaml(CONFIG_PATH → 向上查找)、解析成 raw map、代理 env 查询 不含 schema、不含业务字段语义
各插件 声明自己读哪些字段(tag 结构体)、声明门禁谓词 不读未声明的 key、不访问他插件的声明类型
cmd 声明 host 级 key、注入 ConfigSource、按已解析值初始化 logger/trace/banner 不做 if redis.enabled { ... } 这类跨插件判断

plugins/infra/config 不实现 core.Plugin,不出现在 app.Use() 列表里:它只向内核提供一个 ConfigSource 实例,没有服务、路由或任务可注册。它归 plugins/infra/ 而非 pkg/,是因为它封装了具体运行时依赖(viper、文件系统)并持有装载状态,不符合 pkg/ 的无状态定位。

pkg/idgen 解耦后,backend/pkg/ 恢复"不依赖配置源"的无状态定位。


3. 核心类型与 API

3.1 声明形态:带 tag 的结构体

唯一的批量作者形态是结构体 tag,一个字段同时表达 yaml 路径、env 覆盖名、默认值与敏感标记:

// plugins/infra/cache/redis_config.go —— redis 配置由 redis 插件自己声明
type redisConfig struct {
    Enabled            bool     `config:"enabled"             env:"REDIS_ENABLED"            default:"false"      autoEnable:"REDIS_ADDR"`
    Addrs              []string `config:"addrs"               env:"REDIS_ADDR"`
    Username           string   `config:"username"            env:"REDIS_USERNAME"`
    Password           string   `config:"password"            env:"REDIS_PASSWORD"           secret:"true"`
    DB                 int      `config:"db"                  env:"REDIS_DB"`
    ClusterMode        bool     `config:"cluster_mode"        env:"REDIS_CLUSTER_MODE"`
    MasterName         string   `config:"master_name"         env:"REDIS_MASTER_NAME"`
    KeyPrefix          string   `config:"key_prefix"          env:"REDIS_KEY_PREFIX"`
    MaintNotifications bool     `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS" default:"false"`
    // ...pool/timeout 字段略
}

支持的 tag:config(yaml 相对路径,必填)、env(覆盖用环境变量名)、default(字符串形式,缺省时视为未设置)、autoEnable(该 env 一旦存在即把本布尔字段置 true)、secret(dump 时脱敏)。

3.2 内核接口

// ConfigSource 抽象了"原始值从哪来",由 infra 适配器实现,使内核不绑定 viper。
type ConfigSource interface {
    Lookup(path string) (any, bool)   // config.yaml 中的点分路径
    LookupEnv(name string) (string, bool)
    Describe() string                 // 用于日志,如 "config.yaml" 或 "<env only>"
}

// ConfigBinding 把一个结构体绑定到某个 yaml 前缀上,是插件的声明单元。
type ConfigBinding struct {
    Prefix string // "redis";空串表示字段 key 即完整路径
    Target any    // 指向带 tag 的结构体的指针
}

// ConfigView 是只读的已解析视图,供门禁与零散取值使用。
type ConfigView interface {
    String(key, fallback string) string
    Bool(key string, fallback bool) bool
    Int(key string, fallback int) int
    Duration(key string, fallback time.Duration) time.Duration
    Strings(key string) []string
    WasSet(envName string) bool
    Source(key string) string // "env" | "yaml" | "default",用于诊断
}

// ConfigExtension 是挂载在 Context 上的扩展点,根 Context 与所有 Fork 共享。
type ConfigExtension interface {
    ConfigView
    Declare(pluginID string, bindings ...ConfigBinding) error
    Bind(prefix string, target any) error
    Entries() []ConfigEntry   // 有效配置的脱敏视图
}

core 侧导出别名与泛型入口(沿用仓库既有 core.Provide[T] / core.Inject[T] 风格):

func ConfigGet[T any](v extpoints.ConfigView, key string) (T, error)

3.3 插件侧用法

// 批量绑定(Apply 内)
var cfg redisConfig
if err := ctx.Config().Bind("redis", &cfg); err != nil {
    return err
}

// 单字段读取:带 fallback 的访问器(门禁使用)
secret := ctx.Config().String("app.session_secret", "")

// 单字段读取:需要区分"未设置"与"设置为零值"时用泛型入口
rate, err := core.ConfigGet[float64](ctx.Config(), "otel.sampling_rate")

ConfigEntry 是 Entries() 返回的诊断单元,只含元数据与脱敏后的值:

type ConfigEntry struct {
    Key      string // "redis.password"
    PluginID string // 首次声明者,用于冲突报错点名
    Env      string
    Source   string // "env" | "yaml" | "default"
    Value    string // secret key 输出 "******"
}

Bind 的双重语义:若该 prefix 尚未声明,则按 Target 的 tag 自登记;若已声明,则是纯读取。登记时提供的 env/default/secret 元数据一律参与冲突校验(依 D3),因此自登记不会绕过校验。只有需要早于 Apply 求值的插件才必须显式 DeclareConfig()。

3.4 门禁接口与 Fiber 跳过态

// ConfigGatedPlugin 是可选接口:让内核在 Apply 之前决定插件是否激活。
type ConfigGatedPlugin interface {
    Plugin
    DeclareConfig() []extpoints.ConfigBinding     // 门禁所需 key 必须提前声明
    ConfigEnabled(v extpoints.ConfigView) bool
}

FiberState 新增 FiberSkipped。App.reconcileLocked() 在 Load() 前求值门禁:门禁为 false 的 Fiber 置 FiberSkipped,不计入依赖 satisfied 判定,也不参与 driver 启动。App.Stop() 对 skipped 与 active 一视同仁地按 LIFO 卸载其 scoped Context。

受门禁的插件对(现状仅三对,均为 Redis 存在与否的互斥实现):

启用 跳过 门禁谓词
infra/cache infra/cache_memory redis.enabled
drivers/driver_asynq_worker drivers/driver_inproc_worker redis.enabled
drivers/driver_asynq_cron drivers/driver_inproc_cron redis.enabled

3.5 组合根

src := config.NewSource()                      // plugins/infra/config:仅定位与 raw 解析
app := core.NewApp(
    core.WithProfile(profile),
    core.WithConfigSource(src),
    core.WithConfigDecl(hostBinding...),       // app.* / log.* / otel.*
)
app.Use(
    infradb.New(), logger.New(), storage.New(),
    cache.New(), cache_memory.New(),            // 不再 if/else,门禁决定
    driver_asynq_worker.New(), driver_inproc_worker.New(),
    driver_asynq_cron.New(), driver_inproc_cron.New(),
    admin.New(), user.New(), auth.New(), /* ... */
    driver_http.New(),                          // addr 由插件自己声明读取
)
if err := app.Prepare(); err != nil { return err }   // 解析屏障 + 门禁求值

timeout, _ := app.Context().Config().Duration("app.graceful_shutdown_timeout", 30)
app.SetShutdownTimeout(timeout)

WithShutdownTimeout(d) 保留为显式覆盖入口(测试与非标准装配使用),生产路径改为 Prepare() 之后由已解析视图经 SetShutdownTimeout 设定。driver_http.New(WithAddr(...)) 选项删除,addr 归 driver_http 在 Apply 内声明读取。


4. 解析语义与启动时序

4.1 单 key 优先级链

1. 显式 env 命中          env:"DB_ENABLED"        → 最高优先级
2. autoEnable env 命中    autoEnable:"DB_HOST"     → true(被 1 覆盖)
3. config.yaml 命中       config:"enabled"
4. default tag 兜底

需要保留的既有特殊语义:

  • 标量 env 填充切片字段:REDIS_ADDR=redis:6379 → redis.addrs = ["redis:6379"];CLICKHOUSE_HOST 同理。
  • 隐式启用:DB_HOST → database.enabled=true、REDIS_ADDR → redis.enabled=true、CLICKHOUSE_HOST → clickhouse.enabled=true;显式 *_ENABLED 始终优先于隐式推导。
  • 同一 env 的双重角色:REDIS_ADDR 既是 redis.addrs 的值来源,又是 redis.enabled 的 autoEnable 触发器。引擎按 key 独立解析、允许一个 env 名服务多个 key,实现时不可把它建模成"env → 单一 key"的一对一映射。
  • 时长字段:slow_threshold: 200ms 解析为 time.Duration。
  • 文件定位:CONFIG_PATH 优先;否则从工作目录向上最多 5 层查找 config.yaml(该文件位于仓库根,backend/ 为其子目录)。

4.2 时序

config.NewSource()            # 读 yaml → raw map;零 schema 知识
  ↓
core.NewApp(WithConfigSource) # 记录 host 声明
  ↓
app.Use(...)                  # 遇 DeclareConfig() 立即登记 binding(叶子 key + env + default + secret)
  ↓
app.Prepare()                 # ① 冲突校验 ② 逐 key 解析 ③ 脱敏 dump ④ 门禁求值 → FiberSkipped
  ↓
app.Run() → Reconcile/Apply   # 插件内 Bind/Get 读取已解析值

App.Start() 在未显式调用 Prepare() 时幂等补做,防止遗漏。冲突校验规则:同一 key 的多份声明必须 env 名、default、secret 三项一致,否则 Prepare() 返回错误并点名两个声明者。

4.3 有意的行为变更

# 变更 现状 变更后
C1 default 生效条件 applyDefaults 对零值二次回落(session_age<=0 → 86400) 仅当 env 与 yaml 均缺失时生效;app.session_age<=0 在 Prepare() 判为配置错误(fail fast 优于静默改写)
C2 测试上下文 isTest() 自动禁用 DB/Redis/ClickHouse 并把 sqlite 指向 :memory: 删除该魔法;测试用 core.WithConfigValues(...) 显式声明。未声明 database.enabled 时按 default false 落 sqlite 后备,其路径沿用 postgres.go 既有的 ./data/wavelet.db 回落——需要内存库的用例必须显式注入 database.sqlite_path = ":memory:"
C3 配置 dump printConfig 明文打印全量结构体,含 DB_PASSWORD、APP_SESSION_SECRET 按 secret:"true" 脱敏后输出,并标注每个 key 的来源(env/yaml/default)
C4 队列默认值 硬编码在 pkg/config 的 applyEnvOverrides 移入唯一消费者 driver_asynq_worker 的声明(webhook/whitelist_only/default 三级优先级不变)
C5 非法 env 值 envInt/envBool/envFloat64 在 strconv 失败时静默丢弃 env 值、回落 yaml/default Prepare() 返回 ErrConfigType 并点名 key 与非法值

除此之外,解析结果与现状逐 key 等价(由 §7.1 第 4 条的对拍测试证明)。


5. 声明归属映射

声明方 key 前缀 消费者(含跨插件读)
cmd host 声明集 app.{env,app_name,addr,node_id,graceful_shutdown_timeout}、log.*、otel.* cmd/root.go、cmd/banner.go、core.App
plugins/infra/cache redis.*(含 enabled 门禁、autoEnable: REDIS_ADDR) infra/cache、driver_http(session store)、driver_asynq_worker、driver_asynq_cron
plugins/infra/database database.*、clickhouse.* infra/database、admin、risk_control
plugins/domain/auth app.session_*(cookie/secret/age/domain/secure/http_only) auth、cap、message_gateway、driver_http
plugins/drivers/driver_asynq_worker worker.*(并发、strict_priority、queues 默认值) 自身
其余 按需就近声明 —

跨插件读同一 key(如 cap 读 auth 声明的 app.session_secret)依 D3 走共享声明:cap 也声明该 key,三份元数据必须与 auth 一致,否则启动失败。

Key 命名约定:既有 infra key 保持顶层(redis.*、database.*),以兼容线上 config.yaml;新增插件的私有配置归 plugins.<name>.* 命名空间,与 new-setting skill 的描述对齐。

5.1 pkg/idgen 解耦

  • 删除 init() 中对 config.Config.App.NodeID 的读取。
  • 新增 idgen.Init(nodeID int64) error,由 host 在 Prepare() 之后显式调用(值来自 host 声明的 app.node_id)。
  • 未初始化时 NextUint64ID() panic 并点名"未调用 idgen.Init",而非静默使用 nodeID=0 生成可能与集群冲突的 ID。
  • 11 个调用点的 idgen.NextUint64ID() 签名保持不变;依赖 ID 生成的测试需显式 idgen.Init。这是本次迁移唯一会触及既有测试文件之处。

5.2 明确不在范围内

本设计只改变配置的来源与所有权,不动这些既有全局变量:cache.Redis、driver_asynq_worker.RedisOpt/AsynqClient、infra/database.db。它们各自的收敛属于独立议题。


6. 错误处理

  • Prepare() 以 errors.Join 聚合全部配置错误,哨兵错误:ErrConfigConflict(重复声明不一致)、ErrConfigType(env 值无法转为目标类型)、ErrConfigInvalid(值域校验失败,如 session_age<=0)、ErrConfigNotResolved(Prepare() 之前调用 Bind/Get,错误信息点名正确调用顺序)。
  • 所有配置错误经 error 返回,由 cmd 决定终止方式;core 与 extpoints 内不再有 log.Fatalf。
  • config.yaml 缺失不是错误(沿用"仅用 env"路径,记一条 info 日志);CONFIG_PATH 显式指定但读不到或解析失败 → 返回 error。
  • 门禁 ConfigEnabled(v ConfigView) bool 只读已解析值、用带 fallback 的访问器,配置错误已在 Prepare() 阶段暴露,因此门禁不引入新的错误源。
  • Declare 与 Bind 校验 Target 必须是非 nil 结构体指针,否则返回 error(不 panic)。

7. 测试与验收

7.1 测试分层

  1. 引擎单测(core/extpoints):内存 fake ConfigSource,表驱动覆盖优先级四档、标量 env→切片、autoEnable 与显式 env 的优先关系、冲突校验、脱敏 dump、time.Duration 与嵌套结构体 tag 解析、Prepare() 前访问的错误路径。
  2. 门禁单测(core):互斥插件对恰好激活一个、被跳过插件不计入依赖 satisfied、FiberSkipped 参与 Stop 的 LIFO 卸载。
  3. 适配器单测(plugins/infra/config):t.TempDir() 写 yaml + t.Setenv,禁止相对路径。
  4. 新旧对拍:迁移期间保留一份临时对拍测试,用仓库现网 config.yaml 与 .env 逐 key 比较旧 pkg/config 与新引擎的输出,证明除 C1–C5 外完全等价;验证通过后随旧包一并删除。
  5. 迁移后插件测试:改用 core.WithConfigValues(...) 显式注入;依赖 ID 生成的测试显式 idgen.Init。

7.2 验收标准

  1. backend/pkg/config 不存在,grep -rn "pkg/config\|config\.Config" backend/ 零命中。
  2. core/ 无 viper import;backend/pkg/ 内不出现任何配置源 import。
  3. cmd/app.go 中不存在跨插件配置判断,驱动选型完全由门禁产生。
  4. .env、config.yaml、docker-compose 零改动即可启动,行为等价(除已登记的 C1–C5)。
  5. 同时挂载 cache 与 cache_memory 而仅激活其一——"预声明 + 门禁"的端到端可验证证据;两条路径(Redis 启用 → asynq;禁用 → inproc)各实跑一次。
  6. make code-check、make format、go test ./backend/... 全绿;go run main.go all 实跑通过,覆盖 banner、迁移与门禁。
  7. AGENTS.md、new-setting skill 与白皮书中 ctx.Config().Bind(...) 的签名与 key 命名约定更新为已实现的真实 API。

7.3 实施顺序建议

每阶段独立可验证,供实施计划拆分参考:

阶段 内容 验证
P1 配置引擎(core/extpoints/config.go)+ plugins/infra/config 适配器 + 新旧对拍测试 go test ./backend/core/...;对拍输出等价性报告
P2 门禁与 FiberSkipped、App.Prepare() 解析屏障 core 门禁单测;现有测试全绿(此时旧单例仍在,未迁移)
P3 按 infra → drivers → domain → cmd 顺序迁移 27 个文件;idgen.Init 解耦 每层迁移后 go build ./... + 该层测试;最后实跑两条门禁路径
P4 删除 backend/pkg/config 与对拍测试;更新 AGENTS.md/skill/白皮书 API §7.2 全部验收项逐条复核

附录 A:迁移清单

删除:backend/pkg/config/{config.go,model.go,config_test.go}

新增:backend/core/extpoints/config.go、backend/core/config.go、backend/plugins/infra/config/*、各插件内 <name>_config.go 声明文件

需改写的 27 个文件:

分组 文件
组合根 cmd/app.go、cmd/root.go、cmd/banner.go、cmd/app_test.go、cmd/banner_test.go、cmd/redis_plug_test.go
基础库 pkg/idgen/snowflake.go(连带 pkg/idgen/snowflake_test.go)
infra plugins/infra/cache/redis.go、plugins/infra/database/postgres.go、plugins/infra/database/clickhouse.go
drivers plugins/drivers/driver_http/engine.go、plugins/drivers/driver_http/middlewares.go、plugins/drivers/driver_asynq_worker/utils.go、plugins/drivers/driver_asynq_worker/utils_test.go、plugins/drivers/driver_asynq_cron/plugin.go
domain/admin plugins/domain/admin/handler/db.go、plugins/domain/admin/repository/db.go、plugins/domain/admin/service/db.go、plugins/domain/admin/service/status.go、plugins/domain/admin/service/log_switch.go
domain/其他 plugins/domain/auth/session.go、plugins/domain/cap/service.go、plugins/domain/message_gateway/service/service.go、plugins/domain/system/plugin.go、plugins/domain/risk_control/middleware.go、plugins/domain/risk_control/middleware_test.go、plugins/domain/risk_control/logstore/provider.go

注:risk_control/middleware.go、cap/service.go、message_gateway/service/service.go 等处以 config.Config != nil 做存在性判断的分支,在注入式配置模型下不再可能,迁移时一并消除。


8. 落地回写(P1 + P2 已实施)

实施结果与本设计原述的差异,均已按下列口径落地:

# 设计原述 落地结果 缘由
R1 §4.3 C1、§6 把 app.session_age<=0 列为内核解析错误 引擎不做值域校验,ErrConfigInvalid 保留但未在内核使用;值域由声明者在 Bind 之后校验(P3 由 auth 承担) 引擎被设计成不认识任何业务 key 的语义,把业务规则塞进内核会破坏该不变式
R2 §3.2 ConfigView.Source(key) 更名 Origin(key);新增 Value(key) (any, bool);ConfigExtension 增加 SetSource、Resolved Source 与类型名 ConfigSource 同文件易混淆;Value 支撑 core.ConfigGet[T](Go 方法不能带类型参数);SetSource 进接口以免运行时类型断言
R3 §3.5 仅有 WithShutdownTimeout 新增 App.ShutdownTimeout() 与 SetShutdownTimeout(d) *App 组合根需在 Prepare() 之后把已解析预算写回内核,构造期选项无法表达该顺序
R4 §4.2 时序图把门禁求值画在 Prepare() 内 Prepare() 只建立解析屏障,门禁在 reconcileLocked 每轮调和中求值 App.Use 可在 Prepare() 之后继续挂载插件;只在 Prepare 求值会留下一批永不判定的门禁
R5 未涉及 App 未注入 ConfigSource 时配置能力视为未启用,解析屏障直接放行;实现了 ConfigGatedPlugin 却无配置源的插件 fail fast 点名原因 内核存在大量不使用配置的装配路径(既有测试与嵌入式用法),不能强制要求配置源;但门禁无数据可依时必须报错,而非静默全激活
R6 §4.1 隐含"每个 key 都有 env 覆盖" env 覆盖面完全由声明决定。旧装载器只对部分 key 提供 env(slow_threshold、conn_max_lifetime 等从未有 env 覆盖),对拍镜像必须精确复刻该覆盖面 否则对拍出现假漂移;放宽某 key 的 env 覆盖是 P3 的声明选择,不构成引擎行为变更
R7 §4.1 "向上最多 5 层查找 config.yaml" 该向上查找会越出 git worktree 边界:从 backend/pkg/config 出发第 5 层可命中父级检出的 config.yaml 属既有行为、非本次引入,但在 worktree 中开发会静默使用另一份检出的配置。对拍测试已改为以入库的 config.example.yaml 所在目录为锚;config.yaml 本身被 gitignore,干净克隆中不存在

分期口径:本设计 §7.3 的 P1 + P2 已实施完成;P3(27 个消费文件迁移、pkg/idgen 解耦)与 P4(删除 backend/pkg/config、移除对拍夹具)由后续计划承接。