24 KiB
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")冲突,并且带来两个结构性问题:
- 配置所有权倒挂:任何包都能读到全量配置,因此
cmd直接替cache插件判断 Redis 是否启用、risk_control直接判断clickhouse.enabled。配置的"读者"与"所有者"没有关系约束。 - 隐式全局状态:
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 测试分层
- 引擎单测(
core/extpoints):内存 fakeConfigSource,表驱动覆盖优先级四档、标量 env→切片、autoEnable与显式 env 的优先关系、冲突校验、脱敏 dump、time.Duration与嵌套结构体 tag 解析、Prepare()前访问的错误路径。 - 门禁单测(
core):互斥插件对恰好激活一个、被跳过插件不计入依赖 satisfied、FiberSkipped参与Stop的 LIFO 卸载。 - 适配器单测(
plugins/infra/config):t.TempDir()写 yaml +t.Setenv,禁止相对路径。 - 新旧对拍:迁移期间保留一份临时对拍测试,用仓库现网
config.yaml与.env逐 key 比较旧pkg/config与新引擎的输出,证明除 C1–C5 外完全等价;验证通过后随旧包一并删除。 - 迁移后插件测试:改用
core.WithConfigValues(...)显式注入;依赖 ID 生成的测试显式idgen.Init。
7.2 验收标准
backend/pkg/config不存在,grep -rn "pkg/config\|config\.Config" backend/零命中。core/无 viper import;backend/pkg/内不出现任何配置源 import。cmd/app.go中不存在跨插件配置判断,驱动选型完全由门禁产生。.env、config.yaml、docker-compose 零改动即可启动,行为等价(除已登记的 C1–C5)。- 同时挂载
cache与cache_memory而仅激活其一——"预声明 + 门禁"的端到端可验证证据;两条路径(Redis 启用 → asynq;禁用 → inproc)各实跑一次。 make code-check、make format、go test ./backend/...全绿;go run main.go all实跑通过,覆盖 banner、迁移与门禁。AGENTS.md、new-settingskill 与白皮书中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、移除对拍夹具)由后续计划承接。