Skip to content

Commit

Permalink
clean up logger (#87)
Browse files Browse the repository at this point in the history
  • Loading branch information
ktong authored Feb 2, 2024
1 parent a6cdf44 commit debdd69
Show file tree
Hide file tree
Showing 14 changed files with 358 additions and 397 deletions.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ configuration source(s) (implementation) it actually wants to use. Something lik
// 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 {
// Load configuration from embed file system.
if err := config.Load(fs.New(config, "config/config.json")); err != nil {
// Handle error here.
}
// Load configuration from environment variables.
if err := config.Load(env.New(env.WithPrefix("server"))); err != nil {
// Handle error here.
}
Expand Down
242 changes: 34 additions & 208 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,11 @@
package konf

import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"sync"
"time"

"github.com/go-viper/mapstructure/v2"

Expand Down Expand Up @@ -52,7 +49,6 @@ func New(opts ...Option) *Config {
if option.logger == nil {
option.logger = slog.Default()
}
option.logger = option.logger.WithGroup("konf")
if option.delimiter == "" {
option.delimiter = "."
}
Expand All @@ -70,173 +66,54 @@ func New(opts ...Option) *Config {
return (*Config)(option)
}

// Load loads configuration from the given loaders.
// Load loads configuration from the given loader.
// Each loader takes precedence over the loaders before it.
//
// This method can be called multiple times but it is not concurrency-safe.
// It panics if any loader is nil.
func (c *Config) Load(loaders ...Loader) error {
for i, loader := range loaders {
if loader == nil {
panic(fmt.Sprintf("cannot load config from nil loader at loaders[%d]", i))
}

values, err := loader.Load()
if err != nil {
return fmt.Errorf("load configuration: %w", err)
}
maps.Merge(c.values, values)

provider := &provider{
loader: loader,
values: make(map[string]any),
}
// Merged to empty map to convert to lower case.
maps.Merge(provider.values, values)
c.providers = append(c.providers, provider)
}

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.
// It panics if ctx is nil.
func (c *Config) Watch(ctx context.Context) error { //nolint:cyclop,funlen,gocognit
if ctx == nil {
panic("cannot watch change with nil context")
// It panics if loader is nil.
func (c *Config) Load(loader Loader) error {
if loader == nil {
panic("cannot load config from nil loader")
}

if hasWatcher := slices.ContainsFunc(c.providers, func(provider *provider) bool {
_, ok := provider.loader.(Watcher)

return ok
}); !hasWatcher {
return nil
values, err := loader.Load()
if err != nil {
return fmt.Errorf("load configuration: %w", err)
}
maps.Merge(c.values, values)

watched := true
c.watchOnce.Do(func() {
watched = false
})
if watched {
c.logger.Warn("Config has been watched, call Watch again has no effects.")

return nil
provider := &provider{
loader: loader,
values: make(map[string]any),
}
// Merged to empty map to convert to lower case.
maps.Merge(provider.values, values)
c.providers = append(c.providers, provider)

onChangesChannel := make(chan []func(*Config))
defer close(onChangesChannel)
ctx, cancel := context.WithCancel(ctx)
defer cancel()

var waitGroup sync.WaitGroup
waitGroup.Add(1)
go func() {
defer waitGroup.Done()

for {
select {
case onChanges := <-onChangesChannel:
values := make(map[string]any)
for _, w := range c.providers {
maps.Merge(values, w.values)
}
c.values = values
c.logger.DebugContext(ctx, "Configuration has been updated with change.")

if len(onChanges) > 0 {
func() {
ctx, cancel = context.WithTimeout(ctx, time.Minute)
defer cancel()

done := make(chan struct{})
go func() {
defer close(done)

for _, onChange := range onChanges {
onChange(c)
}
}()

select {
case <-done:
c.logger.DebugContext(ctx, "Configuration has been applied to onChanges.")
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
c.logger.WarnContext(ctx, "Configuration has not been fully applied to onChanges due to timeout."+
" Please check if the onChanges is blocking or takes too long to complete.")
}
}
}()
}

case <-ctx.Done():
return
}
}
}()

errChan := make(chan error, len(c.providers))
for _, provider := range c.providers {
provider := provider

if watcher, ok := provider.loader.(Watcher); ok {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()

onChange := func(values map[string]any) {
// Merged to empty map to convert to lower case.
newValues := make(map[string]any)
maps.Merge(newValues, values)

oldValues := provider.values
provider.values = newValues

// Find the onChanges should be triggered.
onChanges := func() []func(*Config) {
c.onChangesMutex.RLock()
defer c.onChangesMutex.RUnlock()

var callbacks []func(*Config)
for path, onChanges := range c.onChanges {
keys := strings.Split(path, c.delimiter)
if sub(oldValues, keys) != nil || sub(newValues, keys) != nil {
callbacks = append(callbacks, onChanges...)
}
}

return callbacks
}
onChangesChannel <- onChanges()

c.logger.Info(
"Configuration has been changed.",
"loader", watcher,
)
}
return nil
}

c.logger.DebugContext(ctx, "Watching configuration change.", "loader", watcher)
if err := watcher.Watch(ctx, onChange); err != nil {
errChan <- fmt.Errorf("watch configuration change: %w", err)
cancel()
}
}()
}
// Unmarshal reads configuration under the given path from the Config
// and decodes it into the given object pointed to by target.
// The path is case-insensitive.
func (c *Config) Unmarshal(path string, target any) error {
decoder, err := mapstructure.NewDecoder(
&mapstructure.DecoderConfig{
Result: target,
WeaklyTypedInput: true,
DecodeHook: c.decodeHook,
TagName: c.tagName,
},
)
if err != nil {
return fmt.Errorf("new decoder: %w", err)
}
waitGroup.Wait()
close(errChan)

var err error
for e := range errChan {
err = errors.Join(e)
if err := decoder.Decode(sub(c.values, strings.Split(strings.ToLower(path), c.delimiter))); err != nil {
return fmt.Errorf("decode: %w", err)
}

return err
return nil
}

func sub(values map[string]any, keys []string) any {
Expand All @@ -261,57 +138,6 @@ func sub(values map[string]any, keys []string) any {
return next
}

// OnChange registers a callback function that is executed
// when the value of any given path in the Config changes.
// It requires Config.Watch has been called first.
// The paths are case-insensitive.
//
// The onChange function must be non-blocking and usually completes instantly.
// If it requires a long time to complete, it should be executed in a separate goroutine.
//
// This method is concurrency-safe.
// It panics if onChange is nil.
func (c *Config) OnChange(onChange func(*Config), paths ...string) {
if onChange == nil {
panic("cannot register nil onChange")
}

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)
}
}

// Unmarshal reads configuration under the given path from the Config
// and decodes it into the given object pointed to by target.
// The path is case-insensitive.
func (c *Config) Unmarshal(path string, target any) error {
decoder, err := mapstructure.NewDecoder(
&mapstructure.DecoderConfig{
Result: target,
WeaklyTypedInput: true,
DecodeHook: c.decodeHook,
TagName: c.tagName,
},
)
if err != nil {
return fmt.Errorf("new decoder: %w", err)
}

if err := decoder.Decode(sub(c.values, strings.Split(strings.ToLower(path), c.delimiter))); err != nil {
return fmt.Errorf("decode: %w", err)
}

return nil
}

// Explain provides information about how Config resolve each value
// from loaders for the given path.
// The path is case-insensitive.
Expand Down
Loading

0 comments on commit debdd69

Please sign in to comment.