fix config watcher lifecycle and snapshots
This commit is contained in:
@@ -23,6 +23,9 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
- **cache 写操作与计数器/原始 Redis helper 在 Redis 未初始化时返回 `ErrRedisNotReady`**:`Set` / `Delete` / `DeleteByPattern` / `Incr` / `IncrBy` / `Decr` / `GetTTL` / `SetExpire` / `GetRaw` / `SetRaw` 旧行为会静默返回成功或零值,调用方容易误判缓存写入、计数器更新或过期时间设置已经生效;现在统一显式返回错误。公共接口签名不变,但依赖“未启用 Redis 时当作成功”的下游需要改为忽略 `errors.Is(err, cache.ErrRedisNotReady)` 或显式启用 Redis。
|
||||
- **`cache.WithLock` / `cache.WithLockAutoExtend` 未获取到锁时返回 `ErrLockNotAcquired`**:旧行为返回 `nil` 并跳过业务函数,调用方无法区分“业务执行成功”和“根本没有执行”。同时锁 TTL 小于 1ms、续期/重试间隔非正会返回显式错误,避免 Redis PX=0 或 `time.NewTicker(0)` 崩溃。
|
||||
- **`test.Request.Execute()` 改为返回 `*test.Response`**:旧返回值是 `*httptest.ResponseRecorder`,与文档示例中的 `resp.AssertOK(t)` / `resp.ParseJSON(...)` 不一致;现在 `Execute()` 返回带断言和 JSON 解析方法的包装类型。需要原始 recorder 的调用方改用新增 `ExecuteRecorder()`。
|
||||
- **`config.Get()` / `(*config.Manager).Get()` 改为返回配置副本**:旧行为暴露内部 `*Config`,调用方修改返回值会污染全局配置并可能与热重载并发读写竞态;现在返回深拷贝。需要动态替换配置的测试或工具代码请改用 `config.Set(cfg)`。
|
||||
- **`config.Set()` / `(*config.Manager).Set()` 现在返回 `error`**:非 nil 配置会先执行 `Validate()`,非法配置不会覆盖旧配置,并返回 `ErrInvalidConfig` 包装错误。旧代码可以继续忽略返回值,但建议测试和启动路径显式检查。
|
||||
- **`config.GetViper()` / `(*config.Manager).GetViper()` 改为返回 viper 快照**:旧行为暴露内部可变 `*viper.Viper`;现在修改返回对象不会影响全局配置。常规读取请使用 `GetString` / `GetInt` / `GetBool` / `GetStringMap`。
|
||||
- **`jwt.ParseToken` 开始校验 issuer,`RefreshToken` 使用 `jwt.refresh_expire`**:签发者与当前配置不一致的 token 会被拒绝;刷新后的 token 过期时间优先使用 `refresh_expire`,未配置时回退 `expire`。`GenerateTokenWithCustomExpiry` 现在拒绝非正过期时间,`InvalidateTokenByID("")` 返回 `ErrEmptyJTI`。
|
||||
- **`App.Init()` 由 `sync.Once` 改为生命周期状态机**(app.go,M1):5 态 `stateCreated/Initializing/Initialized/Stopping/Stopped` + `lifecycleMu`(RWMutex) + `initMu`(Mutex)。`Shutdown` 后或 `Init` 失败后再调 `Init()` 返回新增导出错误 **`xlgo.ErrAppClosed`**(原 `sync.Once` "多次调用返回首次结果"语义不再适用——已关闭的 App 不可再 Init,需新建 App)。
|
||||
- **`App.Go()` 在 Shutdown 开始或 Init 失败后为 no-op**(app.go,M1):`state >= stateStopping` 时拒绝 `wg.Add` 直接返回,避免与 `Shutdown` 的 `wg.Wait` 竞争 `sync.WaitGroup` 契约(Add 须 happen-before Wait)。依赖"Shutdown 后仍可 Go"的下游需改用独立 goroutine。
|
||||
@@ -39,6 +42,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
- **M9 JWT issuer / refresh expiry 契约修复**:`ParseToken`、`InvalidateToken`、`GetClaimsFromToken` 统一按当前配置校验 issuer;`RefreshToken` 不再忽略 `refresh_expire`;空 JTI 不再写入永不命中的 `jwt_bl:` 黑名单键。
|
||||
- **M10 分布式锁参数与未获锁语义修复**:锁 TTL 统一校验到 Redis 毫秒粒度;`TryLock` 的非正 retry interval 不再 busy-loop;`WithLockAutoExtend` 的非正 extend interval 不再触发 goroutine panic;`UnlockByKey` 在 Redis 未初始化时与 `ForceUnlock` 一样返回 `ErrRedisNotReady`。
|
||||
- **M10 cache 剩余错误语义收口**:新增 `cache.ExistsE` 与可选 `CacheExistChecker`,让调用方能区分 key 不存在和 Redis/backend 故障;保留旧 `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option,`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。
|
||||
- **M2 config 热重载生命周期修复**:`StopWatcher` 会等待已触发的 reload/回调结束;包级 `Load` / `LoadWithWatch` 只有在新配置成功加载并启动 watcher 后才替换默认 manager,失败时保留旧 watcher;`SetDefaultManager` 会停止旧 manager 的 watcher,避免全局置换后遗留 goroutine;数据库配置出现字段时会校验 driver/host/name/port,未知 driver 不再静默回退 MySQL;MySQL DSN 转义用户名/库名,Postgres DSN 统一转义字符串字段。
|
||||
- **M11 SSE 换行注入修复**:`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。
|
||||
- **M15 utils/validation 资源与错误边界修复**:`HTTPClient.Upload` 改为流式 multipart 上传,不再把文件请求体完整缓存在内存中;`AppendFile` / `CopyFile` 返回写侧 `Close` 错误;`CheckPasswordAndUpgrade` 归一化非法 `targetCost`,避免异常配置触发超高 bcrypt cost;`ValidateStruct(nil)` 直接返回 nil。
|
||||
- **M16 测试工具与脚手架边界修复**:`MockDB` / `MockCache` / `MockStorage` 改为并发安全;`MockCache` 与 `MockStorage.UploadFromBytes` 复制字节切片,避免调用方修改污染内部状态;`MockStorage` 拒绝 nil 文件与超过 32MiB 的输入,避免测试 helper 被误用成无上限内存缓冲;`xlgo make` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码。
|
||||
|
||||
+182
-75
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
// 配置错误
|
||||
var (
|
||||
ErrConfigNotLoaded = fmt.Errorf("配置未加载")
|
||||
ErrInvalidConfig = fmt.Errorf("配置非法")
|
||||
)
|
||||
|
||||
// Config 全局配置结构体
|
||||
@@ -39,8 +41,7 @@ type Config struct {
|
||||
// 类型白名单、Storage.Local/OSS 上传策略的扩展名/MIME 白名单)深拷贝底层数组,使返回值
|
||||
// 可被调用方安全修改(含 append/sort/改元素)而不污染框架内部配置、不与其他读者竞态。
|
||||
//
|
||||
// 用于需要可变配置副本的场景。热路径的 Get() 为零分配仍返回内部只读指针——需要改配置时
|
||||
// 用 Clone() 或 Load()(Load 内部已返回 Clone)。
|
||||
// 用于需要可变配置副本的场景。Load/Get/回调均返回 Clone,避免调用方误改全局配置。
|
||||
func (c *Config) Clone() *Config {
|
||||
if c == nil {
|
||||
return nil
|
||||
@@ -72,6 +73,34 @@ func cloneStrings(s []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStringAnyMap(in map[string]any) map[string]any {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = cloneAny(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneAny(v any) any {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
return cloneStringAnyMap(x)
|
||||
case []any:
|
||||
out := make([]any, len(x))
|
||||
for i, item := range x {
|
||||
out[i] = cloneAny(item)
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return cloneStrings(x)
|
||||
default:
|
||||
return x
|
||||
}
|
||||
}
|
||||
|
||||
// AppConfig 应用配置
|
||||
// 使用场景:
|
||||
// - 缓存键名前缀: cache:{site_name}:user:1
|
||||
@@ -133,7 +162,7 @@ type TLSConfig struct {
|
||||
|
||||
// ServerConfig 服务配置
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host"` // 绑定地址,空=监听所有接口(0.0.0.0);"127.0.0.1"=仅本机;内网IP=绑定指定网卡
|
||||
Host string `mapstructure:"host"` // 绑定地址,空=监听所有接口(0.0.0.0);"127.0.0.1"=仅本机;内网IP=绑定指定网卡
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"` // development 或 production
|
||||
ReadTimeout time.Duration `mapstructure:"read_timeout"` // 读超时,如 "15s"
|
||||
@@ -142,7 +171,7 @@ type ServerConfig struct {
|
||||
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` // 优雅关闭超时,如 "30s"
|
||||
MaxHeaderBytes int `mapstructure:"max_header_bytes"` // 最大请求头字节数
|
||||
TLS TLSConfig `mapstructure:"tls"`
|
||||
UnixSocket string `mapstructure:"unix_socket"` // 非空时优先于 Port,监听 unix socket
|
||||
UnixSocket string `mapstructure:"unix_socket"` // 非空时优先于 Port,监听 unix socket
|
||||
ResponseMode string `mapstructure:"response_mode"` // business(默认) 或 rest,见 response.SetMode
|
||||
}
|
||||
|
||||
@@ -291,13 +320,27 @@ func (c *DatabaseConfig) DSN() string {
|
||||
if c.CustomDSN != "" {
|
||||
return c.CustomDSN
|
||||
}
|
||||
if builder, ok := LookupDSNBuilder(c.Driver); ok {
|
||||
driver := c.Driver
|
||||
if strings.TrimSpace(driver) == "" {
|
||||
driver = DriverMySQL
|
||||
}
|
||||
if builder, ok := LookupDSNBuilder(driver); ok {
|
||||
return builder(c)
|
||||
}
|
||||
// 未注册时回退到 MySQL(保持向后兼容)
|
||||
return c.MySQLDSN()
|
||||
}
|
||||
|
||||
func (c DatabaseConfig) isConfigured() bool {
|
||||
return strings.TrimSpace(c.Driver) != "" ||
|
||||
strings.TrimSpace(c.Host) != "" ||
|
||||
c.Port != 0 ||
|
||||
strings.TrimSpace(c.User) != "" ||
|
||||
strings.TrimSpace(c.Password) != "" ||
|
||||
strings.TrimSpace(c.Name) != "" ||
|
||||
strings.TrimSpace(c.CustomDSN) != ""
|
||||
}
|
||||
|
||||
// MySQLDSN 返回 MySQL 连接字符串。
|
||||
// 密码经 url.QueryEscape 转义,避免含 @/:/空格 等特殊字符破坏 DSN(M9)。
|
||||
// loc 由 Timezone 配置,空则默认 "Local"(向后兼容)。
|
||||
@@ -307,19 +350,25 @@ func (c *DatabaseConfig) MySQLDSN() string {
|
||||
loc = "Local"
|
||||
}
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=%s",
|
||||
c.User, url.QueryEscape(c.Password), c.Host, c.Port, c.Name, url.QueryEscape(loc))
|
||||
url.QueryEscape(c.User), url.QueryEscape(c.Password), c.Host, c.Port, url.PathEscape(c.Name), url.QueryEscape(loc))
|
||||
}
|
||||
|
||||
// PostgresDSN 返回 PostgreSQL 连接字符串。
|
||||
// 密码经单引号转义(内嵌单引号翻倍),避免含空格/引号/反斜杠破坏 key=value DSN(M9)。
|
||||
// 字符串字段统一用单引号包裹并转义,避免含空格/引号/反斜杠破坏 key=value DSN。
|
||||
// TimeZone 由 Timezone 配置,空则默认 "Asia/Shanghai"(向后兼容)。
|
||||
func (c *DatabaseConfig) PostgresDSN() string {
|
||||
tz := c.Timezone
|
||||
if tz == "" {
|
||||
tz = "Asia/Shanghai"
|
||||
}
|
||||
return fmt.Sprintf("host=%s port=%d user=%s password='%s' dbname=%s sslmode=disable TimeZone=%s",
|
||||
c.Host, c.Port, c.User, strings.ReplaceAll(c.Password, "'", "''"), c.Name, tz)
|
||||
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable TimeZone=%s",
|
||||
postgresQuote(c.Host), c.Port, postgresQuote(c.User), postgresQuote(c.Password), postgresQuote(c.Name), postgresQuote(tz))
|
||||
}
|
||||
|
||||
func postgresQuote(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `'`, `\'`)
|
||||
return "'" + s + "'"
|
||||
}
|
||||
|
||||
// RedisConfig Redis 配置
|
||||
@@ -499,6 +548,17 @@ func newViper(configPath string) *viper.Viper {
|
||||
return v
|
||||
}
|
||||
|
||||
func cloneViper(src *viper.Viper, configPath string) *viper.Viper {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
cp := newViper(configPath)
|
||||
if err := cp.MergeConfigMap(src.AllSettings()); err != nil {
|
||||
return nil
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
// unmarshalConfig 将 viper 解析到 Config,启用 string→time.Duration decode hook,
|
||||
// 使 ServerConfig/JWTConfig 的 Duration 字段可写 "24h"/"15s" 等字符串。
|
||||
func unmarshalConfig(v *viper.Viper, cfg *Config) error {
|
||||
@@ -609,14 +669,12 @@ func (m *Manager) StartWatcher() error {
|
||||
func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct{}) {
|
||||
defer close(done)
|
||||
const debounce = 200 * time.Millisecond
|
||||
var timer *time.Timer
|
||||
// P1 #16:退出时停掉未触发的去抖 timer,避免 StopWatcher 之后 AfterFunc 仍
|
||||
// reload() 一个调用方认为已停止的 manager。
|
||||
defer func() {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}()
|
||||
timer := time.NewTimer(time.Hour)
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
defer timer.Stop()
|
||||
var timerC <-chan time.Time
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-w.Events:
|
||||
@@ -631,19 +689,24 @@ func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct
|
||||
continue
|
||||
}
|
||||
// 去抖:合并编辑器/工具的连续写事件,仅最后一次触发重载。
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
if !timer.Stop() && timerC != nil {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer = time.AfterFunc(debounce, func() {
|
||||
// reload 内部对非法配置保留旧配置(C10b),错误被忽略——
|
||||
// 监听路径无法向上传播错误,保留旧配置即正确语义。
|
||||
_ = m.reload()
|
||||
})
|
||||
timer.Reset(debounce)
|
||||
timerC = timer.C
|
||||
case _, ok := <-w.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// 非致命错误:继续监听。
|
||||
case <-timerC:
|
||||
timerC = nil
|
||||
// reload 内部对非法配置保留旧配置(C10b),错误被忽略——
|
||||
// 监听路径无法向上传播错误,保留旧配置即正确语义。
|
||||
_ = m.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -669,45 +732,98 @@ func (m *Manager) StopWatcher() {
|
||||
}
|
||||
}
|
||||
|
||||
// Get 获取配置。
|
||||
//
|
||||
// 返回的是 Manager 内部持有的配置指针(共享),调用方**必须视为只读**:
|
||||
// 修改返回值的标量或切片元素会污染全局配置并与其他读取 goroutine 竞态。需要可变副本时
|
||||
// 用 Clone()(返回深拷贝)或 Load()(重载并返回深拷贝)。
|
||||
//
|
||||
// M-G:热路径(jwt 鉴权等每请求调用)保持返回内部指针以零分配,可变副本走 Clone()。
|
||||
// 框架自身的 reload 创建新 Config 并替换 m.cfg 指针,不改写旧 Config 对象,故 Get()
|
||||
// 返回的旧指针在 reload 后仍指向一致的(旧)快照,reload 不引入对旧快照的竞态。
|
||||
// Get 获取配置副本。返回值可由调用方自由修改,不会污染 Manager 内部配置。
|
||||
func (m *Manager) Get() *Config {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.cfg
|
||||
return m.cfg.Clone()
|
||||
}
|
||||
|
||||
// GetViper 获取 viper 实例
|
||||
// GetViper 获取 viper 的只读快照。
|
||||
//
|
||||
// 返回值不是 Manager 内部 viper 指针;调用方修改该快照不会影响全局配置。
|
||||
// 需要扩展配置读取时优先使用 GetString/GetInt/GetBool 等包级 helper。
|
||||
func (m *Manager) GetViper() *viper.Viper {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.v
|
||||
return cloneViper(m.v, m.path)
|
||||
}
|
||||
|
||||
// Set 手动设置配置
|
||||
func (m *Manager) Set(cfg *Config) {
|
||||
// GetString 获取字符串配置。
|
||||
func (m *Manager) GetString(key string) string {
|
||||
if m == nil {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.v == nil {
|
||||
return ""
|
||||
}
|
||||
return m.v.GetString(key)
|
||||
}
|
||||
|
||||
// GetInt 获取整数配置。
|
||||
func (m *Manager) GetInt(key string) int {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.v == nil {
|
||||
return 0
|
||||
}
|
||||
return m.v.GetInt(key)
|
||||
}
|
||||
|
||||
// GetBool 获取布尔配置。
|
||||
func (m *Manager) GetBool(key string) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.v == nil {
|
||||
return false
|
||||
}
|
||||
return m.v.GetBool(key)
|
||||
}
|
||||
|
||||
// GetStringMap 获取字符串映射配置副本。
|
||||
func (m *Manager) GetStringMap(key string) map[string]any {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.v == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneStringAnyMap(m.v.GetStringMap(key))
|
||||
}
|
||||
|
||||
// Set 手动设置配置。非 nil 配置会先 Validate,并以深拷贝形式保存。
|
||||
func (m *Manager) Set(cfg *Config) error {
|
||||
if m == nil {
|
||||
return ErrConfigNotLoaded
|
||||
}
|
||||
if cfg != nil {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return errors.Join(ErrInvalidConfig, err)
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cfg = cfg
|
||||
m.cfg = cfg.Clone()
|
||||
if cfg == nil {
|
||||
m.v = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload 重新加载配置文件。读取、解析、校验(C10b)任一步失败均保留旧配置并返回错误;
|
||||
@@ -760,39 +876,42 @@ func (m *Manager) reload() error {
|
||||
// 都 Store,遗留一个已 StartWatcher 的 manager 无引用可停(goroutine 泄漏)。
|
||||
var pkgLoadMu sync.Mutex
|
||||
|
||||
// Load 加载配置文件(C3.5 修复:替换前停止旧 Manager 的 watcher,防止 goroutine 泄漏)。
|
||||
// P1 #8:全程持 pkgLoadMu 串行化,消除与并发 Load/LoadWithWatch 的 TOCTOU。
|
||||
// Load 加载配置文件。
|
||||
// P1 #8:全程持 pkgLoadMu 串行化。新配置加载成功后才替换默认 Manager 并停止旧 watcher;
|
||||
// 加载失败会保留旧 Manager 与旧 watcher,避免一次错误配置导致热更新链路断掉。
|
||||
func Load(configPath string) (*Config, error) {
|
||||
pkgLoadMu.Lock()
|
||||
defer pkgLoadMu.Unlock()
|
||||
if old := defaultManager.Load(); old != nil {
|
||||
old.StopWatcher()
|
||||
}
|
||||
m := NewManager(configPath)
|
||||
cfg, err := m.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
old := defaultManager.Load()
|
||||
defaultManager.Store(m)
|
||||
if old != nil && old != m {
|
||||
old.StopWatcher()
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadWithWatch 加载配置文件并启用热更新(C3.5 修复:替换前停止旧 watcher)。
|
||||
// LoadWithWatch 加载配置文件并启用热更新。
|
||||
// P1 #8:全程持 pkgLoadMu 串行化,且新 manager 在其 watcher 成功启动后才置换为默认;
|
||||
// 启动失败则停掉半启动的 watcher 并不置换,避免遗留孤儿 watcher。
|
||||
// 启动失败则保留旧 Manager 与旧 watcher,并停掉新 manager 可能半启动的 watcher。
|
||||
func LoadWithWatch(configPath string, onChange func(*Config)) (*Config, error) {
|
||||
pkgLoadMu.Lock()
|
||||
defer pkgLoadMu.Unlock()
|
||||
if old := defaultManager.Load(); old != nil {
|
||||
old.StopWatcher()
|
||||
}
|
||||
m := NewManager(configPath)
|
||||
cfg, err := m.LoadWithWatch(onChange)
|
||||
if err != nil {
|
||||
m.StopWatcher() // 清理可能已半启动的 watcher,避免孤儿 goroutine
|
||||
return nil, err
|
||||
}
|
||||
old := defaultManager.Load()
|
||||
defaultManager.Store(m)
|
||||
if old != nil && old != m {
|
||||
old.StopWatcher()
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -821,9 +940,9 @@ func GetViper() *viper.Viper {
|
||||
return defaultManager.Load().GetViper()
|
||||
}
|
||||
|
||||
// Set 手动设置配置(用于测试或动态修改)
|
||||
func Set(cfg *Config) {
|
||||
defaultManager.Load().Set(cfg)
|
||||
// Set 手动设置配置(用于测试或动态修改)。非 nil 配置会先校验并复制。
|
||||
func Set(cfg *Config) error {
|
||||
return defaultManager.Load().Set(cfg)
|
||||
}
|
||||
|
||||
// Reload 重新加载配置文件
|
||||
@@ -837,48 +956,36 @@ func Reload() error {
|
||||
// 传入 nil 表示重置为空管理器。
|
||||
//
|
||||
// C10a:经 atomic.Pointer.Store 原子置换,消除与并发读取(Get 等)的数据竞争。
|
||||
// 置换后会停止旧 Manager 的 watcher,避免全局默认 manager 切换后遗留热更新 goroutine。
|
||||
func SetDefaultManager(m *Manager) {
|
||||
old := defaultManager.Load()
|
||||
if m == nil {
|
||||
defaultManager.Store(NewManager(""))
|
||||
return
|
||||
m = NewManager("")
|
||||
}
|
||||
defaultManager.Store(m)
|
||||
if old != nil && old != m {
|
||||
old.StopWatcher()
|
||||
}
|
||||
}
|
||||
|
||||
// GetString 获取字符串配置
|
||||
func GetString(key string) string {
|
||||
v := GetViper()
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.GetString(key)
|
||||
return defaultManager.Load().GetString(key)
|
||||
}
|
||||
|
||||
// GetInt 获取整数配置
|
||||
func GetInt(key string) int {
|
||||
v := GetViper()
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return v.GetInt(key)
|
||||
return defaultManager.Load().GetInt(key)
|
||||
}
|
||||
|
||||
// GetBool 获取布尔配置
|
||||
func GetBool(key string) bool {
|
||||
v := GetViper()
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
return v.GetBool(key)
|
||||
return defaultManager.Load().GetBool(key)
|
||||
}
|
||||
|
||||
// GetStringMap 获取字符串映射配置
|
||||
func GetStringMap(key string) map[string]any {
|
||||
v := GetViper()
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return v.GetStringMap(key)
|
||||
return defaultManager.Load().GetStringMap(key)
|
||||
}
|
||||
|
||||
// IsDevelopment 是否开发环境
|
||||
|
||||
@@ -329,3 +329,171 @@ func TestStartWatcherIdempotent(t *testing.T) {
|
||||
}
|
||||
m.StopWatcher()
|
||||
}
|
||||
|
||||
func TestStopWatcherWaitsInFlightReload(t *testing.T) {
|
||||
p := writeConfig(t, "c10_stop_wait.yaml", validConfigYAML(8091))
|
||||
defer os.Remove(p)
|
||||
|
||||
m := config.NewManager(p)
|
||||
if _, err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
m.RegisterCallback(func(*config.Config) {
|
||||
close(entered)
|
||||
<-release
|
||||
})
|
||||
if err := m.StartWatcher(); err != nil {
|
||||
t.Fatalf("StartWatcher: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(p, []byte(validConfigYAML(8092)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("热更新回调未触发")
|
||||
}
|
||||
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
m.StopWatcher()
|
||||
close(stopped)
|
||||
}()
|
||||
select {
|
||||
case <-stopped:
|
||||
t.Fatal("StopWatcher 不应在 reload 回调结束前返回")
|
||||
case <-time.After(120 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("StopWatcher 未等待到 reload 回调结束")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithWatchFailureKeepsOldWatcher(t *testing.T) {
|
||||
p := writeConfig(t, "c10_keep_old.yaml", validConfigYAML(8093))
|
||||
defer os.Remove(p)
|
||||
|
||||
changes := make(chan int, 4)
|
||||
if _, err := config.LoadWithWatch(p, func(c *config.Config) {
|
||||
changes <- c.Server.Port
|
||||
}); err != nil {
|
||||
t.Fatalf("LoadWithWatch old: %v", err)
|
||||
}
|
||||
defer config.StopWatcher()
|
||||
|
||||
missing := filepath.Join(filepath.Dir(p), "missing.yaml")
|
||||
if _, err := config.LoadWithWatch(missing, nil); err == nil {
|
||||
t.Fatal("加载缺失配置应失败")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(p, []byte(validConfigYAML(8094)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
select {
|
||||
case port := <-changes:
|
||||
if port != 8094 {
|
||||
t.Fatalf("旧 watcher 回调端口错误: %d", port)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("加载失败后旧 watcher 不应被停止")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaultManagerStopsOldWatcher(t *testing.T) {
|
||||
oldPath := writeConfig(t, "c10_old_default.yaml", validConfigYAML(8095))
|
||||
newPath := writeConfig(t, "c10_new_default.yaml", validConfigYAML(8096))
|
||||
defer os.Remove(oldPath)
|
||||
defer os.Remove(newPath)
|
||||
|
||||
oldManager := config.NewManager(oldPath)
|
||||
if _, err := oldManager.Load(); err != nil {
|
||||
t.Fatalf("old Load: %v", err)
|
||||
}
|
||||
changes := make(chan int, 4)
|
||||
oldManager.RegisterCallback(func(c *config.Config) {
|
||||
changes <- c.Server.Port
|
||||
})
|
||||
if err := oldManager.StartWatcher(); err != nil {
|
||||
t.Fatalf("old StartWatcher: %v", err)
|
||||
}
|
||||
config.SetDefaultManager(oldManager)
|
||||
|
||||
newManager := config.NewManager(newPath)
|
||||
if _, err := newManager.Load(); err != nil {
|
||||
t.Fatalf("new Load: %v", err)
|
||||
}
|
||||
config.SetDefaultManager(newManager)
|
||||
defer config.SetDefaultManager(nil)
|
||||
|
||||
if err := os.WriteFile(oldPath, []byte(validConfigYAML(8097)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile old: %v", err)
|
||||
}
|
||||
select {
|
||||
case port := <-changes:
|
||||
t.Fatalf("旧 watcher 已停止,不应收到端口 %d", port)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetAndViperReturnCopies(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{Name: "copy", Env: "dev"},
|
||||
CORS: config.CORSConfig{
|
||||
AllowedOrigins: []string{"https://a.example.com"},
|
||||
},
|
||||
}
|
||||
if err := config.Set(cfg); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
defer config.SetDefaultManager(nil)
|
||||
|
||||
cfg.App.Name = "mutated-input"
|
||||
cfg.CORS.AllowedOrigins[0] = "https://mutated-input.example.com"
|
||||
got := config.Get()
|
||||
if got.App.Name != "copy" {
|
||||
t.Fatalf("Set 应保存副本,实际 App.Name=%q", got.App.Name)
|
||||
}
|
||||
if got.CORS.AllowedOrigins[0] != "https://a.example.com" {
|
||||
t.Fatalf("Set 应深拷贝切片,实际 origin=%q", got.CORS.AllowedOrigins[0])
|
||||
}
|
||||
|
||||
got.App.Name = "mutated-get"
|
||||
got.CORS.AllowedOrigins[0] = "https://mutated-get.example.com"
|
||||
gotAgain := config.Get()
|
||||
if gotAgain.App.Name != "copy" || gotAgain.CORS.AllowedOrigins[0] != "https://a.example.com" {
|
||||
t.Fatalf("Get 应返回副本,实际 %+v", gotAgain)
|
||||
}
|
||||
|
||||
if err := config.Set(&config.Config{Server: config.ServerConfig{Port: 99999}}); err == nil {
|
||||
t.Fatal("Set 非法配置应返回错误")
|
||||
}
|
||||
if got := config.Get().App.Name; got != "copy" {
|
||||
t.Fatalf("Set 非法配置不应覆盖旧配置,实际 App.Name=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetViperReturnsSnapshot(t *testing.T) {
|
||||
p := writeConfig(t, "c10_viper_snapshot.yaml", validConfigYAML(8098))
|
||||
defer os.Remove(p)
|
||||
|
||||
if _, err := config.Load(p); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
defer config.SetDefaultManager(nil)
|
||||
|
||||
v := config.GetViper()
|
||||
if v == nil {
|
||||
t.Fatal("GetViper returned nil")
|
||||
}
|
||||
v.Set("app.name", "mutated")
|
||||
if got := config.GetString("app.name"); got != "c10" {
|
||||
t.Fatalf("GetViper 应返回快照,不应污染内部 viper,实际 app.name=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestDatabaseConfigPostgresDSN(t *testing.T) {
|
||||
}
|
||||
|
||||
dsn := db.DSN()
|
||||
expected := "host=localhost port=5432 user=postgres password='password' dbname=testdb sslmode=disable TimeZone=Asia/Shanghai"
|
||||
expected := "host='localhost' port=5432 user='postgres' password='password' dbname='testdb' sslmode=disable TimeZone='Asia/Shanghai'"
|
||||
if dsn != expected {
|
||||
t.Errorf("Postgres DSN = %s, want %s", dsn, expected)
|
||||
}
|
||||
@@ -140,13 +140,13 @@ func TestDatabaseConfigDSNPasswordEscape_M9(t *testing.T) {
|
||||
Name: "testdb",
|
||||
}
|
||||
pdsn := pg.PostgresDSN()
|
||||
if !strings.Contains(pdsn, "password='p''ord'") {
|
||||
if !strings.Contains(pdsn, `password='p\'ord'`) {
|
||||
t.Errorf("Postgres DSN password not escaped: %s", pdsn)
|
||||
}
|
||||
|
||||
// Timezone 可配置
|
||||
pg2 := config.DatabaseConfig{Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n", Timezone: "UTC"}
|
||||
if !strings.Contains(pg2.PostgresDSN(), "TimeZone=UTC") {
|
||||
if !strings.Contains(pg2.PostgresDSN(), "TimeZone='UTC'") {
|
||||
t.Errorf("Postgres DSN should honor Timezone=UTC: %s", pg2.PostgresDSN())
|
||||
}
|
||||
mysql2 := config.DatabaseConfig{Driver: config.DriverMySQL, Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", Timezone: "UTC"}
|
||||
@@ -350,7 +350,7 @@ func TestConfigManagerIsolation(t *testing.T) {
|
||||
func TestConfigSet(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{
|
||||
Name: "Manual",
|
||||
Name: "Manual",
|
||||
SiteName: "manual_site",
|
||||
},
|
||||
}
|
||||
@@ -418,4 +418,4 @@ func TestUploadConfig(t *testing.T) {
|
||||
if upload.MaxFileSize != 10 {
|
||||
t.Error("UploadConfig failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -39,15 +39,21 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Database:仅当配置了 driver 时校验(未启用 mysql 的项目可留空)
|
||||
if strings.TrimSpace(c.Database.Driver) != "" {
|
||||
if c.Database.Host == "" {
|
||||
// Database:出现任一数据库字段时视为启用;driver 为空按 MySQL 兼容处理。
|
||||
if c.Database.isConfigured() {
|
||||
driver := strings.TrimSpace(c.Database.Driver)
|
||||
if driver != "" {
|
||||
if _, ok := LookupDSNBuilder(driver); !ok {
|
||||
problems = append(problems, fmt.Sprintf("database.driver 未注册: %s", driver))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(c.Database.CustomDSN) == "" && strings.TrimSpace(c.Database.Host) == "" {
|
||||
problems = append(problems, "database.host 启用数据库后必填")
|
||||
}
|
||||
if c.Database.Name == "" {
|
||||
if strings.TrimSpace(c.Database.CustomDSN) == "" && strings.TrimSpace(c.Database.Name) == "" {
|
||||
problems = append(problems, "database.name 启用数据库后必填")
|
||||
}
|
||||
if c.Database.Port <= 0 || c.Database.Port > 65535 {
|
||||
if strings.TrimSpace(c.Database.CustomDSN) == "" && (c.Database.Port <= 0 || c.Database.Port > 65535) {
|
||||
problems = append(problems, fmt.Sprintf("database.port 超出范围(1-65535): %d", c.Database.Port))
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -93,11 +93,13 @@ func TestParseTokenWrongSecret(t *testing.T) {
|
||||
// 修改 secret
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "different-secret",
|
||||
Secret: "different-secret-key-12345678901234567890",
|
||||
Expire: 3600,
|
||||
},
|
||||
}
|
||||
config.Set(cfg)
|
||||
if err := config.Set(cfg); err != nil {
|
||||
t.Fatalf("Set wrong secret config: %v", err)
|
||||
}
|
||||
|
||||
// 应该解析失败
|
||||
_, err := jwt.ParseToken(token)
|
||||
|
||||
Reference in New Issue
Block a user