Files
gochat/docs/PHASE2_P0_ENV_CONFIG.md
T
2026-06-04 15:44:48 +08:00

20 KiB
Raw Blame History

GoChat 阶段二 P0-6:环境配置管理改进方案

一、现状分析

1.1 配置文件清单

文件 位置 说明
config.go internal/config/config.go 配置结构体定义 + Load() 函数
validator.go internal/config/validator.go 配置校验逻辑
config_test.go internal/config/config_test.go 配置校验测试
config.yaml configs/config.yaml 主配置文件(开发默认)
config.dev.yaml configs/config.dev.yaml 开发环境覆盖
config.prod.yaml configs/config.prod.yaml 生产环境覆盖
.env.example .env.example 114行,环境变量模板
prometheus_alerts.yml configs/prometheus_alerts.yml 监控告警规则
fluentd.conf configs/fluentd.conf 日志收集配置
redis.conf configs/redis.conf Redis 服务配置

1.2 当前 Viper 使用方式

Load() 函数(config.go:210-255)的配置加载流程:

1. viper.SetConfigName("config")        → 固定文件名 "config"
2. viper.SetConfigType("yaml")          → YAML格式
3. viper.AddConfigPath("./configs")     → 搜索路径1
4. viper.AddConfigPath("./")            → 搜索路径2
5. viper.AddConfigPath("/etc/gochat/")  → 搜索路径3
6. viper.SetEnvPrefix("GOCHAT")         → 环境变量前缀 GOCHAT_
7. viper.AutomaticEnv()                 → 自动映射环境变量
8. viper.SetDefault(...)                → 设置默认值(rate_limit + saml
9. viper.ReadInConfig()                 → 读取配置文件
10. viper.Unmarshal(&cfg)               → 反序列化到结构体
11. 手动补零值默认 → RateLimit零值兜底

1.3 配置结构体(13个)

Config  {
    Server     ServerConfig      (host, port, mode, cors)
    Database   DatabaseConfig    (host, port, user, password, dbname/Name, sslmode, 连接池参数, migration)
    Redis      RedisConfig       (host, port, password, db, url)
    JWT        JWTConfig         (secret, expiry, refresh, access, audience, issuer)
    Log        LogConfig         (level, format)
    Captain    CaptainConfig     (enabled, llm_provider, llm_model, llm_api_key, llm_base_url, embedding, max_tokens, temperature)
    Worker     WorkerConfig      (concurrency)
    OAuth      OAuthConfig       (google, github  OAuthProviderConfig)
    RateLimit  RateLimitConfig   (enabled, requests_per_minute, window_seconds)
    SAML       SAMLConfig        (enabled, idp_metadata_url, idp_metadata_xml, sp_entity_id, acs_url, cert/key paths, attribute_map, clock_drift_tolerance)
}
+ LLMConfig从CaptainConfig派生

1.4 多环境配置现状

已有 config.dev.yamlconfig.prod.yaml,但 未被 Load() 函数使用Load 只读取单一的 config.yaml,没有环境合并逻辑。

docker-compose 文件中设置 GOCHAT_ENV=development/production/test,但这个变量仅用于 Docker 容器标识,不被 Viper 用于选择环境配置文件

Helm values 中有 values-staging.yaml,设置了 GOCHAT_ENV: "staging",但同样只是 ConfigMap 中的标识变量。

1.5 环境变量命名问题

重大缺陷.env.example 中存在命名不一致:

.env.example 变量 Viper期望的变量名(GOCHAT_前缀+自动映射) 问题
POSTGRES_HOST GOCHAT_DATABASE_HOST 前缀不一致
POSTGRES_PORT GOCHAT_DATABASE_PORT 前缀不一致
POSTGRES_USER GOCHAT_DATABASE_USER 前缀不一致
POSTGRES_PASSWORD GOCHAT_DATABASE_PASSWORD 前缀不一致
POSTGRES_DB GOCHAT_DATABASE_DBNAME 前缀+键名不一致
POSTGRES_SSLMODE GOCHAT_DATABASE_SSLMODE 前缀不一致
REDIS_HOST GOCHAT_REDIS_HOST 前缀不一致
REDIS_PORT GOCHAT_REDIS_PORT 前缀不一致
REDIS_PASSWORD GOCHAT_REDIS_PASSWORD 前缀不一致
REDIS_DB GOCHAT_REDIS_DB 前缀不一致
SMTP_* GOCHAT_SMTP_*SMTP未在Config结构体中) 缺少SMTP配置
FRONTEND_URL GOCHAT_FRONTEND_URL(未在Config结构体中) 缺少Frontend配置
OPENAI_API_KEY GOCHAT_CAPTAIN_LLM_API_KEY 前缀不一致
GOCHAT_METRICS_* GOCHAT_METRICS_*Metrics未在Config结构体中) 缺少Metrics配置
GOCHAT_WS_* GOCHAT_WS_*WebSocket未在Config结构体中) 缺少WebSocket配置
GOCHAT_ANALYTICS_* GOCHAT_ANALYTICS_*Analytics未在Config结构体中) 缺少Analytics配置
GOCHAT_FEATURE_* GOCHAT_FEATURE_*FeatureFlags未在Config结构体中) 缺少FeatureFlags配置

核心问题viper.AutomaticEnv() + viper.SetEnvPrefix("GOCHAT") 只能自动映射 GOCHAT_ 前缀的环境变量到嵌套键。没有 EnvKeyReplacer 设置(._ 的映射),也没有 BindEnv() 显式绑定非标准命名的变量。所以大量 POSTGRES_*REDIS_*OPENAI_* 等环境变量实际上不会被 Viper 读取

1.6 其他问题汇总

问题 说明
无环境选择逻辑 config.dev.yaml/config.prod.yaml 存在但未被使用
无配置热加载 没有 WatchConfig/OnConfigChange
无 .env 文件加载 Viper 不自动读取 .env 文件,需 godotenv 或手动处理
环境变量映射断裂 POSTGRES_/REDIS_ 等前缀与 GOCHAT_ 不匹配,AutomaticEnv无法映射
无 EnvKeyReplacer 未设置 viper.RegisterAlias 或 EnvKeyReplacer 来处理嵌套键
缺少多个配置结构体 SMTP/Frontend/Metrics/WebSocket/Analytics/FeatureFlags/SMTP 在.env中但不在Config结构体中
DatabaseConfig 双字段 Name 和 DBName 同时存在,语义混乱
无配置版本号 配置文件没有版本标记,难以追踪兼容性变化
无 staging 环境文件 只有 dev/prod,缺少 config.staging.yaml
配置默认值分散 部分在 viper.SetDefault,部分在 YAML,部分在代码中硬编码兜底

二、改进方案

2.1 配置加载优先级(从低到高)

优先级(低→高,后者覆盖前者):

1. 代码默认值(viper.SetDefault
2. 基础配置文件(configs/config.yaml
3. 环境配置文件(configs/config.{env}.yaml
4. 本地覆盖文件(configs/config.local.yaml,不提交到git
5. 环境变量(GOCHAT_ 前缀)
6. 命令行参数(--server.port 等,可选)

2.2 多环境配置方案

环境选择逻辑

通过 GOCHAT_ENV 环境变量选择环境(默认 development):

GOCHAT_ENV=development  → 加载 config.yaml + config.dev.yaml
GOCHAT_ENV=staging      → 加载 config.yaml + config.staging.yaml
GOCHAT_ENV=production   → 加载 config.yaml + config.prod.yaml
GOCHAT_ENV=test         → 加载 config.yaml(无环境覆盖,测试用单独配置)

configs/ 目录结构

configs/
├── config.yaml              # 基础配置(所有环境共享)
├── config.dev.yaml          # 开发环境覆盖(已存在,需完善)
├── config.staging.yaml      # 预发布环境覆盖(新增)
├── config.prod.yaml         # 生产环境覆盖(已存在,需完善)
├── config.local.yaml        # 本地开发覆盖(新增,加入.gitignore
├── config.test.yaml         # 测试环境覆盖(新增)
├── prometheus_alerts.yml    # 监控告警(保持不变)
├── fluentd.conf             # 日志收集(保持不变)
└── redis.conf               # Redis配置(保持不变)

Load() 函数改进伪代码

func Load() (*Config, error) {
    // 1. 设置基础配置
    viper.SetConfigName("config")
    viper.SetConfigType("yaml")
    viper.AddConfigPath("./configs")
    viper.AddConfigPath("./")
    viper.AddConfigPath("/etc/gochat/")
    
    // 2. 环境变量映射
    viper.SetEnvPrefix("GOCHAT")
    viper.AutomaticEnv()
    viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
    
    // 3. 显式绑定非标准环境变量(兼容旧命名)
    bindLegacyEnvVars()  // POSTGRES_* → database.*, REDIS_* → redis.*
    
    // 4. 设置默认值(集中管理)
    setDefaults()
    
    // 5. 读取基础配置
    if err := viper.ReadInConfig(); err != nil {
        return nil, fmt.Errorf("read base config: %w", err)
    }
    
    // 6. 合理环境配置
    env := viper.GetString("env")  // 从 GOCHAT_ENV 或 config.yaml 读取
    if env == "" {
        env = "development"
    }
    envFile := fmt.Sprintf("config.%s", env)
    viper.SetConfigName(envFile)
    if err := viper.MergeInConfig(); err != nil {
        // 环境配置可选,不存在时不报错
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return nil, fmt.Errorf("merge env config: %w", err)
        }
    }
    
    // 7. 合并本地覆盖(可选,开发用)
    viper.SetConfigName("config.local")
    if err := viper.MergeInConfig(); err != nil {
        // local配置可选,忽略任何错误
    }
    
    // 8. 反序列化
    var cfg Config
    if err := viper.Unmarshal(&cfg); err != nil {
        return nil, fmt.Errorf("unmarshal config: %w", err)
    }
    
    // 9. 设置运行时环境标识
    cfg.Env = env
    
    // 10. 校验
    if err := Validate(&cfg); err != nil {
        return nil, fmt.Errorf("validate config: %w", err)
    }
    
    return &cfg, nil
}

2.3 配置热加载方案

设计思路

Go 服务通常不需要像 Rails 那样频繁热加载,但以下场景有价值:

  • 开发环境:修改日志级别、限流参数后无需重启
  • 生产环境:调整 RateLimit、Worker并发数、Captain开关等

实现方案

// ConfigWatcher 提供配置热加载能力
type ConfigWatcher struct {
    cfg     *Config
    mu      sync.RWMutex
    onChange func(oldCfg, newCfg *Config)
}

// WatchConfig 启动配置文件监控
func WatchConfig(initial *Config, onChange func(old, new *Config)) (*ConfigWatcher, error) {
    watcher := &ConfigWatcher{
        cfg:      initial,
        onChange: onChange,
    }
    
    viper.OnConfigChange(func(e fsnotify.Event) {
        newCfg, err := Reload()
        if err != nil {
            log.Printf("config reload failed: %v", err)
            return
        }
        
        oldCfg := watcher.Get()
        watcher.mu.Lock()
        watcher.cfg = newCfg
        watcher.mu.Unlock()
        
        if watcher.onChange != nil {
            watcher.onChange(oldCfg, newCfg)
        }
    })
    viper.WatchConfig()
    
    return watcher, nil
}

// Get 安全获取当前配置(RLock保护)
func (w *ConfigWatcher) Get() *Config {
    w.mu.RLock()
    defer w.mu.RUnlock()
    return w.cfg
}

// Reload 重新加载配置(不启动watch)
func Reload() (*Config, error) {
    return Load()  // 重新走完整的加载流程
}

安全的配置引用方式

需要修改各模块从 cfg *Config(一次性指针)改为 cfgProvider func() *Config(动态获取):

// 在 server/router 等模块中使用
type ConfigProvider func() *Config

// 初始化时:
// router.Setup(cfgWatcher.Get)  // 传入 getter 函数
// 而不是 router.Setup(cfg)      // 传入静态指针

热加载安全策略

配置项 可热加载 需重启 说明
log.level/format 日志级别即时生效
rate_limit.* 限流参数即时生效
worker.concurrency Worker池大小动态调整
captain.enabled AI功能开关
server.port 端口变更需重启
database.* 数据库连接变更需重启
redis.url Redis连接变更需重启
jwt.secret JWT密钥变更需重启(安全)
saml.* SAML配置变更需重启

2.4 环境变量映射修复

方案A:显式 BindEnv(推荐)

为所有非标准命名环境变量显式绑定:

func bindLegacyEnvVars() {
    // Database - 兼容 POSTGRES_* 前缀
    viper.BindEnv("database.host", "POSTGRES_HOST", "GOCHAT_DATABASE_HOST")
    viper.BindEnv("database.port", "POSTGRES_PORT", "GOCHAT_DATABASE_PORT")
    viper.BindEnv("database.user", "POSTGRES_USER", "GOCHAT_DATABASE_USER")
    viper.BindEnv("database.password", "POSTGRES_PASSWORD", "GOCHAT_DATABASE_PASSWORD")
    viper.BindEnv("database.dbname", "POSTGRES_DB", "GOCHAT_DATABASE_DBNAME")
    viper.BindEnv("database.sslmode", "POSTGRES_SSLMODE", "GOCHAT_DATABASE_SSLMODE")
    
    // Redis - 兼容 REDIS_* 前缀
    viper.BindEnv("redis.host", "REDIS_HOST", "GOCHAT_REDIS_HOST")
    viper.BindEnv("redis.port", "REDIS_PORT", "GOCHAT_REDIS_PORT")
    viper.BindEnv("redis.password", "REDIS_PASSWORD", "GOCHAT_REDIS_PASSWORD")
    viper.BindEnv("redis.db", "REDIS_DB", "GOCHAT_REDIS_DB")
    
    // JWT - 兼容非标准命名
    viper.BindEnv("jwt.secret", "GOCHAT_JWT_SECRET", "JWT_SECRET")
    viper.BindEnv("jwt.expiry_hours", "GOCHAT_JWT_EXPIRY_HOURS")
    
    // Captain - 兼容 OPENAI_* 遗留命名
    viper.BindEnv("captain.llm_api_key", "GOCHAT_CAPTAIN_LLM_API_KEY", "OPENAI_API_KEY")
    viper.BindEnv("captain.llm_model", "GOCHAT_CAPTAIN_LLM_MODEL", "OPENAI_MODEL")
}

方案BEnvKeyReplacer(辅助)

viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
// 使 GOCHAT_SERVER_HOST → server.host 自动映射生效

2.5 新增配置结构体

当前 .env.example 中有多个配置域未纳入 Config 结构体,需要补充:

type Config struct {
    Env         string          `mapstructure:"env"`           // 新增:当前运行环境标识
    Server      ServerConfig    `mapstructure:"server"`
    Database    DatabaseConfig  `mapstructure:"database"`
    Redis       RedisConfig     `mapstructure:"redis"`
    JWT         JWTConfig       `mapstructure:"jwt"`
    Log         LogConfig       `mapstructure:"log"`
    Captain     CaptainConfig   `mapstructure:"captain"`
    Worker      WorkerConfig    `mapstructure:"worker"`
    OAuth       OAuthConfig     `mapstructure:"oauth"`
    RateLimit   RateLimitConfig `mapstructure:"rate_limit"`
    SAML        SAMLConfig      `mapstructure:"saml"`
    SMTP        SMTPConfig      `mapstructure:"smtp"`          // 新增
    Frontend    FrontendConfig  `mapstructure:"frontend"`      // 新增
    Metrics     MetricsConfig   `mapstructure:"metrics"`       // 新增
    WebSocket   WebSocketConfig `mapstructure:"websocket"`     // 新增
    Analytics   AnalyticsConfig `mapstructure:"analytics"`     // 新增
    Feature     FeatureConfig   `mapstructure:"feature"`       // 新增
}

// 新增结构体定义
type SMTPConfig struct {
    Host     string `mapstructure:"host"`
    Port     int    `mapstructure:"port"`
    Username string `mapstructure:"username"`
    Password string `mapstructure:"password"`
    FromEmail string `mapstructure:"from_email"`
}

type FrontendConfig struct {
    URL string `mapstructure:"url"`
}

type MetricsConfig struct {
    Enabled bool `mapstructure:"enabled"`
    Port    int  `mapstructure:"port"`
}

type WebSocketConfig struct {
    MaxMessageSize    int `mapstructure:"max_message_size"`
    ReadBufferSize    int `mapstructure:"read_buffer_size"`
    WriteBufferSize   int `mapstructure:"write_buffer_size"`
    PingInterval      int `mapstructure:"ping_interval"`
    PongTimeout       int `mapstructure:"pong_timeout"`
    MaxSubscriptions  int `mapstructure:"max_subscriptions"`
}

type AnalyticsConfig struct {
    Enabled        bool `mapstructure:"enabled"`
    RetentionDays  int  `mapstructure:"retention_days"`
    FlushInterval  int  `mapstructure:"flush_interval"`
}

type FeatureConfig struct {
    CaptainAI      bool `mapstructure:"captain_ai"`
    AutoAssignment bool `mapstructure:"auto_assignment"`
    CSAT           bool `mapstructure:"csat"`
    Campaigns      bool `mapstructure:"campaigns"`
    MFA            bool `mapstructure:"mfa"`
}

2.6 DatabaseConfig 清理

NameDBName 双字段需要统一:

type DatabaseConfig struct {
    Host            string `mapstructure:"host"`
    Port            int    `mapstructure:"port"`
    User            string `mapstructure:"user"`
    Password        string `mapstructure:"password"`
    DBName          string `mapstructure:"dbname"`           // 统一用 dbname
    SSLMode         string `mapstructure:"sslmode"`
    MaxIdleConns    int    `mapstructure:"max_idle_conns"`
    MaxOpenConns    int    `mapstructure:"max_open_conns"`
    ConnMaxLifetime int    `mapstructure:"conn_max_lifetime"`
    RunMigrations   bool   `mapstructure:"run_migrations"`
    MigrationsPath  string `mapstructure:"migrations_path"`
    LogLevel        string `mapstructure:"log_level"`        // 新增:数据库日志级别
}
// Name 字段废弃 → DSN() 和 MigrateDSN() 只使用 DBName

2.7 配置版本标记

在 config.yaml 中加入版本号:

# config.yaml
version: "2.0"  # 配置格式版本,用于兼容性检查

server:
  host: "0.0.0.0"
  port: 3000
  ...

Load() 中加入版本检查:

cfgVersion := viper.GetString("version")
if cfgVersion != "" && cfgVersion != "2.0" {
    return nil, fmt.Errorf("unsupported config version: %s (expected 2.0)", cfgVersion)
}

2.8 Redis URL 构建优化

当前 RedisConfig 有 Host/Port/Password/DB 和 URL 双模式,建议增加 BuildURL() 方法:

func (r RedisConfig) BuildURL() string {
    if r.URL != "" {
        return r.URL
    }
    auth := ""
    if r.Password != "" {
        auth = fmt.Sprintf(":%s@", r.Password)
    }
    return fmt.Sprintf("redis://%slocalhost:%d/%d", auth, r.Port, r.DB)
}

三、实施计划

阶段一:核心改进(1天)

  1. 修改 Load() 函数:加入环境选择逻辑、MergeInConfig 环境合并
  2. 加入 EnvKeyReplacerstrings.NewReplacer(".", "_")
  3. 加入 BindEnv:显式绑定所有非标准环境变量
  4. 创建 config.staging.yaml 和 config.test.yaml
  5. Config 结构体新增 Env 字段

阶段二:配置补全(0.5天)

  1. 新增配置结构体SMTP/Frontend/Metrics/WebSocket/Analytics/Feature
  2. 清理 DatabaseConfig:统一 DBName,废弃 Name
  3. 更新 config.yaml:加入 version 标记 + 新配置域
  4. 更新 config.dev.yaml / config.prod.yaml:补全新配置域覆盖

阶段三:热加载(0.5天)

  1. 实现 ConfigWatcherWatchConfig + OnConfigChange + sync.RWMutex
  2. 引入 ConfigProvider 模式:关键模块使用 func() *Config 获取动态配置
  3. 热加载安全策略:标记哪些配置可热加载,哪些需重启

阶段四:配置本地覆盖(0.5天)

  1. 创建 config.local.yaml 模板
  2. 更新 .gitignore:加入 config.local.yaml
  3. 更新 validator.go:新增配置域校验
  4. 更新 config_test.go:新增加载逻辑测试

总预估工时:2.5天(含测试)


四、风险与注意事项

风险 对策
MergeInConfig 环境文件不存在时报错 检查 ConfigFileNotFoundError,容错处理
配置热加载与数据库连接池冲突 不可热加载的配置项标记为 "需重启",onChange 回调中只更新安全配置
环境变量命名兼容性 BindEnv 支持多别名,同时支持旧命名(POSTGRES_)和新命名(GOCHAT_DATABASE_
config.local.yaml 误提交 加入 .gitignore,并在 Makefile 中添加 check-git-clean 目标
DatabaseConfig Name→DBName 迁移 DSN() 方法中保留 Name 兜底兼容(if dbname == "" { dbname = d.Name }
viper.Unmarshal 对零值字段不设置默认 继续使用 viper.SetDefault + 代码兜底的双重策略

五、参考