feat: config and config loader implemented

This commit is contained in:
2026-10-10 10:36:18 +03:30
parent b234bd9108
commit d73090946b
7 changed files with 186 additions and 8 deletions
Generated
+1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/references/paho.golang" vcs="Git" /> <mapping directory="$PROJECT_DIR$/references/paho.golang" vcs="Git" />
<mapping directory="$PROJECT_DIR$/references/paho.golang-v0.23.0" vcs="Git" /> <mapping directory="$PROJECT_DIR$/references/paho.golang-v0.23.0" vcs="Git" />
</component> </component>
BIN
View File
Binary file not shown.
+6 -8
View File
@@ -53,14 +53,12 @@ func run() error {
OnConnectError: func(err error) { OnConnectError: func(err error) {
log.Printf("connect attempt failed: %v", err) log.Printf("connect attempt failed: %v", err)
}, },
ClientConfig: paho.ClientConfig{ ClientID: "lab-backend",
ClientID: "lab-backend", OnClientError: func(err error) {
OnClientError: func(err error) { log.Printf("client error: %v", err)
log.Printf("client error: %v", err) },
}, OnServerDisconnect: func(packet *paho.Disconnect) {
OnServerDisconnect: func(packet *paho.Disconnect) { log.Printf("server disconnected: reason=0x%02x", packet.ReasonCode)
log.Printf("server disconnected: reason=0x%02x", packet.ReasonCode)
},
}, },
} }
+8
View File
@@ -0,0 +1,8 @@
[mqtt]
url = "mqtt://127.0.0.1:18883"
client_id = "lab-backend"
username = "backend"
device_id = "device-001"
[app]
connect_timeout = "10s"
+11
View File
@@ -5,6 +5,17 @@ go 1.27.1
require github.com/eclipse/paho.golang v0.23.0 require github.com/eclipse/paho.golang v0.23.0
require ( require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/knadh/koanf/parsers/toml/v2 v2.2.0 // indirect
github.com/knadh/koanf/providers/confmap v1.0.0 // indirect
github.com/knadh/koanf/providers/file v1.2.0 // indirect
github.com/knadh/koanf/v2 v2.3.7 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
golang.org/x/net v0.43.0 // indirect golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.35.0 // indirect
) )
+22
View File
@@ -2,10 +2,30 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk= github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04= github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/toml/v2 v2.2.0 h1:2nV7tHYJ5OZy2BynQ4mOJ6k5bDqbbCzRERLUKBytz3A=
github.com/knadh/koanf/parsers/toml/v2 v2.2.0/go.mod h1:JpjTeK1Ge1hVX0wbof5DMCuDBriR8bWgeQP98eeOZpI=
github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE=
github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U=
github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/v2 v2.3.7 h1:amceufOeoQcq6VFKjm7/ggJ3t0Dkqaxy5fza4j3YgTA=
github.com/knadh/koanf/v2 v2.3.7/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -14,5 +34,7 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+138
View File
@@ -0,0 +1,138 @@
package config
import (
"flag"
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/knadh/koanf/parsers/toml/v2"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)
type Config struct {
MQTT struct {
URL string `koanf:"url"`
ClientID string `koanf:"client_id"`
Username string `koanf:"username"`
Password string `koanf:"password"`
DeviceID string `koanf:"device_id"`
} `koanf:"mqtt"`
App struct {
ConnectTimeout string `koanf:"connect_timeout"`
} `koanf:"app"`
}
// Keep duration input as text; validate/parse it explicitly after merging.
func (c Config) ConnectTimeout() time.Duration {
d, _ := time.ParseDuration(c.App.ConnectTimeout) // Load already validated it.
return d
}
func Load(args []string) (Config, error) {
var result Config
flags := flag.NewFlagSet("mqtt5-learning", flag.ContinueOnError)
filename := flags.String("config", "configs/lab.toml", "TOML file")
broker := flags.String("broker", "", "broker URL override")
clientID := flags.String("client-id", "", "client ID override")
username := flags.String("username", "", "lab username override")
deviceID := flags.String("device-id", "", "device identity override")
timeout := flags.String("connect-timeout", "", "startup deadline override")
if err := flags.Parse(args); err != nil {
return result, err
}
if flags.NArg() != 0 {
return result, fmt.Errorf("unexpected positional arguments")
}
k := koanf.New(".")
defaults := map[string]any{
"mqtt.url": "mqtt://127.0.0.1:18883",
"mqtt.client_id": "lab-backend",
"mqtt.username": "", "mqtt.password": "",
"mqtt.device_id": "device-001",
"app.connect_timeout": "10s",
}
if err := k.Load(confmap.Provider(defaults, "."), nil); err != nil {
return result, fmt.Errorf("defaults: %w", err)
}
// A requested/missing config file is an error, not a silent fallback.
if err := k.Load(file.Provider(*filename), toml.Parser()); err != nil {
return result, fmt.Errorf("TOML: %w", err)
}
// Explicit mapping preserves underscores inside names such as client_id.
mapping := map[string]string{
"TEST_MQTT5_MQTT_URL": "mqtt.url",
"TEST_MQTT5_MQTT_CLIENT_ID": "mqtt.client_id",
"TEST_MQTT5_MQTT_USERNAME": "mqtt.username",
"TEST_MQTT5_MQTT_PASSWORD": "mqtt.password",
"TEST_MQTT5_MQTT_DEVICE_ID": "mqtt.device_id",
"TEST_MQTT5_APP_CONNECT_TIMEOUT": "app.connect_timeout",
}
for name, key := range mapping {
if value, exists := os.LookupEnv(name); exists {
if err := k.Set(key, value); err != nil {
return result, err
}
}
}
overrides := map[string]string{}
flags.Visit(func(f *flag.Flag) {
switch f.Name {
case "broker":
overrides["mqtt.url"] = *broker
case "client-id":
overrides["mqtt.client_id"] = *clientID
case "username":
overrides["mqtt.username"] = *username
case "device-id":
overrides["mqtt.device_id"] = *deviceID
case "connect-timeout":
overrides["app.connect_timeout"] = *timeout
}
})
for key, value := range overrides {
if err := k.Set(key, value); err != nil {
return result, err
}
}
if err := k.Unmarshal("", &result); err != nil {
return result, fmt.Errorf("decode configuration: %w", err)
}
if err := result.Validate(); err != nil {
return result, err
}
return result, nil
}
func (c Config) Validate() error {
u, err := url.Parse(c.MQTT.URL)
if err != nil || u == nil || u.Hostname() == "" || u.Port() == "" {
return fmt.Errorf("mqtt.url must contain a valid host and explicit port")
}
// Early lab only. TLS configuration is deliberately introduced later.
if u.Scheme != "mqtt" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
return fmt.Errorf("early lab requires mqtt://host:port without credentials or extra URL components")
}
if u.Hostname() != "127.0.0.1" && u.Hostname() != "localhost" {
return fmt.Errorf("plaintext learning broker must be on loopback")
}
for _, value := range []string{c.MQTT.ClientID, c.MQTT.Username, c.MQTT.Password} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("client ID and credentials are required")
}
}
if c.MQTT.DeviceID == "" || strings.ContainsAny(c.MQTT.DeviceID, "/+# \t\r\n") {
return fmt.Errorf("device ID must be a nonempty single topic segment")
}
d, err := time.ParseDuration(c.App.ConnectTimeout)
if err != nil || d <= 0 || d > time.Minute {
return fmt.Errorf("connect timeout must be a duration greater than zero and at most 1m")
}
return nil
}