188 lines
6.3 KiB
Markdown
188 lines
6.3 KiB
Markdown
---
|
|
title: Architecture
|
|
description: Internal design of config -- dual-viper layering, the Medium abstraction, and the framework service wrapper.
|
|
---
|
|
|
|
# Architecture
|
|
|
|
## Design Goals
|
|
|
|
1. **Layered resolution** -- a single `Get()` call checks environment, file, and defaults without the caller needing to know which source won.
|
|
2. **Safe persistence** -- environment variables must never bleed into the saved config file.
|
|
3. **Pluggable storage** -- the file system is abstracted behind `io.Medium`, making tests deterministic and enabling future remote backends.
|
|
4. **Framework integration** -- the package satisfies the `core.Config` interface, so any Core service can consume configuration without importing this package directly.
|
|
|
|
## Key Types
|
|
|
|
### Config
|
|
|
|
```go
|
|
type Config struct {
|
|
mu sync.RWMutex
|
|
full *viper.Viper // full configuration (file + env + defaults)
|
|
file *viper.Viper // file-backed configuration only (for persistence)
|
|
medium coreio.Medium
|
|
path string
|
|
}
|
|
```
|
|
|
|
`Config` is the central type. It holds **two** Viper instances:
|
|
|
|
- **`full`** -- contains everything: file values, environment bindings, and explicit `Set()` calls. All reads go through `full`.
|
|
- **`file`** -- contains only values that originated from the config file or were explicitly set via `Set()`. All writes (`Commit()`) go through `file`.
|
|
|
|
This dual-instance design is the key architectural decision. It solves the environment leakage problem: Viper merges environment variables into its settings map, which means a naive `SaveConfig()` would serialise env vars into the YAML file. By maintaining `file` as a clean copy, `Commit()` only persists what should be persisted.
|
|
|
|
### Option
|
|
|
|
```go
|
|
type Option func(*Config)
|
|
```
|
|
|
|
Functional options configure the `Config` at creation time:
|
|
|
|
| Option | Effect |
|
|
|-------------------|----------------------------------------------------|
|
|
| `WithMedium(m)` | Sets the storage backend (defaults to `io.Local`) |
|
|
| `WithPath(path)` | Sets the config file path (defaults to `~/.core/config.yaml`) |
|
|
| `WithEnvPrefix(p)`| Changes the environment variable prefix (defaults to `CORE_CONFIG`) |
|
|
|
|
### Service
|
|
|
|
```go
|
|
type Service struct {
|
|
*core.ServiceRuntime[ServiceOptions]
|
|
config *Config
|
|
}
|
|
```
|
|
|
|
`Service` wraps `Config` as a framework-managed service. It embeds `ServiceRuntime` for typed options and implements two interfaces:
|
|
|
|
- **`core.Config`** -- `Get(key, out)` and `Set(key, v)`
|
|
- **`core.Startable`** -- `OnStartup(ctx)` triggers config file loading during the application lifecycle
|
|
|
|
The service is registered as a factory function:
|
|
|
|
```go
|
|
core.New(core.WithService(config.NewConfigService))
|
|
```
|
|
|
|
The factory receives the `*core.Core` instance, constructs the service, and returns it. The framework calls `OnStartup` at the appropriate lifecycle phase, at which point the config file is loaded.
|
|
|
|
### Env / LoadEnv
|
|
|
|
```go
|
|
func Env(prefix string) iter.Seq2[string, any]
|
|
func LoadEnv(prefix string) map[string]any // deprecated
|
|
```
|
|
|
|
`Env` returns a Go 1.23+ iterator over environment variables matching a given prefix, yielding `(dotKey, value)` pairs. The conversion logic:
|
|
|
|
1. Filter `os.Environ()` for entries starting with `prefix`
|
|
2. Strip the prefix
|
|
3. Lowercase and replace `_` with `.`
|
|
|
|
`LoadEnv` is the older materialising variant and is deprecated in favour of the iterator.
|
|
|
|
## Data Flow
|
|
|
|
### Initialisation (`New`)
|
|
|
|
```
|
|
New(opts...)
|
|
|
|
|
+-- create two viper instances (full, file)
|
|
+-- set env prefix on full ("CORE_CONFIG_" default)
|
|
+-- set env key replacer ("." <-> "_")
|
|
+-- apply functional options
|
|
+-- default medium to io.Local if nil
|
|
+-- default path to ~/.core/config.yaml if empty
|
|
+-- enable full.AutomaticEnv()
|
|
+-- if config file exists:
|
|
LoadFile(medium, path)
|
|
+-- medium.Read(path)
|
|
+-- detect config type from extension
|
|
+-- file.MergeConfig(content)
|
|
+-- full.MergeConfig(content)
|
|
```
|
|
|
|
### Read (`Get`)
|
|
|
|
```
|
|
Get(key, &out)
|
|
|
|
|
+-- RLock
|
|
+-- if key == "":
|
|
| full.Unmarshal(out) // full config into a struct
|
|
+-- else:
|
|
| full.IsSet(key)?
|
|
| yes -> full.UnmarshalKey(key, out)
|
|
| no -> error "key not found"
|
|
+-- RUnlock
|
|
```
|
|
|
|
Because `full` has `AutomaticEnv()` enabled, `full.IsSet(key)` returns true if the key exists in the file **or** as a `CORE_CONFIG_*` environment variable.
|
|
|
|
### Write (`Set` + `Commit`)
|
|
|
|
```
|
|
Set(key, value)
|
|
|
|
|
+-- Lock
|
|
+-- file.Set(key, value) // track for persistence
|
|
+-- full.Set(key, value) // make visible to Get()
|
|
+-- Unlock
|
|
|
|
Commit()
|
|
|
|
|
+-- Lock
|
|
+-- Save(medium, path, file.AllSettings())
|
|
| +-- yaml.Marshal(data)
|
|
| +-- medium.EnsureDir(dir)
|
|
| +-- medium.Write(path, content)
|
|
+-- Unlock
|
|
```
|
|
|
|
Note that `Commit()` serialises `file.AllSettings()`, not `full.AllSettings()`. This is intentional -- it prevents environment variable values from being written to the config file.
|
|
|
|
### File Loading (`LoadFile`)
|
|
|
|
`LoadFile` supports YAML, JSON, TOML, and `.env` files. The config type is inferred from the file extension:
|
|
|
|
- `.yaml`, `.yml` -- YAML
|
|
- `.json` -- JSON
|
|
- `.toml` -- TOML
|
|
- `.env` (no extension, basename `.env`) -- dotenv format
|
|
|
|
Content is merged (not replaced) into both `full` and `file` via `MergeConfig`, so multiple files can be layered.
|
|
|
|
## Concurrency
|
|
|
|
All public methods on `Config` and `Service` are safe for concurrent use. A `sync.RWMutex` protects the internal state:
|
|
|
|
- `Get`, `All` take a read lock
|
|
- `Set`, `Commit`, `LoadFile` take a write lock
|
|
|
|
## The Medium Abstraction
|
|
|
|
File operations go through `io.Medium` (from `forge.lthn.ai/core/go-io`), not `os` directly. This means:
|
|
|
|
- **Tests** use `io.NewMockMedium()` -- an in-memory filesystem with a `Files` map
|
|
- **Production** uses `io.Local` -- the real local filesystem
|
|
- **Future** backends (S3, embedded assets) can implement `Medium` without changing config code
|
|
|
|
## Compile-Time Interface Checks
|
|
|
|
The package includes two compile-time assertions at the bottom of the respective files:
|
|
|
|
```go
|
|
var _ core.Config = (*Config)(nil) // config.go
|
|
var _ core.Config = (*Service)(nil) // service.go
|
|
var _ core.Startable = (*Service)(nil) // service.go
|
|
```
|
|
|
|
These ensure that if the `core.Config` or `core.Startable` interfaces ever change, this package will fail to compile rather than fail at runtime.
|
|
|
|
## Licence
|
|
|
|
EUPL-1.2
|