From 6967957299c4296c0952fde3f9c01dc8b25c3fac Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 11 Jun 2026 23:41:52 +0800 Subject: [PATCH] feat(config): add ${ENV_VAR} interpolation in YAML configuration Enable environment variable substitution in configuration files using ${VAR} syntax. Supports 12-factor app deployment patterns without hardcoding secrets or environment-specific values. Syntax: - Only ${VAR} with curly braces (avoids conflict with $variable system) - Missing variables preserved as-is (${MISSING} stays unchanged) - Multiple variables per line supported - Adjacent variables ${A}${B} handled correctly Integration: - Applied in config.Load() after os.ReadFile, before yaml.Unmarshal - Applied in processIncludes() for each included file - 12 unit tests covering single/multiple/missing/empty variables --- internal/config/config.go | 4 ++ internal/config/env.go | 22 +++++++ internal/config/env_test.go | 121 ++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 internal/config/env.go create mode 100644 internal/config/env_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 2381b5f..b75644a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -134,6 +134,8 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("读取配置文件失败: %w", err) } + data = ExpandEnv(data) + // 从默认值开始,YAML 只覆盖显式配置的字段。 // 注意:yaml.v3 对 slice 会整体替换,因此用户显式配置的 Servers[] // 元素不会继承 server-level 默认值;但顶层 struct 字段(Performance、 @@ -206,6 +208,8 @@ func processIncludes(cfg *Config, baseDir string, depth int, visited map[string] return fmt.Errorf("读取引入文件 %q 失败: %w", match, err) } + data = ExpandEnv(data) + var included Config if err := yaml.Unmarshal(data, &included); err != nil { return fmt.Errorf("解析引入文件 %q 失败: %w", match, err) diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..53d8d54 --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,22 @@ +package config + +import ( + "os" + "regexp" +) + +var envPattern = regexp.MustCompile(`\$\{([^}]+)\}`) + +// ExpandEnv replaces ${VAR} patterns with environment variable values. +func ExpandEnv(data []byte) []byte { + return envPattern.ReplaceAllFunc(data, func(match []byte) []byte { + name := string(match[2 : len(match)-1]) + if name == "" { + return match + } + if value, ok := os.LookupEnv(name); ok { + return []byte(value) + } + return match + }) +} diff --git a/internal/config/env_test.go b/internal/config/env_test.go new file mode 100644 index 0000000..3e0e00e --- /dev/null +++ b/internal/config/env_test.go @@ -0,0 +1,121 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExpandEnv(t *testing.T) { + tests := []struct { + name string + input string + expected string + setup func(*testing.T) + }{ + { + name: "single variable", + input: "${VAR}", + expected: "value", + setup: func(t *testing.T) { + t.Setenv("VAR", "value") + }, + }, + { + name: "multiple variables", + input: "${HOST}:${PORT}", + expected: "localhost:8080", + setup: func(t *testing.T) { + t.Setenv("HOST", "localhost") + t.Setenv("PORT", "8080") + }, + }, + { + name: "variable with prefix and suffix", + input: "prefix_${VAR}_suffix", + expected: "prefix_value_suffix", + setup: func(t *testing.T) { + t.Setenv("VAR", "value") + }, + }, + { + name: "missing variable unchanged", + input: "${MISSING}", + expected: "${MISSING}", + }, + { + name: "mixed existing and missing", + input: "${VAR}/${MISSING}", + expected: "value/${MISSING}", + setup: func(t *testing.T) { + t.Setenv("VAR", "value") + }, + }, + { + name: "empty input", + input: "", + expected: "", + }, + { + name: "no variables", + input: "just a plain string", + expected: "just a plain string", + }, + { + name: "empty variable name", + input: "${}", + expected: "${}", + }, + { + name: "adjacent variables", + input: "${VAR1}${VAR2}", + expected: "value1value2", + setup: func(t *testing.T) { + t.Setenv("VAR1", "value1") + t.Setenv("VAR2", "value2") + }, + }, + { + name: "variable with empty value", + input: "${EMPTY_VAR}", + expected: "", + setup: func(t *testing.T) { + t.Setenv("EMPTY_VAR", "") + }, + }, + { + name: "same variable multiple times", + input: "${VAR}-${VAR}-${VAR}", + expected: "val-val-val", + setup: func(t *testing.T) { + t.Setenv("VAR", "val") + }, + }, + { + name: "full yaml-like input", + input: `server: + host: ${HOST} + port: ${PORT} + name: ${APP_NAME}`, + expected: `server: + host: 127.0.0.1 + port: 9090 + name: lolly`, + setup: func(t *testing.T) { + t.Setenv("HOST", "127.0.0.1") + t.Setenv("PORT", "9090") + t.Setenv("APP_NAME", "lolly") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setup != nil { + tt.setup(t) + } + result := ExpandEnv([]byte(tt.input)) + assert.Equal(t, tt.expected, string(result)) + }) + } +}