mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
feat(core): resolve declared configuration with env and file precedence
按 显式 env > autoEnable > 配置文件 > default 的优先级链解析每个已声明 key,支持标量 env 填充切片、duration 与结构体切片解码,非法 env 值不再 静默回退而是报 ErrConfigType。
This commit is contained in:
@@ -138,12 +138,13 @@ type ConfigExtension interface {
|
||||
// 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
|
||||
mu sync.RWMutex
|
||||
src ConfigSource
|
||||
decls map[string]*configDecl
|
||||
order []string
|
||||
values map[string]any
|
||||
origins map[string]string
|
||||
resolved bool
|
||||
}
|
||||
|
||||
// NewConfigRegistry creates an empty configuration registry. A nil src is allowed so
|
||||
|
||||
@@ -3,7 +3,148 @@
|
||||
|
||||
package extpoints
|
||||
|
||||
import "sort"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Resolve computes the effective value of every declared key. Priority is, in order:
|
||||
// an explicit environment override, an auto-enable trigger, the configuration file,
|
||||
// then the declared default. Resolution is idempotent; later declarations resolve lazily.
|
||||
func (r *ConfigRegistry) Resolve() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.src == nil {
|
||||
return ErrConfigNoSource
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, key := range r.order {
|
||||
if _, done := r.values[key]; done {
|
||||
continue
|
||||
}
|
||||
if err := r.resolveLocked(key); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
r.resolved = true
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Resolved reports whether Resolve has already run.
|
||||
func (r *ConfigRegistry) Resolved() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.resolved
|
||||
}
|
||||
|
||||
// resolveLocked computes the effective value of one key. The caller must hold r.mu.
|
||||
func (r *ConfigRegistry) resolveLocked(key string) error {
|
||||
d, ok := r.decls[key]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrConfigUnknownKey, key)
|
||||
}
|
||||
|
||||
if d.env != "" {
|
||||
if raw, found := r.src.LookupEnv(d.env); found {
|
||||
value, err := convertValue(raw, d.typ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: key %q from environment %s: %w", ErrConfigType, key, d.env, err)
|
||||
}
|
||||
r.values[key], r.origins[key] = value, OriginEnv
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if d.autoEnable != "" && d.typ.Kind() == reflect.Bool {
|
||||
if _, found := r.src.LookupEnv(d.autoEnable); found {
|
||||
r.values[key], r.origins[key] = true, OriginAutoEnable
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if raw, found := r.src.Lookup(key); found {
|
||||
value, err := convertValue(raw, d.typ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: key %q from %s: %w", ErrConfigType, key, r.src.Describe(), err)
|
||||
}
|
||||
r.values[key], r.origins[key] = value, OriginFile
|
||||
return nil
|
||||
}
|
||||
|
||||
if d.def != "" {
|
||||
value, err := convertValue(d.def, d.typ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: default %q for key %q: %w", ErrConfigType, d.def, key, err)
|
||||
}
|
||||
r.values[key], r.origins[key] = value, OriginDefault
|
||||
return nil
|
||||
}
|
||||
|
||||
r.values[key] = reflect.New(d.typ).Elem().Interface()
|
||||
r.origins[key] = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bind resolves the tagged fields of target and assigns them in place. Prefixes that
|
||||
// were never declared self-register, so only gates need DeclareConfig.
|
||||
func (r *ConfigRegistry) Bind(prefix string, target any) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.src == nil {
|
||||
return ErrConfigNoSource
|
||||
}
|
||||
if !r.resolved {
|
||||
return fmt.Errorf("%w: Bind(%q, %T) ran before App.Prepare", ErrConfigNotResolved, prefix, target)
|
||||
}
|
||||
|
||||
elem, err := bindingStruct(target, prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields, err := walkConfigFields(elem.Type(), prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range fields {
|
||||
if _, declared := r.decls[f.key]; !declared {
|
||||
if err := r.addDecl("bind:"+prefix, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, done := r.values[f.key]; !done {
|
||||
if err := r.resolveLocked(f.key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return assignFields(elem, fields, r.values)
|
||||
}
|
||||
|
||||
// assignFields writes resolved values into a freshly walked target struct.
|
||||
func assignFields(elem reflect.Value, fields []configField, values map[string]any) error {
|
||||
for _, f := range fields {
|
||||
field := elem.FieldByName(fieldNameForPath(elem.Type(), f.path))
|
||||
if !field.IsValid() || !field.CanSet() {
|
||||
return fmt.Errorf("%w: field for key %q is not settable", ErrConfigTarget, f.key)
|
||||
}
|
||||
|
||||
value := reflect.ValueOf(values[f.key])
|
||||
if !value.Type().AssignableTo(field.Type()) {
|
||||
return fmt.Errorf("%w: key %q resolves to %s, field expects %s",
|
||||
ErrConfigType, f.key, value.Type(), field.Type())
|
||||
}
|
||||
field.Set(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Entries returns the effective configuration as redacted, key-sorted entries.
|
||||
func (r *ConfigRegistry) Entries() []ConfigEntry {
|
||||
|
||||
@@ -84,3 +84,83 @@ func TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata(t *testing
|
||||
assert.Contains(t, err.Error(), "cache")
|
||||
assert.Contains(t, err.Error(), "driver_http")
|
||||
}
|
||||
|
||||
// queueConfig is a composite element mirroring worker.queues in config.yaml.
|
||||
type queueConfig struct {
|
||||
Name string `config:"name"`
|
||||
Priority int `config:"priority"`
|
||||
}
|
||||
|
||||
type workerConfig struct {
|
||||
Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"`
|
||||
Queues []queueConfig `config:"queues"`
|
||||
}
|
||||
|
||||
type sessionConfig struct {
|
||||
Secret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
|
||||
Age int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
|
||||
}
|
||||
|
||||
func TestResolveScalarDurationAndSlice(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.values["redis.db"] = 1
|
||||
src.values["redis.dial_timeout"] = "5s"
|
||||
src.values["redis.addrs"] = []any{"127.0.0.1:6379"}
|
||||
src.env["REDIS_KEY_PREFIX"] = "refresh:"
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
|
||||
var got redisConfig
|
||||
require.NoError(t, r.Bind("redis", &got))
|
||||
assert.Equal(t, redisConfig{
|
||||
Addrs: []string{"127.0.0.1:6379"}, DB: 1, KeyPrefix: "refresh:", Dial: 5 * time.Second,
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestResolveFillsSliceFromScalarEnvironmentValue(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.env["REDIS_ADDR"] = "redis:6379"
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
|
||||
var got redisConfig
|
||||
require.NoError(t, r.Bind("redis", &got))
|
||||
assert.Equal(t, []string{"redis:6379"}, got.Addrs)
|
||||
}
|
||||
|
||||
func TestResolveCompositeSliceOfStructs(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.values["worker.concurrency"] = 20
|
||||
src.values["worker.queues"] = []any{
|
||||
map[string]any{"name": "webhook", "priority": 10},
|
||||
map[string]any{"name": "default", "priority": 3},
|
||||
}
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))
|
||||
require.NoError(t, r.Resolve())
|
||||
|
||||
var got workerConfig
|
||||
require.NoError(t, r.Bind("worker", &got))
|
||||
assert.Equal(t, workerConfig{
|
||||
Concurrency: 20,
|
||||
Queues: []queueConfig{{Name: "webhook", Priority: 10}, {Name: "default", Priority: 3}},
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestResolveReportsTypeMismatchOnBadEnvironmentValue(t *testing.T) {
|
||||
src := newFakeSource()
|
||||
src.env["WORKER_CONCURRENCY"] = "many"
|
||||
|
||||
r := extpoints.NewConfigRegistry(src)
|
||||
require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))
|
||||
|
||||
err := r.Resolve()
|
||||
require.ErrorIs(t, err, extpoints.ErrConfigType)
|
||||
assert.Contains(t, err.Error(), "worker.concurrency")
|
||||
assert.Contains(t, err.Error(), "WORKER_CONCURRENCY")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package extpoints
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// convertValue coerces a raw value coming from the configuration file or an
|
||||
// environment variable into the declared Go type.
|
||||
func convertValue(raw any, typ reflect.Type) (any, error) {
|
||||
if typ == durationType {
|
||||
return convertDuration(raw)
|
||||
}
|
||||
|
||||
switch typ.Kind() {
|
||||
case reflect.Bool:
|
||||
return convertBool(raw)
|
||||
case reflect.String:
|
||||
return convertString(raw)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return convertNumeric(raw, typ, signedNumbers)
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return convertNumeric(raw, typ, unsignedNumbers)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return convertNumeric(raw, typ, floatingNumbers)
|
||||
case reflect.Slice:
|
||||
return convertSlice(raw, typ)
|
||||
case reflect.Struct:
|
||||
return convertStruct(raw, typ)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ)
|
||||
}
|
||||
}
|
||||
|
||||
func convertBool(raw any) (any, error) {
|
||||
switch v := raw.(type) {
|
||||
case bool:
|
||||
return v, nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %q is not a boolean", ErrConfigType, v)
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %v is not a boolean", ErrConfigType, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func convertString(raw any) (any, error) {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
return v, nil
|
||||
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return fmt.Sprint(v), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %v is not a string", ErrConfigType, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// numericString extracts the textual form of a value so environment overrides,
|
||||
// which always arrive as strings, share one parsing path with file values.
|
||||
func numericString(raw any) (string, bool) {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v), true
|
||||
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return fmt.Sprint(v), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// numericKind selects which strconv family converts a raw value.
|
||||
type numericKind int
|
||||
|
||||
const (
|
||||
signedNumbers numericKind = iota
|
||||
unsignedNumbers
|
||||
floatingNumbers
|
||||
)
|
||||
|
||||
// convertNumeric parses a raw value into the numeric type declared by typ. The three
|
||||
// numeric families share one implementation because they differ only in the strconv
|
||||
// call and the reflect setter.
|
||||
func convertNumeric(raw any, typ reflect.Type, family numericKind) (any, error) {
|
||||
text, ok := numericString(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %v is not a %s", ErrConfigType, raw, typ)
|
||||
}
|
||||
|
||||
out := reflect.New(typ).Elem()
|
||||
var err error
|
||||
|
||||
switch family {
|
||||
case signedNumbers:
|
||||
parsed, parseErr := strconv.ParseInt(text, 10, typ.Bits())
|
||||
out.SetInt(parsed)
|
||||
err = parseErr
|
||||
case unsignedNumbers:
|
||||
parsed, parseErr := strconv.ParseUint(text, 10, typ.Bits())
|
||||
out.SetUint(parsed)
|
||||
err = parseErr
|
||||
default:
|
||||
parsed, parseErr := strconv.ParseFloat(text, typ.Bits())
|
||||
out.SetFloat(parsed)
|
||||
err = parseErr
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
|
||||
}
|
||||
return out.Interface(), nil
|
||||
}
|
||||
|
||||
// convertDuration accepts both Go duration strings such as "200ms" and integer
|
||||
// nanoseconds, mirroring what the previous viper based decoding supported.
|
||||
func convertDuration(raw any) (any, error) {
|
||||
if v, ok := raw.(time.Duration); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
text, ok := numericString(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %v is not a duration", ErrConfigType, raw)
|
||||
}
|
||||
|
||||
if parsed, err := time.ParseDuration(text); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
nanos, err := strconv.ParseInt(text, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %q is not a valid duration", ErrConfigType, text)
|
||||
}
|
||||
return time.Duration(nanos), nil
|
||||
}
|
||||
|
||||
// convertSlice promotes a scalar into a single-element slice so that a value such as
|
||||
// REDIS_ADDR=redis:6379 can populate the redis.addrs list.
|
||||
func convertSlice(raw any, typ reflect.Type) (any, error) {
|
||||
items, ok := sliceItems(raw)
|
||||
if !ok {
|
||||
items = []any{raw}
|
||||
}
|
||||
|
||||
out := reflect.MakeSlice(typ, 0, len(items))
|
||||
for _, item := range items {
|
||||
converted, err := convertValue(item, typ.Elem())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = reflect.Append(out, reflect.ValueOf(converted))
|
||||
}
|
||||
return out.Interface(), nil
|
||||
}
|
||||
|
||||
// sliceItems normalises the several slice shapes a loader may produce.
|
||||
func sliceItems(raw any) ([]any, bool) {
|
||||
switch v := raw.(type) {
|
||||
case []any:
|
||||
return v, true
|
||||
case []string:
|
||||
items := make([]any, len(v))
|
||||
for i, s := range v {
|
||||
items[i] = s
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(raw)
|
||||
if rv.IsValid() && rv.Kind() == reflect.Slice {
|
||||
items := make([]any, rv.Len())
|
||||
for i := range items {
|
||||
items[i] = rv.Index(i).Interface()
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func convertStruct(raw any, typ reflect.Type) (any, error) {
|
||||
table, ok := asStringMap(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %v is not a mapping, cannot decode into %s", ErrConfigType, raw, typ)
|
||||
}
|
||||
|
||||
fields, err := walkConfigFields(typ, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := reflect.New(typ).Elem()
|
||||
for _, f := range fields {
|
||||
item, present := table[f.path]
|
||||
if !present || item == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
converted, err := convertValue(item, f.typ)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s.%s: %w", ErrConfigType, typ.Name(), f.key, err)
|
||||
}
|
||||
out.FieldByName(fieldNameForPath(typ, f.path)).Set(reflect.ValueOf(converted))
|
||||
}
|
||||
return out.Interface(), nil
|
||||
}
|
||||
|
||||
// asStringMap normalises the two map shapes produced by YAML decoders.
|
||||
func asStringMap(raw any) (map[string]any, bool) {
|
||||
switch v := raw.(type) {
|
||||
case map[string]any:
|
||||
return v, true
|
||||
case map[any]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for key, val := range v {
|
||||
name, ok := key.(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out[name] = val
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// fieldNameForPath maps a declared config path back to the Go struct field carrying it.
|
||||
func fieldNameForPath(t reflect.Type, path string) string {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
if t.Field(i).Tag.Get("config") == path {
|
||||
return t.Field(i).Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user