feat(appconfig): 配置文件解析与 flag/file/default 三级合并

新增 internal/appconfig:把「从配置文件启动」所需的解析、密钥展开和
优先级合并抽成可单测的包,避免这块逻辑散落在 main 里。

- 优先级严格为 显式 flag > 配置文件 > 内置默认值;「是否显式传入」由
  flag.Visit 判定,绝不拿解析后的值与默认值比较——Go 的 flag 会无条件
  把默认值写进变量,直接读变量就会让默认值压过配置文件。
- 字段覆盖现有全部 flag:addr/user/password/workdir/bin/frpc/peers/join_key
  (join_key 兼容 joinKey 写法)。
- 字符串字段支持 ${VAR} 与 file:/path 两种展开;环境变量未定义/为空、
  secrets 文件读不到都直接报错,不静默降级成空口令。$$ 转义字面量 $。
- 文件缺失返回 (nil,nil) 不算错误;JSON 非法、未知键(如把 password 拼成
  passwd)、尾部多余内容一律报错,交给调用方响亮退出——静默回落到
  admin/admin 才是真正的安全问题。以 "_" 开头的键作注释键忽略,
  示例配置因此可以内嵌说明。
- -peer 显式传入时整体替换文件里的 peers,而不是追加;-join-key 的内置
  默认来自 $W4F_JOIN_KEY,属于更具体的进程级覆盖,仍优先于文件。
- Values 实现 String()/Summary() 并对手令与 join-key 脱敏,即使误用
  %v 打印也不会泄漏。

回归测试覆盖上述每一条,其中 TestResolveFileBeatsFlagDefaults 专门守
「默认值压过配置文件」这个坑。
This commit is contained in:
JianFeeeee
2026-10-23 09:12:19 +08:00
parent 21eb52df24
commit e0e2bf9e44
3 changed files with 654 additions and 0 deletions

View File

@ -0,0 +1,140 @@
package appconfig
import (
"flag"
"fmt"
"strings"
)
// Field sources, for logging only — never for values.
const (
SourceFlag = "flag"
SourceEnv = "env"
SourceFile = "file"
SourceDefault = "default"
)
// Defaults are the built-in defaults, i.e. exactly what the flags declare.
type Defaults struct {
Addr string
User string
Password string
WorkDir string
}
// Values is the effective runtime configuration after merging.
type Values struct {
Addr string
User string
Password string
WorkDir string
Bin string
FRPC string
Peers []string
JoinKey string
}
// Sources records where each resolved field came from ("" = not configured).
type Sources struct {
Addr string
User string
Password string
WorkDir string
Bin string
FRPC string
Peers string
JoinKey string
}
// ExplicitSet returns the flag names the operator actually typed.
//
// flag.Visit walks only flags that were set on the command line, which is
// precisely the test required here: an unset flag must NOT override a config
// file value with its default.
func ExplicitSet(fs *flag.FlagSet) map[string]bool {
set := map[string]bool{}
fs.Visit(func(f *flag.Flag) { set[f.Name] = true })
return set
}
// Resolve merges per field: explicit flag > config file (non-empty) >
// built-in default.
func Resolve(def Defaults, flags Values, explicit map[string]bool, f *File) (Values, Sources) {
var out Values
var src Sources
var file File
if f != nil {
file = *f
}
// pick covers the four fields that have a built-in default.
pick := func(flagName, flagVal, defVal, fileVal string) (string, string) {
if explicit[flagName] {
return flagVal, SourceFlag
}
if fileVal != "" {
return fileVal, SourceFile
}
return defVal, SourceDefault
}
out.Addr, src.Addr = pick("addr", flags.Addr, def.Addr, file.Addr)
out.User, src.User = pick("user", flags.User, def.User, file.User)
out.Password, src.Password = pick("password", flags.Password, def.Password, file.Password)
out.WorkDir, src.WorkDir = pick("workdir", flags.WorkDir, def.WorkDir, file.WorkDir)
// No built-in default for these: empty means "not configured".
pickOpt := func(flagName, flagVal, fileVal string) (string, string) {
if explicit[flagName] {
return flagVal, SourceFlag
}
if fileVal != "" {
return fileVal, SourceFile
}
return "", ""
}
out.Bin, src.Bin = pickOpt("bin", flags.Bin, file.Bin)
out.FRPC, src.FRPC = pickOpt("frpc", flags.FRPC, file.FRPC)
// join-key is special: its flag default comes from $W4F_JOIN_KEY, i.e. a
// per-process override that is more specific than a value baked into the
// config file. Dropping it here would silently disable env-based cluster
// joining whenever a file happened to be present.
switch {
case explicit["join-key"]:
out.JoinKey, src.JoinKey = flags.JoinKey, SourceFlag
case flags.JoinKey != "":
out.JoinKey, src.JoinKey = flags.JoinKey, SourceEnv
case file.JoinKey != "":
out.JoinKey, src.JoinKey = file.JoinKey, SourceFile
}
// peers is repeatable (-peer a -peer b). An explicit flag REPLACES the
// file's list instead of appending: appending would resurrect peers the
// operator deliberately dropped on the command line.
if explicit["peer"] {
out.Peers, src.Peers = flags.Peers, SourceFlag
} else if len(file.Peers) > 0 {
out.Peers, src.Peers = file.Peers, SourceFile
}
return out, src
}
// Summary renders the resolved configuration for the startup log with every
// secret redacted. Using this instead of ad-hoc formatting means an accidental
// log.Printf("%v", values) still cannot leak the basic-auth password or the
// cluster join key.
func (v Values) Summary() string {
return fmt.Sprintf("addr=%s user=%s password=<redacted> workdir=%s bin=%s frpc=%s peers=%s join-key=<redacted>",
v.Addr, v.User, v.WorkDir, orDash(v.Bin), orDash(v.FRPC), orDash(strings.Join(v.Peers, ",")))
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
// String implements fmt.Stringer so %v/%s on Values is always redacted.
func (v Values) String() string { return v.Summary() }