mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 16:46:37 +08:00
feat(core): add configuration declaration registry
配置读取框架的内核侧抽象:插件用带 config/env/default/autoEnable/secret tag 的结构体声明自己读哪些字段,注册表按 key 归集并对重复声明做一致性 校验,为后续按声明解析与门禁求值提供基础。
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user