feat(core): add configuration declaration registry

配置读取框架的内核侧抽象:插件用带 config/env/default/autoEnable/secret
tag 的结构体声明自己读哪些字段,注册表按 key 归集并对重复声明做一致性
校验,为后续按声明解析与门禁求值提供基础。
This commit is contained in:
ryan
2026-08-29 09:32:53 +08:00
parent ae8bbd98f8
commit c77b5358e2
3 changed files with 387 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
"errors"
"fmt"
"reflect"
"strings"
"sync"
"time"
)
// Sentinel errors returned by the configuration extension point.
var (
// ErrConfigConflict is returned when the same key is declared with disagreeing metadata.
ErrConfigConflict = errors.New("extpoints: conflicting configuration declarations")
// ErrConfigType is returned when a value cannot be converted to the declared type.
ErrConfigType = errors.New("extpoints: configuration value type mismatch")
// ErrConfigUnknownKey is returned when a configuration key was never declared.
ErrConfigUnknownKey = errors.New("extpoints: unknown configuration key")
// ErrConfigNotResolved is returned when typed reads happen before resolution.
ErrConfigNotResolved = errors.New("extpoints: configuration not resolved; run App.Prepare first")
// ErrConfigTarget is returned when a binding target is not an addressable struct pointer.
ErrConfigTarget = errors.New("extpoints: configuration binding target must be a non-nil struct pointer")
// ErrConfigNoSource is returned when resolution is attempted without a registered source.
ErrConfigNoSource = errors.New("extpoints: no configuration source registered")
)
// Configuration origin labels reported by ConfigView.Origin and ConfigEntry.Origin.
const (
// OriginEnv marks a value that came from an environment variable.
OriginEnv = "env"
// OriginAutoEnable marks a boolean enabled by the presence of another environment variable.
OriginAutoEnable = "auto-enable"
// OriginFile marks a value that came from the configuration file.
OriginFile = "file"
// OriginDefault marks a value that came from a declaration default.
OriginDefault = "default"
)
// durationType distinguishes time.Duration from plain int64 during tag walking and decoding.
var durationType = reflect.TypeFor[time.Duration]()
// ConfigSource abstracts where raw configuration values come from, keeping the
// micro-kernel free of concrete loaders such as viper.
type ConfigSource interface {
// Lookup returns the raw value stored at a dotted path in the configuration file.
Lookup(path string) (any, bool)
// LookupEnv returns the raw value of an environment variable.
LookupEnv(name string) (string, bool)
// Describe returns a human readable identity for the source, used in diagnostics.
Describe() string
}
// ConfigBinding declares that a plugin reads every `config` tagged field of Target
// under a dotted configuration prefix.
type ConfigBinding struct {
// Prefix is the dotted configuration path, e.g. "redis". An empty prefix means
// each field's `config` tag is already a full path.
Prefix string
// Target must be a non-nil pointer to a struct carrying `config` tags.
Target any
}
// configField is a single leaf discovered while walking a binding struct's tags.
// key is the fully qualified dotted path used for resolution; path is the raw `config`
// tag value used to locate the Go field again during Bind.
type configField struct {
key string
path string
env string
autoEnable string
def string
secret bool
typ reflect.Type
}
// configDecl is the registered form of a configField, attributed to its declaring plugin.
type configDecl struct {
key string
pluginID string
env string
autoEnable string
def string
secret bool
typ reflect.Type
}
// ConfigEntry is a redacted, self-describing view of one effective configuration key.
type ConfigEntry struct {
Key string
PluginID string
Env string
Origin string
Value string
}
// ConfigView is the read-only surface over effective configuration values.
// Keys are dotted paths such as "redis.enabled".
type ConfigView interface {
Value(key string) (any, bool)
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
Origin(key string) string
}
// ConfigExtension is the plugin-facing configuration extension point mounted on the
// root Context and shared by every forked plugin scope.
type ConfigExtension interface {
ConfigView
// SetSource installs the raw value source after construction, letting the composition
// root build the adapter once the kernel Context already exists.
SetSource(src ConfigSource)
// Declare registers plugin-owned configuration bindings before Apply runs.
Declare(pluginID string, bindings ...ConfigBinding) error
// Bind resolves and assigns the configuration values for a tagged struct.
Bind(prefix string, target any) error
// Resolve computes the effective value of every declared key once.
Resolve() error
// Resolved reports whether Resolve has already run.
Resolved() bool
// Entries returns the redacted effective configuration ordered by key.
Entries() []ConfigEntry
}
// ConfigRegistry implements ConfigExtension. Declarations are additive; values are
// computed once by Resolve and reused by every later read.
type ConfigRegistry struct {
mu sync.RWMutex
src ConfigSource
decls map[string]*configDecl
order []string
values map[string]any
origins map[string]string
}
// NewConfigRegistry creates an empty configuration registry. A nil src is allowed so
// that the kernel can construct the registry before the composition root injects one.
func NewConfigRegistry(src ConfigSource) *ConfigRegistry {
return &ConfigRegistry{
src: src,
decls: make(map[string]*configDecl),
values: make(map[string]any),
origins: make(map[string]string),
}
}
// SetSource installs the raw value source. It is intended for the composition root,
// which builds the adapter after the kernel Context already exists.
func (r *ConfigRegistry) SetSource(src ConfigSource) {
r.mu.Lock()
defer r.mu.Unlock()
r.src = src
}
// Declare registers every `config` tagged leaf of each binding's target struct.
// Repeated declarations of the same key are accepted only when their env, default,
// auto-enable and secret metadata agree; disagreement is ErrConfigConflict.
func (r *ConfigRegistry) Declare(pluginID string, bindings ...ConfigBinding) error {
r.mu.Lock()
defer r.mu.Unlock()
for _, b := range bindings {
if err := r.declareBinding(pluginID, b); err != nil {
return err
}
}
return nil
}
func (r *ConfigRegistry) declareBinding(pluginID string, b ConfigBinding) error {
target, err := bindingStruct(b.Target, b.Prefix)
if err != nil {
return err
}
fields, err := walkConfigFields(target.Type(), b.Prefix)
if err != nil {
return err
}
for _, f := range fields {
if err := r.addDecl(pluginID, f); err != nil {
return err
}
}
return nil
}
// bindingStruct validates that a binding or bind target is a usable struct pointer.
func bindingStruct(target any, prefix string) (reflect.Value, error) {
rv := reflect.ValueOf(target)
if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
return reflect.Value{}, fmt.Errorf("%w: prefix %q received %T", ErrConfigTarget, prefix, target)
}
return rv.Elem(), nil
}
// walkConfigFields collects leaf configuration declarations from `config` tagged fields.
// A field without a `config` tag is skipped, except for embedded structs which are
// recursed into so their own tags resolve under the same prefix.
func walkConfigFields(t reflect.Type, prefix string) ([]configField, error) {
var out []configField
for i := 0; i < t.NumField(); i++ {
sf := t.Field(i)
if sf.PkgPath != "" {
continue
}
path := sf.Tag.Get("config")
if path == "-" {
continue
}
if path == "" {
if sf.Type.Kind() == reflect.Struct && sf.Type != durationType {
nested, err := walkConfigFields(sf.Type, prefix)
if err != nil {
return nil, err
}
out = append(out, nested...)
}
continue
}
out = append(out, configField{
key: joinKey(prefix, path),
path: path,
env: sf.Tag.Get("env"),
autoEnable: sf.Tag.Get("autoEnable"),
def: sf.Tag.Get("default"),
secret: strings.EqualFold(sf.Tag.Get("secret"), "true"),
typ: sf.Type,
})
}
return out, nil
}
func joinKey(prefix, path string) string {
if prefix == "" {
return path
}
return prefix + "." + path
}
// addDecl records one leaf, enforcing the shared-declaration consistency rule.
func (r *ConfigRegistry) addDecl(pluginID string, f configField) error {
if existing, ok := r.decls[f.key]; ok {
if existing.env != f.env || existing.def != f.def ||
existing.autoEnable != f.autoEnable || existing.secret != f.secret {
return fmt.Errorf(
"%w: key %q declared by plugin %q and plugin %q with disagreeing env/default/autoEnable/secret metadata",
ErrConfigConflict, f.key, existing.pluginID, pluginID)
}
return nil
}
r.decls[f.key] = &configDecl{
key: f.key, pluginID: pluginID, env: f.env,
autoEnable: f.autoEnable, def: f.def, secret: f.secret, typ: f.typ,
}
r.order = append(r.order, f.key)
return nil
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import "sort"
// Entries returns the effective configuration as redacted, key-sorted entries.
func (r *ConfigRegistry) Entries() []ConfigEntry {
r.mu.RLock()
defer r.mu.RUnlock()
keys := append([]string(nil), r.order...)
sort.Strings(keys)
out := make([]ConfigEntry, 0, len(keys))
for _, key := range keys {
d := r.decls[key]
out = append(out, ConfigEntry{
Key: d.key, PluginID: d.pluginID, Env: d.env,
Origin: r.origins[key], Value: "pending",
})
}
return out
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"Wavelet/core/extpoints"
)
// fakeSource is an in-memory extpoints.ConfigSource used by configuration engine tests.
type fakeSource struct {
values map[string]any
env map[string]string
}
func newFakeSource() *fakeSource {
return &fakeSource{values: map[string]any{}, env: map[string]string{}}
}
func (f *fakeSource) Lookup(path string) (any, bool) {
v, ok := f.values[path]
return v, ok
}
func (f *fakeSource) LookupEnv(name string) (string, bool) {
v, ok := f.env[name]
return v, ok
}
func (f *fakeSource) Describe() string { return "fake" }
// redisConfig mirrors how a plugin declares the configuration it reads.
type redisConfig struct {
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
DB int `config:"db" env:"REDIS_DB"`
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
Dial time.Duration `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"`
Ignored string `config:"-"`
private string
}
func TestDeclareRegistersTaggedLeafKeys(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
keys := make([]string, 0)
for _, e := range r.Entries() {
keys = append(keys, e.Key)
}
assert.Equal(t, []string{
"redis.addrs", "redis.db", "redis.dial_timeout", "redis.enabled", "redis.key_prefix",
}, keys)
}
func TestDeclareRejectsNonStructPointerTarget(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: redisConfig{}}),
extpoints.ErrConfigTarget)
assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: (*redisConfig)(nil)}),
extpoints.ErrConfigTarget)
}
func TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata(t *testing.T) {
r := extpoints.NewConfigRegistry(newFakeSource())
binding := extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}
require.NoError(t, r.Declare("cache", binding))
require.NoError(t, r.Declare("cache_memory", binding), "identical shared declarations must be allowed")
type conflictingConfig struct {
Enabled bool `config:"enabled" env:"REDIS_ON" default:"true"`
}
err := r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "redis", Target: &conflictingConfig{}})
require.ErrorIs(t, err, extpoints.ErrConfigConflict)
assert.Contains(t, err.Error(), "redis.enabled")
assert.Contains(t, err.Error(), "cache")
assert.Contains(t, err.Error(), "driver_http")
}