chore: 代码审查并对齐版本至 v1.0.4 (by AI)
This commit is contained in:
parent
a9808a931e
commit
f29b426c2d
10
CHANGELOG.md
Normal file
10
CHANGELOG.md
Normal file
@ -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 指南。
|
||||
12
README.md
12
README.md
@ -1,3 +1,11 @@
|
||||
# config
|
||||
# 配置模块 (config)
|
||||
|
||||
Config library migrated from ssgo/config
|
||||
`config` 模块负责从文件(JSON/YAML)加载配置,并支持通过环境变量进行覆盖。
|
||||
|
||||
## API 指南
|
||||
### `LoadConfig(name string, conf interface{}) error`
|
||||
从当前目录、可执行文件目录或用户主目录中加载 `name.json` 或 `name.yml`。随后,它会尝试加载 `env.json` 或 `env.yml` 并使用环境变量进行最终覆盖。
|
||||
|
||||
## 环境变量覆盖规则
|
||||
环境变量使用 `_` 作为分隔符映射到配置结构中的嵌套字段。
|
||||
示例:`DB_HOST` 将映射到 `{"db": {"host": ...}}`。
|
||||
|
||||
24
TEST.md
Normal file
24
TEST.md
Normal file
@ -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 上的性能表现。*
|
||||
44
bench_test.go
Normal file
44
bench_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
139
config.go
Normal file
139
config.go
Normal file
@ -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)
|
||||
}
|
||||
60
config_test.go
Normal file
60
config_test.go
Normal file
@ -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())
|
||||
}
|
||||
}
|
||||
18
go.mod
Normal file
18
go.mod
Normal file
@ -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
|
||||
)
|
||||
20
go.sum
Normal file
20
go.sum
Normal file
@ -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=
|
||||
95
regression_test.go
Normal file
95
regression_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user