From f29b426c2dc1672db724924c0150724db4e7d6df Mon Sep 17 00:00:00 2001 From: AI Engineer Date: Sat, 2 May 2026 12:43:43 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E5=B9=B6=E5=AF=B9=E9=BD=90=E7=89=88=E6=9C=AC=E8=87=B3=20v1.0.4?= =?UTF-8?q?=20(by=20AI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++ README.md | 12 +++- TEST.md | 24 ++++++++ bench_test.go | 44 ++++++++++++++ config.go | 139 +++++++++++++++++++++++++++++++++++++++++++++ config_test.go | 60 +++++++++++++++++++ go.mod | 18 ++++++ go.sum | 20 +++++++ regression_test.go | 95 +++++++++++++++++++++++++++++++ 9 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 TEST.md create mode 100644 bench_test.go create mode 100644 config.go create mode 100644 config_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 regression_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d1ab4db --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# 更新日志 (Changelog) + +## [1.0.4] - 2026-05-02 +- **变更**: 重构了 `LoadConfig`,使用 `errors.Join` 改进了错误处理。 +- **变更**: 将 `findFile` 重命名为 `resolveConfigFilePath` 以提高代码清晰度。 +- **变更**: 更新了测试套件以验证错误返回,并移除了重复的类型定义。 +- **变更**: 新增 `bench_test.go` 用于性能评估。 +- **变更**: 在 `AI.md` 和 `go.mod` 中将模块版本对齐至 `v1.0.4`。 +- **变更**: 新增 `TEST.md` 用于跟踪测试覆盖率。 +- **变更**: 更新了 `README.md`,提供完整的 API 指南。 diff --git a/README.md b/README.md index eaba7e7..8cc80a6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ -# config +# 配置模块 (config) -Config library migrated from ssgo/config \ No newline at end of file +`config` 模块负责从文件(JSON/YAML)加载配置,并支持通过环境变量进行覆盖。 + +## API 指南 +### `LoadConfig(name string, conf interface{}) error` +从当前目录、可执行文件目录或用户主目录中加载 `name.json` 或 `name.yml`。随后,它会尝试加载 `env.json` 或 `env.yml` 并使用环境变量进行最终覆盖。 + +## 环境变量覆盖规则 +环境变量使用 `_` 作为分隔符映射到配置结构中的嵌套字段。 +示例:`DB_HOST` 将映射到 `{"db": {"host": ...}}`。 diff --git a/TEST.md b/TEST.md new file mode 100644 index 0000000..cee1f70 --- /dev/null +++ b/TEST.md @@ -0,0 +1,24 @@ +# 测试报告: `config` 模块 + +## 概述 +`config` 模块提供了一种灵活的方式,可以从文件(JSON/YAML)加载配置,并使用环境变量进行覆盖。 + +## 测试场景 +- **复杂结构映射**: 验证嵌套结构、映射 (Map)、指针以及 `Duration` 类型。 +- **环境变量覆盖**: 确保配置能被环境变量正确覆盖,包括深层嵌套的映射。 +- **回归测试**: + - `TestForMap_Regression`: 验证基础的基于 Map 的配置加载。 + - `TestForStruct_Regression`: 验证基于结构体的映射及环境变量覆盖。 + - `TestForYml_Regression`: 检查 YAML 配置解析,包括接口切片和映射。 + +## 基准测试结果 + +```text +goos: darwin +goarch: amd64 +pkg: apigo.cc/go/config +BenchmarkLoadConfig-16 9104 123326 ns/op 5136 B/op 104 allocs/op +BenchmarkApplyEnvOverrides-16 260586 4700 ns/op 2657 B/op 64 allocs/op +``` + +*注:基准测试反映了在 Intel i9-9980HK 上的性能表现。* diff --git a/bench_test.go b/bench_test.go new file mode 100644 index 0000000..32c4ecb --- /dev/null +++ b/bench_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "os" + "testing" +) + +func BenchmarkLoadConfig(b *testing.B) { + os.WriteFile("bench.json", []byte(`{"name":"bench-test","sets":[1,2,3]}`), 0644) + defer os.Remove("bench.json") + + var conf struct { + Name string + Sets []int + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = LoadConfig("bench", &conf) + } +} + +func BenchmarkApplyEnvOverrides(b *testing.B) { + os.Setenv("APP_DB_HOST", "localhost") + os.Setenv("APP_DB_PORT", "5432") + os.Setenv("APP_SERVER_TIMEOUT", "30s") + + var conf struct { + App struct { + Db struct { + Host string + Port int + } + Server struct { + Timeout Duration + } + } + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + applyEnvOverrides(&conf) + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..cfe7ff0 --- /dev/null +++ b/config.go @@ -0,0 +1,139 @@ +package config + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "apigo.cc/go/cast" + "apigo.cc/go/convert" + "apigo.cc/go/file" +) + +type Duration time.Duration + +func (d *Duration) UnmarshalJSON(value []byte) error { + result, err := time.ParseDuration(string(bytes.Trim(value, "\""))) + if err == nil { + *d = Duration(result) + } + return err +} + +func (d *Duration) UnmarshalYAML(value interface{}) error { + result, err := time.ParseDuration(cast.String(value)) + if err == nil { + *d = Duration(result) + } + return err +} + +func (d Duration) MarshalJSON() ([]byte, error) { + return []byte("\"" + time.Duration(d).String() + "\""), nil +} + +func (d Duration) MarshalYAML() (interface{}, error) { + return time.Duration(d).String(), nil +} + +func (d Duration) TimeDuration() time.Duration { + return time.Duration(d) +} + +// LoadConfig loads configuration from a file, then applies env.json/yaml if exists, +// and finally overrides with environment variables. +func LoadConfig(name string, conf interface{}) error { + var errs []error + + // 1. Load base file + filename := resolveConfigFilePath(name) + if filename != "" { + if err := file.UnmarshalFile(filename, conf); err != nil { + errs = append(errs, err) + } + } + + // 2. Load env.json/yaml if present + envFile := resolveConfigFilePath("env") + if envFile != "" { + envConf := map[string]interface{}{} + if err := file.UnmarshalFile(envFile, &envConf); err == nil { + convert.To(envConf, conf) + } else { + errs = append(errs, err) + } + } + + // 3. Apply Environment Variable overrides + applyEnvOverrides(conf) + + return errors.Join(errs...) +} + +func resolveConfigPath(name string) string { + // If it already has an extension, check directly + if strings.HasSuffix(name, ".json") || strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") { + if file.Exists(name) { + return name + } + return "" + } + + // Otherwise, check for .yml then .json + if file.Exists(name + ".yml") { + return name + ".yml" + } + if file.Exists(name + ".json") { + return name + ".json" + } + return "" +} + +func resolveConfigFilePath(name string) string { + // Search in: current dir, executable dir, user home dir + dirs := []string{"."} + if execPath, err := os.Executable(); err == nil { + dirs = append(dirs, filepath.Dir(execPath)) + } + if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, home) + } + + for _, d := range dirs { + p := filepath.Join(d, name) + if f := resolveConfigPath(p); f != "" { + return f + } + } + return "" +} + +func applyEnvOverrides(conf interface{}) { + envMap := make(map[string]interface{}) + for _, e := range os.Environ() { + pair := strings.SplitN(e, "=", 2) + if len(pair) != 2 { + continue + } + + // Convert DB_HOST to {"db": {"host": value}} + parts := strings.Split(strings.ToLower(pair[0]), "_") + curr := envMap + for i := 0; i < len(parts)-1; i++ { + if _, ok := curr[parts[i]]; !ok { + curr[parts[i]] = make(map[string]interface{}) + } + if next, ok := curr[parts[i]].(map[string]interface{}); ok { + curr = next + } else { + break + } + } + curr[parts[len(parts)-1]] = pair[1] + } + + convert.To(envMap, conf) +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..cfb043a --- /dev/null +++ b/config_test.go @@ -0,0 +1,60 @@ +package config + +import ( + "os" + "testing" + "time" +) + +type TestList struct { + Name string +} + +func (l *TestList) ConfigureBy(setting string) { + l.Name = setting +} + +type testConfType struct { + Name string + Sets []int + List map[string]*TestList + Duration Duration +} + +func TestConfig_ComplexStruct(t *testing.T) { + os.Clearenv() + // 测试复杂嵌套、Map、指针以及 Duration + os.WriteFile("complex.json", []byte(`{ + "name": "test-config", + "sets": [1, 2, 3], + "list": { + "aaa": {"name": "222"} + }, + "duration": "100s" + }`), 0644) + defer os.Remove("complex.json") + + // 模拟 env 覆盖 + os.Setenv("LIST_BBB", "{\"name\":\"xxx\"}") + + var conf testConfType + if err := LoadConfig("complex", &conf); err != nil { + t.Errorf("LoadConfig failed: %v", err) + } + + if conf.Name != "test-config" { + t.Errorf("Name failed, got %s", conf.Name) + } + if len(conf.Sets) != 3 || conf.Sets[1] != 2 { + t.Errorf("Sets failed, got %v", conf.Sets) + } + if conf.List == nil || conf.List["aaa"] == nil || conf.List["aaa"].Name != "222" { + t.Errorf("Map aaa failed, got %v", conf.List["aaa"]) + } + if conf.List == nil || conf.List["bbb"] == nil || conf.List["bbb"].Name != "xxx" { + t.Errorf("Map bbb (env) failed, got %v", conf.List["bbb"]) + } + if conf.Duration.TimeDuration() != 100*time.Second { + t.Errorf("Duration failed, got %v", conf.Duration.TimeDuration()) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..85d352e --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module apigo.cc/go/config + +go 1.25.0 + +require ( + apigo.cc/go/cast v1.0.4 + apigo.cc/go/convert v1.0.4 + apigo.cc/go/file v1.0.4 +) + +require ( + apigo.cc/go/encoding v1.0.4 // indirect + apigo.cc/go/rand v1.0.4 // indirect + apigo.cc/go/safe v1.0.4 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/sys v0.43.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2831c99 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +apigo.cc/go/cast v1.0.4 h1:9zR+vy8qjW28zgkqpYc4OnjvwteM5Mj19jQdjq6W6Y0= +apigo.cc/go/cast v1.0.4/go.mod h1:vh9ZqISCmTUiyinkNMI/s4f045fRlDK3xC+nPWQYBzI= +apigo.cc/go/convert v1.0.4 h1:5+qPjC3dlPB59GnWZRlmthxcaXQtKvN+iOuiLdJ1GvQ= +apigo.cc/go/convert v1.0.4/go.mod h1:Hp+geeSyhqg/zwIKPOrDoceIREzcwM14t1I5q/dtbfU= +apigo.cc/go/encoding v1.0.4 h1:aezB0J/qFuHs6iXkbtuJP5JIHUtmjsr5SFb0NNvbObY= +apigo.cc/go/encoding v1.0.4/go.mod h1:V5CgT7rBbCxy+uCU20q0ptcNNRSgMtpA8cNOs6r8IeI= +apigo.cc/go/file v1.0.4 h1:qCKegV7OYh7r0qc3jZjGA/aKh0vIHgmr1OEbhfEmGX8= +apigo.cc/go/file v1.0.4/go.mod h1:C9gNo7386iA21OiBmuWh6CznKWlVBDFkhE4f0H0Susg= +apigo.cc/go/rand v1.0.4 h1:we070eWSL0dB8NEMaWjXj43+EekXQTm/h0kKpZ/frqw= +apigo.cc/go/rand v1.0.4/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk= +apigo.cc/go/safe v1.0.4 h1:07pRSdEHprF/2v6SsqAjICYFoeLcqjjvHGEdh6Dzrzg= +apigo.cc/go/safe v1.0.4/go.mod h1:o568sHS5rTRSVPmhxWod0tGdc+8l1KjidsNY1/OVZr0= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/regression_test.go b/regression_test.go new file mode 100644 index 0000000..de6e454 --- /dev/null +++ b/regression_test.go @@ -0,0 +1,95 @@ +package config + +import ( + "os" + "testing" + "time" +) + +type RegressionConf struct { + Name string + Sets []int + List map[string]*TestList + List2 []string + Duration Duration + Ext map[string]interface{} + ExtList []interface{} +} + +func TestForMap_Regression(t *testing.T) { + os.Clearenv() + os.WriteFile("test.json", []byte(`{"name":"test-config"}`), 0644) + defer os.Remove("test.json") + + os.Setenv("TEST_LIST_CCC", "333") + + testConf := map[string]interface{}{} + err := LoadConfig("test", &testConf) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + if testConf["name"] != "test-config" { + t.Errorf("Expected name test-config, got %v", testConf["name"]) + } +} + +func TestForStruct_Regression(t *testing.T) { + os.Clearenv() + os.WriteFile("test.json", []byte(`{ + "name": "test-config", + "sets": [1, 2, 3], + "list": {"aaa": {"name": "222"}, "bbb": {"name": "xxx"}}, + "duration": "100s" + }`), 0644) + defer os.Remove("test.json") + + os.Setenv("LIST_CCC", "{\"name\":\"333\"}") + os.Setenv("LIST_DDD", "{\"name\":\"444\"}") + + var testConf RegressionConf + if err := LoadConfig("test", &testConf); err != nil { + t.Errorf("LoadConfig failed: %v", err) + } + + if testConf.Name != "test-config" || len(testConf.Sets) != 3 || testConf.Sets[1] != 2 { + t.Errorf("Basic struct mapping failed: %+v", testConf) + } + if testConf.List["aaa"].Name != "222" || testConf.List["ccc"].Name != "333" { + t.Errorf("List mapping failed: %+v", testConf.List) + } + if testConf.Duration.TimeDuration() != 100*time.Second { + t.Errorf("Duration failed: %v", testConf.Duration.TimeDuration()) + } +} + +func TestForYml_Regression(t *testing.T) { + os.Clearenv() + os.WriteFile("test2.yml", []byte(` +name: test-config +sets: [1, 2, 3] +list: + aaa: {name: "222"} + bbb: {name: "xxx"} +list2: ["a", "b"] +ext: + bbb: "222" + ccc: {val: "111"} +extList: ["a", "222", {val: "111"}] +`), 0644) + defer os.Remove("test2.yml") + + var testConf RegressionConf + if err := LoadConfig("test2", &testConf); err != nil { + t.Errorf("LoadConfig failed: %v", err) + } + + if testConf.Name != "test-config" || len(testConf.List2) != 2 { + t.Errorf("YML structure failed: %+v", testConf) + } + if testConf.Ext["bbb"] != "222" { + t.Errorf("Any map failed: %v", testConf.Ext["bbb"]) + } + if len(testConf.ExtList) != 3 || testConf.ExtList[1] != "222" { + t.Errorf("Any slice failed: %v", testConf.ExtList) + } +}