mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
docs(config): add implementation plan for the Cordis config extension point
覆盖 P1+P2:core 配置引擎、viper 适配器隔离、门禁与 FiberSkipped、 新旧解析对拍。迁移 27 个消费文件与旧单例退场由后续计划承接。
This commit is contained in:
@@ -1691,23 +1691,6 @@ func (a *App) prepareLocked() error {
|
||||
}
|
||||
a.prepared = true
|
||||
|
||||
return a.evaluateGatesLocked()
|
||||
}
|
||||
|
||||
// evaluateGatesLocked skips every plugin whose configuration gate is closed.
|
||||
func (a *App) evaluateGatesLocked() error {
|
||||
for _, f := range a.fibers {
|
||||
if f.State() != FiberPending {
|
||||
continue
|
||||
}
|
||||
gated, ok := f.plugin.(ConfigGatedPlugin)
|
||||
if !ok || gated.ConfigEnabled(a.ctx.Config()) {
|
||||
continue
|
||||
}
|
||||
if err := f.Skip(); err != nil {
|
||||
return fmt.Errorf("core: skip gated plugin %q: %w", f.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1729,6 +1712,8 @@ func (a *App) SetShutdownTimeout(timeout time.Duration) *App {
|
||||
}
|
||||
```
|
||||
|
||||
> **门禁为什么在 `reconcileLocked` 内求值而不是 `Prepare` 里一次性遍历**:`App.Use` 可以在 `Prepare` 之后继续挂载插件(下游定制与动态装配)。只在 `Prepare` 求值会留下一批永不判定的门禁;放在调和循环里则任何时刻新挂载的插件都会被正确判定,且 `Fiber.Skip` 自带"仅 Pending 可跳过"守卫,重复遍历安全。
|
||||
|
||||
`Use` 中,为每个成功登记的插件收集声明(放在 `a.pluginMap[name] = p` 之前):
|
||||
|
||||
```go
|
||||
@@ -1766,27 +1751,52 @@ func (a *App) ApplyPlugins() error {
|
||||
}
|
||||
```
|
||||
|
||||
`reconcileLocked` 的加载循环中,在 `f.DependenciesSatisfied(a.ctx)` 判定之前加门禁守卫,确保未经 `Prepare` 的路径也不会激活门禁关闭的插件:
|
||||
把 `reconcileLocked` 的内层循环替换为带门禁判定的版本(其余保持不变):
|
||||
|
||||
```go
|
||||
func (a *App) reconcileLocked() error {
|
||||
if err := a.prepareLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
view := a.ctx.Config()
|
||||
|
||||
for {
|
||||
progress := false
|
||||
for _, f := range a.fibers {
|
||||
if f.State() == FiberSkipped {
|
||||
if f.State() != FiberPending {
|
||||
continue
|
||||
}
|
||||
if f.State() == FiberPending && f.DependenciesSatisfied(a.ctx) {
|
||||
```
|
||||
|
||||
同时把 `unsatisfied` 收集循环改为跳过 `FiberSkipped`,避免把被门禁排除的插件误报为依赖缺失:
|
||||
if gated, ok := f.plugin.(ConfigGatedPlugin); ok {
|
||||
if !view.Resolved() {
|
||||
continue
|
||||
}
|
||||
if !gated.ConfigEnabled(view) {
|
||||
if err := f.Skip(); err != nil {
|
||||
return fmt.Errorf("core: skip gated plugin %q: %w", f.Name(), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
```go
|
||||
for _, f := range a.fibers {
|
||||
if f.State() == FiberPending {
|
||||
unsatisfied = append(unsatisfied, fmt.Sprintf("%s (waiting for %v)", f.Name(), f.Dependencies()))
|
||||
if f.DependenciesSatisfied(a.ctx) {
|
||||
if err := f.Load(); err != nil {
|
||||
return fmt.Errorf("core: load fiber %q failed: %w", f.Name(), err)
|
||||
}
|
||||
progress = true
|
||||
}
|
||||
}
|
||||
if !progress {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ...existing unsatisfied-dependency reporting unchanged
|
||||
}
|
||||
```
|
||||
|
||||
(`FiberPending` 判定已天然排除 `FiberSkipped`,无需改动。)
|
||||
`unsatisfied` 收集循环无需改动:它只统计 `FiberPending`,被门禁排除的插件已是 `FiberSkipped`。
|
||||
|
||||
- [ ] **Step 4: 运行测试确认通过**
|
||||
|
||||
@@ -2213,6 +2223,7 @@ type engineDatabaseConfig struct {
|
||||
PreferSimpleProtocol bool `config:"prefer_simple_protocol" env:"DB_PREFER_SIMPLE_PROTOCOL"`
|
||||
StatementCacheCapacity int `config:"statement_cache_capacity" env:"DB_STATEMENT_CACHE_CAPACITY"`
|
||||
DefaultQueryExecMode string `config:"default_query_exec_mode" env:"DB_DEFAULT_QUERY_EXEC_MODE"`
|
||||
Replicas []engineReplicaConfig `config:"replicas"`
|
||||
SlowThreshold time.Duration `config:"slow_threshold" env:"DB_SLOW_THRESHOLD"`
|
||||
}
|
||||
|
||||
@@ -2265,40 +2276,63 @@ type engineOtelConfig struct {
|
||||
TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"`
|
||||
}
|
||||
|
||||
// engineReplicaConfig mirrors databaseReplicaConfig, a composite element of database.replicas.
|
||||
type engineReplicaConfig struct {
|
||||
Host string `config:"host"`
|
||||
Port int `config:"port"`
|
||||
Username string `config:"username"`
|
||||
Password string `config:"password"`
|
||||
}
|
||||
|
||||
// engineQueueConfig and engineWorkerConfig mirror the worker section, whose defaults
|
||||
// legitimately move to driver_asynq_worker in P3; the repository file declares them
|
||||
// explicitly, so parity is unaffected.
|
||||
type engineQueueConfig struct {
|
||||
Name string `config:"name"`
|
||||
Priority int `config:"priority"`
|
||||
}
|
||||
|
||||
type engineWorkerConfig struct {
|
||||
Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"`
|
||||
StrictPriority bool `config:"strict_priority" env:"WORKER_STRICT_PRIORITY"`
|
||||
Queues []engineQueueConfig `config:"queues"`
|
||||
}
|
||||
|
||||
func repositoryConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
path := filepath.Join(dir, "..", "..", "config.yaml")
|
||||
path := filepath.Join("..", "..", "config.yaml")
|
||||
if _, statErr := os.Stat(path); statErr != nil {
|
||||
t.Skip("repository root config.yaml is unavailable")
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// copyLike copies same-named, same-typed exported fields from src into dst recursively.
|
||||
func copyLike(dst, src reflect.Value) {
|
||||
for i := 0; i < dst.NumField(); i++ {
|
||||
dstField := dst.Type().Field(i)
|
||||
if dstField.PkgPath != "" {
|
||||
// flatten exports a struct into dotted leaf paths rendered as text. Both sides of the
|
||||
// parity assertion use distinct Go types for the same shape, so values are compared
|
||||
// textually instead of handing cmp a cross-type diff.
|
||||
func flatten(prefix string, v reflect.Value, out map[string]string) {
|
||||
t := v.Type()
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if field.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
srcField := src.FieldByName(dstField.Name)
|
||||
if !srcField.IsValid() {
|
||||
|
||||
fv := v.Field(i)
|
||||
path := prefix + "." + field.Name
|
||||
if fv.Kind() == reflect.Struct && fv.Type() != durationType {
|
||||
flatten(path, fv, out)
|
||||
continue
|
||||
}
|
||||
if srcField.Kind() == reflect.Struct && srcField.Type() != reflect.TypeFor[time.Duration]() {
|
||||
copyLike(dst.Field(i), srcField)
|
||||
continue
|
||||
}
|
||||
if srcField.Type() == dst.Field(i).Type() {
|
||||
dst.Field(i).Set(srcField)
|
||||
}
|
||||
out[path] = fmt.Sprint(fv.Interface())
|
||||
}
|
||||
}
|
||||
|
||||
// durationType mirrors the engine's own notion of a scalar duration field.
|
||||
var durationType = reflect.TypeFor[time.Duration]()
|
||||
|
||||
func TestEngineParityWithLegacyLoader(t *testing.T) {
|
||||
path := repositoryConfig(t)
|
||||
|
||||
@@ -2338,8 +2372,7 @@ func TestEngineParityWithLegacyLoader(t *testing.T) {
|
||||
|
||||
legacy := load(path, false)
|
||||
|
||||
src, err := config.NewSource(config.WithPath(path))
|
||||
require.NoError(t, err)
|
||||
src := newYAMLSource(t, path)
|
||||
|
||||
engine := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, engine.Declare("parity",
|
||||
@@ -2349,6 +2382,7 @@ func TestEngineParityWithLegacyLoader(t *testing.T) {
|
||||
extpoints.ConfigBinding{Prefix: "clickhouse", Target: &engineClickHouseConfig{}},
|
||||
extpoints.ConfigBinding{Prefix: "log", Target: &engineLogConfig{}},
|
||||
extpoints.ConfigBinding{Prefix: "otel", Target: &engineOtelConfig{}},
|
||||
extpoints.ConfigBinding{Prefix: "worker", Target: &engineWorkerConfig{}},
|
||||
))
|
||||
require.NoError(t, engine.Resolve())
|
||||
|
||||
@@ -2358,15 +2392,19 @@ func TestEngineParityWithLegacyLoader(t *testing.T) {
|
||||
var clickhouse engineClickHouseConfig
|
||||
var log engineLogConfig
|
||||
var otel engineOtelConfig
|
||||
require.NoError(t, engine.Bind("app", &app))
|
||||
require.NoError(t, engine.Bind("database", &database))
|
||||
require.NoError(t, engine.Bind("redis", &redis))
|
||||
require.NoError(t, engine.Bind("clickhouse", &clickhouse))
|
||||
require.NoError(t, engine.Bind("log", &log))
|
||||
require.NoError(t, engine.Bind("otel", &otel))
|
||||
var worker engineWorkerConfig
|
||||
for _, binding := range []struct {
|
||||
prefix string
|
||||
target any
|
||||
}{
|
||||
{"app", &app}, {"database", &database}, {"redis", &redis},
|
||||
{"clickhouse", &clickhouse}, {"log", &log}, {"otel", &otel}, {"worker", &worker},
|
||||
} {
|
||||
require.NoError(t, engine.Bind(binding.prefix, binding.target))
|
||||
}
|
||||
|
||||
// The legacy loader keeps one code-level default outside the tags; the
|
||||
// engine expresses it as a declared default, so normalise before diffing.
|
||||
// The legacy loader keeps two code-level defaults outside its tags; the engine
|
||||
// expresses them as declared defaults, so normalise before diffing (spec C1).
|
||||
if legacy.App.SessionAge <= 0 {
|
||||
legacy.App.SessionAge = 86400
|
||||
}
|
||||
@@ -2374,25 +2412,31 @@ func TestEngineParityWithLegacyLoader(t *testing.T) {
|
||||
legacy.Otel.TracerName = "github.com/Rain-kl/Wavelet"
|
||||
}
|
||||
|
||||
copyLike(reflect.ValueOf(&app).Elem(), reflect.ValueOf(legacy.App).Elem())
|
||||
copyLike(reflect.ValueOf(&database).Elem(), reflect.ValueOf(legacy.Database).Elem())
|
||||
copyLike(reflect.ValueOf(&redis).Elem(), reflect.ValueOf(legacy.Redis).Elem())
|
||||
copyLike(reflect.ValueOf(&clickhouse).Elem(), reflect.ValueOf(legacy.ClickHouse).Elem())
|
||||
copyLike(reflect.ValueOf(&log).Elem(), reflect.ValueOf(legacy.Log).Elem())
|
||||
copyLike(reflect.ValueOf(&otel).Elem(), reflect.ValueOf(legacy.Otel).Elem())
|
||||
legacyFlat := map[string]string{}
|
||||
flatten("app", reflect.ValueOf(legacy.App), legacyFlat)
|
||||
flatten("database", reflect.ValueOf(legacy.Database), legacyFlat)
|
||||
flatten("redis", reflect.ValueOf(legacy.Redis), legacyFlat)
|
||||
flatten("clickhouse", reflect.ValueOf(legacy.ClickHouse), legacyFlat)
|
||||
flatten("log", reflect.ValueOf(legacy.Log), legacyFlat)
|
||||
flatten("otel", reflect.ValueOf(legacy.Otel), legacyFlat)
|
||||
flatten("worker", reflect.ValueOf(legacy.Worker), legacyFlat)
|
||||
|
||||
assert.Empty(t, cmp.Diff(app, legacy.App), "app config drifted")
|
||||
assert.Empty(t, cmp.Diff(database, legacy.Database), "database config drifted")
|
||||
assert.Empty(t, cmp.Diff(redis, legacy.Redis), "redis config drifted")
|
||||
assert.Empty(t, cmp.Diff(clickhouse, legacy.ClickHouse), "clickhouse config drifted")
|
||||
assert.Empty(t, cmp.Diff(log, legacy.Log), "log config drifted")
|
||||
assert.Empty(t, cmp.Diff(otel, legacy.Otel), "otel config drifted")
|
||||
engineFlat := map[string]string{}
|
||||
flatten("app", reflect.ValueOf(app), engineFlat)
|
||||
flatten("database", reflect.ValueOf(database), engineFlat)
|
||||
flatten("redis", reflect.ValueOf(redis), engineFlat)
|
||||
flatten("clickhouse", reflect.ValueOf(clickhouse), engineFlat)
|
||||
flatten("log", reflect.ValueOf(log), engineFlat)
|
||||
flatten("otel", reflect.ValueOf(otel), engineFlat)
|
||||
flatten("worker", reflect.ValueOf(worker), engineFlat)
|
||||
|
||||
assert.Empty(t, cmp.Diff(legacyFlat, engineFlat), "engine resolution drifted from legacy loader")
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`require` 需从 testify 引入:在 import 块加 `"github.com/stretchr/testify/require"`。
|
||||
测试文件的 import 块必须包含:`fmt`、`os`、`path/filepath`、`reflect`、`testing`、`time`、`github.com/google/go-cmp/cmp`、`github.com/spf13/viper`、`github.com/stretchr/testify/assert`、`github.com/stretchr/testify/require`、`Wavelet/core/extpoints`。
|
||||
|
||||
- [ ] **Step 4: 运行对拍确认等价**
|
||||
|
||||
@@ -2506,3 +2550,17 @@ git commit -m "docs(config): record the configuration extension point ownership
|
||||
4. 同时挂载门禁谓词相反的两个插件时,恰好一个 `FiberActive`、一个 `FiberSkipped`,且被跳过者 `Apply` 从未执行。
|
||||
5. `core/` 不出现 viper/mapstructure import,且架构脚本能主动拦截该违规。
|
||||
6. 生产启动路径行为未变(仍由 `config.Config` 供值),业务插件与 `cmd` 零改动。
|
||||
|
||||
---
|
||||
|
||||
## 与 spec 的偏差(实施时须回写 spec)
|
||||
|
||||
计划编写阶段的自审发现三处与本 spec 已批准版本不一致的实现,均属实现期发现的正确性问题。在 Task 10 落库时一并回写 `docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md`:
|
||||
|
||||
| # | spec 原述 | 本计划实现 | 理由 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| S1 | §4.3 C1 与 §6 把 `app.session_age<=0` 的判定列为内核解析错误(`ErrConfigInvalid`) | 引擎不做值域校验;`ErrConfigInvalid` 保留为源级校验占位,值域由声明者在 `Bind` 之后自行校验(auth 校验 `SessionAge > 0` 并使 `Apply` 失败) | 引擎被设计成不认识任何业务 key 的语义,让它知道"session_age 必须为正"会破坏该不变式并把业务规则塞进内核 |
|
||||
| S2 | §3.2 `ConfigView` 含 `Source(key) string` | 更名 `Origin(key)`,并新增 `Value(key) (any, bool)`;`ConfigExtension` 新增 `SetSource(src)` 与 `Resolved()` | `Source` 与类型名 `ConfigSource` 在同文件内易混淆;`Value` 是 `core.ConfigGet[T]` 的支撑(Go 方法不能带类型参数);`SetSource` 进接口以避免 `WithConfigSource` 里的运行时类型断言 |
|
||||
| S3 | §3.5 只给出 `WithShutdownTimeout` 与 `app.Prepare()` | 额外新增 `App.ShutdownTimeout()` 读取器与 `SetShutdownTimeout(d) *App`(链式,与既有 `WithProfile` 风格一致) | 组合根需要在 `Prepare()` 之后把已解析的预算写回内核,原先只有构造期选项,无法表达该顺序 |
|
||||
|
||||
回写时同步修正 §7.3 的分期编号:本计划覆盖 P1 + P2,P3 + P4 由后续计划承接(`pkg/idgen` 解耦、27 个文件迁移、删除 `backend/pkg/config`)。
|
||||
|
||||
Reference in New Issue
Block a user