chore: 代码审查并对齐版本至 v1.0.4 (by AI)

This commit is contained in:
AI Engineer 2026-05-02 12:43:43 +08:00
parent a9808a931e
commit f29b426c2d
9 changed files with 420 additions and 2 deletions

10
CHANGELOG.md Normal file
View 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 指南。

View File

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