diff --git a/CHANGELOG.md b/CHANGELOG.md index 593d9eae..fbd5cc26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed -- Split file and fs provider (#49). -- Split Watch and OnChange for watching configuration changes (#52). - -### Removed - -- Remove konf.Logger in favor of slog (#48). +- [BREAKING] Redesign API. ## [v0.2.0] - 3/18/2023 diff --git a/README.md b/README.md index e5013767..44f0d6fb 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ It defers the actual configuration loading to the `Loader` interface. The `Loader` and `Watcher` interface is intended for configuration source library implementers. They are pure interfaces which can be implemented to provide the actual configuration. -This decoupling allows application developers to write code in terms of `konf.Config` +This decoupling allows application developers to write code in terms of `*konf.Config` while the configuration source(s) is managed "up stack" (e.g. in or near `main()`). Application developers can then switch configuration sources(s) as necessary. @@ -29,15 +29,14 @@ configuration source(s) (implementation) it actually wants to use. Something lik var config embed.FS func main() { - // Create the global Config that loads configuration - // from embed file system and environment variables. - config, err := konf.New( - konf.WithLoader( - fs.New(config, "config/config.json"), - env.New(env.WithPrefix("server")), - ), - ) - if err != nil { + // Create the Config. + config := konf.New() + + // Load configuration from embed file system and environment variables. + if err := config.Load( + fs.New(config, "config/config.json"), + env.New(env.WithPrefix("server")), + ); err != nil { // Handle error here. } diff --git a/benchmark_test.go b/benchmark_test.go index 3f78f4c6..adce201f 100644 --- a/benchmark_test.go +++ b/benchmark_test.go @@ -12,11 +12,12 @@ import ( func BenchmarkNew(b *testing.B) { var ( - config konf.Config + config *konf.Config err error ) for i := 0; i < b.N; i++ { - config, err = konf.New(konf.WithLoader(mapLoader{"k": "v"})) + config = konf.New() + err = config.Load(mapLoader{"k": "v"}) } b.StopTimer() @@ -26,7 +27,8 @@ func BenchmarkNew(b *testing.B) { } func BenchmarkGet(b *testing.B) { - config, err := konf.New(konf.WithLoader(mapLoader{"k": "v"})) + config := konf.New() + err := config.Load(mapLoader{"k": "v"}) assert.NoError(b, err) konf.SetGlobal(config) b.ResetTimer() @@ -41,7 +43,8 @@ func BenchmarkGet(b *testing.B) { } func BenchmarkUnmarshal(b *testing.B) { - config, err := konf.New(konf.WithLoader(mapLoader{"k": "v"})) + config := konf.New() + err := config.Load(mapLoader{"k": "v"}) assert.NoError(b, err) konf.SetGlobal(config) b.ResetTimer() diff --git a/config.go b/config.go index c3b0a54c..8fe47f2f 100644 --- a/config.go +++ b/config.go @@ -6,6 +6,7 @@ package konf import ( "context" "encoding" + "errors" "fmt" "log/slog" "reflect" @@ -23,44 +24,57 @@ type Config struct { delimiter string tagName string - onChanges *onChanges - values *provider + values map[string]any providers []*provider + + onChanges map[string][]func(*Config) + onChangesChannel chan []func(*Config) + onChangesMutex sync.RWMutex + + watchOnce sync.Once } -type Unmarshaler interface { - Unmarshal(path string, target any) error +type provider struct { + values map[string]any + watcher Watcher } // New returns a Config with the given Option(s). -func New(opts ...Option) (Config, error) { +func New(opts ...Option) *Config { option := &options{ - Config: Config{ - delimiter: ".", - tagName: "konf", - decodeHook: mapstructure.ComposeDecodeHookFunc( - mapstructure.StringToTimeDurationHookFunc(), - textUnmarshalerHookFunc(), - ), - }, + delimiter: ".", + tagName: "konf", + decodeHook: mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToTimeDurationHookFunc(), + textUnmarshalerHookFunc(), + ), + values: make(map[string]any), + onChanges: make(map[string][]func(*Config)), } for _, opt := range opts { opt(option) } - option.values = &provider{values: make(map[string]any)} - option.providers = make([]*provider, 0, len(option.loaders)) - option.onChanges = &onChanges{onChanges: make(map[string][]func(Unmarshaler))} - for _, loader := range option.loaders { + return (*Config)(option) +} + +// Load loads configuration from given loaders. +// +// Each loader takes precedence over the loaders before it +// while multiple loaders are specified. +// +// This method can be called multiple times but it is not concurrency-safe. +func (c *Config) Load(loaders ...Loader) error { + for _, loader := range loaders { if loader == nil { continue } values, err := loader.Load() if err != nil { - return Config{}, fmt.Errorf("[konf] load configuration: %w", err) + return fmt.Errorf("load configuration: %w", err) } - maps.Merge(option.values.values, values) + maps.Merge(c.values, values) slog.Info( "Configuration has been loaded.", "loader", loader, @@ -72,81 +86,44 @@ func New(opts ...Option) (Config, error) { if w, ok := loader.(Watcher); ok { provider.watcher = w } - option.providers = append(option.providers, provider) + c.providers = append(c.providers, provider) } - return option.Config, nil + return nil } // Watch watches and updates configuration when it changes. // It blocks until ctx is done, or the service returns an error. +// WARNING: All loaders passed in Load after calling Watch do not get watched. // // It only can be called once. Call after first has no effects. -func (c Config) Watch(ctx context.Context) error { //nolint:cyclop,funlen - changeChan := make(chan []func(Unmarshaler)) - defer close(changeChan) - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - var ( - firstErr error - errOnce sync.Once - waitGroup sync.WaitGroup - hasWatcher bool - ) - for _, p := range c.providers { - if p.watcher != nil { - watcher := p - - watcher.watchOnce.Do(func() { - hasWatcher = true - - waitGroup.Add(1) - go func() { - defer waitGroup.Done() - - onChange := func(values map[string]any) { - slog.Info( - "Configuration has been changed.", - "watcher", watcher.watcher, - ) - - // Find the onChanges should be triggered. - oldValues := &provider{values: watcher.values} - newValues := &provider{values: values} - onChanges := c.onChanges.filter(func(path string) bool { - return oldValues.sub(path, c.delimiter) != nil || newValues.sub(path, c.delimiter) != nil - }) - watcher.values = values - changeChan <- onChanges - } - if err := watcher.watcher.Watch(ctx, onChange); err != nil { - errOnce.Do(func() { - firstErr = fmt.Errorf("[konf] watch configuration change: %w", err) - cancel() - }) - } - }() - }) - } - } - - if !hasWatcher { +func (c *Config) Watch(ctx context.Context) error { //nolint:cyclop,funlen,gocognit + initialized := true + c.watchOnce.Do(func() { + initialized = false + }) + if initialized { return nil } + c.onChangesChannel = make(chan []func(*Config)) + defer close(c.onChangesChannel) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var waitGroup sync.WaitGroup waitGroup.Add(1) go func() { defer waitGroup.Done() for { select { - case onChanges := <-changeChan: + case onChanges := <-c.onChangesChannel: values := make(map[string]any) for _, w := range c.providers { maps.Merge(values, w.values) } - c.values.values = values + c.values = values for _, onChange := range onChanges { onChange(c) @@ -157,24 +134,66 @@ func (c Config) Watch(ctx context.Context) error { //nolint:cyclop,funlen } } }() + + errChan := make(chan error, len(c.providers)) + for _, provider := range c.providers { + if provider.watcher != nil { + provider := provider + + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + + onChange := func(values map[string]any) { + slog.Info( + "Configuration has been changed.", + "provider", provider.watcher, + ) + + // Find the onChanges should be triggered. + oldValues := provider.values + provider.values = values + + onChanges := func() []func(*Config) { + c.onChangesMutex.RLock() + defer c.onChangesMutex.RUnlock() + + var callbacks []func(*Config) + for path, onChanges := range c.onChanges { + if sub(oldValues, path, c.delimiter) != nil || sub(values, path, c.delimiter) != nil { + callbacks = append(callbacks, onChanges...) + } + } + + return callbacks + } + c.onChangesChannel <- onChanges() + } + if err := provider.watcher.Watch(ctx, onChange); err != nil { + errChan <- fmt.Errorf("watch configuration change: %w", err) + cancel() + } + }() + } + } waitGroup.Wait() + close(errChan) - return firstErr -} + var err error + for e := range errChan { + err = errors.Join(e) + } -type provider struct { - values map[string]any - watcher Watcher - watchOnce sync.Once + return err } -func (p *provider) sub(path string, delimiter string) any { +func sub(values map[string]any, path string, delimiter string) any { if path == "" { - return p.values + return values } - var next any = p.values - for _, key := range strings.Split(strings.ToLower(path), delimiter) { + var next any = values + for _, key := range strings.Split(path, delimiter) { mp, ok := next.(map[string]any) if !ok { return nil @@ -192,49 +211,30 @@ func (p *provider) sub(path string, delimiter string) any { // OnChange executes the given onChange function while the value of any given path // (or any value is no paths) have been changed. -// // It requires Config.Watch has been called. -func (c Config) OnChange(onchange func(Unmarshaler), paths ...string) { - c.onChanges.append(onchange, paths) -} - -type onChanges struct { - onChanges map[string][]func(Unmarshaler) - mutex sync.RWMutex -} - -func (c *onChanges) append(onchange func(Unmarshaler), paths []string) { - c.mutex.Lock() - defer c.mutex.Unlock() +// +// The paths are case-insensitive. +// +// This method is concurrency-safe. +func (c *Config) OnChange(onchange func(*Config), paths ...string) { + c.onChangesMutex.Lock() + defer c.onChangesMutex.Unlock() if len(paths) == 0 { paths = []string{""} } for _, path := range paths { + path = strings.ToLower(path) c.onChanges[path] = append(c.onChanges[path], onchange) } } -func (c *onChanges) filter(predict func(string) bool) []func(Unmarshaler) { - c.mutex.RLock() - defer c.mutex.RUnlock() - - var callbacks []func(Unmarshaler) - for path, onChanges := range c.onChanges { - if predict(path) { - callbacks = append(callbacks, onChanges...) - } - } - - return callbacks -} - // Unmarshal loads configuration under the given path into the given object // pointed to by target. It supports [konf] tags on struct fields for customized field name. // // The path is case-insensitive. -func (c Config) Unmarshal(path string, target any) error { +func (c *Config) Unmarshal(path string, target any) error { decoder, err := mapstructure.NewDecoder( &mapstructure.DecoderConfig{ Result: target, @@ -244,11 +244,11 @@ func (c Config) Unmarshal(path string, target any) error { }, ) if err != nil { - return fmt.Errorf("[konf] new decoder: %w", err) + return fmt.Errorf("new decoder: %w", err) } - if err := decoder.Decode(c.values.sub(path, c.delimiter)); err != nil { - return fmt.Errorf("[konf] decode: %w", err) + if err := decoder.Decode(sub(c.values, strings.ToLower(path), c.delimiter)); err != nil { + return fmt.Errorf("decode: %w", err) } return nil diff --git a/config_test.go b/config_test.go index 44cda7ba..773a224f 100644 --- a/config_test.go +++ b/config_test.go @@ -20,11 +20,12 @@ func TestConfig_Unmarshal(t *testing.T) { testcases := []struct { description string opts []konf.Option - assert func(konf.Config) + loaders []konf.Loader + assert func(*konf.Config) }{ { description: "empty values", - assert: func(config konf.Config) { + assert: func(config *konf.Config) { var value string assert.NoError(t, config.Unmarshal("config", &value)) assert.Equal(t, "", value) @@ -32,8 +33,9 @@ func TestConfig_Unmarshal(t *testing.T) { }, { description: "nil loader", - opts: []konf.Option{konf.WithLoader(nil)}, - assert: func(config konf.Config) { + opts: []konf.Option{}, + loaders: []konf.Loader{nil}, + assert: func(config *konf.Config) { var value string assert.NoError(t, config.Unmarshal("config", &value)) assert.Equal(t, "", value) @@ -41,8 +43,8 @@ func TestConfig_Unmarshal(t *testing.T) { }, { description: "for primary type", - opts: []konf.Option{konf.WithLoader(mapLoader{"config": "string"})}, - assert: func(config konf.Config) { + loaders: []konf.Loader{mapLoader{"config": "string"}}, + assert: func(config *konf.Config) { var value string assert.NoError(t, config.Unmarshal("config", &value)) assert.Equal(t, "string", value) @@ -50,8 +52,8 @@ func TestConfig_Unmarshal(t *testing.T) { }, { description: "config for struct", - opts: []konf.Option{konf.WithLoader(mapLoader{"config": "struct"})}, - assert: func(config konf.Config) { + loaders: []konf.Loader{mapLoader{"config": "struct"}}, + assert: func(config *konf.Config) { var value struct { Config string } @@ -61,16 +63,14 @@ func TestConfig_Unmarshal(t *testing.T) { }, { description: "default delimiter", - opts: []konf.Option{ - konf.WithLoader( - mapLoader{ - "config": map[string]any{ - "nest": "string", - }, + loaders: []konf.Loader{ + mapLoader{ + "config": map[string]any{ + "nest": "string", }, - ), + }, }, - assert: func(config konf.Config) { + assert: func(config *konf.Config) { var value string assert.NoError(t, config.Unmarshal("config.nest", &value)) assert.Equal(t, "string", value) @@ -80,15 +80,15 @@ func TestConfig_Unmarshal(t *testing.T) { description: "customized delimiter", opts: []konf.Option{ konf.WithDelimiter("_"), - konf.WithLoader( - mapLoader{ - "config": map[string]any{ - "nest": "string", - }, + }, + loaders: []konf.Loader{ + mapLoader{ + "config": map[string]any{ + "nest": "string", }, - ), + }, }, - assert: func(config konf.Config) { + assert: func(config *konf.Config) { var value string assert.NoError(t, config.Unmarshal("config_nest", &value)) assert.Equal(t, "string", value) @@ -96,16 +96,14 @@ func TestConfig_Unmarshal(t *testing.T) { }, { description: "non string key", - opts: []konf.Option{ - konf.WithLoader( - mapLoader{ - "config": map[int]any{ - 1: "string", - }, + loaders: []konf.Loader{ + mapLoader{ + "config": map[int]any{ + 1: "string", }, - ), + }, }, - assert: func(config konf.Config) { + assert: func(config *konf.Config) { var value string assert.NoError(t, config.Unmarshal("config.nest", &value)) assert.Equal(t, "", value) @@ -119,7 +117,8 @@ func TestConfig_Unmarshal(t *testing.T) { t.Run(testcase.description, func(t *testing.T) { t.Parallel() - config, err := konf.New(testcase.opts...) + config := konf.New(testcase.opts...) + err := config.Load(testcase.loaders...) assert.NoError(t, err) testcase.assert(config) }) @@ -135,8 +134,9 @@ func (m mapLoader) Load() (map[string]any, error) { func TestConfig_Watch(t *testing.T) { t.Parallel() + config := konf.New() watcher := mapWatcher(make(chan map[string]any)) - config, err := konf.New(konf.WithLoader(watcher)) + err := config.Load(watcher) assert.NoError(t, err) var value string @@ -150,7 +150,7 @@ func TestConfig_Watch(t *testing.T) { }() var newValue atomic.Value - config.OnChange(func(unmarshaler konf.Unmarshaler) { + config.OnChange(func(config *konf.Config) { var value string assert.NoError(t, config.Unmarshal("config", &value)) newValue.Store(value) @@ -185,13 +185,14 @@ func (m mapWatcher) change(values map[string]any) { func TestConfig_Watch_error(t *testing.T) { t.Parallel() - config, err := konf.New(konf.WithLoader(errorWatcher{})) + config := konf.New() + err := config.Load(errorWatcher{}) assert.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - assert.EqualError(t, config.Watch(ctx), "[konf] watch configuration change: watch error") + assert.EqualError(t, config.Watch(ctx), "watch configuration change: watch error") } type errorWatcher struct{} @@ -207,8 +208,9 @@ func (errorWatcher) Watch(context.Context, func(map[string]any)) error { func TestConfig_error(t *testing.T) { t.Parallel() - _, err := konf.New(konf.WithLoader(errorLoader{})) - assert.EqualError(t, err, "[konf] load configuration: load error") + config := konf.New() + err := config.Load(errorLoader{}) + assert.EqualError(t, err, "load configuration: load error") } type errorLoader struct{} diff --git a/example_test.go b/example_test.go index 86b87c34..4ced0f91 100644 --- a/example_test.go +++ b/example_test.go @@ -42,16 +42,15 @@ func ExampleUnmarshal() { var testdata embed.FS func ExampleSetGlobal() { - cfg, err := konf.New( - konf.WithLoader( - kfs.New(testdata, "testdata/config.json"), - env.New(env.WithPrefix("server")), - ), + config := konf.New() + err := config.Load( + kfs.New(testdata, "testdata/config.json"), + env.New(env.WithPrefix("server")), ) if err != nil { // Handle error here. panic(err) } - konf.SetGlobal(cfg) + konf.SetGlobal(config) // Output: } diff --git a/global.go b/global.go index a28a2992..a33fff06 100644 --- a/global.go +++ b/global.go @@ -42,22 +42,25 @@ func Unmarshal(path string, target any) error { // // It requires Watch has been called. func OnChange(onChange func(), paths ...string) { - getGlobal().OnChange(func(Unmarshaler) { onChange() }, paths...) + getGlobal().OnChange(func(*Config) { onChange() }, paths...) } // SetGlobal makes config as the global Config. After this call, // the konf package's functions (e.g. konf.Get) will read from the global config. -// This method is not concurrency-safe. // // The default global config only loads configuration from environment variables. -func SetGlobal(config Config) { +// +// This method can be called multiple times but it is not concurrency-safe. +func SetGlobal(config *Config) { global = config } -func getGlobal() Config { +func getGlobal() *Config { globalOnce.Do(func() { - if reflect.ValueOf(global).IsZero() { - global, _ = New(WithLoader(env.New())) + if global == nil { + global = New() + // It's safe to ignore error here since env loader does not return error. + _ = global.Load(env.New()) } }) @@ -66,6 +69,6 @@ func getGlobal() Config { //nolint:gochecknoglobals var ( - global Config + global *Config globalOnce sync.Once ) diff --git a/global_test.go b/global_test.go index 943fb35b..4e443dea 100644 --- a/global_test.go +++ b/global_test.go @@ -17,9 +17,10 @@ import ( func TestUnmarshal(t *testing.T) { t.Parallel() - cfg, err := konf.New(konf.WithLoader(mapLoader{"config": "string"})) + config := konf.New() + err := config.Load(mapLoader{"config": "string"}) assert.NoError(t, err) - konf.SetGlobal(cfg) + konf.SetGlobal(config) var v string assert.NoError(t, konf.Unmarshal("config", &v)) @@ -29,17 +30,19 @@ func TestUnmarshal(t *testing.T) { func TestGet(t *testing.T) { t.Parallel() - cfg, err := konf.New(konf.WithLoader(mapLoader{"config": "string"})) + config := konf.New() + err := config.Load(mapLoader{"config": "string"}) assert.NoError(t, err) - konf.SetGlobal(cfg) + konf.SetGlobal(config) assert.Equal(t, "string", konf.Get[string]("config")) } func TestGet_error(t *testing.T) { - cfg, err := konf.New(konf.WithLoader(mapLoader{"config": "string"})) + config := konf.New() + err := config.Load(mapLoader{"config": "string"}) assert.NoError(t, err) - konf.SetGlobal(cfg) + konf.SetGlobal(config) buf := new(bytes.Buffer) log.SetOutput(buf) @@ -47,7 +50,7 @@ func TestGet_error(t *testing.T) { assert.True(t, !konf.Get[bool]("config")) expected := "ERROR Could not read config, return empty value instead." + - " error=\"[konf] decode: cannot parse '' as bool: strconv.ParseBool: parsing \\\"string\\\": invalid syntax\"" + + " error=\"decode: cannot parse '' as bool: strconv.ParseBool: parsing \\\"string\\\": invalid syntax\"" + " path=config type=bool\n" assert.Equal(t, expected, buf.String()) } diff --git a/option.go b/option.go index 1a22d128..15cfc02a 100644 --- a/option.go +++ b/option.go @@ -5,16 +5,6 @@ package konf import "github.com/mitchellh/mapstructure" -// WithLoader provides the loaders that configuration is loaded from. -// -// Each loader takes precedence over the loaders before it -// while multiple loaders are specified. -func WithLoader(loaders ...Loader) Option { - return func(options *options) { - options.loaders = append(options.loaders, loaders...) - } -} - // WithDelimiter provides the delimiter when specifying config path. // // The default delimiter is `.`, which makes config path like `parent.child.key`. @@ -43,8 +33,4 @@ func WithDecodeHook(decodeHook mapstructure.DecodeHookFunc) Option { // Option configures the given Config. type Option func(*options) -type options struct { - Config - - loaders []Loader -} +type options Config