From 4653afc5542feadc63c16387a1fcf4df1fddbd81 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 29 Aug 2026 09:38:39 +0800 Subject: [PATCH] feat(core): resolve declared configuration with env and file precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 显式 env > autoEnable > 配置文件 > default 的优先级链解析每个已声明 key,支持标量 env 填充切片、duration 与结构体切片解码,非法 env 值不再 静默回退而是报 ErrConfigType。 --- backend/core/extpoints/config.go | 13 +- backend/core/extpoints/config_resolve.go | 143 ++++++++++++- backend/core/extpoints/config_test.go | 80 ++++++++ backend/core/extpoints/config_value.go | 243 +++++++++++++++++++++++ 4 files changed, 472 insertions(+), 7 deletions(-) create mode 100644 backend/core/extpoints/config_value.go diff --git a/backend/core/extpoints/config.go b/backend/core/extpoints/config.go index b9a4b452..2258cd6a 100644 --- a/backend/core/extpoints/config.go +++ b/backend/core/extpoints/config.go @@ -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 diff --git a/backend/core/extpoints/config_resolve.go b/backend/core/extpoints/config_resolve.go index 00546aa5..d28a17b9 100644 --- a/backend/core/extpoints/config_resolve.go +++ b/backend/core/extpoints/config_resolve.go @@ -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 { diff --git a/backend/core/extpoints/config_test.go b/backend/core/extpoints/config_test.go index 3021866e..f89ecce8 100644 --- a/backend/core/extpoints/config_test.go +++ b/backend/core/extpoints/config_test.go @@ -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") +} diff --git a/backend/core/extpoints/config_value.go b/backend/core/extpoints/config_value.go new file mode 100644 index 00000000..1660f94b --- /dev/null +++ b/backend/core/extpoints/config_value.go @@ -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 "" +}