Files
monibuca/pkg/config/config.go
2025-11-04 17:27:43 +08:00

540 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
Package config provides a flexible, multi-source configuration system with priority-based value resolution.
## Overview
The config package implements a hierarchical configuration system that allows values to be set from
multiple sources with a defined priority order. This enables powerful features like:
- Environment variable overrides
- Dynamic runtime modifications
- Global and per-instance defaults
- Type-safe configuration using Go structs
## Configuration Priority
The system resolves values using the following priority order (highest to lowest):
1. Modify - Dynamic runtime modifications
2. Env - Environment variables
3. File - Values from config file
4. defaultYaml - Embedded default YAML configs
5. Global - Global/shared configuration
6. Default - Struct tag defaults or zero values
## Core Workflow
The configuration resolution follows a 5-step initialization process:
### Step 1: Parse
- Initialize the configuration tree from Go struct definitions
- Apply default values using struct tags
- Build the property map for all exported fields
- Set up environment variable prefixes
### Step 2: ParseGlobal
- Apply global/shared configuration values
- Useful for settings that should be consistent across instances
### Step 3: ParseDefaultYaml
- Load embedded default YAML configurations
- Provides sensible defaults without hardcoding in Go
### Step 4: ParseUserFile
- Read and apply user-provided configuration files
- Normalizes key names (removes hyphens, underscores, lowercases)
- Handles both struct mappings and single-value assignments
### Step 5: ParseModifyFile
- Apply dynamic runtime modifications
- Tracks changes separately for API purposes
- Automatically cleans up empty/unchanged values
## Key Features
### Type Conversion
The unmarshal function handles automatic conversion between different types:
- Basic types (int, string, bool, etc.)
- Duration strings with unit validation
- Regexp patterns
- Nested structs (with special handling for single non-struct values)
- Pointers, maps, slices, and arrays
- Fallback to YAML marshaling for unknown types
### Special Behaviors
- Single non-struct values are automatically assigned to the first field of struct types
- Key names are normalized (lowercase, remove hyphens/underscores)
- Environment variables use underscore-separated uppercase prefixes
- The "plugin" field is always skipped during parsing
- Fields with yaml:"-" tag are ignored
## Usage Example
```go
type Config struct {
Host string `yaml:"host" default:"localhost"`
Port int `yaml:"port" default:"8080"`
Timeout time.Duration `yaml:"timeout" default:"30s"`
}
cfg := &Config{}
var c Config
c.Parse(cfg)
// Load from various sources...
config := c.GetValue().(*Config)
```
## API Structure
The main types and functions:
- Config: Core configuration node with value priority tracking
- Parse: Initialize configuration from struct
- ParseGlobal/ParseDefaultYaml/ParseUserFile/ParseModifyFile: Load from sources
- GetValue/GetMap: Retrieve resolved values
- MarshalJSON: Serialize configuration for API responses
*/
package config
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"reflect"
"regexp"
"strings"
"time"
"github.com/mcuadros/go-defaults"
"gopkg.in/yaml.v3"
)
type Config struct {
Ptr reflect.Value // Points to config struct value, priority: Modify > Env > File > defaultYaml > Global > Default
Modify any // Dynamic modified value
Env any // Value from environment variable
File any // Value from config file
Global *Config // Value from global config (pointer type)
Default any // Default value
Enum []struct {
Label string `json:"label"`
Value any `json:"value"`
}
name string // Lowercase key name
propsMap map[string]*Config
props []*Config
tag reflect.StructTag
}
var (
durationType = reflect.TypeOf(time.Duration(0))
regexpType = reflect.TypeOf(Regexp{})
basicTypes = []reflect.Kind{
reflect.Bool,
reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64,
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64,
reflect.Float32,
reflect.Float64,
reflect.String,
}
)
func (config *Config) Range(f func(key string, value Config)) {
if m, ok := config.GetValue().(map[string]Config); ok {
for k, v := range m {
f(k, v)
}
}
}
func (config *Config) IsMap() bool {
_, ok := config.GetValue().(map[string]Config)
return ok
}
func (config *Config) Get(key string) (v *Config) {
if config.propsMap == nil {
config.propsMap = make(map[string]*Config)
}
key = strings.ToLower(key)
if v, ok := config.propsMap[key]; ok {
return v
} else {
v = &Config{
name: key,
}
config.propsMap[key] = v
config.props = append(config.props, v)
return v
}
}
func (config *Config) Has(key string) (ok bool) {
if config.propsMap == nil {
return false
}
_, ok = config.propsMap[strings.ToLower(key)]
return ok
}
func (config *Config) MarshalJSON() ([]byte, error) {
if config.propsMap == nil {
return json.Marshal(config.GetValue())
}
return json.Marshal(config.propsMap)
}
func (config *Config) GetValue() any {
return config.Ptr.Interface()
}
// Parse step 1: Read default values from config struct
func (config *Config) Parse(s any, prefix ...string) {
var t reflect.Type
var v reflect.Value
if vv, ok := s.(reflect.Value); ok {
t, v = vv.Type(), vv
} else {
t, v = reflect.TypeOf(s), reflect.ValueOf(s)
}
if t.Kind() == reflect.Pointer {
t, v = t.Elem(), v.Elem()
}
isStruct := t.Kind() == reflect.Struct && t != regexpType
if isStruct {
defaults.SetDefaults(v.Addr().Interface())
}
config.Ptr = v
if !v.IsValid() {
fmt.Println("parse to ", prefix, config.name, s, "is not valid")
return
}
if l := len(prefix); l > 0 { // Read environment variables
_, isUnmarshaler := v.Addr().Interface().(yaml.Unmarshaler)
tag := config.tag.Get("default")
if tag != "" && isUnmarshaler {
v.Set(config.assign(tag))
}
if envValue := os.Getenv(strings.Join(prefix, "_")); envValue != "" {
v.Set(config.assign(envValue))
config.Env = v.Interface()
}
}
config.Default = v.Interface()
if isStruct {
for i, j := 0, t.NumField(); i < j; i++ {
ft, fv := t.Field(i), v.Field(i)
if !ft.IsExported() {
continue
}
name := strings.ToLower(ft.Name)
if name == "plugin" {
continue // Skip plugin field
}
if tag := ft.Tag.Get("yaml"); tag != "" {
if tag == "-" {
continue // Skip field if tag is "-"
}
name, _, _ = strings.Cut(tag, ",") // Use yaml tag name, ignore options
}
prop := config.Get(name)
prop.tag = ft.Tag
if len(prefix) > 0 {
prop.Parse(fv, append(prefix, strings.ToUpper(ft.Name))...) // Recursive parse with env prefix
} else {
prop.Parse(fv)
}
for _, kv := range strings.Split(ft.Tag.Get("enum"), ",") { // Parse enum options from tag
kvs := strings.Split(kv, ":")
if len(kvs) != 2 {
continue
}
var tmp struct {
Value any
}
yaml.Unmarshal([]byte(fmt.Sprintf("value: %s", strings.TrimSpace(kvs[0]))), &tmp)
prop.Enum = append(prop.Enum, struct {
Label string `json:"label"`
Value any `json:"value"`
}{
Label: strings.TrimSpace(kvs[1]),
Value: tmp.Value,
})
}
}
}
}
// ParseGlobal step 2: Read global config
func (config *Config) ParseGlobal(g *Config) {
config.Global = g
if config.propsMap != nil {
for k, v := range config.propsMap {
v.ParseGlobal(g.Get(k))
}
} else {
config.Ptr.Set(g.Ptr) // If no sub-properties, copy value directly
}
}
// ParseDefaultYaml step 3: Read embedded default config
func (config *Config) ParseDefaultYaml(defaultYaml map[string]any) {
if defaultYaml == nil {
return
}
for k, v := range defaultYaml {
if config.Has(k) {
if prop := config.Get(k); prop.props != nil {
if v != nil {
prop.ParseDefaultYaml(v.(map[string]any))
}
} else {
dv := prop.assign(v)
prop.Default = dv.Interface()
if prop.Env == nil { // Only set if no env var override
prop.Ptr.Set(dv)
}
}
}
}
}
// ParseFile step 4: Read user config file
func (config *Config) ParseUserFile(conf map[string]any) {
if conf == nil {
return
}
config.File = conf
for k, v := range conf {
k = strings.ReplaceAll(k, "-", "") // Normalize key name: remove hyphens
k = strings.ReplaceAll(k, "_", "") // Normalize key name: remove underscores
k = strings.ToLower(k)
if config.Has(k) {
if prop := config.Get(k); prop.props != nil {
if v != nil {
switch vv := v.(type) {
case map[string]any:
prop.ParseUserFile(vv)
default:
// If the value is not a map (single non-struct value), assign it to the first field
prop.props[0].Ptr.Set(reflect.ValueOf(v))
}
}
} else {
fv := prop.assign(v)
if fv.IsValid() {
prop.File = fv.Interface()
if prop.Env == nil { // Only set if no env var override
prop.Ptr.Set(fv)
}
} else {
// Continue with invalid field
slog.Error("Attempted to access invalid field during config parsing: %s", v)
}
}
}
}
}
// ParseModifyFile step 5: Read dynamic modified config
func (config *Config) ParseModifyFile(conf map[string]any) {
if conf == nil {
return
}
config.Modify = conf
for k, v := range conf {
if config.Has(k) {
if prop := config.Get(k); prop.props != nil {
if v != nil {
vmap := v.(map[string]any)
prop.ParseModifyFile(vmap)
if len(vmap) == 0 { // Remove empty map
delete(conf, k)
}
}
} else {
mv := prop.assign(v)
v = mv.Interface()
vwm := prop.valueWithoutModify() // Get value without modify
if equal(vwm, v) { // No change, remove from modify
delete(conf, k)
if prop.Modify != nil {
prop.Modify = nil
prop.Ptr.Set(reflect.ValueOf(vwm))
}
continue
}
prop.Modify = v
prop.Ptr.Set(mv)
}
}
}
if len(conf) == 0 { // Clear modify if empty
config.Modify = nil
}
}
func (config *Config) valueWithoutModify() any {
// Return value with priority: Env > File > Global > Default (excluding Modify)
if config.Env != nil {
return config.Env
}
if config.File != nil {
return config.File
}
if config.Global != nil {
return config.Global.GetValue()
}
return config.Default
}
func equal(vwm, v any) bool {
switch ft := reflect.TypeOf(vwm); ft {
case regexpType:
return vwm.(Regexp).String() == v.(Regexp).String()
default:
switch ft.Kind() {
case reflect.Slice, reflect.Array, reflect.Map:
return reflect.DeepEqual(vwm, v)
}
return vwm == v
}
}
func (config *Config) GetMap() map[string]any {
// Convert config tree to map representation
m := make(map[string]any)
for k, v := range config.propsMap {
if v.props != nil { // Has sub-properties
if vv := v.GetMap(); vv != nil {
m[k] = vv
}
} else if v.GetValue() != nil { // Leaf value
m[k] = v.GetValue()
}
}
if len(m) > 0 {
return m
}
return nil
}
var regexPureNumber = regexp.MustCompile(`^\d+$`)
func unmarshal(ft reflect.Type, v any) (target reflect.Value) {
source := reflect.ValueOf(v)
// Fast path: directly return if both are basic types
for _, t := range basicTypes {
if source.Kind() == t && ft.Kind() == t {
return source
}
}
switch ft {
case durationType:
target = reflect.New(ft).Elem()
if source.Type() == durationType {
return source
} else if source.IsZero() || !source.IsValid() {
target.SetInt(0)
} else {
timeStr := source.String()
// Parse duration string, but reject pure numbers (must have unit)
if d, err := time.ParseDuration(timeStr); err == nil && !regexPureNumber.MatchString(timeStr) {
target.SetInt(int64(d))
} else {
slog.Error("invalid duration value please add unit (s,m,h,d)eg: 100ms, 10s, 4m, 1h", "value", timeStr)
os.Exit(1)
}
}
case regexpType:
target = reflect.New(ft).Elem()
regexpStr := source.String()
target.Set(reflect.ValueOf(Regexp{regexp.MustCompile(regexpStr)}))
default:
switch ft.Kind() {
case reflect.Pointer:
return unmarshal(ft.Elem(), v).Addr() // Recurse to element type
case reflect.Struct:
newStruct := reflect.New(ft)
defaults.SetDefaults(newStruct.Interface())
if value, ok := v.(map[string]any); ok {
// If the value is a map, unmarshal each field by matching keys
for i := 0; i < ft.NumField(); i++ {
key := strings.ToLower(ft.Field(i).Name)
if vv, ok := value[key]; ok {
newStruct.Elem().Field(i).Set(unmarshal(ft.Field(i).Type, vv))
}
}
} else {
// If the value is not a map (single non-struct value), assign it to the first field
newStruct.Elem().Field(0).Set(unmarshal(ft.Field(0).Type, v))
}
return newStruct.Elem()
case reflect.Map:
if v != nil {
target = reflect.MakeMap(ft)
for k, v := range v.(map[string]any) {
// Unmarshal key and value recursively
target.SetMapIndex(unmarshal(ft.Key(), k), unmarshal(ft.Elem(), v))
}
}
case reflect.Slice:
if v != nil {
s := v.([]any)
target = reflect.MakeSlice(ft, len(s), len(s))
for i, v := range s {
target.Index(i).Set(unmarshal(ft.Elem(), v)) // Unmarshal each element
}
}
default:
if v != nil {
// For unknown types, use YAML marshal/unmarshal as fallback
var out []byte
var err error
if vv, ok := v.(string); ok {
out = []byte(fmt.Sprintf("%s: %s", "value", vv))
} else {
out, err = yaml.Marshal(map[string]any{"value": v})
if err != nil {
panic(err)
}
}
// Create temporary struct with single Value field
tmpValue := reflect.New(reflect.StructOf([]reflect.StructField{
{
Name: "Value",
Type: ft,
},
}))
err = yaml.Unmarshal(out, tmpValue.Interface())
if err != nil {
panic(err)
}
return tmpValue.Elem().Field(0)
}
}
}
return
}
func (config *Config) assign(v any) reflect.Value {
// Convert value to the same type as Ptr
return unmarshal(config.Ptr.Type(), v)
}
func Parse(target any, conf map[string]any) {
var c Config
c.Parse(target)
c.ParseModifyFile(conf)
}